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
43 changes: 43 additions & 0 deletions Backend/src/gists/gist-cleanup.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
61 changes: 61 additions & 0 deletions Backend/src/health/health.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
116 changes: 116 additions & 0 deletions Backend/src/ipfs/ipfs.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => {
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');
});
});
});
Loading
Loading