mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
226 lines
7.9 KiB
TypeScript
226 lines
7.9 KiB
TypeScript
/**
|
|
* Unit tests for memories/helpersService — MEM-HELPERS-001 to MEM-HELPERS-020.
|
|
* Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths.
|
|
*/
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService';
|
|
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
|
|
import { createUser, createTrip } from '../../helpers/factories';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// ── DB setup ─────────────────────────────────────────────────────────────────
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
canAccessTrip: (tripId: any, userId: number) =>
|
|
db
|
|
.prepare(
|
|
`
|
|
SELECT t.id FROM trips t
|
|
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
|
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
|
`,
|
|
)
|
|
.get(userId, tripId, userId),
|
|
isOwner: (tripId: any, userId: number) =>
|
|
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
|
};
|
|
return { testDb: db, dbMock: mock };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
const { mockSafeFetch } = vi.hoisted(() => ({
|
|
mockSafeFetch: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/utils/ssrfGuard', () => {
|
|
class SsrfBlockedError extends Error {
|
|
constructor(msg: string) {
|
|
super(msg);
|
|
this.name = 'SsrfBlockedError';
|
|
}
|
|
}
|
|
return {
|
|
safeFetch: mockSafeFetch,
|
|
SsrfBlockedError,
|
|
checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })),
|
|
};
|
|
});
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
mockSafeFetch.mockReset();
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// ── mapDbError ────────────────────────────────────────────────────────────────
|
|
|
|
describe('mapDbError', () => {
|
|
it('MEM-HELPERS-001: returns 409 for unique constraint error', () => {
|
|
const err = new Error('UNIQUE constraint failed: users.email');
|
|
const result = mapDbError(err, 'fallback');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.status).toBe(409);
|
|
expect(result.error.message).toBe('Resource already exists');
|
|
});
|
|
|
|
it('MEM-HELPERS-002: returns 409 for generic constraint error', () => {
|
|
const err = new Error('constraint violation');
|
|
const result = mapDbError(err, 'fallback');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.status).toBe(409);
|
|
});
|
|
|
|
it('MEM-HELPERS-003: returns 500 with original message for non-constraint error', () => {
|
|
const err = new Error('Something went wrong');
|
|
const result = mapDbError(err, 'fallback');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.status).toBe(500);
|
|
expect(result.error.message).toBe('Something went wrong');
|
|
});
|
|
|
|
it('MEM-HELPERS-004: returns 500 for generic DB error', () => {
|
|
const err = new Error('disk I/O error');
|
|
const result = mapDbError(err, 'fallback');
|
|
expect(result.error.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
// ── getAlbumIdFromLink ────────────────────────────────────────────────────────
|
|
|
|
describe('getAlbumIdFromLink', () => {
|
|
it('MEM-HELPERS-005: returns 404 when trip access is denied', () => {
|
|
const result = getAlbumIdFromLink('9999', 'link-1', 1);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.status).toBe(404);
|
|
});
|
|
|
|
it('MEM-HELPERS-006: returns 404 when album link is not found', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
const result = getAlbumIdFromLink(String(trip.id), 'nonexistent-link', user.id);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error.status).toBe(404);
|
|
expect(result.error.message).toBe('Album link not found');
|
|
});
|
|
|
|
it('MEM-HELPERS-007: returns album_id when link exists', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
|
|
// Insert with auto-increment id (INTEGER PRIMARY KEY)
|
|
const ins = testDb
|
|
.prepare('INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)')
|
|
.run(trip.id, user.id, 'immich', 'album-123', 'My Album');
|
|
const linkId = ins.lastInsertRowid;
|
|
|
|
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
|
|
expect(result.success).toBe(true);
|
|
expect((result as any).data).toBe('album-123');
|
|
});
|
|
});
|
|
|
|
// ── pipeAsset ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('pipeAsset', () => {
|
|
function mockResponse(overrides: Record<string, any> = {}) {
|
|
return {
|
|
status: vi.fn().mockReturnThis(),
|
|
set: vi.fn().mockReturnThis(),
|
|
end: vi.fn(),
|
|
json: vi.fn(),
|
|
headersSent: false,
|
|
...overrides,
|
|
} as any;
|
|
}
|
|
|
|
it('MEM-HELPERS-009: calls response.end() when resp.body is null', async () => {
|
|
mockSafeFetch.mockResolvedValue({
|
|
status: 200,
|
|
headers: { get: vi.fn(() => null) },
|
|
body: null,
|
|
});
|
|
const res = mockResponse();
|
|
|
|
await pipeAsset('https://example.com/asset', res);
|
|
|
|
expect(res.end).toHaveBeenCalled();
|
|
});
|
|
|
|
it('MEM-HELPERS-010: returns 400 when SsrfBlockedError is thrown', async () => {
|
|
mockSafeFetch.mockRejectedValue(new SsrfBlockedError('SSRF blocked'));
|
|
const res = mockResponse({ headersSent: false });
|
|
|
|
await pipeAsset('https://internal.example.com/asset', res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
|
});
|
|
|
|
it('MEM-HELPERS-011: returns 500 for generic fetch error', async () => {
|
|
mockSafeFetch.mockRejectedValue(new Error('Network error'));
|
|
const res = mockResponse({ headersSent: false });
|
|
|
|
await pipeAsset('https://example.com/asset', res);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(res.json).toHaveBeenCalledWith({ error: 'Failed to fetch asset' });
|
|
});
|
|
|
|
it('MEM-HELPERS-012: calls response.end() when headersSent is true on error', async () => {
|
|
mockSafeFetch.mockRejectedValue(new Error('fail'));
|
|
const res = mockResponse({ headersSent: true });
|
|
|
|
await pipeAsset('https://example.com/asset', res);
|
|
|
|
expect(res.end).toHaveBeenCalled();
|
|
expect(res.json).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('MEM-HELPERS-013: sets content-type header when present in response', async () => {
|
|
mockSafeFetch.mockResolvedValue({
|
|
status: 200,
|
|
headers: {
|
|
get: (h: string) => {
|
|
if (h === 'content-type') return 'image/jpeg';
|
|
return null;
|
|
},
|
|
},
|
|
body: null,
|
|
});
|
|
const res = mockResponse();
|
|
|
|
await pipeAsset('https://example.com/img.jpg', res);
|
|
|
|
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/jpeg');
|
|
expect(res.end).toHaveBeenCalled();
|
|
});
|
|
});
|