Spaces:
Sleeping
Sleeping
| /** | |
| * Unit tests for memories/helpersService β MEM-HELPERS-001 to MEM-HELPERS-020. | |
| * Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths. | |
| */ | |
| 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' })), | |
| }; | |
| }); | |
| import { createTables } from '../../../src/db/schema'; | |
| import { runMigrations } from '../../../src/db/migrations'; | |
| import { resetTestDb } from '../../helpers/test-db'; | |
| import { createUser, createTrip } from '../../helpers/factories'; | |
| import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService'; | |
| import { SsrfBlockedError } from '../../../src/utils/ssrfGuard'; | |
| 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(); | |
| }); | |
| }); | |