From b25248e0568122ccaa13ad2e2ecd90627e24da95 Mon Sep 17 00:00:00 2001 From: basseyanam03-dev Date: Tue, 20 Oct 2026 01:52:37 +0100 Subject: [PATCH 1/4] test(indexer): cover registry event notifications --- test/indexer/ngoRegistry.test.ts | 124 ++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/test/indexer/ngoRegistry.test.ts b/test/indexer/ngoRegistry.test.ts index ee70e52..d7fb1b5 100644 --- a/test/indexer/ngoRegistry.test.ts +++ b/test/indexer/ngoRegistry.test.ts @@ -1,12 +1,16 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/notifications/service.js', () => ({ dispatchNotification: vi.fn() })); import { prisma } from '../../src/db.js'; import { handleNgoRegistryEvent } from '../../src/indexer/handlers/ngoRegistry.js'; +import { dispatchNotification } from '../../src/notifications/service.js'; import { fakeAddress, resetDb } from '../helpers/db.js'; import { addressScVal, makeEvent, stringScVal, symbolScVal } from '../helpers/events.js'; describe('handleNgoRegistryEvent', () => { afterEach(async () => { + vi.mocked(dispatchNotification).mockClear(); await resetDb(); }); @@ -76,4 +80,122 @@ describe('handleNgoRegistryEvent', () => { ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); expect(ngo?.verified).toBe(false); }); + + it('dispatches ngo_approved notification for approved event', async () => { + const owner = fakeAddress('E'); + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Save the Whales' } }); + + const event = makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal('')); + await handleNgoRegistryEvent(event); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(true); + + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'ngo_approved', + ownerAddress: owner, + ngoId: ngo!.id, + eventId: event.id, + }); + }); + + it('dispatches ngo_revoked notification for revoked event', async () => { + const owner = fakeAddress('F'); + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Green Earth', verified: true } }); + + const event = makeEvent([symbolScVal('revoked'), addressScVal(owner)], stringScVal('')); + await handleNgoRegistryEvent(event); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.verified).toBe(false); + + expect(dispatchNotification).toHaveBeenCalledWith({ + type: 'ngo_revoked', + ownerAddress: owner, + ngoId: ngo!.id, + eventId: event.id, + }); + }); + + it('does not dispatch notification for register event', async () => { + const owner = fakeAddress('G'); + + await handleNgoRegistryEvent( + makeEvent([symbolScVal('register'), addressScVal(owner)], stringScVal('Ocean Cleanup')), + ); + + const ngo = await prisma.ngo.findUnique({ where: { ownerAddress: owner } }); + expect(ngo?.name).toBe('Ocean Cleanup'); + expect(ngo?.verified).toBe(false); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + + it('writes a StreamEvent row for register with correct type, payload, ledger, and txHash', async () => { + const owner = fakeAddress('H'); + const txHash = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'; + + const event = makeEvent( + [symbolScVal('register'), addressScVal(owner)], + stringScVal('Wildlife Fund'), + { ledger: 12345, txHash }, + ); + await handleNgoRegistryEvent(event); + + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'register' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.type).toBe('register'); + expect(streamEvent?.streamId).toBeNull(); + expect(streamEvent?.ledger).toBe(12345); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ + ownerAddress: owner, + name: 'Wildlife Fund', + }); + }); + + it('writes a StreamEvent row for approved with correct type, payload, ledger, and txHash', async () => { + const owner = fakeAddress('I'); + const txHash = 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3'; + + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Health Initiative' } }); + + const event = makeEvent([symbolScVal('approved'), addressScVal(owner)], stringScVal(''), { + ledger: 23456, + txHash, + }); + await handleNgoRegistryEvent(event); + + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'approved' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.type).toBe('approved'); + expect(streamEvent?.streamId).toBeNull(); + expect(streamEvent?.ledger).toBe(23456); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ + ownerAddress: owner, + }); + }); + + it('writes a StreamEvent row for revoked with correct type, payload, ledger, and txHash', async () => { + const owner = fakeAddress('J'); + const txHash = 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4'; + + await prisma.ngo.create({ data: { ownerAddress: owner, name: 'Education Fund', verified: true } }); + + const event = makeEvent([symbolScVal('revoked'), addressScVal(owner)], stringScVal(''), { + ledger: 34567, + txHash, + }); + await handleNgoRegistryEvent(event); + + const streamEvent = await prisma.streamEvent.findFirst({ where: { type: 'revoked' } }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.type).toBe('revoked'); + expect(streamEvent?.streamId).toBeNull(); + expect(streamEvent?.ledger).toBe(34567); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ + ownerAddress: owner, + }); + }); }); From 9b224af6f53920751b8d920b7775b912aca83edf Mon Sep 17 00:00:00 2001 From: basseyanam03-dev Date: Tue, 20 Oct 2026 01:52:43 +0100 Subject: [PATCH 2/4] test(indexer): cover the out-of-window branch --- src/indexer/worker.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/indexer/worker.test.ts b/src/indexer/worker.test.ts index 9599392..bdee880 100644 --- a/src/indexer/worker.test.ts +++ b/src/indexer/worker.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as checkpoint from './checkpoint.js'; import * as contracts from './contracts.js'; import * as rpc from '../stellar/rpc.js'; -import { startIndexer } from './worker.js'; +import { pollOnce, startIndexer } from './worker.js'; vi.mock('./checkpoint.js'); vi.mock('../stellar/rpc.js'); @@ -38,4 +38,27 @@ describe('startIndexer', () => { await vi.runAllTimersAsync(); expect(vi.mocked(rpc.getLatestLedgerSequence).mock.calls.length).toBe(callsBefore); }); + + it('recovers when the configured START_LEDGER is outside the RPC retention window', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(checkpoint.getCheckpointState).mockResolvedValue({ + lastLedger: 500, + lastEventId: undefined, + }); + vi.mocked(rpc.getLatestLedgerSequence).mockResolvedValue(10000); + vi.mocked(rpc.rpcServer.getEvents).mockRejectedValueOnce({ + message: 'startLedger must be within the ledger range [9000, 10000]', + }); + + const handler = vi.fn(); + await pollOnce(handler); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('checkpoint 500 has aged out'), + ); + expect(checkpoint.saveCheckpoint).toHaveBeenCalledWith(10000); + expect(handler).not.toHaveBeenCalled(); + + vi.mocked(console.warn).mockRestore(); + }); }); From a87ed55df117dc6ebfc7a330234c3862d8600600 Mon Sep 17 00:00:00 2001 From: basseyanam03-dev Date: Tue, 20 Oct 2026 01:52:49 +0100 Subject: [PATCH 3/4] test(admin): cover PATCH /admin/ngos/:id --- test/routes/adminNgos.test.ts | 168 ++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 test/routes/adminNgos.test.ts diff --git a/test/routes/adminNgos.test.ts b/test/routes/adminNgos.test.ts new file mode 100644 index 0000000..bbe10dd --- /dev/null +++ b/test/routes/adminNgos.test.ts @@ -0,0 +1,168 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { prisma } from '../../src/db.js'; +import { buildServer } from '../../src/server.js'; +import { signAdminRequest } from '../helpers/adminAuth.js'; +import { fakeAddress, resetDb } from '../helpers/db.js'; + +const adminKeypair = Keypair.random(); + +describe('PATCH /admin/ngos/:id', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + await resetDb(); + }); + + it('requires admin auth and rejects an unsigned request with 401', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('A'), name: 'Green Earth', verified: true }, + }); + + const response = await app.inject({ + method: 'PATCH', + url: `/admin/ngos/${ngo.id}`, + payload: { displayName: 'Updated Name' }, + }); + + expect(response.statusCode).toBe(401); + + await app.close(); + }); + + it('returns 404 for an unknown NGO id', async () => { + const app = buildServer(); + + const unknownId = '00000000-0000-0000-0000-000000000000'; + const url = `/admin/ngos/${unknownId}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 'Updated Name' }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ error: 'not_found' }); + + await app.close(); + }); + + it('returns 400 for a malformed (non-UUID) id', async () => { + const app = buildServer(); + + const url = '/admin/ngos/not-a-valid-uuid'; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 'Updated Name' }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + + await app.close(); + }); + + it('applies a displayName override to an NGO', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('B'), name: 'Original Name', verified: true }, + }); + + const url = `/admin/ngos/${ngo.id}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 'Curated Public Name' }, + }); + + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.id).toBe(ngo.id); + expect(body.name).toBe('Curated Public Name'); + expect(body.displayName).toBe('Curated Public Name'); + + // Verify it was persisted + const updated = await prisma.ngo.findUnique({ where: { id: ngo.id } }); + expect(updated?.displayName).toBe('Curated Public Name'); + expect(updated?.name).toBe('Original Name'); // Original name unchanged + + await app.close(); + }); + + it('clears a displayName override when set to null', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { + ownerAddress: fakeAddress('C'), + name: 'Original Name', + displayName: 'Previous Override', + verified: true, + }, + }); + + const url = `/admin/ngos/${ngo.id}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: null }, + }); + + expect(response.statusCode).toBe(200); + + const body = response.json(); + expect(body.id).toBe(ngo.id); + expect(body.name).toBe('Original Name'); // Falls back to original name + expect(body.displayName).toBeNull(); + + // Verify it was persisted + const updated = await prisma.ngo.findUnique({ where: { id: ngo.id } }); + expect(updated?.displayName).toBeNull(); + expect(updated?.name).toBe('Original Name'); + + await app.close(); + }); + + it('returns 400 for an invalid request body', async () => { + const app = buildServer(); + + const ngo = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('D'), name: 'Test NGO', verified: true }, + }); + + const url = `/admin/ngos/${ngo.id}`; + const headers = signAdminRequest(adminKeypair, 'PATCH', url); + + const response = await app.inject({ + method: 'PATCH', + url, + headers, + payload: { displayName: 123 }, // Invalid: should be string or null + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + await app.close(); + }); +}); From a11f67794a056c1a03b3762255a7657d77997b87 Mon Sep 17 00:00:00 2001 From: basseyanam03-dev Date: Tue, 20 Oct 2026 01:52:59 +0100 Subject: [PATCH 4/4] test(indexer): cover StreamEvent rows for every event --- test/indexer/donationVault.test.ts | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/test/indexer/donationVault.test.ts b/test/indexer/donationVault.test.ts index 89b94ff..618311c 100644 --- a/test/indexer/donationVault.test.ts +++ b/test/indexer/donationVault.test.ts @@ -504,4 +504,77 @@ describe('handleDonationVaultEvent', () => { }); expect(deadLetter).toBeNull(); }); + + it('writes a StreamEvent row for withdraw with correct type, streamId, ledger, txHash, and payload', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('Y') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('Z'), name: 'NGO Z' }, + }); + await prisma.stream.create({ + data: { + onChainId: 70n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('AA'), + rate: '10', + balance: '1000', + withdrawn: '0', + status: 'ACTIVE', + }, + }); + + const txHash = 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5'; + const event = makeEvent([symbolScVal('withdraw'), u64ScVal(70n)], i128ScVal(300n), { + ledger: 45678, + txHash, + }); + await handleDonationVaultEvent(event); + + const streamEvent = await prisma.streamEvent.findFirst({ + where: { type: 'withdraw', streamId: 70n }, + }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.type).toBe('withdraw'); + expect(streamEvent?.streamId).toBe(70n); + expect(streamEvent?.ledger).toBe(45678); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ accrued: '300' }); + }); + + it('writes a StreamEvent row for cancel with correct type, streamId, ledger, txHash, and payload', async () => { + const donorRow = await prisma.donor.create({ data: { address: fakeAddress('AB') } }); + const ngoRow = await prisma.ngo.create({ + data: { ownerAddress: fakeAddress('AC'), name: 'NGO AC' }, + }); + await prisma.stream.create({ + data: { + onChainId: 80n, + donorId: donorRow.id, + ngoId: ngoRow.id, + tokenAddress: fakeAddress('AD'), + rate: '15', + balance: '2000', + withdrawn: '500', + status: 'ACTIVE', + }, + }); + + const txHash = 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6'; + const event = makeEvent( + [symbolScVal('cancel'), u64ScVal(80n)], + xdr.ScVal.scvVec([i128ScVal(600n), i128ScVal(1400n)]), + { ledger: 56789, txHash }, + ); + await handleDonationVaultEvent(event); + + const streamEvent = await prisma.streamEvent.findFirst({ + where: { type: 'cancel', streamId: 80n }, + }); + expect(streamEvent).not.toBeNull(); + expect(streamEvent?.type).toBe('cancel'); + expect(streamEvent?.streamId).toBe(80n); + expect(streamEvent?.ledger).toBe(56789); + expect(streamEvent?.txHash).toBe(txHash); + expect(streamEvent?.payload).toMatchObject({ accrued: '600', refund: '1400' }); + }); });