diff --git a/docs/read-model-rebuild.md b/docs/read-model-rebuild.md index 435ded3..086fe58 100644 --- a/docs/read-model-rebuild.md +++ b/docs/read-model-rebuild.md @@ -78,6 +78,22 @@ Source of truth (events / primary tables) Duration depends on DB instance size, network, and whether indexes are rebuilt inline or deferred. +## Indexer replay dry-run + +Use the admin replay endpoint in dry-run mode to validate replay inputs without producing audit-write side effects. + +```bash +curl -X POST "$API_BASE_URL/admin/indexer/replay" \ + -H "Content-Type: application/json" \ + -H "x-admin-id: " \ + -d '{"startLedger": 123456, "dryRun": true}' +``` + +Notes: +- `dryRun` defaults to `false`; omit it to run a normal replay initiation. +- In dry-run mode, the response includes `dryRun: true` and no audit event is written. +- `startLedger` must be a positive integer in both dry-run and normal mode. + ## Rollback guidance If the rebuild produces incorrect data: diff --git a/src/modules/admin/admin.controllers.test.ts b/src/modules/admin/admin.controllers.test.ts new file mode 100644 index 0000000..6aaf68c --- /dev/null +++ b/src/modules/admin/admin.controllers.test.ts @@ -0,0 +1,97 @@ +import { httpReplayIndexerEvents } from './admin.controllers'; +import { emitAuditEvent } from '../../utils/audit.utils'; +import { AdminRequest } from '../../middlewares/admin-guard.middleware'; +import { Response } from 'express'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/audit.utils', () => ({ + emitAuditEvent: jest.fn(), +})); + +describe('httpReplayIndexerEvents', () => { + const next = jest.fn(); + + const createRes = (): Response => + ({ + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }) as unknown as Response; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns validation error when dryRun is not a boolean', async () => { + const req = { + body: { startLedger: 10, dryRun: 'true' }, + adminId: 'admin-1', + } as unknown as AdminRequest; + const res = createRes(); + + await httpReplayIndexerEvents(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ + message: 'Invalid request body', + details: expect.arrayContaining([expect.objectContaining({ field: 'dryRun' })]), + }), + }) + ); + expect(emitAuditEvent).not.toHaveBeenCalled(); + }); + + it('does not emit audit event when dryRun=true', async () => { + const req = { + body: { startLedger: 20, dryRun: true }, + adminId: 'admin-2', + } as unknown as AdminRequest; + const res = createRes(); + + await httpReplayIndexerEvents(req, res, next); + + expect(emitAuditEvent).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + type: 'INDEXER_REPLAY_INITIATED', + startLedger: 20, + dryRun: true, + initiatedBy: 'admin-2', + }), + }) + ); + }); + + it('emits audit event when dryRun=false', async () => { + const req = { + body: { startLedger: 30, dryRun: false }, + adminId: 'admin-3', + } as unknown as AdminRequest; + const res = createRes(); + + await httpReplayIndexerEvents(req, res, next); + + expect(emitAuditEvent).toHaveBeenCalledWith( + expect.objectContaining({ + actor: 'admin-3', + action: 'replay_indexer_events', + targetId: '30', + metadata: expect.objectContaining({ startLedger: 30, dryRun: false }), + }) + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/src/modules/admin/admin.controllers.ts b/src/modules/admin/admin.controllers.ts index c1e1086..66e4679 100644 --- a/src/modules/admin/admin.controllers.ts +++ b/src/modules/admin/admin.controllers.ts @@ -84,7 +84,7 @@ export const httpUpdateCreatorMetadata: AsyncController = async (req, res, next) export const httpReplayIndexerEvents: AsyncController = async (req: AdminRequest, res: Response, next) => { try { - const { startLedger } = req.body as { startLedger?: number }; + const { startLedger, dryRun = false } = req.body as { startLedger?: number; dryRun?: boolean }; const adminId = req.adminId; if (typeof startLedger !== 'number' || startLedger < 1) { @@ -92,21 +92,29 @@ export const httpReplayIndexerEvents: AsyncController = async (req: AdminRequest { field: 'startLedger', message: 'startLedger must be a positive integer' }, ]); } + if (typeof dryRun !== 'boolean') { + return sendValidationError(res, 'Invalid request body', [ + { field: 'dryRun', message: 'dryRun must be a boolean' }, + ]); + } const replayInitiated = { type: 'INDEXER_REPLAY_INITIATED', startLedger, + dryRun, initiatedBy: adminId, timestamp: new Date().toISOString(), }; - await emitAuditEvent({ - actor: adminId || 'unknown', - action: 'replay_indexer_events', - target: 'IndexerQueue', - targetId: String(startLedger), - metadata: { startLedger }, - }); + if (!dryRun) { + await emitAuditEvent({ + actor: adminId || 'unknown', + action: 'replay_indexer_events', + target: 'IndexerQueue', + targetId: String(startLedger), + metadata: { startLedger, dryRun }, + }); + } sendSuccess(res, replayInitiated); } catch (error) {