Skip to content
Open
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
56 changes: 55 additions & 1 deletion server/src/indexer/parsers/event-parser.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { RawSorobanEvent } from '../types/soroban-events.types';

type MockPrisma = {
transaction: { findUnique: jest.Mock; create: jest.Mock };
campaign: { upsert: jest.Mock; update: jest.Mock };
campaign: {
upsert: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
user: { upsert: jest.Mock };
investment: { create: jest.Mock };
tranche: { create: jest.Mock };
Expand All @@ -20,6 +24,7 @@ function makeMockPrisma(): MockPrisma {
campaign: {
upsert: jest.fn().mockResolvedValue({}),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
user: {
upsert: jest.fn().mockResolvedValue({}),
Expand Down Expand Up @@ -256,6 +261,55 @@ describe('EventParserService', () => {
});
});

describe('TrancheReleased -> InProduction status', () => {
it('flips a Funded campaign to InProduction on the first release', async () => {
await service.processEvent(
rawEvent(
'e5-status-1',
['TrancheReleased', CAMPAIGN_ID],
[FARMER, 1700000000n, 400n],
),
);

expect(prisma.campaign.updateMany).toHaveBeenCalledWith({
where: { id: '123', status: 'Funded' },
data: { status: 'InProduction' },
});
});

it('leaves an already-InProduction campaign unchanged on later releases', async () => {
// Simulate an already-released campaign: the conditional update matches
// nothing (status is no longer Funded), so no additional write occurs.
prisma.campaign.updateMany.mockResolvedValueOnce({ count: 0 });

await service.processEvent(
rawEvent(
'e5-status-2a',
['TrancheReleased', CAMPAIGN_ID],
[FARMER, 1700000000n, 400n],
),
);
await service.processEvent(
rawEvent(
'e5-status-2b',
['TrancheReleased', CAMPAIGN_ID],
[FARMER, 1700000000n, 400n],
),
);

expect(prisma.campaign.updateMany).toHaveBeenCalledTimes(2);
// Every status mutation stays guarded by status: 'Funded', so a second
// release can never reset or duplicate-write an InProduction campaign.
for (const call of prisma.campaign.updateMany.mock.calls) {
expect(call[0]).toEqual({
where: { id: '123', status: 'Funded' },
data: { status: 'InProduction' },
});
}
expect(prisma.tranche.create).toHaveBeenCalledTimes(2);
});
});

describe('HarvestReported', () => {
it('marks the campaign Harvested with the outcome', async () => {
await service.processEvent(
Expand Down
8 changes: 8 additions & 0 deletions server/src/indexer/parsers/event-parser.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,14 @@ export class EventParserService {
txHash: parsed.txHash,
},
});
// The first tranche release transitions the campaign from Funded into
// InProduction (mirroring the escrow contract's own state machine). The
// conditional `where` (id + current status) makes repeated releases on an
// already-InProduction campaign a no-op instead of an unnecessary write.
await this.prisma.campaign.updateMany({
where: { id: data.campaignId, status: 'Funded' },
data: { status: 'InProduction' },
});
this.logger.log(
{ campaignId: data.campaignId, recipient: data.recipient },
'Tranche released',
Expand Down