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
128 changes: 128 additions & 0 deletions backend/src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,132 @@ describe('AdminService (Verified Addresses)', () => {
);
});
});

describe('adminResolveMarket', () => {
let marketRepository: any;
let predictionRepository: any;
let analyticsService: any;
let sorobanService: any;
let notificationsService: any;

const makeMarket = (overrides: Record<string, unknown> = {}) => ({
id: 'market-1',
on_chain_market_id: 'chain-1',
title: 'Will it rain tomorrow?',
is_resolved: false,
is_cancelled: false,
resolved_outcome: null,
outcome_options: ['Yes', 'No'],
...overrides,
});

beforeEach(() => {
marketRepository = service['marketsRepository'];
predictionRepository = service['predictionsRepository'];
analyticsService = service['analyticsService'];
sorobanService = service['sorobanService'];
notificationsService = service['notificationsService'];

marketRepository.save = jest
.fn()
.mockImplementation((entity: any) => Promise.resolve(entity));
predictionRepository.find = jest.fn().mockResolvedValue([]);
notificationsService.create.mockResolvedValue(undefined);
sorobanService.resolveMarket.mockResolvedValue(undefined);
analyticsService.invalidateMarketResolutionCaches.mockResolvedValue(
undefined,
);
});

it('invalidates the market resolution caches only after the resolution is persisted', async () => {
const market = makeMarket();
marketRepository.findOne.mockResolvedValue(market);

const saveOrder: string[] = [];
marketRepository.save.mockImplementation(async (entity: any) => {
saveOrder.push('save');
return entity;
});
analyticsService.invalidateMarketResolutionCaches.mockImplementation(
async () => {
saveOrder.push('invalidate');
},
);

await service.adminResolveMarket(
'market-1',
{ resolved_outcome: 'Yes' },
'admin-1',
);

expect(saveOrder).toEqual(['save', 'invalidate']);
expect(
analyticsService.invalidateMarketResolutionCaches,
).toHaveBeenCalledWith('market-1', 'chain-1', []);
});

it('passes the affected users from every prediction on the market', async () => {
const market = makeMarket();
marketRepository.findOne.mockResolvedValue(market);
predictionRepository.find.mockResolvedValue([
{
user: { id: 'user-1', stellar_address: 'GADDR1' },
chosen_outcome: 'Yes',
},
{
user: { id: 'user-2', stellar_address: 'GADDR2' },
chosen_outcome: 'No',
},
]);

await service.adminResolveMarket(
'market-1',
{ resolved_outcome: 'Yes' },
'admin-1',
);

expect(
analyticsService.invalidateMarketResolutionCaches,
).toHaveBeenCalledWith('market-1', 'chain-1', ['user-1', 'user-2']);
});

it('does not invalidate caches when the on-chain resolution fails', async () => {
const market = makeMarket();
marketRepository.findOne.mockResolvedValue(market);
sorobanService.resolveMarket.mockRejectedValue(
new Error('soroban timeout'),
);

await expect(
service.adminResolveMarket(
'market-1',
{ resolved_outcome: 'Yes' },
'admin-1',
),
).rejects.toThrow('Failed to resolve market on Soroban');

expect(marketRepository.save).not.toHaveBeenCalled();
expect(
analyticsService.invalidateMarketResolutionCaches,
).not.toHaveBeenCalled();
});

it('rejects resolving a market that is already resolved', async () => {
marketRepository.findOne.mockResolvedValue(
makeMarket({ is_resolved: true }),
);

await expect(
service.adminResolveMarket(
'market-1',
{ resolved_outcome: 'Yes' },
'admin-1',
),
).rejects.toThrow('Market is already resolved');

expect(
analyticsService.invalidateMarketResolutionCaches,
).not.toHaveBeenCalled();
});
});
});
66 changes: 65 additions & 1 deletion backend/src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ describe('AnalyticsService', () => {
});

