diff --git a/Backend/src/gists/gist-cleanup.service.spec.ts b/Backend/src/gists/gist-cleanup.service.spec.ts new file mode 100644 index 00000000..a5202b06 --- /dev/null +++ b/Backend/src/gists/gist-cleanup.service.spec.ts @@ -0,0 +1,43 @@ +import { Logger } from '@nestjs/common'; + +import { GistCleanupService } from './gist-cleanup.service'; +import { GistRepository } from './gist.repository'; + +describe('GistCleanupService', () => { + let deleteExpired: jest.Mock; + let service: GistCleanupService; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + deleteExpired = jest.fn(); + service = new GistCleanupService({ deleteExpired } as unknown as GistRepository); + logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('calls deleteExpired and logs the count when gists were removed', async () => { + deleteExpired.mockResolvedValue(3); + + await service.handleExpiredGists(); + + expect(deleteExpired).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('Cleaned up 3 expired gist(s)'); + }); + + it('does not log when nothing expired', async () => { + deleteExpired.mockResolvedValue(0); + + await service.handleExpiredGists(); + + expect(deleteExpired).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('lets a repository error propagate (the method does not catch)', async () => { + deleteExpired.mockRejectedValue(new Error('db unavailable')); + + await expect(service.handleExpiredGists()).rejects.toThrow('db unavailable'); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/Backend/src/health/health.controller.spec.ts b/Backend/src/health/health.controller.spec.ts new file mode 100644 index 00000000..f4358f80 --- /dev/null +++ b/Backend/src/health/health.controller.spec.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + let query: jest.Mock; + let controller: HealthController; + + beforeEach(() => { + query = jest.fn(); + controller = new HealthController({ query } as unknown as DataSource); + }); + + it('reports ok when the database and PostGIS checks pass', async () => { + query.mockResolvedValueOnce([{ '?column?': 1 }]).mockResolvedValueOnce([{ version: '3.4.0' }]); + + const result = await controller.check(); + + expect(result.status).toBe('ok'); + expect(result.services.database).toEqual({ status: 'ok' }); + expect(result.services.postgis).toEqual({ status: 'ok', message: 'PostGIS 3.4.0' }); + expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp); + }); + + it('reports degraded and surfaces the message when the database check fails', async () => { + query + .mockRejectedValueOnce(new Error('connection refused')) + .mockResolvedValueOnce([{ version: '3.4.0' }]); + + const result = await controller.check(); + + expect(result.status).toBe('degraded'); + expect(result.services.database).toEqual({ status: 'error', message: 'connection refused' }); + expect(result.services.postgis.status).toBe('ok'); + }); + + it('reports degraded and surfaces the message when the PostGIS check fails', async () => { + query + .mockResolvedValueOnce([{ '?column?': 1 }]) + .mockRejectedValueOnce(new Error('function postgis_lib_version() does not exist')); + + const result = await controller.check(); + + expect(result.status).toBe('degraded'); + expect(result.services.database.status).toBe('ok'); + expect(result.services.postgis).toEqual({ + status: 'error', + message: 'function postgis_lib_version() does not exist', + }); + }); + + it('reports degraded with both errors when both checks fail', async () => { + query.mockRejectedValueOnce(new Error('db down')).mockRejectedValueOnce(new Error('no postgis')); + + const result = await controller.check(); + + expect(result.status).toBe('degraded'); + expect(result.services.database).toEqual({ status: 'error', message: 'db down' }); + expect(result.services.postgis).toEqual({ status: 'error', message: 'no postgis' }); + }); +}); diff --git a/Backend/src/ipfs/ipfs.service.spec.ts b/Backend/src/ipfs/ipfs.service.spec.ts new file mode 100644 index 00000000..76094b6f --- /dev/null +++ b/Backend/src/ipfs/ipfs.service.spec.ts @@ -0,0 +1,116 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { IpfsService } from './ipfs.service'; + +const pinJSONToIPFS = jest.fn(); +const PinataClient = jest.fn().mockImplementation(() => ({ pinJSONToIPFS })); + +jest.mock('@pinata/sdk', () => PinataClient, { virtual: true }); + +const buildService = (values: Record = {}) => { + const config = { + get: jest.fn((key: string, fallback?: unknown) => values[key] ?? fallback), + } as unknown as ConfigService; + return new IpfsService(config); +}; + +const LIVE = { PINATA_API_KEY: 'key', PINATA_SECRET_KEY: 'secret' }; + +describe('IpfsService', () => { + beforeEach(() => { + pinJSONToIPFS.mockReset(); + PinataClient.mockClear(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('dev mode (no Pinata keys)', () => { + it('returns a mock CID without touching Pinata', async () => { + const service = buildService(); + + const result = await service.pinJson({ a: 1 }); + + expect(result.mock).toBe(true); + expect(result.cid).toMatch(/^mock_Qm[0-9a-f]{32}$/); + expect(PinataClient).not.toHaveBeenCalled(); + }); + + it('returns a placeholder document from getJson', async () => { + const service = buildService(); + + const doc = await service.getJson('mock_Qmabc'); + + expect(doc).toMatchObject({ cid: 'mock_Qmabc', mock: true }); + }); + }); + + describe('live mode', () => { + it('pins through the Pinata client and returns the IpfsHash', async () => { + pinJSONToIPFS.mockResolvedValue({ IpfsHash: 'QmLive' }); + + const result = await buildService(LIVE).pinJson({ a: 1 }); + + expect(PinataClient).toHaveBeenCalledWith('key', 'secret'); + expect(pinJSONToIPFS).toHaveBeenCalledWith({ a: 1 }, expect.any(Object)); + expect(result).toEqual({ cid: 'QmLive', mock: false }); + }); + + it('retries a failed pin and succeeds', async () => { + jest.useFakeTimers(); + pinJSONToIPFS + .mockRejectedValueOnce(new Error('temporary')) + .mockResolvedValueOnce({ IpfsHash: 'QmRetry' }); + + const promise = buildService(LIVE).pinJson({ a: 1 }); + await jest.advanceTimersByTimeAsync(500); + + await expect(promise).resolves.toEqual({ cid: 'QmRetry', mock: false }); + expect(pinJSONToIPFS).toHaveBeenCalledTimes(2); + }); + + it('throws the last error once all attempts fail', async () => { + jest.useFakeTimers(); + pinJSONToIPFS.mockRejectedValue(new Error('still down')); + + const promise = buildService({ ...LIVE, IPFS_RETRY_ATTEMPTS: 2 }).pinJson({ a: 1 }); + const assertion = expect(promise).rejects.toThrow('still down'); + await jest.advanceTimersByTimeAsync(1000); + + await assertion; + expect(pinJSONToIPFS).toHaveBeenCalledTimes(2); + }); + }); + + describe('getJson with a real CID', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('fetches the document from the configured gateway', async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ hello: 'world' }) }); + global.fetch = fetchMock as unknown as typeof fetch; + const service = buildService({ ...LIVE, 'ipfs.gateway': 'https://gw.example/ipfs' }); + + const doc = await service.getJson('QmReal'); + + expect(fetchMock).toHaveBeenCalledWith('https://gw.example/ipfs/QmReal'); + expect(doc).toEqual({ hello: 'world' }); + }); + + it('throws when the gateway responds with an error status', async () => { + jest.useFakeTimers(); + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 502 }) as unknown as typeof fetch; + const service = buildService({ ...LIVE, IPFS_RETRY_ATTEMPTS: 1 }); + + await expect(service.getJson('QmReal')).rejects.toThrow('IPFS fetch failed: 502'); + }); + }); +}); diff --git a/Backend/src/soroban/soroban.service.live.spec.ts b/Backend/src/soroban/soroban.service.live.spec.ts new file mode 100644 index 00000000..0d16ab2d --- /dev/null +++ b/Backend/src/soroban/soroban.service.live.spec.ts @@ -0,0 +1,261 @@ +/** + * Live-mode tests for SorobanService with a mocked RPC server: the success path and one + * failure path for each live method, including GistContractError mapping. + */ +const mockServer = { + getAccount: jest.fn(), + prepareTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), + simulateTransaction: jest.fn(), + getEvents: jest.fn(), + getLatestLedger: jest.fn(), +}; + +jest.mock('@stellar/stellar-sdk', () => ({ + Address: { fromString: jest.fn((k: string) => ({ address: k })) }, + BASE_FEE: '100', + Networks: { TESTNET: 'Test SDF Network ; September 2015' }, + Contract: jest.fn().mockImplementation(() => ({ + contractId: () => 'CONTRACT', + call: jest.fn(() => ({})), + })), + Keypair: { + fromSecret: jest.fn(() => ({ publicKey: () => 'GPUBLIC' })), + }, + TransactionBuilder: jest.fn().mockImplementation(() => { + const builder = { + addOperation: jest.fn(() => builder), + setTimeout: jest.fn(() => builder), + build: jest.fn(() => ({ sign: jest.fn() })), + }; + return builder; + }), + nativeToScVal: jest.fn((value: unknown) => ({ native: value })), + scValToNative: jest.fn((value: unknown) => + value && typeof value === 'object' && 'native' in value + ? (value as { native: unknown }).native + : value, + ), + xdr: {}, + rpc: { + Server: jest.fn().mockImplementation(() => mockServer), + Api: { + isSimulationError: (sim: { error?: string }) => 'error' in sim, + GetTransactionStatus: { SUCCESS: 'SUCCESS', FAILED: 'FAILED', NOT_FOUND: 'NOT_FOUND' }, + }, + }, +})); + +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { GistContractError, GistError, SorobanService } from './soroban.service'; + +const buildService = () => { + const env: Record = { + CONTRACT_ID_GIST_REGISTRY: 'CONTRACT', + STELLAR_SECRET_KEY: 'SSECRET', + SOROBAN_RETRY_ATTEMPTS: 1, + STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + }; + const config = { + get: jest.fn((key: string, fallback?: unknown) => env[key] ?? fallback), + } as unknown as ConfigService; + return new SorobanService(config); +}; + +const simulationOk = (native: unknown) => ({ result: { retval: { native } } }); + +describe('SorobanService (live mode, mocked RPC)', () => { + let service: SorobanService; + + beforeEach(() => { + Object.values(mockServer).forEach((fn) => fn.mockReset()); + mockServer.getAccount.mockResolvedValue({ accountId: () => 'GPUBLIC' }); + mockServer.prepareTransaction.mockImplementation(async (tx: unknown) => tx); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + service = buildService(); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('postGist', () => { + it('submits the transaction and returns the gist id and tx hash', async () => { + mockServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'tx-1' }); + mockServer.getTransaction.mockResolvedValue({ + status: 'SUCCESS', + returnValue: { native: { ok: 42n } }, + }); + + const result = await service.postGist(null, 'cell', 'cid', 3600); + + expect(result).toEqual({ gistId: '42', txHash: 'tx-1', mock: false }); + expect(mockServer.sendTransaction).toHaveBeenCalledTimes(1); + }); + + it('throws when the RPC rejects the transaction', async () => { + mockServer.sendTransaction.mockResolvedValue({ status: 'ERROR', hash: 'tx-2' }); + + await expect(service.postGist(null, 'cell', 'cid')).rejects.toThrow( + 'Soroban post_gist rejected', + ); + }); + + it('maps a contract Err result to a typed GistContractError', async () => { + mockServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'tx-3' }); + mockServer.getTransaction.mockResolvedValue({ + status: 'SUCCESS', + returnValue: { native: { err: GistError.CooldownActive } }, + }); + + const error = await service.postGist(null, 'cell', 'cid').catch((err: unknown) => err); + + expect(error).toBeInstanceOf(GistContractError); + expect((error as GistContractError).code).toBe(GistError.CooldownActive); + }); + }); + + describe('getGist', () => { + it('decodes the gist record returned by the simulation', async () => { + mockServer.simulateTransaction.mockResolvedValue( + simulationOk({ + gist_id: 7n, + location_cell: 'cell', + content_hash: 'cid', + created_at: 100n, + expires_at: 200n, + hidden: false, + author: null, + }), + ); + + const result = await service.getGist('7'); + + expect(result).toEqual({ + gistId: '7', + locationCell: 'cell', + contentHash: 'cid', + createdAt: 100, + expiresAt: 200, + hidden: false, + author: null, + mock: false, + }); + }); + + it('returns null when the contract has no such gist', async () => { + mockServer.simulateTransaction.mockResolvedValue(simulationOk(null)); + + await expect(service.getGist('7')).resolves.toBeNull(); + }); + + it('throws when the simulation fails', async () => { + mockServer.simulateTransaction.mockResolvedValue({ error: 'HostError' }); + + await expect(service.getGist('7')).rejects.toThrow('Soroban get_gist failed for gist 7'); + }); + }); + + describe('isActive', () => { + it('returns the boolean from the simulation', async () => { + mockServer.simulateTransaction.mockResolvedValue(simulationOk(true)); + + await expect(service.isActive('7')).resolves.toEqual({ active: true, mock: false }); + }); + + it('throws when the simulation fails', async () => { + mockServer.simulateTransaction.mockResolvedValue({ error: 'HostError' }); + + await expect(service.isActive('7')).rejects.toThrow('Soroban is_active failed for gist 7'); + }); + }); + + describe('listGistsByCell', () => { + it('normalizes every record in the returned vector', async () => { + const record = { + gist_id: 1n, + location_cell: 'cell', + content_hash: 'cid', + created_at: 1, + expires_at: 2, + hidden: false, + author: null, + }; + mockServer.simulateTransaction.mockResolvedValue(simulationOk([record, record])); + + const result = await service.listGistsByCell('cell', 0, 10); + + expect(result.mock).toBe(false); + expect(result.gists).toHaveLength(2); + expect(result.gists[0]).toMatchObject({ gistId: '1', locationCell: 'cell' }); + }); + + it('throws when the contract returns something other than a vector', async () => { + mockServer.simulateTransaction.mockResolvedValue(simulationOk('nope')); + + await expect(service.listGistsByCell('cell', 0, 10)).rejects.toThrow( + 'returned non-array for cell cell', + ); + }); + }); + + describe('getAdmin', () => { + it('returns the admin address', async () => { + mockServer.simulateTransaction.mockResolvedValue(simulationOk('GADMIN')); + + await expect(service.getAdmin()).resolves.toEqual({ admin: 'GADMIN', mock: false }); + }); + + it('throws when the simulation fails', async () => { + mockServer.simulateTransaction.mockResolvedValue({ error: 'HostError' }); + + await expect(service.getAdmin()).rejects.toThrow('Soroban get_admin failed'); + }); + }); + + describe('reportGist', () => { + it('returns the new report count', async () => { + mockServer.sendTransaction.mockResolvedValue({ status: 'PENDING', hash: 'tx-4' }); + mockServer.getTransaction.mockResolvedValue({ + status: 'SUCCESS', + returnValue: { native: 4n }, + }); + + await expect(service.reportGist('7')).resolves.toEqual({ count: 4, mock: false }); + }); + + it('throws when the RPC rejects the transaction', async () => { + mockServer.sendTransaction.mockResolvedValue({ status: 'ERROR', hash: 'tx-5' }); + + await expect(service.reportGist('7')).rejects.toThrow('Soroban report_gist rejected'); + }); + }); + + describe('getEventsSince', () => { + it('decodes the events returned by the RPC', async () => { + mockServer.getEvents.mockResolvedValue({ + events: [ + { + topic: [{ native: 'gist_deleted' }], + value: { native: { gist_id: 5n } }, + ledger: 10, + id: 'event-1', + }, + ], + latestLedger: 11, + }); + + const events = await service.getEventsSince(9); + + expect(events).toEqual([{ type: 'gist_deleted', gistId: '5', ledger: 10, id: 'event-1' }]); + }); + + it('propagates an RPC failure', async () => { + mockServer.getEvents.mockRejectedValue(new Error('rpc unavailable')); + + await expect(service.getEventsSince(9)).rejects.toThrow('rpc unavailable'); + }); + }); +});