diff --git a/docs/indexer/CONTRIBUTOR_EXPECTATIONS.md b/docs/indexer/CONTRIBUTOR_EXPECTATIONS.md new file mode 100644 index 0000000..18413eb --- /dev/null +++ b/docs/indexer/CONTRIBUTOR_EXPECTATIONS.md @@ -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. diff --git a/docs/indexer/EVENT_PROCESSING.md b/docs/indexer/EVENT_PROCESSING.md index 6c16a11..d0e9e94 100644 --- a/docs/indexer/EVENT_PROCESSING.md +++ b/docs/indexer/EVENT_PROCESSING.md @@ -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`. diff --git a/src/modules/admin/admin.replay.integration.test.ts b/src/modules/admin/admin.replay.integration.test.ts new file mode 100644 index 0000000..af0bab1 --- /dev/null +++ b/src/modules/admin/admin.replay.integration.test.ts @@ -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.', + }) + ); + }); +}); diff --git a/src/modules/creator/creator-detail-cache-headers.integration.test.ts b/src/modules/creator/creator-detail-cache-headers.integration.test.ts index 156b18a..7615f69 100644 --- a/src/modules/creator/creator-detail-cache-headers.integration.test.ts +++ b/src/modules/creator/creator-detail-cache-headers.integration.test.ts @@ -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; } @@ -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' }); @@ -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, + }) + ); }); }); diff --git a/src/modules/creator/creator-profile-protected.integration.test.ts b/src/modules/creator/creator-profile-protected.integration.test.ts new file mode 100644 index 0000000..5e42379 --- /dev/null +++ b/src/modules/creator/creator-profile-protected.integration.test.ts @@ -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', + }), + }) + ); + }); +}); diff --git a/src/modules/creator/creator-profile.handlers.ts b/src/modules/creator/creator-profile.handlers.ts index 509c793..aff633d 100644 --- a/src/modules/creator/creator-profile.handlers.ts +++ b/src/modules/creator/creator-profile.handlers.ts @@ -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, @@ -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( diff --git a/src/modules/creator/creator-profile.service.test.ts b/src/modules/creator/creator-profile.service.test.ts index 66dc075..c2ac918 100644 --- a/src/modules/creator/creator-profile.service.test.ts +++ b/src/modules/creator/creator-profile.service.test.ts @@ -4,8 +4,27 @@ import { } from './creator-profile.service'; import { UpsertCreatorProfileBodySchema } from './creator-profile.schemas'; +const updateMock = jest.fn(); +const findFirstMock = jest.fn(); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findFirst: (...args: unknown[]) => findFirstMock(...args), + update: (...args: unknown[]) => updateMock(...args), + }, + }, +})); + describe('getCreatorProfile', () => { + beforeEach(() => { + findFirstMock.mockReset(); + updateMock.mockReset(); + }); + it('returns the placeholder profile shape for the requested creator id', async () => { + findFirstMock.mockResolvedValue(null); + const result = await getCreatorProfile('creator-1'); expect(result).toEqual({ @@ -24,37 +43,47 @@ describe('getCreatorProfile', () => { }); it('echoes the creator id verbatim so callers can correlate the response', async () => { + findFirstMock.mockResolvedValue(null); + const result = await getCreatorProfile('whatever-id-123'); expect(result.creatorId).toBe('whatever-id-123'); }); }); describe('upsertCreatorProfile', () => { + beforeEach(() => { + findFirstMock.mockReset(); + updateMock.mockReset(); + updateMock.mockResolvedValue({ id: 'creator-1' }); + }); + it('returns the placeholder envelope with the accepted payload', async () => { const payload = UpsertCreatorProfileBodySchema.parse({ displayName: 'Alice Example', bio: 'Building things.', links: [{ label: 'site', url: 'https://example.com' }], }); + updateMock.mockResolvedValueOnce({ id: 'creator-1' }); const result = await upsertCreatorProfile('creator-1', payload); expect(result).toEqual({ creatorId: 'creator-1', acceptedProfile: payload, - metadata: { source: 'placeholder', persisted: false }, + metadata: { source: 'database', persisted: true }, }); }); - it('flags persisted=false until backing storage is wired up', async () => { + it('flags persisted=true once the backing storage update succeeds', async () => { const payload = UpsertCreatorProfileBodySchema.parse({ displayName: 'Bob', }); + updateMock.mockResolvedValueOnce({ id: 'creator-2' }); const result = await upsertCreatorProfile('creator-2', payload); - expect(result.metadata.persisted).toBe(false); - expect(result.metadata.source).toBe('placeholder'); + expect(result.metadata.persisted).toBe(true); + expect(result.metadata.source).toBe('database'); }); it('rejects an invalid payload at the schema boundary, not in the service', () => { @@ -77,4 +106,44 @@ describe('upsertCreatorProfile', () => { expect(result.acceptedProfile.links).toHaveLength(8); }); + + it('truncates long write payload fields before persistence', async () => { + const longDisplayName = 'A'.repeat(120); + const longBio = 'B'.repeat(1200); + const longLabel = 'L'.repeat(80); + const longTitle = 'T'.repeat(140); + const longDescription = 'D'.repeat(700); + + const result = await upsertCreatorProfile('creator-4', { + displayName: longDisplayName, + bio: longBio, + links: [ + { + label: longLabel, + url: 'https://example.com', + }, + ], + perks: [ + { + title: longTitle, + description: longDescription, + }, + ], + } as never); + + expect(result.acceptedProfile.displayName).toHaveLength(80); + expect(result.acceptedProfile.bio).toHaveLength(1000); + expect(result.acceptedProfile.links?.[0]?.label).toHaveLength(40); + expect(result.acceptedProfile.perks?.[0]?.title).toHaveLength(100); + expect(result.acceptedProfile.perks?.[0]?.description).toHaveLength(500); + expect(updateMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'creator-4' }, + data: expect.objectContaining({ + displayName: expect.any(String), + bio: expect.any(String), + }), + }) + ); + }); }); diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 63eb1b7..ea2f0fc 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -7,6 +7,15 @@ import { import { CREATOR_DETAIL_DEFAULT_SELECT } from '../../constants/creator-detail-include.constants'; import { formatIsoTimestamp } from '../../utils/iso-timestamp.utils'; import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; +import { truncateString } from '../../utils/string-truncate.utils'; + +const CREATOR_PROFILE_LIMITS = { + displayName: 80, + bio: 1000, + linkLabel: 40, + perkTitle: 100, + perkDescription: 500, +} as const; function normalizeProfileLinks( links: UpsertCreatorProfileBody['links'] @@ -17,10 +26,28 @@ function normalizeProfileLinks( return links.map((link) => ({ ...link, + label: truncateString(link.label, CREATOR_PROFILE_LIMITS.linkLabel), url: normalizeSocialLinkUrl(link.url), })); } +function normalizeProfilePerks( + perks: UpsertCreatorProfileBody['perks'] +): UpsertCreatorProfileBody['perks'] { + if (!perks) { + return perks; + } + + return perks.map((perk) => ({ + ...perk, + title: truncateString(perk.title, CREATOR_PROFILE_LIMITS.perkTitle), + description: truncateString( + perk.description, + CREATOR_PROFILE_LIMITS.perkDescription + ), + })); +} + function buildCreatorDetailCacheMissContext(creatorId: string) { return { event: 'creator_detail_cache_miss', @@ -102,7 +129,14 @@ export async function upsertCreatorProfile( }> { const normalizedPayload: UpsertCreatorProfileBody = { ...payload, + displayName: payload.displayName + ? truncateString(payload.displayName, CREATOR_PROFILE_LIMITS.displayName) + : payload.displayName, + bio: payload.bio + ? truncateString(payload.bio, CREATOR_PROFILE_LIMITS.bio) + : payload.bio, links: normalizeProfileLinks(payload.links), + perks: normalizeProfilePerks(payload.perks), }; const profile = await prisma.creatorProfile.update({ diff --git a/src/utils/string-truncate.utils.test.ts b/src/utils/string-truncate.utils.test.ts new file mode 100644 index 0000000..c74885b --- /dev/null +++ b/src/utils/string-truncate.utils.test.ts @@ -0,0 +1,21 @@ +import { truncateString } from './string-truncate.utils'; + +describe('truncateString', () => { + it('returns the original string when it is below the limit', () => { + expect(truncateString('hello', 10)).toBe('hello'); + }); + + it('returns the original string when it exactly matches the limit', () => { + expect(truncateString('hello', 5)).toBe('hello'); + }); + + it('truncates the string when it exceeds the limit', () => { + expect(truncateString('hello world', 5)).toBe('hello'); + }); + + it('rejects negative limits', () => { + expect(() => truncateString('hello', -1)).toThrow( + 'maxLength must be a non-negative finite number' + ); + }); +}); diff --git a/src/utils/string-truncate.utils.ts b/src/utils/string-truncate.utils.ts new file mode 100644 index 0000000..f19e69d --- /dev/null +++ b/src/utils/string-truncate.utils.ts @@ -0,0 +1,17 @@ +/** + * Truncate a string to a maximum character length without changing shorter values. + * + * This is intentionally simple and deterministic so callers can keep data within + * a known storage or display limit before writing to persistence layers. + */ +export function truncateString(value: string, maxLength: number): string { + if (!Number.isFinite(maxLength) || maxLength < 0) { + throw new RangeError('maxLength must be a non-negative finite number'); + } + + if (value.length <= maxLength) { + return value; + } + + return value.slice(0, maxLength); +} diff --git a/src/utils/test/protected-route-request.utils.ts b/src/utils/test/protected-route-request.utils.ts new file mode 100644 index 0000000..e4f30bc --- /dev/null +++ b/src/utils/test/protected-route-request.utils.ts @@ -0,0 +1,39 @@ +type ProtectedRouteHeaderName = 'x-admin-id' | 'x-wallet-address'; + +export type ProtectedRouteHeaderOverrides = Partial< + Record +>; + +const DEFAULT_PROTECTED_ROUTE_HEADERS: Record< + ProtectedRouteHeaderName, + string +> = { + 'x-admin-id': 'admin-test-1', + 'x-wallet-address': 'GTESTWALLETADDRESS', +}; + +/** + * Apply the headers required by protected route tests. + * + * Individual headers can be overridden or removed by passing `null`/`undefined` + * for a specific key. + */ +export function withProtectedRouteHeaders< + T extends { set(name: string, value: string): T; set(headers: Record): T } +>(request: T, overrides: ProtectedRouteHeaderOverrides = {}): T { + const headers: Partial> = { + ...DEFAULT_PROTECTED_ROUTE_HEADERS, + }; + + (Object.keys(overrides) as ProtectedRouteHeaderName[]).forEach((name) => { + const value = overrides[name]; + if (value === undefined || value === null) { + delete headers[name]; + return; + } + + headers[name] = value; + }); + + return request.set(headers as Record); +}