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
40 changes: 40 additions & 0 deletions docs/indexer/CONTRIBUTOR_EXPECTATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Indexer Contributor Expectations

This guide is for contributors modifying the indexer, replay tooling, or any
code that changes how on-chain events become read-model updates.

## Invariants to Preserve

- Event processing must remain idempotent. Replaying the same event should not
create duplicate rows or double-apply state transitions.
- Event ordering matters when a downstream read model depends on the chain
sequence. Preserve ledger and event index ordering unless the change
explicitly introduces a new ordering rule.
- Deduplication should continue to happen before a batch is processed so the
same on-chain event is not handled twice in one pass.
- Background jobs should remain replay-safe. A retried job should either no-op
or converge to the same state as the original run.
- Any DLQ or retry behavior should keep the original failure context intact so
operators can diagnose the failure without guessing.

## Testing Expectations

- Add or update unit tests for helper logic, filtering, deduplication, or
state transition behavior.
- Add integration coverage when the change affects request handling, replay
flow, or cache/read-model consistency.
- Run the narrowest targeted test set first, then the broader repo checks
before opening a PR.
- If a change touches generated Prisma types or schema-dependent code, run the
generation step again before testing.

## Deployment Considerations

- Avoid deploying indexer changes without confirming the replay path is safe on
existing data.
- Review any feature flags that control dedupe, DLQ handling, or cursor
staleness warnings before rollout.
- If a change affects backfill or replay timing, verify the job lock TTL still
covers the expected runtime.
- Monitor DLQ volume, replay logs, and stale-cursor warnings after deployment
so regressions are visible quickly.
2 changes: 2 additions & 0 deletions docs/indexer/EVENT_PROCESSING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

The indexer processes events from the blockchain to update the read models and activity feeds. To ensure data consistency and prevent duplicate processing, the following strategies are employed.

Before changing this pipeline, review [Indexer Contributor Expectations](./CONTRIBUTOR_EXPECTATIONS.md) for the invariants, testing expectations, and deployment notes that apply to indexer work.

## 1. Deduplication

Before processing a batch of events, they should be deduped based on their unique identifier on the chain: `transactionHash` and `eventIndex`.
Expand Down
96 changes: 96 additions & 0 deletions src/modules/admin/admin.replay.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
jest.mock('chalk', () => ({
red: (text: string) => text,
green: (text: string) => text,
magenta: (text: string) => text,
cyan: (text: string) => text,
}));

jest.mock('tspec', () => ({
TspecDocsMiddleware: jest.fn().mockResolvedValue([]),
}));

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
creatorProfile: {
findUnique: jest.fn(),
update: jest.fn(),
},
stellarWallet: {
findUnique: jest.fn(),
},
},
}));

jest.mock('../../utils/logger.utils', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
isLevelEnabled: jest.fn().mockReturnValue(false),
},
}));

jest.mock('../../config', () => ({
envConfig: {
MODE: 'test',
PORT: 3000,
ENABLE_REQUEST_LOGGING: false,
},
appConfig: { allowedOrigins: [] },
}));

jest.mock('../../utils/background-job-lock.utils', () => ({
acquireJobLock: jest.fn(() => ({
acquired: true,
expiresAt: '2026-01-01T00:00:00.000Z',
})),
}));

jest.mock('../../utils/audit.utils', () => ({
emitAuditEvent: jest.fn(),
}));

import supertest from 'supertest';
import app from '../../app';
import { withProtectedRouteHeaders } from '../../utils/test/protected-route-request.utils';