it('uses a distinct cache entry for a different user', async () => {
const otherUser = { ...baseUser, id: 'user-id-2' } as User;
const otherUser = { ...baseUser, id: 'user-id-2' };
usersRepository.findOne.mockImplementation((opts: any) =>
Promise.resolve(opts.where.id === otherUser.id ? otherUser : baseUser),
);
Expand Down Expand Up @@ -734,5 +734,69 @@ describe('AnalyticsService', () => {

expect(warnSpy).toHaveBeenCalled();
});

it('getMarketAnalytics read right after invalidation reflects post-resolution predictions, not the cached pre-resolution value', async () => {
const predictionsRepo = module.get(getRepositoryToken(Prediction));
const market = {
id: 'market-1',
on_chain_market_id: 'chain-1',
title: 'Will it rain tomorrow?',
outcome_options: ['Yes', 'No'],
total_pool_stroops: '1000',
participant_count: 1,
end_time: new Date(Date.now() + 60_000),
} as Market;

marketsRepository.findOne.mockResolvedValue(market);
predictionsRepo.find = jest
.fn()
.mockResolvedValueOnce([]) // pre-resolution: no predictions counted yet
.mockResolvedValueOnce([{ chosen_outcome: 'Yes' }]); // post-resolution: the settled prediction now counts

const beforeResolution = await service.getMarketAnalytics('market-1');
expect(
beforeResolution.outcome_distribution.find((o) => o.outcome === 'Yes')
?.count,
).toBe(0);

await service.invalidateMarketResolutionCaches('market-1', 'chain-1', []);

const afterResolution = await service.getMarketAnalytics('market-1');
expect(
afterResolution.outcome_distribution.find((o) => o.outcome === 'Yes')
?.count,
).toBe(1);
expect(marketsRepository.findOne).toHaveBeenCalledTimes(2);
});

it('getCategoryAnalytics read right after invalidation reflects the resolved market, not the cached pre-resolution active count', async () => {
const market = {
id: 'market-1',
category: 'Weather',
is_resolved: false,
is_cancelled: false,
total_pool_stroops: '1000',
participant_count: 1,
} as Market;

marketsRepository.find
.mockResolvedValueOnce([market]) // pre-resolution: still active
.mockResolvedValueOnce([{ ...market, is_resolved: true }]); // post-resolution

const beforeResolution = await service.getCategoryAnalytics();
const weatherBefore = beforeResolution.categories.find(
(c) => c.name === 'Weather',
);
expect(weatherBefore?.active_markets).toBe(1);

await service.invalidateMarketResolutionCaches('market-1', null, []);

const afterResolution = await service.getCategoryAnalytics();
const weatherAfter = afterResolution.categories.find(
(c) => c.name === 'Weather',
);
expect(weatherAfter?.active_markets).toBe(0);
expect(marketsRepository.find).toHaveBeenCalledTimes(2);
});
});
});
119 changes: 93 additions & 26 deletions backend/src/creator-events/creator-events.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,35 +269,33 @@ describe('CreatorEventsService getPayoutByAddress', () => {

const makeLeaderboardEntry = (
overrides: Partial<CreatorEventLeaderboardEntry> = {},
): CreatorEventLeaderboardEntry =>
({
id: 'leaderboard-entry-1',
event_id: 'event-1',
user_address: '0xParticipant',
rank: 5,
total_predictions: 3,
correct_predictions: 1,
accuracy_percentage: 33.33,
is_winner: false,
completion_time: null,
created_at: new Date('2026-05-01T00:00:00.000Z'),
...overrides,
}) as CreatorEventLeaderboardEntry;
): CreatorEventLeaderboardEntry => ({
id: 'leaderboard-entry-1',
event_id: 'event-1',
user_address: '0xParticipant',
rank: 5,
total_predictions: 3,
correct_predictions: 1,
accuracy_percentage: 33.33,
is_winner: false,
completion_time: null,
created_at: new Date('2026-05-01T00:00:00.000Z'),
...overrides,
});

const makePayout = (
overrides: Partial<CreatorEventPayout> = {},
): CreatorEventPayout =>
({
id: 'payout-1',
event_id: 'event-1',
user_address: '0xParticipant',
payout_amount_stroops: '0',
is_claimed: false,
leaderboard_entry_id: 'leaderboard-entry-1',
leaderboard_entry: makeLeaderboardEntry(),
created_at: new Date('2026-05-02T00:00:00.000Z'),
...overrides,
}) as CreatorEventPayout;
): CreatorEventPayout => ({
id: 'payout-1',
event_id: 'event-1',
user_address: '0xParticipant',
payout_amount_stroops: '0',
is_claimed: false,
leaderboard_entry_id: 'leaderboard-entry-1',
leaderboard_entry: makeLeaderboardEntry(),
created_at: new Date('2026-05-02T00:00:00.000Z'),
...overrides,
});

