test: expand test suite to 87.3% backend coverage

Add new integration test files covering previously untested routes:
- categories.test.ts — GET /api/categories
- oidc.test.ts — full OIDC login flow (callback, state, errors)
- settings.test.ts — GET/PUT /api/settings, bulk save
- tags.test.ts — CRUD for trip tags
- todo.test.ts — todo items CRUD and reorder

Add new unit test files covering service-layer logic:
- adminService.test.ts — user/invite management, packing templates, OIDC settings
- atlasService.test.ts — atlas search and place enrichment
- authServiceDb.test.ts — DB-backed auth helpers (login, register, MFA)
- backupService.test.ts — export/import/restore logic
- categoryService.test.ts — category CRUD
- dayService.test.ts — day management and accommodation helpers
- mapsService.test.ts — route/directions helpers
- oidcService.test.ts — OIDC state, auth code, role resolution, user upsert
- packingService.test.ts — packing item/bag/template operations
- placeService.test.ts — place CRUD and tag attachment
- settingsService.test.ts — settings get/set/bulk
- tagService.test.ts — tag CRUD
- todoService.test.ts — todo CRUD and reorder
- tripService.test.ts — trip CRUD, member management, archiving
- vacayService.test.ts — vacay integration helpers
- tripAccess.test.ts (middleware) — requireTripAccess middleware

Expand existing integration and unit test files with additional cases
across admin, atlas, auth, backup, collab, days, files, maps, memories
(Immich/Synology), notifications, places, reservations, share, vacay,
weather, auth middleware, ephemeral tokens, notification preferences,
permissions, SSRF guard, and WebSocket connection tests.

Update test helpers (factories.ts, test-db.ts) with new factory
functions and seed data required by the expanded suite.

Fix minor issues in server/src/routes/reservations.ts and
server/src/services/atlasService.ts surfaced by new test coverage.

Update sonar-project.properties to reflect new coverage thresholds.
This commit is contained in:
jubnl
2026-04-06 20:06:46 +02:00
parent 5bcadb3cc6
commit b4922322ae
49 changed files with 12177 additions and 36 deletions
+146
View File
@@ -42,6 +42,15 @@ vi.mock('../../src/config', () => ({
updateJwtSecret: () => {},
}));
// Partially mock collabService to make fetchLinkPreview controllable
vi.mock('../../src/services/collabService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/collabService')>();
return {
...actual,
fetchLinkPreview: vi.fn().mockResolvedValue({ title: null, description: null, image: null, url: '' }),
};
});
import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
@@ -49,6 +58,7 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../helpers/factories';
import { authCookie, generateToken } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import * as collabService from '../../src/services/collabService';
const app: Application = createApp();
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
@@ -637,4 +647,140 @@ describe('Collab validation', () => {
.send({ text: 'A'.repeat(5001) });
expect(res.status).toBe(400);
});
it('COLLAB-008 — poll with fewer than 2 options returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/polls`)
.set('Cookie', authCookie(user.id))
.send({ question: 'Only one option?', options: ['Option A'] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/2 options/i);
});
});
describe('Link preview', () => {
it('COLLAB-025 — GET /collab/link-preview without url returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/url/i);
});
it('COLLAB-025 — GET /collab/link-preview returns preview for valid URL', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
title: 'Example Domain',
description: 'A test page',
image: null,
url: 'https://example.com',
});
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.title).toBe('Example Domain');
});
it('COLLAB-026 — GET /collab/link-preview returns 400 when fetchLinkPreview returns error', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(collabService.fetchLinkPreview).mockResolvedValueOnce({
title: null,
description: null,
image: null,
url: 'http://127.0.0.1',
error: 'Requests to loopback and link-local addresses are not allowed',
} as any);
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=http://127.0.0.1`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
});
it('COLLAB-027 — GET /collab/link-preview catches thrown errors and returns fallback', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.mocked(collabService.fetchLinkPreview).mockRejectedValueOnce(new Error('Unexpected error'));
const res = await request(app)
.get(`/api/trips/${trip.id}/collab/link-preview?url=https://example.com`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.title).toBeNull();
});
});
describe('Message reactions toggle', () => {
it('COLLAB-028 — POST /collab/messages/:msgId/react adds a reaction', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgRes = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Hello!' });
expect(msgRes.status).toBe(201);
const messageId = msgRes.body.message.id;
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
expect(thumbsUp).toBeDefined();
expect(thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id)).toBe(true);
});
it('COLLAB-029 — POST /collab/messages/:msgId/react on same emoji removes it (toggle)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgRes = await request(app)
.post(`/api/trips/${trip.id}/collab/messages`)
.set('Cookie', authCookie(user.id))
.send({ text: 'Toggle me!' });
expect(msgRes.status).toBe(201);
const messageId = msgRes.body.message.id;
// First call — adds the reaction
await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
// Second call with same emoji — should toggle it off
const res = await request(app)
.post(`/api/trips/${trip.id}/collab/messages/${messageId}/react`)
.set('Cookie', authCookie(user.id))
.send({ emoji: '👍' });
expect(res.status).toBe(200);
expect(res.body.reactions).toBeDefined();
const thumbsUp = res.body.reactions.find((r: any) => r.emoji === '👍');
// After toggling off, either the entry is absent or the user is no longer in it
const userStillReacted = thumbsUp && thumbsUp.users && thumbsUp.users.some((u: any) => u.user_id === user.id || u === user.id);
expect(userStillReacted).toBeFalsy();
});
});