describe('POST /api/v1/admin/indexer/replay — protected route headers', () => {
it('accepts a request with the default protected headers', async () => {
const res = await withProtectedRouteHeaders(
supertest(app)
.post('/api/v1/admin/indexer/replay')
.send({ startLedger: 12, dryRun: true })
);

expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.objectContaining({
success: true,
data: expect.objectContaining({
type: 'INDEXER_REPLAY_INITIATED',
startLedger: 12,
dryRun: true,
initiatedBy: 'admin-test-1',
}),
})
);
});

it('returns 403 when the admin header is removed through an override', async () => {
const res = await withProtectedRouteHeaders(
supertest(app)
.post('/api/v1/admin/indexer/replay')
.send({ startLedger: 12, dryRun: true }),
{ 'x-admin-id': undefined }
);

expect(res.status).toBe(403);
expect(res.body).toEqual(
expect.objectContaining({
type: 'FORBIDDEN',
message: 'Admin authorization required.',
})
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ function makeRes(): any {
headers[name.toLowerCase()] = value;
return res;
});
res.set = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockImplementation((name: string, value: string) => {
headers[name.toLowerCase()] = value;
return res;
});
res._headers = headers;
return res;
}
Expand Down Expand Up @@ -107,7 +110,7 @@ describe('GET /api/v1/creators/:creatorId/profile — cache headers', () => {
expect(cacheControlCalls[0][1]).toBe(upstreamValue);
});

it('returns HTTP 200 alongside the cache header for a found profile', async () => {
it('returns HTTP 200 alongside the cache header and response timestamp for a found profile', async () => {
jest.spyOn(creatorProfileService, 'getCreatorProfile').mockResolvedValue(FIXTURE_PROFILE);

const req = makeReq({ creatorId: 'creator-abc' });
Expand All @@ -118,5 +121,14 @@ describe('GET /api/v1/creators/:creatorId/profile — cache headers', () => {

expect(res.status).toHaveBeenCalledWith(200);
expect(res._headers['cache-control']).toBeDefined();
expect(res._headers['x-response-timestamp']).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
data: FIXTURE_PROFILE,
})
);
});
});
114 changes: 114 additions & 0 deletions src/modules/creator/creator-profile-protected.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
jest.mock('chalk', () => ({
red: (text: string) => text,
green: (text: string) => text,
magenta: (text: string) => text,
cyan: (text: string) => text,
}));

jest.mock('tspec', () => ({
TspecDocsMiddleware: jest.fn().mockResolvedValue([]),
}));

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
creatorProfile: {
findFirst: jest.fn(),
update: jest.fn(),
},
stellarWallet: {
findUnique: jest.fn(),
},
},
}));

jest.mock('../../utils/logger.utils', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
isLevelEnabled: jest.fn().mockReturnValue(false),
},
}));

jest.mock('../../config', () => ({
envConfig: {
MODE: 'test',
PORT: 3000,
ENABLE_REQUEST_LOGGING: false,
},
appConfig: { allowedOrigins: [] },
}));

jest.mock('../../utils/wallet-ownership.utils', () => ({
checkCreatorProfileOwnership: jest.fn(),
}));

jest.mock('./creator-profile.service', () => ({
getCreatorProfile: jest.fn(),
upsertCreatorProfile: jest.fn(async (creatorId: string, payload: unknown) => ({
creatorId,
acceptedProfile: payload,
metadata: { source: 'database', persisted: true },
})),
}));

import supertest from 'supertest';
import app from '../../app';
import { withProtectedRouteHeaders } from '../../utils/test/protected-route-request.utils';
import * as walletOwnership from '../../utils/wallet-ownership.utils';

const mockedCheck =
walletOwnership.checkCreatorProfileOwnership as jest.MockedFunction<
typeof walletOwnership.checkCreatorProfileOwnership
>;

describe('PUT /api/v1/creators/:creatorId/profile — protected route headers', () => {
beforeEach(() => {
mockedCheck.mockReset();
mockedCheck.mockResolvedValue({
status: 'granted',
ownerUserId: 'user-1',
});
});

it('accepts a request with the default protected headers', async () => {
const res = await withProtectedRouteHeaders(
supertest(app)
.put('/api/v1/creators/creator-1/profile')
.send({ displayName: 'Alice Example' })
);

expect(res.status).toBe(202);
expect(res.body).toEqual(
expect.objectContaining({
success: true,
data: expect.objectContaining({
creatorId: 'creator-1',
acceptedProfile: expect.objectContaining({
displayName: 'Alice Example',
}),
}),
})
);
});

it('returns 401 when the wallet header is removed through an override', async () => {
const res = await withProtectedRouteHeaders(
supertest(app)
.put('/api/v1/creators/creator-1/profile')
.send({ displayName: 'Alice Example' }),
{ 'x-wallet-address': undefined }
);

expect(res.status).toBe(401);
expect(res.body).toEqual(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: 'UNAUTHORIZED',
}),
})
);
});
});
2 changes: 2 additions & 0 deletions src/modules/creator/creator-profile.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
zodIssuesToDetails,
ErrorCode,
} from '../../utils/api-response.utils';
import { attachTimestampHeader } from '../../utils/timestamp-headers.utils';
import { logger } from '../../utils/logger.utils';
import {
CreatorProfileParamsSchema,
Expand Down Expand Up @@ -36,6 +37,7 @@ export async function getCreatorProfileHandler(req: Request, res: Response) {
}

const profile = await getCreatorProfile(paramsResult.data.creatorId);
attachTimestampHeader(res);
return sendSuccess(res, profile, 200, 'Creator profile retrieved');
} catch (error) {
logger.error(
Expand Down
Loading
Loading