beforeEach(async () => {
creatorEventPayoutRepository = {
Expand Down Expand Up @@ -540,3 +538,72 @@ describe('CreatorEventsService getLeaderboard', () => {
expect(contractService.getEventLeaderboard).not.toHaveBeenCalled();
});
});

// NOTE: `invalidatePredictionStatsCache` is not currently called from any
// write path in this codebase (verified via a repo-wide grep for its name) -
// there is no prediction/match-result mutation flow that invokes it yet, so
// there is nothing to test for "runs after the write commits" or "a read
// right after invalidation sees fresh data" (#1825's original ask assumed
// this wiring already existed). These tests cover the function's own
// behavior in isolation instead. Wiring it into a mutation flow is a
// separate, deliberate follow-up for whoever owns that flow's design.
describe('CreatorEventsService invalidatePredictionStatsCache', () => {
let service: CreatorEventsService;
let cacheManager: { get: jest.Mock; set: jest.Mock; del: jest.Mock };

beforeEach(async () => {
cacheManager = { get: jest.fn(), set: jest.fn(), del: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
providers: [
CreatorEventsService,
{ provide: ContractService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: getRepositoryToken(CreatorEvent), useValue: {} },
{
provide: getRepositoryToken(CreatorEventLeaderboardEntry),
useValue: {},
},
{ provide: getRepositoryToken(Match), useValue: {} },
{ provide: getRepositoryToken(MatchPrediction), useValue: {} },
{ provide: getRepositoryToken(User), useValue: {} },
{ provide: getRepositoryToken(CreatorEventPayout), useValue: {} },
{ provide: CACHE_MANAGER, useValue: cacheManager },
],
}).compile();

service = module.get<CreatorEventsService>(CreatorEventsService);
});

it('clears the event stats cache key for the given event', async () => {
await service.invalidatePredictionStatsCache('event-1');

expect(cacheManager.del).toHaveBeenCalledWith(
'/creator-events/event-1/stats',
);
expect(cacheManager.del).toHaveBeenCalledTimes(1);
});

it('also clears the per-user score cache key when an address is given', async () => {
await service.invalidatePredictionStatsCache('event-1', 'GADDR1');

expect(cacheManager.del).toHaveBeenCalledWith(
'/creator-events/event-1/stats',
);
expect(cacheManager.del).toHaveBeenCalledWith(
'/creator-events/event-1/score/GADDR1',
);
expect(cacheManager.del).toHaveBeenCalledTimes(2);
});

it('does not throw and settles cleanly when called concurrently for the same event', async () => {
await expect(
Promise.all([
service.invalidatePredictionStatsCache('event-1', 'GADDR1'),
service.invalidatePredictionStatsCache('event-1', 'GADDR1'),
]),
).resolves.toEqual([undefined, undefined]);

expect(cacheManager.del).toHaveBeenCalledTimes(4);
});
});
Loading