Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
cache-dependency-path: frontend/package.json

- name: Install dependencies
run: npm install
Expand Down Expand Up @@ -64,7 +64,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
cache-dependency-path: backend/package.json

- name: Install dependencies
run: npm install
Expand Down Expand Up @@ -97,7 +97,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
cache-dependency-path: backend/package.json

- name: Install dependencies
run: npm install
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/preview-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ jobs:
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
cache-dependency-path: frontend/package.json

- run: npm ci
- run: npm install

- run: npm run build

Expand Down
1 change: 0 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Node.js dependencies
node_modules/
package-lock.json
yarn.lock
bun.lockb

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const PortfolioAnalyticsController = require('../../../src/controllers/portfolioAnalyticsController');
const Trade = require('../../../src/models/Trade');
const PortfolioAnalyticsController = require('../../src/controllers/portfolioAnalyticsController');
const Trade = require('../../src/models/Trade');

jest.mock('../../../src/models/Trade');
jest.mock('../../../src/config/logger', () => ({
jest.mock('../../src/models/Trade');
jest.mock('../../src/config/logger', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
Expand Down
29 changes: 26 additions & 3 deletions backend/__tests__/integration/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,32 @@ jest.mock('stellar-sdk', () => ({
}
}));

jest.mock('mongoose', () => ({
connection: { readyState: 1 }
}));
jest.mock('mongoose', () => {
class MockSchema {
constructor() {
this.methods = {};
this.statics = {};
}

index() {}
pre() {}
post() {}
virtual() {
return { get: jest.fn() };
}
}

MockSchema.Types = {
Mixed: Object,
ObjectId: String
};

return {
connection: { readyState: 1 },
Schema: MockSchema,
model: jest.fn()
};
});

jest.mock('../../src/config/logger', () => ({
info: jest.fn(),
Expand Down
48 changes: 42 additions & 6 deletions backend/__tests__/integration/failureRecovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,49 @@
auth: jest.fn(),
}));

jest.mock('mongoose', () => ({
connection: {
get readyState() {
return mockDbHealthy ? 1 : 0;
jest.mock('mongoose', () => {
class MockSchema {
constructor() {
this.methods = {};
this.statics = {};
}

index() {}
pre() {}
post() {}
virtual() {
return { get: jest.fn() };
}
}

MockSchema.Types = {
Mixed: Object,
ObjectId: String,
};

return {
connection: {
get readyState() {
return mockDbHealthy ? 1 : 0;
},
},
},
}));
Schema: MockSchema,
model: jest.fn((modelName) => {
const MockModel = jest.fn();
MockModel.modelName = modelName;
MockModel.find = jest.fn();
MockModel.findOne = jest.fn();
MockModel.findById = jest.fn();
MockModel.findByIdAndUpdate = jest.fn();
MockModel.findOneAndUpdate = jest.fn();
MockModel.countDocuments = jest.fn();
MockModel.aggregate = jest.fn();
MockModel.create = jest.fn();
MockModel.updateOne = jest.fn();
return MockModel;
}),
};
});

jest.mock('../../src/services/stellarService', () => ({
getNetworkStatus: jest.fn(async () => {
Expand Down Expand Up @@ -118,7 +154,7 @@
'sports-api': { successCount: 7, failureCount: 0, failureRate: 0.0, lastFailure: null },
};
}),
resolveWithFallback: jest.fn(async ({ category }) => {

Check warning on line 157 in backend/__tests__/integration/failureRecovery.test.js

View workflow job for this annotation

GitHub Actions / Backend (Lint, Test)

'category' is defined but never used. Allowed unused args must match /^_/u
if (!mockOraclePrimaryHealthy) {
// Primary (coingecko) is down — fallback to chainlink
return { outcome: 'yes', confidence: 0.82, source: 'chainlink', usedFallback: true };
Expand Down
159 changes: 159 additions & 0 deletions backend/__tests__/integration/oracleConsensusEngine.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Integration tests for the end-to-end Oracle Consensus Engine (#219).
*
* Exercises the real ConsensusEngine wired into oracleService.aggregateResults,
* including provider weighting, staleness rejection, statistical outlier
* rejection, configurable thresholds, and audit-log persistence of oracle
* decisions.
*/

jest.mock('../../src/config/logger', () => ({
oracle: jest.fn(),
error: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn()
}));

jest.mock('../../src/models', () => ({ Market: {} }));

const mockOracleAudit = jest.fn().mockResolvedValue({});
jest.mock('../../src/services/auditService', () => ({
oracle: (...args) => mockOracleAudit(...args)
}));

const oracleService = require('../../src/services/oracleService');

describe('Oracle Consensus Engine — end-to-end integration', () => {
beforeEach(() => {
oracleService.setWeights({});
oracleService.clearRetryQueue();
oracleService.resultCache.clear();
mockOracleAudit.mockClear();
});

it('reaches weighted consensus across providers and records an audit log', () => {
const now = Date.now();

const aggregated = oracleService.aggregateResults(
[
{ source: 'coingecko', outcome: 'yes', confidence: 1, data: {}, timestamp: new Date(now).toISOString() },
{ source: 'chainlink', outcome: 'yes', confidence: 1, data: {}, timestamp: new Date(now).toISOString() },
{ source: 'news-api', outcome: 'no', confidence: 1, data: {}, timestamp: new Date(now).toISOString() }
],
{ marketId: 'market-consensus-1', consensusThreshold: 0.6 }
);

// weights: coingecko 0.4, chainlink 0.5, news-api 0.25 -> yes 0.9 / total 1.15 ≈ 0.783
expect(aggregated.outcome).toBe('yes');
expect(aggregated.consensusReached).toBe(true);
expect(aggregated.data.consensus.agreementRatio).toBeGreaterThan(0.6);

expect(mockOracleAudit).toHaveBeenCalledWith(
'oracle.consensus_reached',
expect.objectContaining({
target: { type: 'market', id: 'market-consensus-1' },
metadata: expect.objectContaining({ finalOutcome: 'yes' })
})
);
});

it('does not persist an audit log when marketId is omitted (pure aggregation call)', () => {
oracleService.aggregateResults([
{ source: 'coingecko', outcome: 'yes', confidence: 1, data: {} },
{ source: 'news-api', outcome: 'no', confidence: 1, data: {} }
]);

expect(mockOracleAudit).not.toHaveBeenCalled();
});

it('excludes stale provider responses from consensus and logs the rejection', () => {
const now = Date.now();
const staleTimestamp = new Date(now - 10 * 60 * 1000).toISOString(); // 10 minutes old

const aggregated = oracleService.aggregateResults(
[
{ source: 'coingecko', outcome: 'yes', confidence: 1, data: {}, timestamp: new Date(now).toISOString() },
{ source: 'chainlink', outcome: 'no', confidence: 1, data: {}, timestamp: staleTimestamp }
],
{ marketId: 'market-stale-1', maxAgeMs: 5 * 60 * 1000, consensusThreshold: 0.5 }
);

expect(aggregated.data.consensus.rejected.stale.map((r) => r.source)).toContain('chainlink');
expect(aggregated.data.breakdown.map((b) => b.source)).toEqual(['coingecko']);
expect(aggregated.outcome).toBe('yes');
});

it('rejects statistically outlying numeric provider readings', () => {
const now = Date.now();
const ts = new Date(now).toISOString();

const aggregated = oracleService.aggregateResults(
[
{ source: 'src1', outcome: 'yes', confidence: 1, data: { currentPrice: 100 }, timestamp: ts },
{ source: 'src2', outcome: 'yes', confidence: 1, data: { currentPrice: 102 }, timestamp: ts },
{ source: 'src3', outcome: 'yes', confidence: 1, data: { currentPrice: 98 }, timestamp: ts },
{ source: 'src4', outcome: 'no', confidence: 1, data: { currentPrice: 50000 }, timestamp: ts }
],
{ marketId: 'market-outlier-1' }
);

expect(aggregated.data.consensus.rejected.outliers.map((r) => r.source)).toEqual(['src4']);
expect(aggregated.data.breakdown.map((b) => b.source)).toEqual(['src1', 'src2', 'src3']);
expect(aggregated.outcome).toBe('yes');
});

it('applies a per-market configurable consensus threshold', () => {
const now = Date.now();
const ts = new Date(now).toISOString();
const responses = [
{ source: 'a', outcome: 'yes', confidence: 1, data: {}, timestamp: ts },
{ source: 'b', outcome: 'no', confidence: 1, data: {}, timestamp: ts }
];

const strict = oracleService.aggregateResults(responses, {
marketId: 'market-threshold-1',
consensusThreshold: 0.6
});
expect(strict.consensusReached).toBe(false);

const lenient = oracleService.aggregateResults(responses, {
marketId: 'market-threshold-2',
consensusThreshold: 0.5
});
expect(lenient.consensusReached).toBe(true);
});

it('resolves a market end-to-end via resolveWithFallback honoring market-level oracleConfig thresholds', async () => {
const now = Date.now();
const ts = new Date(now).toISOString();

Check warning on line 129 in backend/__tests__/integration/oracleConsensusEngine.test.js

View workflow job for this annotation

GitHub Actions / Backend (Lint, Test)

'ts' is assigned a value but never used

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note test

Unused variable ts.

oracleService.resolvers['e2e-primary'] = jest.fn().mockResolvedValue({
outcome: 'yes',
confidence: 1,
data: { provider: 'e2e-primary' }
});

const market = {
marketId: 'market-e2e-1',
category: 'generic',
oracleConfig: {
sources: ['e2e-primary'],
consensusThreshold: 0.4,
minConsensusResponses: 1
}
};

const result = await oracleService.resolveWithFallback(market, { skipQueue: true });

expect(result).not.toBeNull();
expect(result.outcome).toBe('yes');
expect(result.consensusReached).toBe(true);
expect(mockOracleAudit).toHaveBeenCalledWith(
'oracle.consensus_reached',
expect.objectContaining({ target: { type: 'market', id: 'market-e2e-1' } })
);

delete oracleService.resolvers['e2e-primary'];
});
});
Loading
Loading