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
119 changes: 110 additions & 9 deletions server/src/indexer/parsers/event-parser.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,98 @@ describe('EventParserService', () => {
expect(errorSpy).toHaveBeenCalled();
expect(prisma.campaign.upsert).not.toHaveBeenCalled();
});

// Regression tests for enum-decoding shapes (issue #170):
// The activity-log mirror emits ActivityAction enum which can decode as:
// 1. Bare string (e.g., 'CampaignRegistered')
// 2. Single-element array (e.g., ['CampaignRegistered'])
// 3. Object with tag (e.g., { tag: 'CampaignRegistered' })
// All three must NOT be mistaken for a title/name.
describe('enum-decoding shape regression', () => {
it('FarmerRegistered: bare-string enum tag is not misread as name', async () => {
await service.processEvent(
rawEvent(
'e-fr-bare',
['FarmerRegistered', FARMER],
[FARMER, 'FarmerRegistered', 1700000000n, 12],
),
);
expect(prisma.user.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { address: FARMER }, update: {} }),
);
});

it('FarmerRegistered: array enum tag is not misread as name', async () => {
await service.processEvent(
rawEvent(
'e-fr-arr',
['FarmerRegistered', FARMER],
[FARMER, ['FarmerRegistered'], 1700000000n, 12],
),
);
expect(prisma.user.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { address: FARMER }, update: {} }),
);
});

it('FarmerRegistered: object enum tag is not misread as name', async () => {
await service.processEvent(
rawEvent(
'e-fr-obj',
['FarmerRegistered', FARMER],
[FARMER, { tag: 'FarmerRegistered' }, 1700000000n, 12],
),
);
expect(prisma.user.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { address: FARMER }, update: {} }),
);
});

it('CampaignRegistered: bare-string enum tag is not misread as title', async () => {
await service.processEvent(
rawEvent(
'e-cr-bare',
['CampaignRegistered', CAMPAIGN_ID],
[FARMER, 'CampaignRegistered', 1700000000n, 13],
),
);
expect(prisma.campaign.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ farmer: FARMER, title: '' }),
}),
);
});

it('CampaignRegistered: array enum tag is not misread as title', async () => {
await service.processEvent(
rawEvent(
'e-cr-arr',
['CampaignRegistered', CAMPAIGN_ID],
[FARMER, ['CampaignRegistered'], 1700000000n, 13],
),
);
expect(prisma.campaign.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ farmer: FARMER, title: '' }),
}),
);
});

it('CampaignRegistered: object enum tag is not misread as title', async () => {
await service.processEvent(
rawEvent(
'e-cr-obj',
['CampaignRegistered', CAMPAIGN_ID],
[FARMER, { tag: 'CampaignRegistered' }, 1700000000n, 13],
),
);
expect(prisma.campaign.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({ farmer: FARMER, title: '' }),
}),
);
});
});
});

describe('CampaignEscrowLinked', () => {
Expand Down Expand Up @@ -689,9 +781,12 @@ describe('EventParserService', () => {
describe('broadcast-after-persist wiring', () => {
it('emits a realtime event only after the DB write succeeds', async () => {
const emitCampaignEvent = jest.fn();
const withRealtime = new EventParserService(prisma as any, {
emitCampaignEvent,
} as any);
const withRealtime = new EventParserService(
prisma as any,
{
emitCampaignEvent,
} as any,
);

await withRealtime.processEvent(
rawEvent(
Expand All @@ -713,9 +808,12 @@ describe('EventParserService', () => {
it('does not emit for already-persisted (replayed) events', async () => {
const emitCampaignEvent = jest.fn();
prisma.transaction.findUnique.mockResolvedValueOnce({ id: 'e-rt2' });
const withRealtime = new EventParserService(prisma as any, {
emitCampaignEvent,
} as any);
const withRealtime = new EventParserService(
prisma as any,
{
emitCampaignEvent,
} as any,
);

await withRealtime.processEvent(
rawEvent(
Expand All @@ -730,9 +828,12 @@ describe('EventParserService', () => {

it('does not emit when the parsed event has no campaignId', async () => {
const emitCampaignEvent = jest.fn();
const withRealtime = new EventParserService(prisma as any, {
emitCampaignEvent,
} as any);
const withRealtime = new EventParserService(
prisma as any,
{
emitCampaignEvent,
} as any,
);

await withRealtime.processEvent(
rawEvent(
Expand Down
39 changes: 31 additions & 8 deletions server/src/indexer/parsers/event-parser.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ function isTagLike(v: unknown): boolean {
return false;
}

/**
* Heuristic: enum variants (ActivityAction) are PascalCase without spaces.
* User-provided titles/names typically contain spaces or lowercase.
* This distinguishes bare-string enum tags from genuine titles.
*/
function looksLikeEnumTag(v: unknown): boolean {
return typeof v === 'string' && /^[A-Z][a-zA-Z0-9]*$/.test(v);
}

@Injectable()
export class EventParserService {
private readonly logger = new Logger(EventParserService.name);
Expand Down Expand Up @@ -665,8 +674,15 @@ export class EventParserService {
const farmer = asString(topics[1], 'farmer');
const timestamp = asNumber(arr[arr.length - 2], 'timestamp');
const ledgerSequence = asNumber(arr[arr.length - 1], 'ledgerSequence');
const nameCandidate = arr.length >= 4 ? arr[1] : undefined;
const name = typeof nameCandidate === 'string' ? nameCandidate : undefined;
// Disambiguate deterministically:
// - Direct call: (farmer, name:string, timestamp, ledger_sequence) => 4 elements
// - Activity mirror: (actor, timestamp, ledger_sequence) => 3 elements
// If a 4-element payload has an enum tag at index 1 (decoded as array/object),
// treat it as activity mirror and ignore the name.
const name =
arr.length === 4 && !isTagLike(arr[1]) && !looksLikeEnumTag(arr[1])
? asString(arr[1], 'name')
: undefined;
const data: FarmerRegisteredData = {
farmer,
name,
Expand All @@ -689,21 +705,28 @@ export class EventParserService {
topics: unknown[],
arr: unknown[],
): ParsedEvent {
if (arr.length < 4)
if (arr.length < 3)
throw new Error(
'CampaignRegistered payload must have at least 4 elements',
'CampaignRegistered payload must have at least 3 elements',
);
const campaignId = asString(topics[1], 'campaignId');
// value[0] is the farmer/actor address in both emission shapes.
const farmer = asString(arr[0], 'farmer');
const timestamp = asNumber(arr[arr.length - 2], 'timestamp');
const ledgerSequence = asNumber(arr[arr.length - 1], 'ledgerSequence');
// The direct campaign_registered() call publishes a plain-string title in
// slot 1; the activity-log mirror publishes an ActivityAction enum tag
// there instead. Only trust it as a title when it decoded to a string.
// Disambiguate deterministically:
// - Direct call: (farmer, title:string, timestamp, ledger_sequence)
// - Activity mirror: (actor, action_type:enum, timestamp, ledger_sequence)
// Enum decodes as array ['Variant'], { tag: 'Variant' }, or bare string 'Variant'.
// Use isTagLike for array/object; for bare strings, use PascalCase heuristic
// (enum variants are PascalCase without spaces; titles typically contain spaces/lowercase).
const titleCandidate = arr[1];
const title =
typeof titleCandidate === 'string' ? titleCandidate : undefined;
!isTagLike(titleCandidate) &&
!looksLikeEnumTag(titleCandidate) &&
typeof titleCandidate === 'string'
? titleCandidate
: undefined;
const data: CampaignCreatedData = {
campaignId,
farmer,
Expand Down