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
@@ -0,0 +1,700 @@
/**
* Unit tests for adminService — ADMIN-SVC-001 through ADMIN-SVC-050.
* Uses a real in-memory SQLite DB. Focuses on validation/error branches
* that the integration tests don't exercise.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, 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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
encrypt_api_key: (v: string) => v,
decrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
vi.mock('../../../src/mcp', () => ({
revokeUserSessions: vi.fn(),
}));
vi.mock('../../../src/demo/demo-reset', () => ({
saveBaseline: vi.fn(),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import {
listUsers,
createUser as svcCreateUser,
updateUser,
deleteUser,
getStats,
getPermissions,
savePermissions,
getAuditLog,
listInvites,
createInvite,
deleteInvite,
getBagTracking,
updateBagTracking,
listPackingTemplates,
createPackingTemplate,
updatePackingTemplate,
deletePackingTemplate,
createTemplateCategory,
updateTemplateCategory,
deleteTemplateCategory,
getPackingTemplate,
createTemplateItem,
updateTemplateItem,
deleteTemplateItem,
getOidcSettings,
updateOidcSettings,
saveDemoBaseline,
getGithubReleases,
checkVersion,
listAddons,
updateAddon,
listMcpTokens,
deleteMcpToken,
} from '../../../src/services/adminService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listUsers ─────────────────────────────────────────────────────────────────
describe('listUsers', () => {
it('ADMIN-SVC-001 — returns all users with online:false', () => {
createUser(testDb);
createUser(testDb);
const users = listUsers() as any[];
expect(users.length).toBeGreaterThanOrEqual(2);
expect(users.every((u: any) => u.online === false)).toBe(true);
});
});
// ── createUser ────────────────────────────────────────────────────────────────
describe('createUser (service)', () => {
it('ADMIN-SVC-002 — creates a user successfully', () => {
const result = svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' }) as any;
expect(result.user).toBeDefined();
expect(result.user.email).toBe('new@test.com');
});
it('ADMIN-SVC-003 — returns 400 when username is missing', () => {
const result = svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' }) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-004 — returns 400 for invalid role', () => {
const result = svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' }) as any;
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid role/i);
});
it('ADMIN-SVC-005 — returns 409 for duplicate username', () => {
createUser(testDb);
const { user } = createUser(testDb);
const result = svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-006 — returns 409 for duplicate email', () => {
const { user } = createUser(testDb);
const result = svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-007 — returns 400 for weak password', () => {
const result = svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' }) as any;
expect(result.status).toBe(400);
});
});
// ── updateUser ────────────────────────────────────────────────────────────────
describe('updateUser', () => {
it('ADMIN-SVC-008 — updates username successfully', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { username: 'updatedname' }) as any;
expect(result.user).toBeDefined();
expect(result.user.username).toBe('updatedname');
});
it('ADMIN-SVC-009 — returns 404 for non-existent user', () => {
const result = updateUser('99999', { username: 'ghost' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-010 — returns 400 for invalid role', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { role: 'superadmin' }) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-011 — returns 409 when username is taken', () => {
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const result = updateUser(String(u2.id), { username: u1.username }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-012 — returns 409 when email is taken', () => {
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const result = updateUser(String(u2.id), { email: u1.email }) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-013 — returns 400 for weak password', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { password: 'weak' }) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-014 — tracks changed fields in result', () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { username: 'newname', role: 'admin' }) as any;
expect(result.changed).toContain('username');
expect(result.changed).toContain('role');
});
});
// ── deleteUser ────────────────────────────────────────────────────────────────
describe('deleteUser', () => {
it('ADMIN-SVC-015 — deletes user successfully', () => {
const { user: admin } = createAdmin(testDb);
const { user } = createUser(testDb);
const result = deleteUser(String(user.id), admin.id) as any;
expect(result.email).toBe(user.email);
});
it('ADMIN-SVC-016 — returns 400 when deleting own account', () => {
const { user: admin } = createAdmin(testDb);
const result = deleteUser(String(admin.id), admin.id) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-017 — returns 404 for non-existent user', () => {
const { user: admin } = createAdmin(testDb);
const result = deleteUser('99999', admin.id) as any;
expect(result.status).toBe(404);
});
});
// ── getStats ──────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('ADMIN-SVC-018 — returns numeric counts for all stats', () => {
const stats = getStats() as any;
expect(typeof stats.totalUsers).toBe('number');
expect(typeof stats.totalTrips).toBe('number');
expect(typeof stats.totalPlaces).toBe('number');
expect(typeof stats.totalFiles).toBe('number');
});
});
// ── getPermissions / savePermissions ─────────────────────────────────────────
describe('Permissions', () => {
it('ADMIN-SVC-019 — getPermissions returns an array of actions', () => {
const result = getPermissions() as any;
expect(Array.isArray(result.permissions)).toBe(true);
expect(result.permissions.length).toBeGreaterThan(0);
});
it('ADMIN-SVC-020 — savePermissions persists a permission change', () => {
savePermissions({ trip_create: 'admin' });
const result = getPermissions() as any;
const perm = result.permissions.find((p: any) => p.key === 'trip_create');
expect(perm.level).toBe('admin');
});
});
// ── getAuditLog ───────────────────────────────────────────────────────────────
describe('getAuditLog', () => {
it('ADMIN-SVC-021 — returns entries array with total', () => {
const result = getAuditLog({}) as any;
expect(Array.isArray(result.entries)).toBe(true);
expect(typeof result.total).toBe('number');
expect(result.limit).toBe(100);
expect(result.offset).toBe(0);
});
it('ADMIN-SVC-022 — respects limit and offset params', () => {
const result = getAuditLog({ limit: '10', offset: '0' }) as any;
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
});
it('ADMIN-SVC-023 — caps limit at 500', () => {
const result = getAuditLog({ limit: '9999' }) as any;
expect(result.limit).toBe(500);
});
});
// ── Invites ───────────────────────────────────────────────────────────────────
describe('Invites', () => {
it('ADMIN-SVC-024 — createInvite returns invite with token', () => {
const { user: admin } = createAdmin(testDb);
const result = createInvite(admin.id, { max_uses: 5 }) as any;
expect(result.invite.token).toBeDefined();
expect(result.invite.max_uses).toBe(5);
});
it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => {
const { user: admin } = createAdmin(testDb);
const result = createInvite(admin.id, {}) as any;
expect(result.uses).toBe(1);
});
it('ADMIN-SVC-026 — listInvites returns array', () => {
const { user: admin } = createAdmin(testDb);
createInvite(admin.id, {});
const invites = listInvites() as any[];
expect(invites.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-027 — deleteInvite removes invite', () => {
const { user: admin } = createAdmin(testDb);
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
const result = deleteInvite(String(invite.id)) as any;
expect(result.error).toBeUndefined();
const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id);
expect(check).toBeUndefined();
});
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => {
const result = deleteInvite('99999') as any;
expect(result.status).toBe(404);
});
});
// ── Bag tracking ──────────────────────────────────────────────────────────────
describe('Bag tracking', () => {
it('ADMIN-SVC-029 — getBagTracking returns enabled state', () => {
const result = getBagTracking() as any;
expect(typeof result.enabled).toBe('boolean');
});
it('ADMIN-SVC-030 — updateBagTracking persists the value', () => {
updateBagTracking(true);
expect((getBagTracking() as any).enabled).toBe(true);
updateBagTracking(false);
expect((getBagTracking() as any).enabled).toBe(false);
});
});
// ── Packing templates ─────────────────────────────────────────────────────────
describe('Packing templates', () => {
it('ADMIN-SVC-031 — createPackingTemplate returns template', () => {
const { user: admin } = createAdmin(testDb);
const result = createPackingTemplate('Beach Trip', admin.id) as any;
expect(result.template.name).toBe('Beach Trip');
});
it('ADMIN-SVC-032 — createPackingTemplate returns 400 for empty name', () => {
const { user: admin } = createAdmin(testDb);
const result = createPackingTemplate('', admin.id) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-033 — listPackingTemplates returns array', () => {
const { user: admin } = createAdmin(testDb);
createPackingTemplate('Template A', admin.id);
const templates = listPackingTemplates() as any[];
expect(templates.length).toBeGreaterThanOrEqual(1);
});
it('ADMIN-SVC-034 — updatePackingTemplate updates name', () => {
const { user: admin } = createAdmin(testDb);
const created = createPackingTemplate('Old Name', admin.id) as any;
const result = updatePackingTemplate(String(created.template.id), { name: 'New Name' }) as any;
expect(result.template.name).toBe('New Name');
});
it('ADMIN-SVC-035 — updatePackingTemplate returns 404 for non-existent', () => {
const result = updatePackingTemplate('99999', { name: 'Ghost' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-036 — deletePackingTemplate removes template', () => {
const { user: admin } = createAdmin(testDb);
const created = createPackingTemplate('To Delete', admin.id) as any;
const result = deletePackingTemplate(String(created.template.id)) as any;
expect(result.name).toBe('To Delete');
});
it('ADMIN-SVC-037 — deletePackingTemplate returns 404 for non-existent', () => {
const result = deletePackingTemplate('99999') as any;
expect(result.status).toBe(404);
});
});
// ── Template categories ───────────────────────────────────────────────────────
describe('Template categories', () => {
it('ADMIN-SVC-038 — createTemplateCategory creates a category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
expect(result.category.name).toBe('Clothing');
});
it('ADMIN-SVC-039 — createTemplateCategory returns 400 for empty name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = createTemplateCategory(String(tpl.template.id), '') as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-040 — createTemplateCategory returns 404 for missing template', () => {
const result = createTemplateCategory('99999', 'Clothing') as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-041 — updateTemplateCategory updates name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Old') as any;
const result = updateTemplateCategory(String(tpl.template.id), String(cat.category.id), { name: 'New' }) as any;
expect(result.category.name).toBe('New');
});
it('ADMIN-SVC-042 — updateTemplateCategory returns 404 for missing category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = updateTemplateCategory(String(tpl.template.id), '99999', { name: 'X' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-043 — deleteTemplateCategory removes category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Remove Me') as any;
const result = deleteTemplateCategory(String(tpl.template.id), String(cat.category.id)) as any;
expect(result.error).toBeUndefined();
});
it('ADMIN-SVC-044 — deleteTemplateCategory returns 404 for missing', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = deleteTemplateCategory(String(tpl.template.id), '99999') as any;
expect(result.status).toBe(404);
});
});
// ── getAuditLog — JSON details parsing ───────────────────────────────────────
describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-045 — parses JSON details when present', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'test_action', JSON.stringify({ key: 'val' })
);
const result = getAuditLog({}) as any;
expect(result.entries.length).toBeGreaterThanOrEqual(1);
const entry = result.entries.find((e: any) => e.action === 'test_action');
expect(entry).toBeDefined();
expect(entry.details).toEqual({ key: 'val' });
});
it('ADMIN-SVC-046 — handles invalid JSON gracefully with _parse_error flag', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'bad_json_action', 'not-valid-json{'
);
const result = getAuditLog({}) as any;
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
expect(entry).toBeDefined();
expect(entry.details).toEqual({ _parse_error: true });
});
});
// ── OIDC Settings ─────────────────────────────────────────────────────────────
describe('OIDC Settings', () => {
it('ADMIN-SVC-047 — getOidcSettings returns default empty values when no OIDC configured', () => {
const result = getOidcSettings() as any;
expect(result.issuer).toBe('');
expect(result.client_id).toBe('');
expect(result.oidc_only).toBe(false);
expect(result.client_secret_set).toBe(false);
expect(result.display_name).toBe('');
expect(result.discovery_url).toBe('');
});
it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', () => {
updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' });
const result = getOidcSettings() as any;
expect(result.issuer).toBe('https://auth.example.com');
expect(result.client_id).toBe('my-client');
});
it('ADMIN-SVC-049 — updateOidcSettings sets oidc_only flag correctly', () => {
updateOidcSettings({ oidc_only: true });
const enabled = getOidcSettings() as any;
expect(enabled.oidc_only).toBe(true);
updateOidcSettings({ oidc_only: false });
const disabled = getOidcSettings() as any;
expect(disabled.oidc_only).toBe(false);
});
});
// ── saveDemoBaseline ──────────────────────────────────────────────────────────
describe('saveDemoBaseline', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('ADMIN-SVC-050 — returns 404 when DEMO_MODE is not "true"', () => {
vi.stubEnv('DEMO_MODE', 'false');
const result = saveDemoBaseline() as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
it('ADMIN-SVC-051 — returns a defined result object when DEMO_MODE is "true"', () => {
// saveDemoBaseline() uses a dynamic CJS require() whose mock cannot be
// intercepted via vi.mock in this test environment (tsx runtime + CJS loader).
// The function either succeeds (message) or falls through the catch to a
// 500 error. Either way the result must be a defined, non-null object.
vi.stubEnv('DEMO_MODE', 'true');
const result = saveDemoBaseline() as any;
expect(result).toBeDefined();
expect(typeof result).toBe('object');
// The 404 branch must NOT be taken — DEMO_MODE is "true".
expect(result.status).not.toBe(404);
});
});
// ── getGithubReleases ─────────────────────────────────────────────────────────
describe('getGithubReleases', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('ADMIN-SVC-052 — returns empty array when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await getGithubReleases();
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('ADMIN-SVC-053 — returns releases array when fetch succeeds', async () => {
const mockReleases = [
{ id: 1, tag_name: 'v3.0.0', name: 'Release 3.0.0', html_url: 'https://github.com/example/releases/tag/v3.0.0' },
{ id: 2, tag_name: 'v2.9.9', name: 'Release 2.9.9', html_url: 'https://github.com/example/releases/tag/v2.9.9' },
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockReleases,
}));
const result = await getGithubReleases();
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect((result as any[])[0].tag_name).toBe('v3.0.0');
});
});
// ── checkVersion ──────────────────────────────────────────────────────────────
describe('checkVersion', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('ADMIN-SVC-054 — returns update_available:false when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await checkVersion() as any;
expect(result.update_available).toBe(false);
expect(result.current).toBeDefined();
expect(result.latest).toBeDefined();
});
it('ADMIN-SVC-055 — returns update_available:true when latest version is greater than current', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
}));
const result = await checkVersion() as any;
expect(result.update_available).toBe(true);
expect(result.latest).toBe('999.0.0');
expect(result.release_url).toBe('https://github.com/example/releases/tag/v999.0.0');
});
});
// ── getPackingTemplate ────────────────────────────────────────────────────────
describe('getPackingTemplate', () => {
it('ADMIN-SVC-056 — returns template with categories and items when template exists', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Full Template', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Clothing') as any;
createTemplateItem(String(tpl.template.id), String(cat.category.id), 'T-Shirt');
const result = getPackingTemplate(String(tpl.template.id)) as any;
expect(result.template).toBeDefined();
expect(result.template.name).toBe('Full Template');
expect(Array.isArray(result.categories)).toBe(true);
expect(result.categories.length).toBeGreaterThanOrEqual(1);
expect(Array.isArray(result.items)).toBe(true);
expect(result.items.length).toBeGreaterThanOrEqual(1);
expect(result.items[0].name).toBe('T-Shirt');
});
it('ADMIN-SVC-057 — returns 404 for non-existent template', () => {
const result = getPackingTemplate('99999') as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
});
// ── Template items ────────────────────────────────────────────────────────────
describe('Template items', () => {
it('ADMIN-SVC-058 — createTemplateItem returns item with name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const result = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Backpack') as any;
expect(result.item).toBeDefined();
expect(result.item.name).toBe('Backpack');
});
it('ADMIN-SVC-059 — createTemplateItem returns 400 for empty name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const result = createTemplateItem(String(tpl.template.id), String(cat.category.id), '') as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-060 — createTemplateItem returns 404 for non-existent category', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const result = createTemplateItem(String(tpl.template.id), '99999', 'Item') as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-061 — updateTemplateItem updates name', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const item = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'Old Item') as any;
const result = updateTemplateItem(String(item.item.id), { name: 'New Item' }) as any;
expect(result.item.name).toBe('New Item');
});
it('ADMIN-SVC-062 — updateTemplateItem returns 404 for non-existent item', () => {
const result = updateTemplateItem('99999', { name: 'Ghost' }) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-063 — deleteTemplateItem removes item', () => {
const { user: admin } = createAdmin(testDb);
const tpl = createPackingTemplate('Tpl', admin.id) as any;
const cat = createTemplateCategory(String(tpl.template.id), 'Gear') as any;
const item = createTemplateItem(String(tpl.template.id), String(cat.category.id), 'To Delete') as any;
const result = deleteTemplateItem(String(item.item.id)) as any;
expect(result.error).toBeUndefined();
const check = testDb.prepare('SELECT id FROM packing_template_items WHERE id = ?').get(item.item.id);
expect(check).toBeUndefined();
});
it('ADMIN-SVC-064 — deleteTemplateItem returns 404 for non-existent item', () => {
const result = deleteTemplateItem('99999') as any;
expect(result.status).toBe(404);
});
});
// ── listAddons ────────────────────────────────────────────────────────────────
describe('listAddons', () => {
it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', () => {
const result = listAddons() as any[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
const addonIds = result.map((a: any) => a.id);
expect(addonIds).toContain('packing');
expect(addonIds).toContain('budget');
});
});
// ── updateAddon ───────────────────────────────────────────────────────────────
describe('updateAddon', () => {
it('ADMIN-SVC-066 — updateAddon enables and disables a seeded addon', () => {
const disabled = updateAddon('mcp', { enabled: false }) as any;
expect(disabled.addon).toBeDefined();
expect(disabled.addon.enabled).toBe(false);
const enabled = updateAddon('mcp', { enabled: true }) as any;
expect(enabled.addon.enabled).toBe(true);
});
it('ADMIN-SVC-067 — updateAddon returns 404 for unknown addon id', () => {
const result = updateAddon('nonexistent-addon-xyz', { enabled: true }) as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
});
// ── MCP Tokens ────────────────────────────────────────────────────────────────
describe('MCP Tokens', () => {
it('ADMIN-SVC-068 — listMcpTokens returns empty array initially', () => {
const result = listMcpTokens() as any[];
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('ADMIN-SVC-069 — deleteMcpToken returns 404 for non-existent token', () => {
const result = deleteMcpToken('99999') as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
});
@@ -0,0 +1,506 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup (real in-memory SQLite — same pattern as mcp unit tests) ────────
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, t.user_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);
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 { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const result = db.prepare(
'INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)'
).run(tripId, name, address, cat?.id ?? null);
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
// Stub fetch so reverseGeocodeCountry never makes real HTTP calls
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
}));
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('ATLAS-UNIT-001: returns mostVisited null when trips have no resolvable countries (guards reduce on empty array)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Mystery Trip' });
// Place with no address and no coordinates → can't resolve country
insertPlace(testDb, trip.id, 'Unknown Place', null);
const stats = await getStats(user.id);
expect(stats.mostVisited).toBeNull();
expect(stats.countries).toEqual([]);
expect(stats.stats.totalPlaces).toBe(1);
expect(stats.stats.totalCountries).toBe(0);
});
it('ATLAS-UNIT-002: returns the country with the highest placeCount as mostVisited', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Euro Tour' });
// 3 places in France, 1 in Germany → France should win
for (let i = 0; i < 3; i++) {
insertPlace(testDb, trip.id, `Paris Place ${i}`, `Street ${i}, Paris, France`);
}
insertPlace(testDb, trip.id, 'Berlin Place', 'Some Street, Berlin, Germany');
const stats = await getStats(user.id);
expect(stats.mostVisited).not.toBeNull();
expect(stats.mostVisited!.code).toBe('FR');
expect(stats.mostVisited!.placeCount).toBe(3);
expect(stats.countries).toHaveLength(2);
expect(stats.stats.totalCountries).toBe(2);
});
it('ATLAS-UNIT-003: returns manually marked countries when user has no trips', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'AU');
const stats = await getStats(user.id);
expect(stats.countries).toHaveLength(2);
expect(stats.countries.map((c: { code: string }) => c.code).sort()).toEqual(['AU', 'JP']);
expect(stats.stats.totalTrips).toBe(0);
expect(stats.stats.totalCountries).toBe(2);
});
it('ATLAS-UNIT-004: single country yields mostVisited equal to that country', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Italy Trip' });
insertPlace(testDb, trip.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
const stats = await getStats(user.id);
expect(stats.mostVisited).not.toBeNull();
expect(stats.mostVisited!.code).toBe('IT');
expect(stats.mostVisited!.placeCount).toBe(1);
});
});
// ── getCached / setCache ────────────────────────────────────────────────────
describe('getCached and setCache', () => {
it('ATLAS-SVC-001: getCached returns undefined for unknown coordinates', () => {
// Use uniquely large lat values to guarantee no prior cache entry
const result = getCached(9001.001, 9001.001);
expect(result).toBeUndefined();
});
it('ATLAS-SVC-002: setCache then getCached returns the stored code', () => {
setCache(9002.002, 9002.002, 'DE');
const result = getCached(9002.002, 9002.002);
expect(result).toBe('DE');
});
it('ATLAS-SVC-003: setCache can store null (country unknown)', () => {
setCache(9003.003, 9003.003, null);
const result = getCached(9003.003, 9003.003);
expect(result).toBeNull();
});
it('ATLAS-SVC-004: different coordinates return different cached values', () => {
setCache(9004.004, 9004.004, 'FR');
setCache(9004.005, 9004.005, 'ES');
expect(getCached(9004.004, 9004.004)).toBe('FR');
expect(getCached(9004.005, 9004.005)).toBe('ES');
});
});
// ── getCountryFromCoords ────────────────────────────────────────────────────
describe('getCountryFromCoords', () => {
it('ATLAS-SVC-005: returns country code for Paris coordinates (France)', () => {
// Paris: approximately 48.85°N, 2.35°E — well inside FR bounding box
const code = getCountryFromCoords(48.85, 2.35);
expect(code).toBe('FR');
});
it('ATLAS-SVC-006: returns country code for NYC coordinates (USA)', () => {
// New York City: approximately 40.71°N, -74.0°W — inside US bounding box
const code = getCountryFromCoords(40.71, -74.0);
expect(code).toBe('US');
});
it('ATLAS-SVC-007: returns null for coordinates with no country match (0,0)', () => {
// Gulf of Guinea — no COUNTRY_BOXES entry covers 0°N, 0°E
const code = getCountryFromCoords(0.0, 0.0);
expect(code).toBeNull();
});
});
// ── getCountryFromAddress ───────────────────────────────────────────────────
describe('getCountryFromAddress', () => {
it('ATLAS-SVC-008: returns null for null address', () => {
expect(getCountryFromAddress(null)).toBeNull();
});
it('ATLAS-SVC-009: returns null for empty string', () => {
expect(getCountryFromAddress('')).toBeNull();
});
it('ATLAS-SVC-010: parses "France" in last position to "FR"', () => {
expect(getCountryFromAddress('Eiffel Tower, Paris, France')).toBe('FR');
});
it('ATLAS-SVC-011: returns 2-letter ISO code directly when last part is uppercase 2-letter', () => {
// "US" is uppercase and exactly 2 characters — returned verbatim
expect(getCountryFromAddress('123 Main St, New York, US')).toBe('US');
});
it('ATLAS-SVC-012: returns null for unrecognized country name', () => {
expect(getCountryFromAddress('Unknown City, Unknown Country')).toBeNull();
});
});
// ── reverseGeocodeCountry ───────────────────────────────────────────────────
describe('reverseGeocodeCountry', () => {
it('ATLAS-SVC-013: returns null when fetch fails (ok:false)', async () => {
// The beforeEach stub already returns ok:false — this is the default path
const code = await reverseGeocodeCountry(9013.013, 9013.013);
expect(code).toBeNull();
});
it('ATLAS-SVC-014: returns country code when Nominatim returns valid response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ address: { country_code: 'fr' } }),
}));
// Berlin-ish coords not used elsewhere — unique to avoid cache collision
const code = await reverseGeocodeCountry(52.52, 13.40);
expect(code).toBe('FR');
});
it('ATLAS-SVC-015: returns null when fetch throws a network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const code = await reverseGeocodeCountry(9015.015, 9015.015);
expect(code).toBeNull();
});
it('ATLAS-SVC-016: returns cached result on second call (fetch called only once)', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ address: { country_code: 'gb' } }),
});
vi.stubGlobal('fetch', mockFetch);
// Use unique coords so neither call hits a prior cache entry
const first = await reverseGeocodeCountry(9016.016, 9016.016);
const second = await reverseGeocodeCountry(9016.016, 9016.016);
expect(first).toBe('GB');
expect(second).toBe('GB');
// fetch should have been invoked only once; the second call uses the in-memory cache
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
// ── getRegionGeo ────────────────────────────────────────────────────────────
describe('getRegionGeo', () => {
it('ATLAS-SVC-017: returns empty FeatureCollection when fetch throws a network error', async () => {
// Override the default stub to throw so loadAdmin1Geo's .catch handler runs,
// returning null — which causes getRegionGeo to return the empty FeatureCollection.
// (The default ok:false stub does NOT trigger the catch; it still resolves json()
// to {}, which loadAdmin1Geo caches as a non-null truthy value.)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network failure')));
const result = await getRegionGeo(['DE', 'FR']);
expect(result).toEqual({ type: 'FeatureCollection', features: [] });
});
it('ATLAS-SVC-018: returns filtered features for matching country codes when fetch returns mock GeoJSON', async () => {
// ATLAS-SVC-017 ran with a throwing fetch, so admin1GeoCache is null and
// admin1GeoLoading is null — this test's fetch override will be called.
const mockGeoJson = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: {} },
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
],
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockGeoJson,
}));
// Pass lowercase 'de' — getRegionGeo uppercases internally for matching
const result = await getRegionGeo(['de']);
expect(result.type).toBe('FeatureCollection');
expect(result.features).toHaveLength(1);
expect(result.features[0].properties.iso_a2).toBe('DE');
});
});
// ── Helpers for new tests ────────────────────────────────────────────────────
function insertPlaceWithCoords(db: any, tripId: number, name: string, lat: number, lng: number, address: string | null = null) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const result = db.prepare(
'INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, name, address, lat, lng, cat?.id ?? null);
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
// ── getStats — extended ──────────────────────────────────────────────────────
describe('getStats — extended', () => {
it('ATLAS-UNIT-005: totalDays is calculated when trip has start_date and end_date', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Short Trip', start_date: '2024-03-01', end_date: '2024-03-03' });
const stats = await getStats(user.id);
// March 1, 2, 3 → diff = 2 + 1 = 3
expect(stats.stats.totalDays).toBe(3);
});
it('ATLAS-UNIT-006: totalDays is 0 when trip has no dates', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Dateless' });
const stats = await getStats(user.id);
expect(stats.stats.totalDays).toBe(0);
});
it('ATLAS-UNIT-007: manually marked country is merged when user has trips but no resolvable places for that country', async () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'Japan Trip', start_date: '2024-01-01', end_date: '2024-01-10' });
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
const stats = await getStats(user.id);
const codes = stats.countries.map((c: any) => c.code);
expect(codes).toContain('JP');
const jp = stats.countries.find((c: any) => c.code === 'JP');
expect(jp?.placeCount).toBe(0);
});
it('ATLAS-UNIT-008: lastTrip is resolved with a country code when its places have an address', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Past France Trip', start_date: '2023-05-01', end_date: '2023-05-10' });
insertPlace(testDb, trip.id, 'Eiffel Tower', 'Champ de Mars, Paris, France');
const stats = await getStats(user.id);
expect(stats.lastTrip).not.toBeNull();
expect(stats.lastTrip!.countryCode).toBe('FR');
});
it('ATLAS-UNIT-009: nextTrip has daysUntil calculated', async () => {
const { user } = createUser(testDb);
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const futureDateStr = futureDate.toISOString().split('T')[0];
createTrip(testDb, user.id, { title: 'Future Trip', start_date: futureDateStr });
const stats = await getStats(user.id);
expect(stats.nextTrip).not.toBeNull();
expect(stats.nextTrip!.daysUntil).toBeGreaterThan(0);
});
it('ATLAS-UNIT-010: streak counts consecutive years with trips and firstYear is the earliest', async () => {
const { user } = createUser(testDb);
const currentYear = new Date().getFullYear();
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-06-01`, end_date: `${currentYear}-06-10` });
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-07-01`, end_date: `${currentYear - 1}-07-10` });
const stats = await getStats(user.id);
expect(stats.streak).toBeGreaterThanOrEqual(1);
expect(stats.firstYear).toBe(currentYear - 1);
});
it('ATLAS-UNIT-011: tripsThisYear counts only trips whose start_date is in the current year', async () => {
const { user } = createUser(testDb);
const currentYear = new Date().getFullYear();
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-03-01` });
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-03-01` });
const stats = await getStats(user.id);
expect(stats.tripsThisYear).toBe(1);
});
it('ATLAS-UNIT-012: lastTrip is null when all trips end in the future', async () => {
const { user } = createUser(testDb);
const nextYear = new Date().getFullYear() + 1;
createTrip(testDb, user.id, { title: 'Future', start_date: `${nextYear}-01-01`, end_date: `${nextYear}-01-10` });
const stats = await getStats(user.id);
expect(stats.lastTrip).toBeNull();
});
});
// ── getCountryPlaces ─────────────────────────────────────────────────────────
describe('getCountryPlaces', () => {
it('ATLAS-UNIT-013: returns empty result when user has no trips', () => {
const { user } = createUser(testDb);
const result = getCountryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(0);
expect(result.trips).toHaveLength(0);
expect(result.manually_marked).toBe(false);
});
it('ATLAS-UNIT-014: returns matching places when place address resolves to the requested country', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'France Trip' });
insertPlace(testDb, trip.id, 'Louvre', '75001 Paris, France');
insertPlace(testDb, trip.id, 'Berlin Wall', 'Bernauer Str., Berlin, Germany');
const result = getCountryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('Louvre');
expect(result.trips).toHaveLength(1);
expect(result.trips[0].id).toBe(trip.id);
});
it('ATLAS-UNIT-015: manually_marked is true when country is in visited_countries', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'JP');
createTrip(testDb, user.id, { title: 'Japan' });
const result = getCountryPlaces(user.id, 'JP');
expect(result.manually_marked).toBe(true);
});
it('ATLAS-UNIT-016: place with coordinates resolves via bbox when address is absent', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Coord Trip' });
// Paris coordinates (48.85°N, 2.35°E) — falls inside FR bounding box
insertPlaceWithCoords(testDb, trip.id, 'Secret Paris Spot', 48.85, 2.35);
const result = getCountryPlaces(user.id, 'FR');
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('Secret Paris Spot');
});
});
// ── getVisitedRegions ────────────────────────────────────────────────────────
describe('getVisitedRegions', () => {
it('ATLAS-UNIT-017: returns empty regions object when user has no trips', async () => {
const { user } = createUser(testDb);
const result = await getVisitedRegions(user.id);
expect(result.regions).toEqual({});
});
it('ATLAS-UNIT-018: returns manually marked regions even when user has no places with coordinates', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE');
testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bayern', 'DE');
const result = await getVisitedRegions(user.id);
expect(result.regions['DE']).toBeDefined();
const codes = result.regions['DE'].map((r: any) => r.code);
expect(codes).toContain('DE-BY');
const bayernRegion = result.regions['DE'].find((r: any) => r.code === 'DE-BY');
expect(bayernRegion?.manuallyMarked).toBe(true);
});
it('ATLAS-UNIT-019: geocodes places with lat/lng using reverseGeocodeRegion via fetch', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
address: {
country_code: 'fr',
'ISO3166-2-lvl4': 'FR-75',
state: 'Île-de-France',
},
}),
}));
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);
const resultPromise = getVisitedRegions(user.id);
// Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.regions['FR']).toBeDefined();
vi.useRealTimers();
});
it('ATLAS-UNIT-020: places already cached in place_regions are not re-geocoded', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Cached Trip' });
const place = insertPlaceWithCoords(testDb, trip.id, 'Cached Place', 48.85, 2.35);
// Pre-populate the place_regions cache so the fetch path is never reached
testDb.prepare(
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
).run(place.id, 'FR', 'FR-75', 'Île-de-France');
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', mockFetch);
const result = await getVisitedRegions(user.id);
expect(mockFetch).not.toHaveBeenCalled();
expect(result.regions['FR']).toBeDefined();
const codes = result.regions['FR'].map((r: any) => r.code);
expect(codes).toContain('FR-75');
});
});
@@ -0,0 +1,596 @@
/**
* authServiceDb.test.ts
*
* DB-centric unit tests for authService.ts using a real in-memory SQLite database.
* Pure function tests live in authService.test.ts (stub DB); this file covers
* functions that require actual DB queries to exercise their logic.
*/
// ---------------------------------------------------------------------------
// vi.hoisted: build the real in-memory DB and the module mock before any import
// ---------------------------------------------------------------------------
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: () => {},
canAccessTrip: (tripId: any, userId: number) =>
db
.prepare(
`SELECT t.id, t.user_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: () => {},
}));
vi.mock('../../../src/services/mfaCrypto', () => ({
encryptMfaSecret: vi.fn((s) => `enc:${s}`),
decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')),
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: vi.fn((v) => v),
maybe_encrypt_api_key: vi.fn((v) => v),
mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)),
encrypt_api_key: vi.fn((v) => v),
}));
vi.mock('../../../src/services/permissions', () => ({
getAllPermissions: vi.fn(() => ({})),
checkPermission: vi.fn(),
}));
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
vi.mock('../../../src/scheduler', () => ({
startTripReminders: vi.fn(),
buildCronExpression: vi.fn(),
loadSettings: vi.fn(() => ({ enabled: false })),
VALID_INTERVALS: ['daily', 'weekly', 'monthly'],
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import {
updateSettings,
getSettings,
listUsers,
getAppSettings,
validateKeys,
isOidcOnlyMode,
setupMfa,
enableMfa,
disableMfa,
validateInviteToken,
registerUser,
loginUser,
changePassword,
verifyMfaLogin,
createMcpToken,
deleteMcpToken,
} from '../../../src/services/authService';
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => resetTestDb(testDb));
afterAll(() => testDb.close());
// ---------------------------------------------------------------------------
// updateSettings
// ---------------------------------------------------------------------------
describe('updateSettings', () => {
it('AUTH-DB-001: updates username successfully', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { username: 'newname' });
expect(result.success).toBe(true);
expect(result.user?.username).toBe('newname');
});
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { username: 'x' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/between 2 and 50/i);
});
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { username: 'bad name' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/only contain/i);
});
it('AUTH-DB-004: returns 409 when username is already taken by another user', () => {
const { user: user1 } = createUser(testDb, { username: 'alice' });
const { user: user2 } = createUser(testDb, { username: 'bob' });
const result = updateSettings(user2.id, { username: user1.username });
expect(result.status).toBe(409);
expect(result.error).toMatch(/already taken/i);
});
it('AUTH-DB-005: updates email successfully', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { email: 'new@example.com' });
expect(result.success).toBe(true);
expect(result.user?.email).toBe('new@example.com');
});
it('AUTH-DB-006: returns 400 for invalid email format', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, { email: 'not-an-email' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid email/i);
});
it('AUTH-DB-007: returns 409 when email is already taken by another user', () => {
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
const { user: user2 } = createUser(testDb);
const result = updateSettings(user2.id, { email: user1.email });
expect(result.status).toBe(409);
expect(result.error).toMatch(/already taken/i);
});
it('AUTH-DB-008: returns success with no field changes when empty body is passed', () => {
const { user } = createUser(testDb);
const result = updateSettings(user.id, {});
expect(result.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// getSettings
// ---------------------------------------------------------------------------
describe('getSettings', () => {
it('AUTH-DB-009: returns 403 for non-admin user', () => {
const { user } = createUser(testDb);
const result = getSettings(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', () => {
const { user } = createAdmin(testDb);
testDb
.prepare('UPDATE users SET maps_api_key = ?, openweather_api_key = ? WHERE id = ?')
.run('maps-key-value', 'weather-key-value', user.id);
const result = getSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.settings).toBeDefined();
expect(result.settings).toHaveProperty('maps_api_key');
expect(result.settings).toHaveProperty('openweather_api_key');
});
});
// ---------------------------------------------------------------------------
// listUsers
// ---------------------------------------------------------------------------
describe('listUsers', () => {
it('AUTH-DB-011: returns all users except self, sorted by username', () => {
const { user: self } = createUser(testDb, { username: 'zzself' });
createUser(testDb, { username: 'alice' });
createUser(testDb, { username: 'charlie' });
createUser(testDb, { username: 'bob' });
const result = listUsers(self.id);
expect(result).toHaveLength(3);
const names = result.map((u) => u.username);
expect(names).toEqual([...names].sort());
expect(names).not.toContain('zzself');
});
it('AUTH-DB-012: returns empty array when only one user exists', () => {
const { user } = createUser(testDb);
const result = listUsers(user.id);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// getAppSettings
// ---------------------------------------------------------------------------
describe('getAppSettings', () => {
it('AUTH-DB-013: returns 403 for non-admin', () => {
const { user } = createUser(testDb);
const result = getAppSettings(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
});
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => {
const { user } = createAdmin(testDb);
testDb
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
.run();
const result = getAppSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.data).toBeDefined();
expect(result.data).toHaveProperty('allow_registration', 'true');
});
});
// ---------------------------------------------------------------------------
// validateKeys
// ---------------------------------------------------------------------------
describe('validateKeys', () => {
it('AUTH-DB-015: returns 403 for non-admin', async () => {
const { user } = createUser(testDb);
const result = await validateKeys(user.id);
expect(result.status).toBe(403);
expect(result.error).toMatch(/admin/i);
expect(result.maps).toBe(false);
expect(result.weather).toBe(false);
});
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
const { user } = createAdmin(testDb);
const result = await validateKeys(user.id);
expect(result.maps).toBe(false);
expect(result.weather).toBe(false);
expect(result.maps_details).toBeNull();
});
it('AUTH-DB-017: returns { maps: true } when fetch returns 200', async () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
status: 200,
statusText: 'OK',
text: async () => '',
} as Response);
const result = await validateKeys(user.id);
expect(result.maps).toBe(true);
expect(result.maps_details?.ok).toBe(true);
fetchSpy.mockRestore();
});
it('AUTH-DB-018: returns { maps: false } when fetch throws a network error', async () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockRejectedValueOnce(new Error('Network failure'));
const result = await validateKeys(user.id);
expect(result.maps).toBe(false);
expect(result.maps_details?.error_status).toBe('FETCH_ERROR');
expect(result.maps_details?.error_message).toBe('Network failure');
fetchSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// isOidcOnlyMode
// ---------------------------------------------------------------------------
describe('isOidcOnlyMode', () => {
it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => {
vi.stubEnv('OIDC_ONLY', '');
expect(isOidcOnlyMode()).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', '');
vi.stubEnv('OIDC_CLIENT_ID', '');
expect(isOidcOnlyMode()).toBe(false);
vi.unstubAllEnvs();
});
it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
expect(isOidcOnlyMode()).toBe(true);
vi.unstubAllEnvs();
});
});
// ---------------------------------------------------------------------------
// setupMfa
// ---------------------------------------------------------------------------
describe('setupMfa', () => {
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => {
vi.stubEnv('DEMO_MODE', 'true');
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const result = setupMfa(user.id, 'demo@nomad.app');
expect(result.status).toBe(403);
expect(result.error).toMatch(/demo mode/i);
vi.unstubAllEnvs();
});
it('AUTH-DB-023: returns 400 when MFA is already enabled', () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id);
const result = setupMfa(user.id, user.email);
expect(result.status).toBe(400);
expect(result.error).toMatch(/already enabled/i);
});
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => {
const { user } = createUser(testDb);
const result = setupMfa(user.id, user.email);
expect(result.error).toBeUndefined();
expect(typeof result.secret).toBe('string');
expect(result.secret!.length).toBeGreaterThan(0);
expect(typeof result.otpauth_url).toBe('string');
expect(result.otpauth_url).toMatch(/^otpauth:\/\/totp\//);
expect(result.qrPromise).toBeInstanceOf(Promise);
});
});
// ---------------------------------------------------------------------------
// enableMfa
// ---------------------------------------------------------------------------
describe('enableMfa', () => {
it('AUTH-DB-025: returns 400 when no verification code is provided', () => {
const { user } = createUser(testDb);
const result = enableMfa(user.id, undefined);
expect(result.status).toBe(400);
expect(result.error).toMatch(/code is required/i);
});
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => {
const { user } = createUser(testDb);
// No setupMfa called first, so no pending entry exists
const result = enableMfa(user.id, '123456');
expect(result.status).toBe(400);
expect(result.error).toMatch(/no mfa setup in progress/i);
});
});
// ---------------------------------------------------------------------------
// disableMfa
// ---------------------------------------------------------------------------
describe('disableMfa', () => {
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => {
vi.stubEnv('DEMO_MODE', 'true');
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const result = disableMfa(user.id, 'demo@nomad.app', {
password: 'password123',
code: '000000',
});
expect(result.status).toBe(403);
expect(result.error).toMatch(/demo mode/i);
vi.unstubAllEnvs();
});
it('AUTH-DB-028: returns 400 when password or code is missing', () => {
const { user } = createUser(testDb);
const missingCode = disableMfa(user.id, user.email, { password: 'pass', code: undefined });
expect(missingCode.status).toBe(400);
expect(missingCode.error).toMatch(/password and authenticator code/i);
const missingPassword = disableMfa(user.id, user.email, { password: undefined, code: '123456' });
expect(missingPassword.status).toBe(400);
expect(missingPassword.error).toMatch(/password and authenticator code/i);
});
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => {
const { user } = createUser(testDb);
// mfa_enabled defaults to 0 / not set
const result = disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
expect(result.status).toBe(400);
expect(result.error).toMatch(/not enabled/i);
});
});
// ---------------------------------------------------------------------------
// validateInviteToken
// ---------------------------------------------------------------------------
describe('validateInviteToken', () => {
it('AUTH-DB-030: returns 404 for unknown token', () => {
const result = validateInviteToken('no-such-token');
expect(result.status).toBe(404);
});
it('AUTH-DB-031: returns 410 when max_uses exceeded', () => {
// createInviteToken with used_count already at max
const invite = createInviteToken(testDb, { max_uses: 1 });
// manually set used_count = 1 to simulate exhaustion
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
const result = validateInviteToken(invite.token);
expect(result.status).toBe(410);
});
it('AUTH-DB-032: returns 410 when expired', () => {
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
const result = validateInviteToken(invite.token);
expect(result.status).toBe(410);
});
});
// ---------------------------------------------------------------------------
// registerUser — OIDC-only / registration-disabled
// ---------------------------------------------------------------------------
describe('registerUser — OIDC-only / registration-disabled', () => {
it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', () => {
createUser(testDb); // ensure userCount > 0
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
const result = registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/SSO/i);
});
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => {
createUser(testDb); // ensure userCount > 0
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// loginUser — OIDC-only mode
// ---------------------------------------------------------------------------
describe('loginUser — OIDC-only mode', () => {
it('AUTH-DB-035: returns 403 when oidc_only=true', () => {
const { user, password } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
const result = loginUser({ email: user.email, password });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// changePassword — OIDC-only mode
// ---------------------------------------------------------------------------
describe('changePassword — OIDC-only mode', () => {
it('AUTH-DB-036: returns 403 when oidc_only=true', () => {
const { user, password } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run();
const result = changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
expect(result.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
// disableMfa — require_mfa policy
// ---------------------------------------------------------------------------
describe('disableMfa — require_mfa policy', () => {
it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
const result = disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/cannot be disabled/i);
});
});
// ---------------------------------------------------------------------------
// verifyMfaLogin — validation
// ---------------------------------------------------------------------------
describe('verifyMfaLogin — validation', () => {
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => {
const result = verifyMfaLogin({ mfa_token: undefined, code: undefined });
expect(result.status).toBe(400);
});
it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
expect(result.status).toBe(401);
expect(result.error).toMatch(/invalid/i);
});
it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
const result = verifyMfaLogin({ mfa_token: tok, code: '123456' });
expect(result.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
// MCP token service
// ---------------------------------------------------------------------------
describe('MCP token service', () => {
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => {
const { user } = createUser(testDb);
const result = createMcpToken(user.id, undefined);
expect(result.status).toBe(400);
});
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => {
const { user } = createUser(testDb);
const result = createMcpToken(user.id, 'a'.repeat(101));
expect(result.status).toBe(400);
});
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => {
const { user } = createUser(testDb);
const result = createMcpToken(user.id, 'My Token');
expect(result.token).toBeDefined();
expect((result.token as any).raw_token).toMatch(/^trek_/);
});
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => {
const { user } = createUser(testDb);
for (let i = 0; i < 10; i++) {
testDb.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
}
const result = createMcpToken(user.id, 'One More');
expect(result.status).toBe(400);
});
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => {
const { user } = createUser(testDb);
const result = deleteMcpToken(user.id, '99999');
expect(result.status).toBe(404);
});
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => {
const { user } = createUser(testDb);
const created = createMcpToken(user.id, 'Deletable Token');
const tokenId = String((created.token as any).id);
const result = deleteMcpToken(user.id, tokenId);
expect(result).toEqual({ success: true });
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
expect(row).toBeUndefined();
});
});
@@ -0,0 +1,932 @@
/**
* Unit tests for backupService.
* Covers BACKUP-031 to BACKUP-060.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ---------------------------------------------------------------------------
// Hoisted mocks — must be defined before any vi.mock() calls
// ---------------------------------------------------------------------------
const fsMock = vi.hoisted(() => ({
existsSync: vi.fn(),
mkdirSync: vi.fn(),
createWriteStream: vi.fn(),
unlinkSync: vi.fn(),
statSync: vi.fn(),
readdirSync: vi.fn(),
createReadStream: vi.fn(),
rmSync: vi.fn(),
copyFileSync: vi.fn(),
cpSync: vi.fn(),
}));
const archiverInstanceMock = vi.hoisted(() => ({
pipe: vi.fn(),
file: vi.fn(),
directory: vi.fn(),
finalize: vi.fn(),
on: vi.fn(),
}));
const archiverMock = vi.hoisted(() => vi.fn());
const unzipperMock = vi.hoisted(() => ({
Extract: vi.fn(),
}));
const dbMock = vi.hoisted(() => ({
db: {
exec: vi.fn(),
prepare: vi.fn(),
},
closeDb: vi.fn(),
reinitialize: vi.fn(),
getPlaceWithTags: vi.fn(),
canAccessTrip: vi.fn(),
isOwner: vi.fn(),
}));
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a'.repeat(64),
updateJwtSecret: () => {},
}));
vi.mock('fs', () => ({ default: fsMock, ...fsMock }));
vi.mock('archiver', () => ({ default: archiverMock }));
vi.mock('unzipper', () => ({ default: unzipperMock }));
vi.mock('../../../src/scheduler', () => ({
VALID_INTERVALS: ['hourly', 'daily', 'weekly', 'monthly'],
loadSettings: vi.fn(() => ({
enabled: false,
interval: 'daily',
keep_days: 7,
hour: 2,
day_of_week: 0,
day_of_month: 1,
})),
saveSettings: vi.fn(),
start: vi.fn(),
}));
import {
formatSize,
parseIntField,
parseAutoBackupBody,
isValidBackupFilename,
checkRateLimit,
createBackup,
deleteBackup,
restoreFromZip,
BACKUP_RATE_WINDOW,
backupFilePath,
backupFileExists,
listBackups,
updateAutoSettings,
} from '../../../src/services/backupService';
// ---------------------------------------------------------------------------
// formatSize
// ---------------------------------------------------------------------------
describe('BACKUP-031 formatSize', () => {
it('formats bytes < 1024 as B', () => {
expect(formatSize(500)).toBe('500 B');
});
it('formats bytes in KB range', () => {
expect(formatSize(1024)).toBe('1.0 KB');
expect(formatSize(2048)).toBe('2.0 KB');
});
it('formats bytes in MB range', () => {
expect(formatSize(1024 * 1024)).toBe('1.0 MB');
expect(formatSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
});
it('boundary: exactly 1024 bytes is 1.0 KB', () => {
expect(formatSize(1023)).toBe('1023 B');
expect(formatSize(1024)).toBe('1.0 KB');
});
});
// ---------------------------------------------------------------------------
// parseIntField
// ---------------------------------------------------------------------------
describe('BACKUP-032 parseIntField', () => {
it('returns numeric value as-is when finite', () => {
expect(parseIntField(5, 99)).toBe(5);
});
it('floors float numbers', () => {
expect(parseIntField(7.9, 0)).toBe(7);
});
it('parses numeric strings', () => {
expect(parseIntField('12', 0)).toBe(12);
});
it('returns fallback for non-numeric string', () => {
expect(parseIntField('abc', 3)).toBe(3);
});
it('returns fallback for null', () => {
expect(parseIntField(null, 7)).toBe(7);
});
it('returns fallback for undefined', () => {
expect(parseIntField(undefined, 7)).toBe(7);
});
it('returns fallback for Infinity', () => {
expect(parseIntField(Infinity, 5)).toBe(5);
});
it('returns fallback for empty string', () => {
expect(parseIntField('', 4)).toBe(4);
});
});
// ---------------------------------------------------------------------------
// parseAutoBackupBody
// ---------------------------------------------------------------------------
describe('BACKUP-033 parseAutoBackupBody', () => {
it('parses all valid fields', () => {
const result = parseAutoBackupBody({
enabled: true,
interval: 'weekly',
keep_days: 14,
hour: 6,
day_of_week: 5,
day_of_month: 15,
});
expect(result).toEqual({
enabled: true,
interval: 'weekly',
keep_days: 14,
hour: 6,
day_of_week: 5,
day_of_month: 15,
});
});
it('defaults to daily when interval is invalid', () => {
const result = parseAutoBackupBody({ interval: 'not-valid' });
expect(result.interval).toBe('daily');
});
it('clamps hour to 0-23', () => {
expect(parseAutoBackupBody({ hour: 999 }).hour).toBe(23);
expect(parseAutoBackupBody({ hour: -1 }).hour).toBe(0);
});
it('clamps day_of_week to 0-6', () => {
expect(parseAutoBackupBody({ day_of_week: 10 }).day_of_week).toBe(6);
expect(parseAutoBackupBody({ day_of_week: -1 }).day_of_week).toBe(0);
});
it('clamps day_of_month to 1-28', () => {
expect(parseAutoBackupBody({ day_of_month: 99 }).day_of_month).toBe(28);
expect(parseAutoBackupBody({ day_of_month: 0 }).day_of_month).toBe(1);
});
it('treats enabled = "true" string as true', () => {
expect(parseAutoBackupBody({ enabled: 'true' }).enabled).toBe(true);
});
it('treats enabled = 1 as true', () => {
expect(parseAutoBackupBody({ enabled: 1 }).enabled).toBe(true);
});
it('treats enabled = false as false', () => {
expect(parseAutoBackupBody({ enabled: false }).enabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// isValidBackupFilename
// ---------------------------------------------------------------------------
describe('BACKUP-034 isValidBackupFilename', () => {
it('accepts valid backup filename', () => {
expect(isValidBackupFilename('backup-2026-04-06T12-00-00.zip')).toBe(true);
});
it('rejects path traversal', () => {
expect(isValidBackupFilename('../../etc/passwd')).toBe(false);
});
it('rejects filename without .zip extension', () => {
expect(isValidBackupFilename('backup-2026-04-06T12-00-00.tar.gz')).toBe(false);
});
it('rejects filename with spaces', () => {
expect(isValidBackupFilename('backup 2026.zip')).toBe(false);
});
it('rejects empty string', () => {
expect(isValidBackupFilename('')).toBe(false);
});
it('accepts filename with hyphens and underscores', () => {
expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// checkRateLimit
// ---------------------------------------------------------------------------
describe('BACKUP-035 checkRateLimit', () => {
// Each test uses a unique key to avoid state pollution between tests
it('allows first request', () => {
expect(checkRateLimit('test-key-1', 3, BACKUP_RATE_WINDOW)).toBe(true);
});
it('allows requests up to maxAttempts', () => {
const key = 'test-key-2';
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true);
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true);
});
it('blocks request exceeding maxAttempts within window', () => {
const key = 'test-key-3';
checkRateLimit(key, 2, BACKUP_RATE_WINDOW);
checkRateLimit(key, 2, BACKUP_RATE_WINDOW);
expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(false);
});
it('resets counter after window expires', () => {
vi.useFakeTimers();
const key = 'test-key-4';
const windowMs = 100;
checkRateLimit(key, 1, windowMs);
checkRateLimit(key, 1, windowMs); // this one is blocked
vi.advanceTimersByTime(200);
// After window expires, should be allowed again
expect(checkRateLimit(key, 1, windowMs)).toBe(true);
vi.useRealTimers();
});
});
// ---------------------------------------------------------------------------
// createBackup
// ---------------------------------------------------------------------------
describe('BACKUP-036 createBackup', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-036a — happy path: creates zip and returns BackupInfo', async () => {
// Set up fs mocks
fsMock.existsSync.mockImplementation((p: string) => {
// backupsDir exists, dbPath does not (skip DB file), uploadsDir does not exist
return false;
});
fsMock.mkdirSync.mockReturnValue(undefined);
// Mock WriteStream with event emitter behaviour
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
// Mock archiver instance
archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => {
// noop — no error
});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
// Trigger 'close' on the output stream to resolve the Promise
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 2048, birthtime: new Date('2026-04-06T12:00:00Z') });
const result = await createBackup();
expect(result).toHaveProperty('filename');
expect(result.filename).toMatch(/^backup-.*\.zip$/);
expect(result.size).toBe(2048);
expect(result.sizeText).toBe('2.0 KB');
expect(result).toHaveProperty('created_at');
expect(archiverMock).toHaveBeenCalledWith('zip', { zlib: { level: 9 } });
expect(archiverInstanceMock.pipe).toHaveBeenCalled();
expect(archiverInstanceMock.finalize).toHaveBeenCalled();
});
it('BACKUP-036b — WAL checkpoint error is swallowed (non-critical)', async () => {
// db.exec throws on WAL checkpoint
dbMock.db.exec.mockImplementationOnce(() => { throw new Error('WAL checkpoint failed'); });
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
fsMock.existsSync.mockReturnValue(false);
fsMock.mkdirSync.mockReturnValue(undefined);
archiverInstanceMock.on.mockImplementation((_event: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 512, birthtime: new Date('2026-04-06T12:00:00Z') });
// Should not throw even though WAL checkpoint failed
const result = await createBackup();
expect(result).toHaveProperty('filename');
expect(result.size).toBe(512);
});
it('BACKUP-036c — archiver error cleans up partial file and re-throws', async () => {
fsMock.existsSync.mockReturnValue(false);
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const archiveEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => {
archiveEvents[event] = cb;
});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
// Simulate archive error instead of success
if (archiveEvents['error']) archiveEvents['error'](new Error('disk full'));
});
archiverMock.mockReturnValue(archiverInstanceMock);
// The output file "exists" after partial write so cleanup runs
fsMock.existsSync.mockImplementation((p: string) => {
// Return true only when checking the output path (ends with .zip)
return String(p).endsWith('.zip');
});
fsMock.unlinkSync.mockReturnValue(undefined);
await expect(createBackup()).rejects.toThrow('disk full');
// Partial file should have been removed
expect(fsMock.unlinkSync).toHaveBeenCalled();
});
it('BACKUP-036d — includes travel.db when it exists', async () => {
fsMock.existsSync.mockImplementation((p: string) => {
// backupsDir does not need to be created (exists), dbPath exists, no uploads
if (String(p).endsWith('travel.db')) return true;
return false;
});
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
// archive.file should have been called with the db path
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
expect.stringContaining('travel.db'),
{ name: 'travel.db' }
);
});
it('BACKUP-036e — includes uploads directory when it exists', async () => {
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('uploads')) return true;
return false;
});
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
on: vi.fn((event: string, cb: Function) => {
writableEvents[event] = cb;
}),
};
fsMock.createWriteStream.mockReturnValue(fakeWriteStream);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
await createBackup();
expect(archiverInstanceMock.directory).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
'uploads'
);
});
});
// ---------------------------------------------------------------------------
// deleteBackup
// ---------------------------------------------------------------------------
describe('BACKUP-037 deleteBackup', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-037a — happy path: calls unlinkSync with correct path', () => {
fsMock.unlinkSync.mockReturnValue(undefined);
deleteBackup('backup-2026-04-06T12-00-00.zip');
expect(fsMock.unlinkSync).toHaveBeenCalledOnce();
expect(fsMock.unlinkSync).toHaveBeenCalledWith(
expect.stringContaining('backup-2026-04-06T12-00-00.zip')
);
});
it('BACKUP-037b — throws when unlinkSync throws (file not found)', () => {
fsMock.unlinkSync.mockImplementation(() => {
const err: NodeJS.ErrnoException = new Error('ENOENT: no such file or directory');
err.code = 'ENOENT';
throw err;
});
expect(() => deleteBackup('backup-missing.zip')).toThrow('ENOENT');
});
});
// ---------------------------------------------------------------------------
// restoreFromZip
// ---------------------------------------------------------------------------
describe('BACKUP-038 restoreFromZip', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-038a — returns error when travel.db not found in zip', async () => {
// Simulate successful extraction but missing travel.db
const fakeReadStream = { pipe: vi.fn() };
const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) };
fsMock.createReadStream.mockReturnValue(fakeReadStream);
fakeReadStream.pipe.mockReturnValue(fakeExtractStream);
unzipperMock.Extract.mockReturnValue(fakeExtractStream);
// extractedDb does not exist
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return false;
return true; // extractDir exists for cleanup
});
fsMock.rmSync.mockReturnValue(undefined);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.error).toMatch(/travel\.db not found/i);
expect(result.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// better-sqlite3 mock — hoisted by Vitest regardless of file position
// ---------------------------------------------------------------------------
const DatabaseMock = vi.hoisted(() => vi.fn());
vi.mock('better-sqlite3', () => ({ default: DatabaseMock }));
// ---------------------------------------------------------------------------
// backupFilePath
// ---------------------------------------------------------------------------
describe('BACKUP-039 backupFilePath', () => {
it('BACKUP-039a — returns a path ending with the given filename', () => {
const result = backupFilePath('backup-test.zip');
expect(result).toMatch(/backup-test\.zip$/);
});
});
// ---------------------------------------------------------------------------
// backupFileExists
// ---------------------------------------------------------------------------
describe('BACKUP-040 backupFileExists', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-040a — returns true when existsSync returns true', () => {
fsMock.existsSync.mockReturnValue(true);
expect(backupFileExists('backup-2026-01-01T00-00-00.zip')).toBe(true);
expect(fsMock.existsSync).toHaveBeenCalledWith(
expect.stringContaining('backup-2026-01-01T00-00-00.zip')
);
});
it('BACKUP-040b — returns false when existsSync returns false', () => {
fsMock.existsSync.mockReturnValue(false);
expect(backupFileExists('backup-missing.zip')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// listBackups
// ---------------------------------------------------------------------------
describe('BACKUP-041 listBackups', () => {
beforeEach(() => {
vi.clearAllMocks();
// ensureBackupsDir: backupsDir already exists so mkdirSync is not called
fsMock.existsSync.mockReturnValue(true);
});
it('BACKUP-041a — returns empty array when no .zip files in directory', () => {
fsMock.readdirSync.mockReturnValue([]);
expect(listBackups()).toEqual([]);
});
it('BACKUP-041b — returns BackupInfo array for each .zip file', () => {
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']);
fsMock.statSync.mockReturnValue({
size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'),
});
const result = listBackups();
expect(result).toHaveLength(1);
expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip');
expect(result[0].size).toBe(1024);
expect(result[0].sizeText).toBe('1.0 KB');
expect(result[0].created_at).toBe('2026-01-01T00:00:00.000Z');
});
it('BACKUP-041c — sorts results newest-first', () => {
fsMock.readdirSync.mockReturnValue([
'backup-2026-01-01T00-00-00.zip',
'backup-2026-06-01T00-00-00.zip',
]);
fsMock.statSync.mockImplementation((p: string) => {
if (String(p).includes('2026-01-01')) {
return { size: 512, birthtime: new Date('2026-01-01T00:00:00Z') };
}
return { size: 2048, birthtime: new Date('2026-06-01T00:00:00Z') };
});
const result = listBackups();
expect(result).toHaveLength(2);
expect(result[0].filename).toBe('backup-2026-06-01T00-00-00.zip');
expect(result[1].filename).toBe('backup-2026-01-01T00-00-00.zip');
});
it('BACKUP-041d — filters out non-.zip files', () => {
fsMock.readdirSync.mockReturnValue([
'backup-2026-01-01T00-00-00.zip',
'README.txt',
'backup-partial.tar.gz',
]);
fsMock.statSync.mockReturnValue({
size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'),
});
const result = listBackups();
expect(result).toHaveLength(1);
expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip');
});
});
// ---------------------------------------------------------------------------
// restoreFromZip — extended paths (BACKUP-042 through BACKUP-046)
// ---------------------------------------------------------------------------
/** Shared helper: configures the stream mocks so extraction succeeds. */
function setupSuccessfulExtraction() {
const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) };
const fakeReadStream = { pipe: vi.fn().mockReturnValue(fakeExtractStream) };
fsMock.createReadStream.mockReturnValue(fakeReadStream);
unzipperMock.Extract.mockReturnValue(fakeExtractStream);
return { fakeReadStream, fakeExtractStream };
}
describe('BACKUP-042 restoreFromZip — integrity check fails', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-042a — returns status 400 with integrity check error message', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.rmSync.mockReturnValue(undefined);
const fakeDbInstance = {
prepare: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue({ integrity_check: 'corruption' }),
all: vi.fn(),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/integrity check/i);
expect(fsMock.rmSync).toHaveBeenCalled();
});
});
describe('BACKUP-043 restoreFromZip — missing required table', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-043a — returns status 400 with missing required table error', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.rmSync.mockReturnValue(undefined);
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([{ name: 'users' }, { name: 'trips' }]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/missing required table/i);
expect(fsMock.rmSync).toHaveBeenCalled();
});
});
describe('BACKUP-044 restoreFromZip — Database constructor throws (invalid SQLite)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-044a — returns status 400 with "not a valid SQLite database" error', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.rmSync.mockReturnValue(undefined);
DatabaseMock.mockImplementation(() => {
throw new Error('file is not a database');
});
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toMatch(/not a valid SQLite database/i);
expect(fsMock.rmSync).toHaveBeenCalled();
});
});
describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function setupAllTablesPresent() {
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
return fakeDbInstance;
}
it('BACKUP-045a — returns { success: true } on full success', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return false;
return true;
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
const result = await restoreFromZip('/data/tmp/upload.zip');
expect(result).toEqual({ success: true });
});
it('BACKUP-045b — closeDb is called before file copy operations', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
const callOrder: string[] = [];
dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); });
fsMock.copyFileSync.mockImplementation(() => { callOrder.push('copyFileSync'); });
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return false;
return true;
});
await restoreFromZip('/data/tmp/upload.zip');
expect(callOrder.indexOf('closeDb')).toBeLessThan(callOrder.indexOf('copyFileSync'));
});
it('BACKUP-045c — reinitialize is called even when copyFileSync throws', async () => {
setupSuccessfulExtraction();
setupAllTablesPresent();
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return false;
return true;
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockImplementation(() => {
throw new Error('disk full');
});
fsMock.rmSync.mockReturnValue(undefined);
await expect(restoreFromZip('/data/tmp/upload.zip')).rejects.toThrow('disk full');
expect(dbMock.reinitialize).toHaveBeenCalled();
});
});
describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('BACKUP-046a — cpSync is called to copy uploads when they exist in the archive', async () => {
setupSuccessfulExtraction();
const fakeDbInstance = {
prepare: vi.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
DatabaseMock.mockReturnValue(fakeDbInstance);
fsMock.existsSync.mockImplementation((p: string) => {
// travel.db present, extractedUploads present
if (String(p).endsWith('travel.db')) return true;
if (String(p).includes('uploads')) return true;
return true;
});
fsMock.readdirSync.mockImplementation((p: string) => {
// uploadsDir has one subdirectory 'photos'; 'photos' has one file
if (String(p).includes('uploads') && !String(p).includes('restore-')) {
return ['photos'] as any;
}
if (String(p).includes('photos')) return ['img1.jpg'] as any;
return [] as any;
});
fsMock.statSync.mockReturnValue({ isDirectory: () => true } as any);
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.copyFileSync.mockReturnValue(undefined);
fsMock.cpSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
await restoreFromZip('/data/tmp/upload.zip');
expect(fsMock.cpSync).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
expect.stringContaining('uploads'),
{ recursive: true, force: true }
);
});
});
// ---------------------------------------------------------------------------
// updateAutoSettings
// ---------------------------------------------------------------------------
describe('BACKUP-047 updateAutoSettings', () => {
let schedulerMock: typeof import('../../../src/scheduler');
beforeEach(async () => {
vi.clearAllMocks();
schedulerMock = await import('../../../src/scheduler');
});
it('BACKUP-047a — calls scheduler.saveSettings with the parsed settings', () => {
updateAutoSettings({ enabled: true, interval: 'weekly', hour: 6 });
expect(schedulerMock.saveSettings).toHaveBeenCalledOnce();
expect(schedulerMock.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, interval: 'weekly', hour: 6 })
);
});
it('BACKUP-047b — calls scheduler.start() after saving', () => {
const saveOrder: string[] = [];
(schedulerMock.saveSettings as ReturnType<typeof vi.fn>).mockImplementation(() => {
saveOrder.push('saveSettings');
});
(schedulerMock.start as ReturnType<typeof vi.fn>).mockImplementation(() => {
saveOrder.push('start');
});
updateAutoSettings({ enabled: false });
expect(saveOrder).toEqual(['saveSettings', 'start']);
});
it('BACKUP-047c — returns the parsed settings object', () => {
const result = updateAutoSettings({
enabled: true,
interval: 'monthly',
keep_days: 30,
hour: 3,
day_of_week: 2,
day_of_month: 15,
});
expect(result).toEqual({
enabled: true,
interval: 'monthly',
keep_days: 30,
hour: 3,
day_of_week: 2,
day_of_month: 15,
});
});
});
@@ -0,0 +1,189 @@
/**
* Unit tests for categoryService — CAT-SVC-001 through CAT-SVC-015.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
listCategories,
createCategory,
getCategoryById,
updateCategory,
deleteCategory,
} from '../../../src/services/categoryService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listCategories ────────────────────────────────────────────────────────────
describe('listCategories', () => {
it('CAT-SVC-001 — returns an array (seeded defaults are present after migrations)', () => {
// Migrations seed default categories, so the list is never empty in a fully initialized DB
const cats = listCategories() as any[];
expect(Array.isArray(cats)).toBe(true);
expect(cats.length).toBeGreaterThan(0);
});
it('CAT-SVC-002 — results are ordered by name ascending (custom categories sort correctly)', () => {
const { user } = createUser(testDb);
createCategory(user.id, 'Zoo');
createCategory(user.id, 'Aquarium');
// Migrations seed default categories; verify ordering by checking our custom ones appear in sorted order
const names = (listCategories() as any[]).map((c: any) => c.name);
const aquariumIdx = names.indexOf('Aquarium');
const zooIdx = names.indexOf('Zoo');
expect(aquariumIdx).toBeGreaterThanOrEqual(0);
expect(zooIdx).toBeGreaterThanOrEqual(0);
expect(aquariumIdx).toBeLessThan(zooIdx);
});
it('CAT-SVC-003 — returns categories from all users (including seeded defaults)', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
const before = (listCategories() as any[]).length;
createCategory(a.id, 'Cat-A');
createCategory(b.id, 'Cat-B');
expect(listCategories()).toHaveLength(before + 2);
});
});
// ── createCategory ────────────────────────────────────────────────────────────
describe('createCategory', () => {
it('CAT-SVC-004 — creates a category with name, color, and icon', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Restaurant', '#ff5500', '🍽️') as any;
expect(cat.name).toBe('Restaurant');
expect(cat.color).toBe('#ff5500');
expect(cat.icon).toBe('🍽️');
expect(cat.user_id).toBe(user.id);
});
it('CAT-SVC-005 — defaults color to #6366f1 when not provided', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Default Color') as any;
expect(cat.color).toBe('#6366f1');
});
it('CAT-SVC-006 — defaults icon to 📍 when not provided', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Default Icon') as any;
expect(cat.icon).toBe('📍');
});
it('CAT-SVC-007 — returns the inserted row with an id', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'WithId') as any;
expect(typeof cat.id).toBe('number');
expect(cat.id).toBeGreaterThan(0);
});
});
// ── getCategoryById ───────────────────────────────────────────────────────────
describe('getCategoryById', () => {
it('CAT-SVC-008 — returns category for a valid id', () => {
const { user } = createUser(testDb);
const created = createCategory(user.id, 'Find Me') as any;
const found = getCategoryById(created.id) as any;
expect(found).toBeDefined();
expect(found.name).toBe('Find Me');
});
it('CAT-SVC-009 — returns undefined for non-existent id', () => {
expect(getCategoryById(99999)).toBeUndefined();
});
it('CAT-SVC-010 — accepts string id (coerced by SQLite)', () => {
const { user } = createUser(testDb);
const created = createCategory(user.id, 'StringId') as any;
const found = getCategoryById(String(created.id)) as any;
expect(found).toBeDefined();
expect(found.id).toBe(created.id);
});
});
// ── updateCategory ────────────────────────────────────────────────────────────
describe('updateCategory', () => {
it('CAT-SVC-011 — updates name, color, and icon', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'Old', '#aaaaaa', '❓') as any;
const updated = updateCategory(cat.id, 'New', '#bbbbbb', '✅') as any;
expect(updated.name).toBe('New');
expect(updated.color).toBe('#bbbbbb');
expect(updated.icon).toBe('✅');
});
it('CAT-SVC-012 — COALESCE: omitting name preserves existing name', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'KeepName', '#aaaaaa', '⭐') as any;
const updated = updateCategory(cat.id, undefined, '#cccccc', '🔥') as any;
expect(updated.name).toBe('KeepName');
expect(updated.color).toBe('#cccccc');
});
it('CAT-SVC-013 — COALESCE: omitting color preserves existing color', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'KeepColor', '#dddddd', '⭐') as any;
const updated = updateCategory(cat.id, 'NewName', undefined, '🌟') as any;
expect(updated.name).toBe('NewName');
expect(updated.color).toBe('#dddddd');
});
});
// ── deleteCategory ────────────────────────────────────────────────────────────
describe('deleteCategory', () => {
it('CAT-SVC-014 — deletes the category from the database', () => {
const { user } = createUser(testDb);
const cat = createCategory(user.id, 'ToDelete') as any;
deleteCategory(cat.id);
expect(getCategoryById(cat.id)).toBeUndefined();
});
it('CAT-SVC-015 — deleting a non-existent category does not throw', () => {
expect(() => deleteCategory(99999)).not.toThrow();
});
});
@@ -0,0 +1,403 @@
/**
* Unit tests for dayService — DAY-SVC-001 through DAY-SVC-030.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
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: (placeId: any) => {
const place: any = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import {
verifyTripAccess,
getAssignmentsForDay,
listDays,
createDay as svcCreateDay,
getDay,
updateDay,
deleteDay,
listAccommodations,
validateAccommodationRefs,
createAccommodation,
getAccommodation,
updateAccommodation,
deleteAccommodation,
} from '../../../src/services/dayService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── verifyTripAccess ──────────────────────────────────────────────────────────
describe('verifyTripAccess', () => {
it('DAY-SVC-001 — returns trip row for owner', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = verifyTripAccess(trip.id, user.id) as any;
expect(result).toBeDefined();
expect(result.id).toBe(trip.id);
});
it('DAY-SVC-002 — returns falsy for non-member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
});
});
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
describe('getAssignmentsForDay', () => {
it('DAY-SVC-003 — returns empty array when day has no assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
expect(getAssignmentsForDay(day.id)).toEqual([]);
});
it('DAY-SVC-004 — returns assignments with nested place object', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower', lat: 48.8, lng: 2.3 }) as any;
createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
const assignments = getAssignmentsForDay(day.id) as any[];
expect(assignments).toHaveLength(1);
expect(assignments[0].place).toBeDefined();
expect(assignments[0].place.name).toBe('Eiffel Tower');
expect(assignments[0].place.lat).toBe(48.8);
});
it('DAY-SVC-005 — assignment includes tags array (empty when place has none)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'No Tags' }) as any;
createDayAssignment(testDb, day.id, place.id);
const assignments = getAssignmentsForDay(day.id) as any[];
expect(Array.isArray(assignments[0].place.tags)).toBe(true);
});
it('DAY-SVC-006 — assignments are ordered by order_index ASC', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const p1 = createPlace(testDb, trip.id, { name: 'Second' }) as any;
const p2 = createPlace(testDb, trip.id, { name: 'First' }) as any;
createDayAssignment(testDb, day.id, p1.id, { order_index: 2 });
createDayAssignment(testDb, day.id, p2.id, { order_index: 1 });
const assignments = getAssignmentsForDay(day.id) as any[];
expect(assignments[0].place.name).toBe('First');
expect(assignments[1].place.name).toBe('Second');
});
});
// ── listDays ──────────────────────────────────────────────────────────────────
describe('listDays', () => {
it('DAY-SVC-007 — returns { days: [] } for trip with no days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = listDays(trip.id) as any;
expect(result.days).toEqual([]);
});
it('DAY-SVC-008 — returns days with assignments nested', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createDay(testDb, trip.id);
const result = listDays(trip.id) as any;
expect(result.days).toHaveLength(1);
expect(Array.isArray(result.days[0].assignments)).toBe(true);
});
});
// ── createDay ─────────────────────────────────────────────────────────────────
describe('createDay (service)', () => {
it('DAY-SVC-009 — creates a day with auto-incremented day_number', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const d1 = svcCreateDay(trip.id) as any;
const d2 = svcCreateDay(trip.id) as any;
expect(d1.day_number).toBe(1);
expect(d2.day_number).toBe(2);
});
it('DAY-SVC-010 — returns day with empty assignments array', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = svcCreateDay(trip.id) as any;
expect(Array.isArray(day.assignments)).toBe(true);
expect(day.assignments).toHaveLength(0);
});
});
// ── getDay / updateDay / deleteDay ────────────────────────────────────────────
describe('getDay', () => {
it('DAY-SVC-011 — returns day when id and tripId match', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const found = getDay(day.id, trip.id) as any;
expect(found).toBeDefined();
expect(found.id).toBe(day.id);
});
it('DAY-SVC-012 — returns undefined for non-existent day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getDay(99999, trip.id)).toBeUndefined();
});
});
describe('updateDay', () => {
it('DAY-SVC-013 — updates notes and returns updated day with assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const updated = updateDay(day.id, day, { notes: 'Updated notes' }) as any;
expect(updated.notes).toBe('Updated notes');
expect(Array.isArray(updated.assignments)).toBe(true);
});
it('DAY-SVC-014 — updates title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const updated = updateDay(day.id, day, { title: 'Day 1 - City Tour' }) as any;
expect(updated.title).toBe('Day 1 - City Tour');
});
});
describe('deleteDay', () => {
it('DAY-SVC-015 — deletes the day', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
deleteDay(day.id);
expect(getDay(day.id, trip.id)).toBeUndefined();
});
});
// ── validateAccommodationRefs ─────────────────────────────────────────────────
describe('validateAccommodationRefs', () => {
it('DAY-SVC-016 — returns no errors when all refs are valid', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const errors = validateAccommodationRefs(trip.id, place.id, day.id, day.id);
expect(errors).toHaveLength(0);
});
it('DAY-SVC-017 — returns error when place does not exist in trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const errors = validateAccommodationRefs(trip.id, 99999, day.id, day.id);
expect(errors.some((e: any) => e.field === 'place_id')).toBe(true);
});
it('DAY-SVC-018 — returns error when start_day_id is invalid', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const errors = validateAccommodationRefs(trip.id, place.id, 99999, day.id);
expect(errors.some((e: any) => e.field === 'start_day_id')).toBe(true);
});
});
// ── createAccommodation ───────────────────────────────────────────────────────
describe('createAccommodation', () => {
it('DAY-SVC-019 — creates accommodation and returns it with place info', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
check_in: '15:00',
check_out: '11:00',
}) as any;
expect(accom).toBeDefined();
expect(accom.place_name).toBe('Grand Hotel');
});
it('DAY-SVC-020 — auto-creates a linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
expect(reservation).toBeDefined();
expect(reservation.type).toBe('hotel');
expect(reservation.status).toBe('confirmed');
});
});
// ── getAccommodation ──────────────────────────────────────────────────────────
describe('getAccommodation', () => {
it('DAY-SVC-021 — returns accommodation for valid id and trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
const found = getAccommodation(accom.id, trip.id) as any;
expect(found).toBeDefined();
expect(found.id).toBe(accom.id);
});
it('DAY-SVC-022 — returns undefined for non-existent accommodation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getAccommodation(99999, trip.id)).toBeUndefined();
});
});
// ── updateAccommodation ───────────────────────────────────────────────────────
describe('updateAccommodation', () => {
it('DAY-SVC-023 — updates check-in and check-out times', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const existing = getAccommodation(accom.id, trip.id)!;
const updated = updateAccommodation(accom.id, existing as any, { check_in: '16:00', check_out: '12:00' }) as any;
expect(updated).toBeDefined();
// Verify linked reservation metadata was synced
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
expect(reservation).toBeDefined();
const meta = JSON.parse(reservation.metadata || '{}');
expect(meta.check_in_time).toBe('16:00');
expect(meta.check_out_time).toBe('12:00');
});
it('DAY-SVC-024 — preserves existing fields when not updated', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
confirmation: 'ABC123',
}) as any;
const existing = getAccommodation(accom.id, trip.id)!;
updateAccommodation(accom.id, existing as any, { check_in: '14:00' });
const row = getAccommodation(accom.id, trip.id) as any;
expect(row.confirmation).toBe('ABC123');
});
});
// ── deleteAccommodation ───────────────────────────────────────────────────────
describe('deleteAccommodation', () => {
it('DAY-SVC-025 — deletes accommodation and its linked reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
const result = deleteAccommodation(accom.id);
expect(result.linkedReservationId).toBe(reservation.id);
// Accommodation is gone
expect(getAccommodation(accom.id, trip.id)).toBeUndefined();
// Reservation is gone
const deletedRes = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
expect(deletedRes).toBeUndefined();
});
it('DAY-SVC-026 — returns null linkedReservationId when no reservation was linked', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createDayAccommodation(testDb, trip.id, place.id, day.id, day.id) as any;
// Remove the auto-created reservation so there's no linked one
testDb.prepare('DELETE FROM reservations WHERE accommodation_id = ?').run(accom.id);
const result = deleteAccommodation(accom.id);
expect(result.linkedReservationId).toBeNull();
});
});
@@ -68,4 +68,49 @@ describe('ephemeralTokens', () => {
expect(result).toBeNull();
});
});
describe('startTokenCleanup / stopTokenCleanup', () => {
it('startTokenCleanup starts the interval (second call is no-op)', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
startTokenCleanup();
startTokenCleanup(); // should be no-op, not throw
// Token created while cleanup is running should still be consumable (interval hasn't fired)
const token = createEphemeralToken(1, 'ws')!;
expect(consumeEphemeralToken(token, 'ws')).toBe(1);
stopTokenCleanup();
vi.useRealTimers();
});
it('stopTokenCleanup clears the interval and allows restart', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
startTokenCleanup();
stopTokenCleanup();
stopTokenCleanup(); // calling stop twice should not throw
startTokenCleanup(); // should be able to start again after stop
stopTokenCleanup();
// After stop, tokens should still be consumable (cleanup didn't run)
const token = createEphemeralToken(2, 'download')!;
expect(consumeEphemeralToken(token, 'download')).toBe(2);
vi.useRealTimers();
});
it('cleanup interval removes expired tokens', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken, startTokenCleanup, stopTokenCleanup } = await getModule();
startTokenCleanup();
const token = createEphemeralToken(1, 'ws')!; // 30s TTL
// Advance past TTL AND past cleanup interval (60s)
vi.advanceTimersByTime(65_000);
// Token should have been cleaned up by the interval
const result = consumeEphemeralToken(token, 'ws');
expect(result).toBeNull();
stopTokenCleanup();
vi.useRealTimers();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@ import {
getAdminGlobalPref,
getActiveChannels,
getAvailableChannels,
isWebhookConfigured,
} from '../../../src/services/notificationPreferencesService';
beforeAll(() => {
@@ -316,3 +317,19 @@ describe('setAdminPreferences', () => {
expect(row?.value).toBe('1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// isWebhookConfigured
// ─────────────────────────────────────────────────────────────────────────────
describe('isWebhookConfigured', () => {
it('NPREF-026 — returns false when webhook is not in active channels', () => {
// No notification_channels configured → defaults don't include webhook
expect(isWebhookConfigured()).toBe(false);
});
it('NPREF-027 — returns true when webhook is in active channels', () => {
setNotificationChannels(testDb, 'webhook');
expect(isWebhookConfigured()).toBe(true);
});
});
@@ -0,0 +1,391 @@
/**
* Unit tests for oidcService OIDC-SVC-001 through OIDC-SVC-025.
* Covers state management, auth codes, role resolution, findOrCreateUser,
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } 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, t.user_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-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
createState,
consumeState,
createAuthCode,
consumeAuthCode,
resolveOidcRole,
frontendUrl,
findOrCreateUser,
discover,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
issuer: 'https://oidc.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
displayName: 'SSO',
discoveryUrl: null,
};
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.OIDC_ADMIN_VALUE;
delete process.env.OIDC_ADMIN_CLAIM;
delete process.env.NODE_ENV;
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── createState / consumeState ────────────────────────────────────────────────
describe('createState / consumeState', () => {
it('OIDC-SVC-001: createState returns a hex token', () => {
const state = createState('https://example.com/callback');
expect(state).toMatch(/^[0-9a-f]{64}$/);
});
it('OIDC-SVC-002: consumeState returns stored data and deletes state', () => {
const state = createState('https://example.com/callback', 'invite-abc');
const data = consumeState(state);
expect(data).not.toBeNull();
expect(data!.redirectUri).toBe('https://example.com/callback');
expect(data!.inviteToken).toBe('invite-abc');
// State is consumed — second call returns null
expect(consumeState(state)).toBeNull();
});
it('OIDC-SVC-003: consumeState returns null for unknown state', () => {
expect(consumeState('not-a-real-state')).toBeNull();
});
it('OIDC-SVC-004: two different states do not conflict', () => {
const s1 = createState('http://a.example.com');
const s2 = createState('http://b.example.com');
expect(s1).not.toBe(s2);
expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com');
expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com');
});
});
// ── createAuthCode / consumeAuthCode ─────────────────────────────────────────
describe('createAuthCode / consumeAuthCode', () => {
it('OIDC-SVC-005: createAuthCode returns a UUID-like string', () => {
const code = createAuthCode('my.jwt.token');
expect(typeof code).toBe('string');
expect(code.length).toBeGreaterThan(0);
});
it('OIDC-SVC-006: consumeAuthCode returns the stored token', () => {
const code = createAuthCode('real.jwt.here');
const result = consumeAuthCode(code);
expect('token' in result).toBe(true);
expect((result as { token: string }).token).toBe('real.jwt.here');
});
it('OIDC-SVC-007: auth code is single-use (second consume returns error)', () => {
const code = createAuthCode('single.use.token');
consumeAuthCode(code); // first use
const second = consumeAuthCode(code);
expect('error' in second).toBe(true);
});
it('OIDC-SVC-008: consumeAuthCode returns error for unknown code', () => {
const result = consumeAuthCode('not-a-real-code');
expect('error' in result).toBe(true);
});
});
// ── resolveOidcRole ───────────────────────────────────────────────────────────
describe('resolveOidcRole', () => {
it('OIDC-SVC-009: returns admin when isFirstUser is true', () => {
expect(resolveOidcRole({ sub: 'x' }, true)).toBe('admin');
});
it('OIDC-SVC-010: returns user when no OIDC_ADMIN_VALUE is set', () => {
delete process.env.OIDC_ADMIN_VALUE;
expect(resolveOidcRole({ sub: 'x', groups: ['admins'] }, false)).toBe('user');
});
it('OIDC-SVC-011: returns admin when groups array contains OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users', 'trek-admins'] }, false)).toBe('admin');
});
it('OIDC-SVC-012: returns user when groups array does not contain OIDC_ADMIN_VALUE', () => {
process.env.OIDC_ADMIN_VALUE = 'trek-admins';
expect(resolveOidcRole({ sub: 'x', groups: ['trek-users'] }, false)).toBe('user');
});
it('OIDC-SVC-013: uses custom OIDC_ADMIN_CLAIM when set', () => {
process.env.OIDC_ADMIN_VALUE = 'superadmin';
process.env.OIDC_ADMIN_CLAIM = 'roles';
expect(resolveOidcRole({ sub: 'x', roles: ['superadmin', 'editor'] }, false)).toBe('admin');
});
it('OIDC-SVC-014: handles string claim (exact match)', () => {
process.env.OIDC_ADMIN_VALUE = 'admin';
process.env.OIDC_ADMIN_CLAIM = 'role';
expect(resolveOidcRole({ sub: 'x', role: 'admin' }, false)).toBe('admin');
expect(resolveOidcRole({ sub: 'x', role: 'editor' }, false)).toBe('user');
});
});
// ── frontendUrl ───────────────────────────────────────────────────────────────
describe('frontendUrl', () => {
it('OIDC-SVC-015: prepends localhost:5173 in non-production', () => {
delete process.env.NODE_ENV;
expect(frontendUrl('/login?oidc_code=abc')).toBe('http://localhost:5173/login?oidc_code=abc');
});
it('OIDC-SVC-016: returns bare path in production', () => {
process.env.NODE_ENV = 'production';
expect(frontendUrl('/login?oidc_code=abc')).toBe('/login?oidc_code=abc');
delete process.env.NODE_ENV;
});
});
// ── discover ──────────────────────────────────────────────────────────────────
describe('discover', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-017: fetches and returns discovery document', async () => {
const doc = {
authorization_endpoint: 'https://oidc.example.com/auth',
token_endpoint: 'https://oidc.example.com/token',
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => doc,
}));
// Use unique issuer to bypass module-level cache from other tests
const result = await discover('https://unique-1.example.com');
expect(result.authorization_endpoint).toBe(doc.authorization_endpoint);
expect(result.token_endpoint).toBe(doc.token_endpoint);
});
it('OIDC-SVC-018: throws when provider returns non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
await expect(discover('https://bad-issuer.example.com')).rejects.toThrow();
});
});
// ── issuer trailing-slash regex (ReDoS guard) ─────────────────────────────────
describe('getOidcConfig issuer trailing-slash regex', () => {
it('OIDC-SVC-019: /\\/+$/ strips trailing slashes in < 5ms', () => {
// The regex /\/+$/ in getOidcConfig: issuer.replace(/\/+$/, '')
// Adversarial input: many trailing slashes — should not backtrack catastrophically
const adversarial = 'https://oidc.example.com' + '/'.repeat(10000);
const start = Date.now();
const result = adversarial.replace(/\/+$/, '');
const elapsed = Date.now() - start;
expect(result).toBe('https://oidc.example.com');
expect(elapsed).toBeLessThan(100);
});
});
// ── findOrCreateUser ──────────────────────────────────────────────────────────
describe('findOrCreateUser', () => {
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
const result = findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
const { user } = createUser(testDb, { email: 'bob@example.com' });
const result = findOrCreateUser(
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-022: creates new user when registration is open', () => {
const result = findOrCreateUser(
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
});
it('OIDC-SVC-023: first user gets admin role', () => {
// DB is empty after resetTestDb
const result = findOrCreateUser(
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
});
it('OIDC-SVC-024: returns registration_disabled error when registration is off', () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
MOCK_CONFIG
);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('registration_disabled');
});
it('OIDC-SVC-025: links oidc_sub when existing user has none', () => {
const { user } = createUser(testDb, { email: 'charlie@example.com' });
// Ensure no oidc_sub set
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie' },
MOCK_CONFIG
);
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBe('sub-charlie-linked');
});
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_ADMIN_VALUE = 'admins';
const result = findOrCreateUser(
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
MOCK_CONFIG
);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
const dbUser = testDb.prepare('SELECT role FROM users WHERE id = ?').get(user.id) as any;
expect(dbUser.role).toBe('admin');
});
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
MOCK_CONFIG,
'tok-valid'
);
expect('user' in result).toBe(true);
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-valid'").get() as any;
expect(token.used_count).toBe(1);
});
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
MOCK_CONFIG,
'tok-expired'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'expired-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 0 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-expired'").get() as any;
expect(token.used_count).toBe(0);
});
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
MOCK_CONFIG,
'tok-full'
);
// User is still created because open registration is allowed
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT id FROM users WHERE email = 'full-invitee@example.com'").get();
expect(newUser).toBeDefined();
// Invite used_count must remain 1 (token was treated as invalid)
const token = testDb.prepare("SELECT used_count FROM invite_tokens WHERE token = 'tok-full'").get() as any;
expect(token.used_count).toBe(1);
});
});
@@ -0,0 +1,255 @@
/**
* Unit tests for packingService.ts uncovered functions.
* Covers PACK-SVC-001 to PACK-SVC-012.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB mock setup (vi.hoisted so it is available before vi.mock calls) ────────
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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
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 {
saveAsTemplate,
applyTemplate,
setBagMembers,
createBag,
deleteBag,
bulkImport,
} from '../../../src/services/packingService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── saveAsTemplate ────────────────────────────────────────────────────────────
describe('saveAsTemplate', () => {
it('PACK-SVC-001: saves packing items as a template with correct categories and item count', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shirt', 'Clothes', 0);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shorts', 'Clothes', 1);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 2);
const result = saveAsTemplate(trip.id, user.id, 'My Template');
expect(result).not.toBeNull();
expect(result!.name).toBe('My Template');
expect(result!.categoryCount).toBe(2);
expect(result!.itemCount).toBe(3);
const template = testDb.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result!.id) as any;
expect(template).toBeDefined();
expect(template.name).toBe('My Template');
expect(template.created_by).toBe(user.id);
});
it('PACK-SVC-002: returns null when trip has no packing items', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = saveAsTemplate(trip.id, user.id, 'Empty');
expect(result).toBeNull();
});
});
// ── applyTemplate ─────────────────────────────────────────────────────────────
describe('applyTemplate', () => {
it('PACK-SVC-003: adds template items to a trip packing list', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Insert a template with one category and two items directly
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Camping', user.id);
const templateId = templateResult.lastInsertRowid as number;
const catResult = testDb.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, 'Gear', 0);
const catId = catResult.lastInsertRowid as number;
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Tent', 0);
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Sleeping Bag', 1);
const result = applyTemplate(trip.id, templateId);
expect(result).not.toBeNull();
expect(Array.isArray(result)).toBe(true);
expect((result as any[]).length).toBe(2);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
expect(items.length).toBe(2);
expect(items.map((i: any) => i.name)).toContain('Tent');
expect(items.map((i: any) => i.name)).toContain('Sleeping Bag');
});
it('PACK-SVC-004: returns null when template has no items', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Empty Template', user.id);
const templateId = templateResult.lastInsertRowid as number;
const result = applyTemplate(trip.id, templateId);
expect(result).toBeNull();
});
});
// ── createBag / deleteBag ─────────────────────────────────────────────────────
describe('createBag / deleteBag', () => {
it('PACK-SVC-005: createBag inserts a bag and returns it', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = createBag(trip.id, { name: 'Carry-On', color: '#ff0000' }) as any;
expect(result).not.toBeNull();
expect(result.name).toBe('Carry-On');
expect(result.color).toBe('#ff0000');
expect(result.trip_id).toBe(trip.id);
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.id) as any;
expect(bag).toBeDefined();
expect(bag.name).toBe('Carry-On');
});
it('PACK-SVC-006: deleteBag removes the bag and returns true', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bag = createBag(trip.id, { name: 'Checked Bag' }) as any;
expect(bag).not.toBeNull();
const deleted = deleteBag(trip.id, bag.id);
expect(deleted).toBe(true);
const row = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bag.id);
expect(row).toBeUndefined();
});
it('PACK-SVC-007: deleteBag returns false for non-existent bag', () => {
const result = deleteBag(1, 99999);
expect(result).toBe(false);
});
});
// ── setBagMembers ─────────────────────────────────────────────────────────────
describe('setBagMembers', () => {
it('PACK-SVC-008: sets bag members (replaces existing)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bag = createBag(trip.id, { name: 'Main Bag' }) as any;
const result = setBagMembers(trip.id, bag.id, [user.id]) as any[];
expect(result).not.toBeNull();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].user_id).toBe(user.id);
});
it('PACK-SVC-009: setBagMembers with empty array clears all members', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const bag = createBag(trip.id, { name: 'Main Bag' }) as any;
// First add a member
setBagMembers(trip.id, bag.id, [user.id]);
// Then clear
const result = setBagMembers(trip.id, bag.id, []) as any[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(0);
});
it('PACK-SVC-010: setBagMembers returns null for non-existent bag', () => {
const result = setBagMembers(1, 99999, []);
expect(result).toBeNull();
});
});
// ── bulkImport with bag field ─────────────────────────────────────────────────
describe('bulkImport with bag field', () => {
it('PACK-SVC-011: bulk import with bag field creates the bag if it does not exist', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = bulkImport(trip.id, [{ name: 'Shirt', bag: 'Carry-On' }]);
expect(result).toHaveLength(1);
expect(result[0]).toBeDefined();
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
expect(bags).toHaveLength(1);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
expect(items).toHaveLength(1);
expect(items[0].bag_id).toBe(bags[0].id);
});
it('PACK-SVC-012: bulk import with same bag name reuses existing bag', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = bulkImport(trip.id, [
{ name: 'Shirt', bag: 'Carry-On' },
{ name: 'Pants', bag: 'Carry-On' },
]);
expect(result).toHaveLength(2);
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
expect(bags).toHaveLength(1);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
expect(items).toHaveLength(2);
expect(items[0].bag_id).toBe(bags[0].id);
expect(items[1].bag_id).toBe(bags[0].id);
});
});
+33 -2
View File
@@ -1,16 +1,21 @@
import { describe, it, expect, vi } from 'vitest';
// Mutable rows array so individual tests can inject DB rows
const dbRows: { key: string; value: string }[] = [];
// Mock database — permissions module queries app_settings at runtime
vi.mock('../../../src/db/database', () => ({
db: {
prepare: () => ({
all: () => [], // no custom permissions → fall back to defaults
all: () => dbRows, // no custom permissions → fall back to defaults
run: vi.fn(),
get: vi.fn(),
}),
transaction: (fn: () => void) => fn,
},
}));
import { checkPermission, getPermissionLevel, PERMISSION_ACTIONS } from '../../../src/services/permissions';
import { checkPermission, getPermissionLevel, savePermissions, invalidatePermissionsCache, PERMISSION_ACTIONS } from '../../../src/services/permissions';
describe('permissions', () => {
describe('checkPermission — admin bypass', () => {
@@ -80,4 +85,30 @@ describe('permissions', () => {
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
});
});
describe('savePermissions — invalid action key is skipped', () => {
it('returns skipped array containing invalid action key', () => {
const result = savePermissions({ nonexistent_action: 'trip_member' });
expect(result.skipped).toContain('nonexistent_action');
});
it('returns skipped array when level is not in allowedLevels for the action', () => {
// trip_delete only allows ['admin', 'trip_owner'], so 'trip_member' is invalid
const result = savePermissions({ trip_delete: 'trip_member' });
expect(result.skipped).toContain('trip_delete');
});
});
describe('checkPermission — default case', () => {
it('returns false when permission level is an unrecognized value', () => {
// Inject a DB row with an unknown level for trip_edit, then invalidate cache
dbRows.push({ key: 'perm_trip_edit', value: 'unknown_level' });
invalidatePermissionsCache();
const result = checkPermission('trip_edit', 'user', 10, 10, false);
// Clean up for subsequent tests
dbRows.length = 0;
invalidatePermissionsCache();
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,451 @@
/**
* Unit tests for placeService PLACE-SVC-001 through PLACE-SVC-025.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
* Skips importGpx / importGoogleList / searchPlaceImage (require external I/O).
*/
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: (placeId: any) => {
const place: any = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listPlaces ────────────────────────────────────────────────────────────────
describe('listPlaces', () => {
it('PLACE-SVC-001 — returns empty array when trip has no places', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(listPlaces(String(trip.id), {})).toEqual([]);
});
it('PLACE-SVC-002 — returns all places for a trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'Alpha' });
createPlace(testDb, trip.id, { name: 'Beta' });
const places = listPlaces(String(trip.id), {}) as any[];
expect(places).toHaveLength(2);
});
it('PLACE-SVC-003 — does not return places from other trips', () => {
const { user } = createUser(testDb);
const t1 = createTrip(testDb, user.id);
const t2 = createTrip(testDb, user.id);
createPlace(testDb, t1.id, { name: 'T1 Place' });
createPlace(testDb, t2.id, { name: 'T2 Place' });
const places = listPlaces(String(t1.id), {}) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('T1 Place');
});
it('PLACE-SVC-004 — filters by search term (name)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createPlace(testDb, trip.id, { name: 'Louvre Museum' });
const places = listPlaces(String(trip.id), { search: 'Eiffel' }) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('Eiffel Tower');
});
it('PLACE-SVC-005 — attaches tags array to each place (empty when none)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPlace(testDb, trip.id, { name: 'No Tags' });
const places = listPlaces(String(trip.id), {}) as any[];
expect(Array.isArray(places[0].tags)).toBe(true);
expect(places[0].tags).toHaveLength(0);
});
it('PLACE-SVC-006 — attaches category object when place has a category', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const cat = createCategory(testDb, { name: 'Museum', user_id: user.id }) as any;
const place = createPlace(testDb, trip.id, { name: 'Art Museum' }) as any;
testDb.prepare('UPDATE places SET category_id = ? WHERE id = ?').run(cat.id, place.id);
const places = listPlaces(String(trip.id), {}) as any[];
expect(places[0].category).toBeDefined();
expect(places[0].category.name).toBe('Museum');
});
});
// ── createPlace (via service) ─────────────────────────────────────────────────
describe('createPlace (service)', () => {
it('PLACE-SVC-007 — creates a place and returns it with tags array', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = svcCreatePlace(String(trip.id), { name: 'New Place', lat: 48.8, lng: 2.3 }) as any;
expect(place).toBeDefined();
expect(place.name).toBe('New Place');
expect(Array.isArray(place.tags)).toBe(true);
});
it('PLACE-SVC-008 — creates a place with tags', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag = createTag(testDb, user.id, { name: 'Highlight' }) as any;
const place = svcCreatePlace(String(trip.id), { name: 'Tagged Place', tags: [tag.id] }) as any;
expect(place.tags).toHaveLength(1);
expect(place.tags[0].id).toBe(tag.id);
});
it('PLACE-SVC-009 — place is associated with correct trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = svcCreatePlace(String(trip.id), { name: 'My Place' }) as any;
const row = testDb.prepare('SELECT trip_id FROM places WHERE id = ?').get(place.id) as any;
expect(row.trip_id).toBe(trip.id);
});
});
// ── getPlace ──────────────────────────────────────────────────────────────────
describe('getPlace', () => {
it('PLACE-SVC-010 — returns the place when tripId and placeId match', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Find Me' }) as any;
const found = getPlace(String(trip.id), String(place.id)) as any;
expect(found).toBeDefined();
expect(found.name).toBe('Find Me');
});
it('PLACE-SVC-011 — returns null when place belongs to different trip', () => {
const { user } = createUser(testDb);
const t1 = createTrip(testDb, user.id);
const t2 = createTrip(testDb, user.id);
const place = createPlace(testDb, t1.id, { name: 'T1 Place' }) as any;
expect(getPlace(String(t2.id), String(place.id))).toBeNull();
});
it('PLACE-SVC-012 — returns null for non-existent placeId', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getPlace(String(trip.id), '99999')).toBeNull();
});
});
// ── updatePlace ───────────────────────────────────────────────────────────────
describe('updatePlace', () => {
it('PLACE-SVC-013 — updates place name and lat/lng', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 0, lng: 0 }) as any;
const updated = updatePlace(String(trip.id), String(place.id), { name: 'New', lat: 48.8, lng: 2.3 }) as any;
expect(updated.name).toBe('New');
expect(updated.lat).toBe(48.8);
expect(updated.lng).toBe(2.3);
});
it('PLACE-SVC-014 — returns null for non-existent place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(updatePlace(String(trip.id), '99999', { name: 'Ghost' })).toBeNull();
});
it('PLACE-SVC-015 — updates tags (replaces old set)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag1 = createTag(testDb, user.id, { name: 'Old Tag' }) as any;
const tag2 = createTag(testDb, user.id, { name: 'New Tag' }) as any;
const place = svcCreatePlace(String(trip.id), { name: 'Taggable', tags: [tag1.id] }) as any;
const updated = updatePlace(String(trip.id), String(place.id), { tags: [tag2.id] }) as any;
expect(updated.tags).toHaveLength(1);
expect(updated.tags[0].id).toBe(tag2.id);
});
it('PLACE-SVC-016 — clears tags when tags: [] is passed', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tag = createTag(testDb, user.id, { name: 'Temp' }) as any;
const place = svcCreatePlace(String(trip.id), { name: 'Untaggable', tags: [tag.id] }) as any;
const updated = updatePlace(String(trip.id), String(place.id), { tags: [] }) as any;
expect(updated.tags).toHaveLength(0);
});
});
// ── deletePlace ───────────────────────────────────────────────────────────────
describe('deletePlace', () => {
it('PLACE-SVC-017 — deletes a place and returns true', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'To Delete' }) as any;
expect(deletePlace(String(trip.id), String(place.id))).toBe(true);
expect(getPlace(String(trip.id), String(place.id))).toBeNull();
});
it('PLACE-SVC-018 — returns false for non-existent place', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(deletePlace(String(trip.id), '99999')).toBe(false);
});
it('PLACE-SVC-019 — deleting one place does not remove others', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const p1 = createPlace(testDb, trip.id, { name: 'Keep' }) as any;
const p2 = createPlace(testDb, trip.id, { name: 'Remove' }) as any;
deletePlace(String(trip.id), String(p2.id));
const remaining = listPlaces(String(trip.id), {}) as any[];
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(p1.id);
});
});
// ── importGpx ─────────────────────────────────────────────────────────────────
describe('importGpx', () => {
it('PLACE-SVC-020 — returns null when buffer has no <gpx> root', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = importGpx(String(trip.id), Buffer.from('<not-gpx/>'));
expect(result).toBeNull();
});
it('PLACE-SVC-021 — imports <wpt> waypoints as places', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(2);
expect(places[0].name).toBe('Paris');
expect(places[1].name).toBe('London');
});
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<rte>
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
</rte>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(2);
expect(places[0].name).toBe('Start');
expect(places[1].name).toBe('End');
});
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<trk>
<name>My Track</name>
<trkseg>
<trkpt lat="48.8566" lon="2.3522"><ele>100</ele></trkpt>
<trkpt lat="48.8570" lon="2.3530"><ele>102</ele></trkpt>
</trkseg>
</trk>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('My Track');
const geometry = JSON.parse(places[0].route_geometry);
expect(Array.isArray(geometry)).toBe(true);
expect(geometry).toHaveLength(2);
});
it('PLACE-SVC-024 — <wpt> and <trk> together: waypoints plus track appended', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<wpt lat="48.8566" lon="2.3522"><name>POI</name></wpt>
<trk>
<name>Track</name>
<trkseg>
<trkpt lat="48.8566" lon="2.3522"></trkpt>
<trkpt lat="48.8570" lon="2.3530"></trkpt>
</trkseg>
</trk>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
// 1 wpt + 1 trk
expect(places).toHaveLength(2);
const trackPlace = places.find((p: any) => p.name === 'Track') as any;
expect(trackPlace).toBeDefined();
const geometry = JSON.parse(trackPlace.route_geometry);
expect(geometry).toHaveLength(2);
});
it('PLACE-SVC-025 — returns null when GPX has no usable elements', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1"></gpx>`);
const result = importGpx(String(trip.id), gpx);
expect(result).toBeNull();
});
});
// ── importGoogleList ──────────────────────────────────────────────────────────
describe('importGoogleList', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any;
expect(result.error).toMatch(/Could not extract list ID/);
expect(result.status).toBe(400);
});
it('PLACE-SVC-027 — returns error when Google Maps API responds with non-ok status', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 }));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.error).toMatch(/Failed to fetch list/);
expect(result.status).toBe(502);
});
it('PLACE-SVC-028 — imports places from a valid Google Maps list response', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
]],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.listName).toBe('My Test List');
expect(result.places).toHaveLength(2);
expect(result.places[0].name).toBe('Paris');
expect(result.places[1].name).toBe('London');
});
it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.error).toBeDefined();
expect(result.status).toBe(400);
});
});
// ── searchPlaceImage ──────────────────────────────────────────────────────────
describe('searchPlaceImage', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('PLACE-SVC-030 — returns 404 when place does not exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await searchPlaceImage(String(trip.id), '99999', user.id) as any;
expect(result.error).toBeDefined();
expect(result.status).toBe(404);
});
it('PLACE-SVC-031 — returns 400 when user has no Unsplash API key', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
expect(result.error).toMatch(/No Unsplash API key/);
expect(result.status).toBe(400);
});
it('PLACE-SVC-032 — returns photos when Unsplash API responds successfully', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('test-unsplash-key', user.id);
const mockPhotos = [
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: mockPhotos }),
status: 200,
}));
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
expect(result.photos).toHaveLength(1);
expect(result.photos[0].id).toBe('photo1');
expect(result.photos[0].url).toBe('https://img.example.com/1');
expect(result.photos[0].photographer).toBe('Photographer');
});
});
@@ -0,0 +1,224 @@
/**
* Unit tests for settingsService SET-SVC-001 through SET-SVC-020.
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
* so we don't need real encryption for most tests.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB + apiKeyCrypto mock ────────────────────────────────────────────────────
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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Passthrough crypto — value comes back unchanged for most tests
vi.mock('../../../src/services/apiKeyCrypto', () => ({
maybe_encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { getUserSettings, upsertSetting, bulkUpsertSettings } from '../../../src/services/settingsService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── getUserSettings ───────────────────────────────────────────────────────────
describe('getUserSettings', () => {
it('SET-SVC-001 — returns empty object when user has no settings', () => {
const { user } = createUser(testDb);
expect(getUserSettings(user.id)).toEqual({});
});
it('SET-SVC-002 — returns stored plain string values', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(user.id);
const s = getUserSettings(user.id);
expect(s.theme).toBe('dark');
});
it('SET-SVC-003 — JSON-parses values that are valid JSON', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'count', '42')").run(user.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'flag', 'true')").run(user.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'obj', '{\"x\":1}')").run(user.id);
const s = getUserSettings(user.id);
expect(s.count).toBe(42);
expect(s.flag).toBe(true);
expect(s.obj).toEqual({ x: 1 });
});
it('SET-SVC-004 — falls back to raw string when value is not valid JSON', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'raw', 'not-json')").run(user.id);
const s = getUserSettings(user.id);
expect(s.raw).toBe('not-json');
});
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
it('SET-SVC-006 — webhook_url with empty value returns empty string', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', '')").run(user.id);
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('');
});
it('SET-SVC-007 — only returns settings for the requesting user', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_a', '\"a\"')").run(a.id);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'key_b', '\"b\"')").run(b.id);
const s = getUserSettings(a.id);
expect(s).toHaveProperty('key_a');
expect(s).not.toHaveProperty('key_b');
});
});
// ── upsertSetting ─────────────────────────────────────────────────────────────
describe('upsertSetting', () => {
it('SET-SVC-008 — inserts a new setting', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'language', 'en');
const s = getUserSettings(user.id);
expect(s.language).toBe('en');
});
it('SET-SVC-009 — updates an existing setting (ON CONFLICT)', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'language', 'en');
upsertSetting(user.id, 'language', 'fr');
const s = getUserSettings(user.id);
expect(s.language).toBe('fr');
});
it('SET-SVC-010 — serializes object values as JSON', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'prefs', { dark: true, size: 14 });
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'prefs'").get(user.id) as any;
expect(raw.value).toBe('{"dark":true,"size":14}');
});
it('SET-SVC-011 — serializes boolean values as strings', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'notifications', true);
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
expect(raw.value).toBe('true');
});
it('SET-SVC-012 — webhook_url passes through maybe_encrypt_api_key', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
// With passthrough mock, value is stored as-is
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
expect(raw.value).toBe('https://hook.example.com');
// But getUserSettings masks it
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
});
// ── bulkUpsertSettings ────────────────────────────────────────────────────────
describe('bulkUpsertSettings', () => {
it('SET-SVC-013 — inserts multiple settings in one call', () => {
const { user } = createUser(testDb);
bulkUpsertSettings(user.id, { a: 'alpha', b: 'beta', c: 'gamma' });
const s = getUserSettings(user.id);
expect(s.a).toBe('alpha');
expect(s.b).toBe('beta');
expect(s.c).toBe('gamma');
});
it('SET-SVC-014 — returns the count of settings processed', () => {
const { user } = createUser(testDb);
const count = bulkUpsertSettings(user.id, { x: 1, y: 2, z: 3 });
expect(count).toBe(3);
});
it('SET-SVC-015 — updates existing keys (ON CONFLICT)', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'theme', 'light');
bulkUpsertSettings(user.id, { theme: 'dark', lang: 'en' });
const s = getUserSettings(user.id);
expect(s.theme).toBe('dark');
expect(s.lang).toBe('en');
});
it('SET-SVC-016 — returns 0 for empty settings object', () => {
const { user } = createUser(testDb);
const count = bulkUpsertSettings(user.id, {});
expect(count).toBe(0);
});
it('SET-SVC-017 — all changes are committed atomically (transaction)', () => {
const { user } = createUser(testDb);
bulkUpsertSettings(user.id, { p: '1', q: '2' });
const rows = testDb.prepare('SELECT key FROM settings WHERE user_id = ?').all(user.id) as any[];
const keys = rows.map((r: any) => r.key);
expect(keys).toContain('p');
expect(keys).toContain('q');
});
it('SET-SVC-018 — settings from different users do not interfere', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
bulkUpsertSettings(a.id, { shared_key: 'from-a' });
bulkUpsertSettings(b.id, { shared_key: 'from-b' });
expect((getUserSettings(a.id) as any).shared_key).toBe('from-a');
expect((getUserSettings(b.id) as any).shared_key).toBe('from-b');
});
it('SET-SVC-019 — rolls back and re-throws when DB write fails mid-transaction', () => {
const { user } = createUser(testDb);
const origPrepare = testDb.prepare.bind(testDb);
let intercepted = false;
vi.spyOn(testDb, 'prepare').mockImplementationOnce((sql: string) => {
const stmt = origPrepare(sql);
intercepted = true;
return { run: () => { throw new Error('forced DB error'); } } as any;
});
expect(() => bulkUpsertSettings(user.id, { k: 'v' })).toThrow('forced DB error');
expect(intercepted).toBe(true);
vi.restoreAllMocks();
});
});
@@ -0,0 +1,180 @@
/**
* Unit tests for tagService TAG-SVC-001 through TAG-SVC-015.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/services/tagService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── listTags ──────────────────────────────────────────────────────────────────
describe('listTags', () => {
it('TAG-SVC-001 — returns empty array when user has no tags', () => {
const { user } = createUser(testDb);
expect(listTags(user.id)).toEqual([]);
});
it('TAG-SVC-002 — returns only tags belonging to the user', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
createTag(a.id, 'A-Tag');
createTag(b.id, 'B-Tag');
const tags = listTags(a.id) as any[];
expect(tags).toHaveLength(1);
expect(tags[0].name).toBe('A-Tag');
});
it('TAG-SVC-003 — results are ordered by name ascending', () => {
const { user } = createUser(testDb);
createTag(user.id, 'Zebra');
createTag(user.id, 'Apple');
createTag(user.id, 'Mango');
const names = (listTags(user.id) as any[]).map((t: any) => t.name);
expect(names).toEqual(['Apple', 'Mango', 'Zebra']);
});
});
// ── createTag ─────────────────────────────────────────────────────────────────
describe('createTag', () => {
it('TAG-SVC-004 — creates a tag with provided name and color', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'Beach', '#ff0000') as any;
expect(tag.name).toBe('Beach');
expect(tag.color).toBe('#ff0000');
expect(tag.user_id).toBe(user.id);
});
it('TAG-SVC-005 — defaults to #10b981 when no color provided', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'Default') as any;
expect(tag.color).toBe('#10b981');
});
it('TAG-SVC-006 — returns the inserted row with an id', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'WithId') as any;
expect(typeof tag.id).toBe('number');
expect(tag.id).toBeGreaterThan(0);
});
});
// ── getTagByIdAndUser ─────────────────────────────────────────────────────────
describe('getTagByIdAndUser', () => {
it('TAG-SVC-007 — returns the tag when id and user_id match', () => {
const { user } = createUser(testDb);
const created = createTag(user.id, 'Find Me') as any;
const found = getTagByIdAndUser(created.id, user.id) as any;
expect(found).toBeDefined();
expect(found.name).toBe('Find Me');
});
it('TAG-SVC-008 — returns undefined when tag belongs to different user', () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
const tag = createTag(a.id, 'Private') as any;
expect(getTagByIdAndUser(tag.id, b.id)).toBeUndefined();
});
it('TAG-SVC-009 — returns undefined for non-existent tag id', () => {
const { user } = createUser(testDb);
expect(getTagByIdAndUser(99999, user.id)).toBeUndefined();
});
});
// ── updateTag ─────────────────────────────────────────────────────────────────
describe('updateTag', () => {
it('TAG-SVC-010 — updates both name and color', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'Old', '#aaaaaa') as any;
const updated = updateTag(tag.id, 'New', '#bbbbbb') as any;
expect(updated.name).toBe('New');
expect(updated.color).toBe('#bbbbbb');
});
it('TAG-SVC-011 — COALESCE: omitting name preserves existing name', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'KeepMe', '#aaaaaa') as any;
const updated = updateTag(tag.id, undefined, '#cccccc') as any;
expect(updated.name).toBe('KeepMe');
expect(updated.color).toBe('#cccccc');
});
it('TAG-SVC-012 — COALESCE: omitting color preserves existing color', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'ColorKeep', '#dddddd') as any;
const updated = updateTag(tag.id, 'NewName', undefined) as any;
expect(updated.name).toBe('NewName');
expect(updated.color).toBe('#dddddd');
});
});
// ── deleteTag ─────────────────────────────────────────────────────────────────
describe('deleteTag', () => {
it('TAG-SVC-013 — deletes the tag from the database', () => {
const { user } = createUser(testDb);
const tag = createTag(user.id, 'ToDelete') as any;
deleteTag(tag.id);
expect(getTagByIdAndUser(tag.id, user.id)).toBeUndefined();
});
it('TAG-SVC-014 — deleting a non-existent tag does not throw', () => {
expect(() => deleteTag(99999)).not.toThrow();
});
it('TAG-SVC-015 — deleting one tag does not affect other tags', () => {
const { user } = createUser(testDb);
const t1 = createTag(user.id, 'Keep') as any;
const t2 = createTag(user.id, 'Remove') as any;
deleteTag(t2.id);
const remaining = listTags(user.id) as any[];
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(t1.id);
});
});
@@ -0,0 +1,287 @@
/**
* Unit tests for todoService TODO-SVC-001 through TODO-SVC-020.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
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, t.user_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: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import {
verifyTripAccess,
listItems,
createItem,
updateItem,
deleteItem,
getCategoryAssignees,
updateCategoryAssignees,
reorderItems,
} from '../../../src/services/todoService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── verifyTripAccess ──────────────────────────────────────────────────────────
describe('verifyTripAccess', () => {
it('TODO-SVC-001: returns trip for owner', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = verifyTripAccess(trip.id, user.id);
expect(result).toBeDefined();
expect((result as any).id).toBe(trip.id);
});
it('TODO-SVC-002: returns null for non-member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
});
it('TODO-SVC-003: returns trip for member', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = verifyTripAccess(trip.id, member.id);
expect(result).toBeDefined();
});
});
// ── listItems / createItem ────────────────────────────────────────────────────
describe('listItems and createItem', () => {
it('TODO-SVC-004: listItems returns empty array for new trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(listItems(trip.id)).toEqual([]);
});
it('TODO-SVC-005: createItem inserts a todo with name only', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Buy snacks' }) as any;
expect(item).toBeDefined();
expect(item.name).toBe('Buy snacks');
expect(item.checked).toBe(0);
expect(item.trip_id).toBe(trip.id);
expect(item.sort_order).toBe(0);
});
it('TODO-SVC-006: createItem assigns incrementing sort_order', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createItem(trip.id, { name: 'A' }) as any;
const b = createItem(trip.id, { name: 'B' }) as any;
expect(b.sort_order).toBe(a.sort_order + 1);
});
it('TODO-SVC-007: createItem stores optional fields', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, {
name: 'Pack bag',
category: 'Prep',
description: 'All the gear',
priority: 3,
}) as any;
expect(item.category).toBe('Prep');
expect(item.description).toBe('All the gear');
expect(item.priority).toBe(3);
});
it('TODO-SVC-008: listItems returns items ordered by sort_order', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createItem(trip.id, { name: 'First' });
createItem(trip.id, { name: 'Second' });
createItem(trip.id, { name: 'Third' });
const items = listItems(trip.id) as any[];
expect(items).toHaveLength(3);
expect(items[0].sort_order).toBeLessThanOrEqual(items[1].sort_order);
expect(items[1].sort_order).toBeLessThanOrEqual(items[2].sort_order);
});
});
// ── updateItem ────────────────────────────────────────────────────────────────
describe('updateItem', () => {
it('TODO-SVC-009: returns null for non-existent item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull();
});
it('TODO-SVC-010: toggles checked status', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Visit museum' }) as any;
const updated = updateItem(trip.id, item.id, { checked: 1 }, ['checked']) as any;
expect(updated.checked).toBe(1);
const back = updateItem(trip.id, item.id, { checked: 0 }, ['checked']) as any;
expect(back.checked).toBe(0);
});
it('TODO-SVC-011: updates name and category', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Old' }) as any;
const updated = updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category']) as any;
expect(updated.name).toBe('New');
expect(updated.category).toBe('Misc');
});
it('TODO-SVC-012: clears due_date when key is present with null value', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Task', due_date: '2026-06-01' }) as any;
const updated = updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any;
expect(updated.due_date).toBeNull();
});
});
// ── deleteItem ────────────────────────────────────────────────────────────────
describe('deleteItem', () => {
it('TODO-SVC-013: returns false for non-existent item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(deleteItem(trip.id, 99999)).toBe(false);
});
it('TODO-SVC-014: deletes item and returns true', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createItem(trip.id, { name: 'Gone' }) as any;
expect(deleteItem(trip.id, item.id)).toBe(true);
expect(listItems(trip.id)).toHaveLength(0);
});
});
// ── reorderItems ──────────────────────────────────────────────────────────────
describe('reorderItems', () => {
it('TODO-SVC-015: assigns sort_order matching orderedIds array position', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createItem(trip.id, { name: 'A' }) as any;
const b = createItem(trip.id, { name: 'B' }) as any;
const c = createItem(trip.id, { name: 'C' }) as any;
reorderItems(trip.id, [c.id, a.id, b.id]);
const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
expect(rows[0].id).toBe(c.id);
expect(rows[1].id).toBe(a.id);
expect(rows[2].id).toBe(b.id);
});
});
// ── category assignees ────────────────────────────────────────────────────────
describe('getCategoryAssignees / updateCategoryAssignees', () => {
it('TODO-SVC-016: returns empty object for new trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getCategoryAssignees(trip.id)).toEqual({});
});
it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const rows = updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]) as any[];
expect(rows).toHaveLength(2);
const assignees = getCategoryAssignees(trip.id) as any;
expect(assignees['Packing']).toHaveLength(2);
});
it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
updateCategoryAssignees(trip.id, 'Packing', [owner.id]);
const cleared = updateCategoryAssignees(trip.id, 'Packing', []) as any[];
expect(cleared).toHaveLength(0);
const assignees = getCategoryAssignees(trip.id) as any;
expect(assignees['Packing']).toBeUndefined();
});
it('TODO-SVC-019: getCategoryAssignees groups by category name', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
updateCategoryAssignees(trip.id, 'Shopping', [owner.id]);
updateCategoryAssignees(trip.id, 'Logistics', [member.id]);
const assignees = getCategoryAssignees(trip.id) as any;
expect(Object.keys(assignees)).toHaveLength(2);
expect(assignees['Shopping']).toHaveLength(1);
expect(assignees['Logistics']).toHaveLength(1);
});
it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]);
// Replace with just owner
updateCategoryAssignees(trip.id, 'Food', [owner.id]);
const assignees = getCategoryAssignees(trip.id) as any;
expect(assignees['Food']).toHaveLength(1);
expect(assignees['Food'][0].user_id).toBe(owner.id);
});
});
@@ -0,0 +1,179 @@
/**
* Unit tests for tripService exportICS function (TRIP-SVC-001 through TRIP-SVC-009).
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation } from '../../helpers/factories';
import { exportICS } from '../../../src/services/tripService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('exportICS', () => {
it('TRIP-SVC-001: returns VCALENDAR wrapper', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'My Vacation',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('BEGIN:VCALENDAR');
expect(ics).toContain('END:VCALENDAR');
});
it('TRIP-SVC-002: trip with start_date + end_date includes all-day VEVENT', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, {
title: 'Summer Holiday',
start_date: '2025-06-01',
end_date: '2025-06-07',
});
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250601');
expect(ics).toContain('SUMMARY:Summer Holiday');
});
it('TRIP-SVC-003: reservation with full datetime (includes T) → DTSTART without VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Morning Flight',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02T09:00', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART:20250602T090000');
expect(ics).not.toContain('DTSTART;VALUE=DATE');
});
it('TRIP-SVC-004: reservation with date-only → DTSTART;VALUE=DATE', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Hotel Check-in',
type: 'hotel',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTSTART;VALUE=DATE:20250602');
});
it('TRIP-SVC-005: reservation metadata with flight info appears in DESCRIPTION', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'CDG to JFK',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?')
.run(
'2025-06-02T09:00',
JSON.stringify({
airline: 'Air Test',
flight_number: 'AT100',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
reservation.id
);
const { ics } = exportICS(trip.id);
expect(ics).toContain('Airline: Air Test');
expect(ics).toContain('Flight: AT100');
});
it('TRIP-SVC-006: special characters in title are escaped', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Trip; First, Best' });
const { ics } = exportICS(trip.id);
expect(ics).toContain('Trip\\; First\\, Best');
});
it('TRIP-SVC-007: throws NotFoundError for non-existent trip', () => {
expect(() => exportICS(99999)).toThrow();
});
it('TRIP-SVC-008: returns a filename derived from trip title', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'My Trip 2025' });
const { filename } = exportICS(trip.id);
expect(filename).toMatch(/My.Trip.2025\.ics/);
});
it('TRIP-SVC-009: reservation with end time includes DTEND', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
const reservation = createReservation(testDb, trip.id, {
title: 'Afternoon Tour',
type: 'activity',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?')
.run('2025-06-02T14:00', '2025-06-02T16:00', reservation.id);
const { ics } = exportICS(trip.id);
expect(ics).toContain('DTEND:20250602T160000');
});
});
@@ -0,0 +1,745 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup (real in-memory SQLite) ─────────────────────────────────────────
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: () => {},
canAccessTrip: () => null,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
// Mock websocket so notifyPlanUsers doesn't throw
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
getOwnPlan,
getActivePlan,
getPlanUsers,
migrateHolidayCalendars,
updatePlan,
addHolidayCalendar,
updateHolidayCalendar,
deleteHolidayCalendar,
setUserColor,
acceptInvite,
declineInvite,
cancelInvite,
getAvailableUsers,
listYears,
addYear,
deleteYear,
getEntries,
toggleEntry,
toggleCompanyHoliday,
getStats,
applyHolidayCalendars,
} from '../../../src/services/vacayService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
// Stub fetch with empty holiday list by default so updatePlan / applyHolidayCalendars
// never makes real network calls.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
}));
});
afterAll(() => {
vi.unstubAllGlobals();
testDb.close();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Insert a vacay_plan_members row directly (no service factory for it). */
function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): void {
testDb.prepare(
"INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)"
).run(planId, userId, status);
}
/** Fast helper: create a user and immediately materialise their own plan. */
function setupUserWithPlan() {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
return { user, plan };
}
// ── getOwnPlan ────────────────────────────────────────────────────────────────
describe('getOwnPlan', () => {
it('VACAY-SVC-001: creates a new plan on first call for a fresh user', () => {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
expect(plan).toBeDefined();
expect(plan.owner_id).toBe(user.id);
expect(plan.id).toBeGreaterThan(0);
});
it('VACAY-SVC-002: returns the same plan on a second call (idempotent)', () => {
const { user } = createUser(testDb);
const first = getOwnPlan(user.id);
const second = getOwnPlan(user.id);
expect(second.id).toBe(first.id);
});
it('VACAY-SVC-003: seeds the current year row in vacay_years after plan creation', () => {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
const yr = new Date().getFullYear();
const row = testDb
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
.get(plan.id, yr);
expect(row).toBeDefined();
});
it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', () => {
const { user } = createUser(testDb);
const plan = getOwnPlan(user.id);
const yr = new Date().getFullYear();
const row = testDb
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, yr) as { vacation_days: number } | undefined;
expect(row).toBeDefined();
expect(row!.vacation_days).toBe(30);
});
});
// ── getActivePlan ─────────────────────────────────────────────────────────────
describe('getActivePlan', () => {
it('VACAY-SVC-005: returns own plan when user has no accepted membership in another plan', () => {
const { user, plan } = setupUserWithPlan();
const active = getActivePlan(user.id);
expect(active.id).toBe(plan.id);
expect(active.owner_id).toBe(user.id);
});
it('VACAY-SVC-006: returns the shared plan when user has an accepted membership in another plan', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
// Make sure member also has their own plan materialised first
getOwnPlan(member.id);
insertMember(ownerPlan.id, member.id, 'accepted');
const active = getActivePlan(member.id);
expect(active.id).toBe(ownerPlan.id);
});
it('VACAY-SVC-007: pending membership does NOT override own plan as active', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
getOwnPlan(member.id);
insertMember(ownerPlan.id, member.id, 'pending');
const active = getActivePlan(member.id);
// Should still point to member's own plan
expect(active.owner_id).toBe(member.id);
});
});
// ── getPlanUsers ──────────────────────────────────────────────────────────────
describe('getPlanUsers', () => {
it('VACAY-SVC-008: returns [owner] for a solo plan', () => {
const { user, plan } = setupUserWithPlan();
const users = getPlanUsers(plan.id);
expect(users).toHaveLength(1);
expect(users[0].id).toBe(user.id);
});
it('VACAY-SVC-009: returns [owner, member] after an accepted membership is inserted', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: member } = createUser(testDb);
insertMember(plan.id, member.id, 'accepted');
const users = getPlanUsers(plan.id);
expect(users).toHaveLength(2);
expect(users.map(u => u.id)).toContain(owner.id);
expect(users.map(u => u.id)).toContain(member.id);
});
it('VACAY-SVC-010: pending membership members are NOT included in plan users', () => {
const { plan } = setupUserWithPlan();
const { user: pendingUser } = createUser(testDb);
insertMember(plan.id, pendingUser.id, 'pending');
const users = getPlanUsers(plan.id);
expect(users.map(u => u.id)).not.toContain(pendingUser.id);
});
it('VACAY-SVC-011: returns empty array for a non-existent plan id', () => {
const users = getPlanUsers(99999);
expect(users).toEqual([]);
});
});
// ── migrateHolidayCalendars ───────────────────────────────────────────────────
describe('migrateHolidayCalendars', () => {
it('VACAY-SVC-012: does nothing when holidays_enabled is falsy', async () => {
const { plan } = setupUserWithPlan();
const planRow = { ...plan, holidays_enabled: 0, holidays_region: 'DE' };
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id);
expect(rows).toHaveLength(0);
});
it('VACAY-SVC-013: inserts a calendar row when holidays_enabled=1 and holidays_region is set', async () => {
const { plan } = setupUserWithPlan();
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'DE' };
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id) as { region: string }[];
expect(rows).toHaveLength(1);
expect(rows[0].region).toBe('DE');
});
it('VACAY-SVC-014: does nothing if a calendar row already exists (no duplicate)', async () => {
const { plan } = setupUserWithPlan();
const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'FR' };
await migrateHolidayCalendars(plan.id, planRow);
// Call a second time — should NOT insert another row
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id);
expect(rows).toHaveLength(1);
});
});
// ── updatePlan ────────────────────────────────────────────────────────────────
describe('updatePlan', () => {
it('VACAY-SVC-015: updates block_weekends flag', async () => {
const { plan } = setupUserWithPlan();
await updatePlan(plan.id, { block_weekends: true }, undefined);
const updated = testDb
.prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?')
.get(plan.id) as { block_weekends: number };
expect(updated.block_weekends).toBe(1);
});
it('VACAY-SVC-016: updates holidays_enabled flag', async () => {
const { plan } = setupUserWithPlan();
await updatePlan(plan.id, { holidays_enabled: true }, undefined);
const updated = testDb
.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?')
.get(plan.id) as { holidays_enabled: number };
expect(updated.holidays_enabled).toBe(1);
});
it('VACAY-SVC-017: returns the updated plan object with boolean-coerced flags', async () => {
const { plan } = setupUserWithPlan();
const result = await updatePlan(plan.id, { block_weekends: false }, undefined);
expect(result.plan.block_weekends).toBe(false);
expect(typeof result.plan.holidays_enabled).toBe('boolean');
});
it('VACAY-SVC-018: resets carried_over to 0 for all user_years when carry_over_enabled is set to false', async () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
// Manually set a non-zero carried_over value
testDb
.prepare('UPDATE vacay_user_years SET carried_over = 5 WHERE user_id = ? AND plan_id = ? AND year = ?')
.run(user.id, plan.id, yr);
await updatePlan(plan.id, { carry_over_enabled: false }, undefined);
const row = testDb
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, yr) as { carried_over: number };
expect(row.carried_over).toBe(0);
});
});
// ── addHolidayCalendar ────────────────────────────────────────────────────────
describe('addHolidayCalendar', () => {
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined);
expect(cal).toBeDefined();
expect(cal.id).toBeGreaterThan(0);
expect(cal.region).toBe('GB');
expect(cal.label).toBe('UK Holidays');
expect(cal.color).toBe('#ff0000');
});
it('VACAY-SVC-020: uses default color #fecaca when no color is provided', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined);
expect(cal.color).toBe('#fecaca');
});
});
// ── updateHolidayCalendar ─────────────────────────────────────────────────────
describe('updateHolidayCalendar', () => {
it('VACAY-SVC-021: changes label and color on an existing calendar', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined);
const updated = updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined);
expect(updated).not.toBeNull();
expect(updated!.label).toBe('Deutschland');
expect(updated!.color).toBe('#112233');
});
it('VACAY-SVC-022: returns null when the calendar id does not exist in the plan', () => {
const { plan } = setupUserWithPlan();
const result = updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined);
expect(result).toBeNull();
});
});
// ── deleteHolidayCalendar ─────────────────────────────────────────────────────
describe('deleteHolidayCalendar', () => {
it('VACAY-SVC-023: removes the calendar row and returns true on success', () => {
const { plan } = setupUserWithPlan();
const cal = addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined);
const result = deleteHolidayCalendar(cal.id, plan.id, undefined);
expect(result).toBe(true);
const row = testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE id = ?').get(cal.id);
expect(row).toBeUndefined();
});
it('VACAY-SVC-024: returns false when the calendar does not exist', () => {
const { plan } = setupUserWithPlan();
const result = deleteHolidayCalendar(99999, plan.id, undefined);
expect(result).toBe(false);
});
});
// ── setUserColor ──────────────────────────────────────────────────────────────
describe('setUserColor', () => {
it('VACAY-SVC-025: inserts a color for a user in a plan', () => {
const { user, plan } = setupUserWithPlan();
setUserColor(user.id, plan.id, '#123456', undefined);
const row = testDb
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
.get(user.id, plan.id) as { color: string } | undefined;
expect(row?.color).toBe('#123456');
});
it('VACAY-SVC-026: updates the color when called a second time (upsert)', () => {
const { user, plan } = setupUserWithPlan();
setUserColor(user.id, plan.id, '#aaaaaa', undefined);
setUserColor(user.id, plan.id, '#bbbbbb', undefined);
const row = testDb
.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?')
.get(user.id, plan.id) as { color: string };
expect(row.color).toBe('#bbbbbb');
});
});
// ── listYears / addYear / deleteYear ──────────────────────────────────────────
describe('listYears', () => {
it('VACAY-SVC-027: returns the seeded current year for a freshly created plan', () => {
const { plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
const years = listYears(plan.id);
expect(years).toContain(yr);
});
});
describe('addYear', () => {
it('VACAY-SVC-028: inserts a new year and creates a user_year record', () => {
const { user, plan } = setupUserWithPlan();
const newYear = new Date().getFullYear() + 2;
addYear(plan.id, newYear, undefined);
const years = listYears(plan.id);
expect(years).toContain(newYear);
const userYear = testDb
.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, newYear) as { vacation_days: number } | undefined;
expect(userYear).toBeDefined();
expect(userYear!.vacation_days).toBe(30);
});
it('VACAY-SVC-029: carries over remaining days to the new year when carry_over_enabled is true', () => {
const { user, plan } = setupUserWithPlan();
const currentYear = new Date().getFullYear();
const nextYear = currentYear + 1;
// Enable carry-over and seed some entries for the current year
testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id);
// Ensure current year row exists with 10 vacation days
testDb.prepare(`
INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over)
VALUES (?, ?, ?, 10, 0)
`).run(user.id, plan.id, currentYear);
// Add 3 entries (used days) in the current year
for (let day = 1; day <= 3; day++) {
const dateStr = `${currentYear}-06-0${day}`;
testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, '');
}
addYear(plan.id, nextYear, undefined);
const userYear = testDb
.prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?')
.get(user.id, plan.id, nextYear) as { carried_over: number } | undefined;
// 10 vacation days - 3 used = 7 carried over
expect(userYear?.carried_over).toBe(7);
});
});
describe('deleteYear', () => {
it('VACAY-SVC-030: removes the year row and its associated entries', () => {
const { user, plan } = setupUserWithPlan();
const targetYear = new Date().getFullYear() + 3;
addYear(plan.id, targetYear, undefined);
// Insert an entry for that year
testDb
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(plan.id, user.id, `${targetYear}-07-15`, '');
deleteYear(plan.id, targetYear, undefined);
const yearRow = testDb
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
.get(plan.id, targetYear);
expect(yearRow).toBeUndefined();
const entries = testDb
.prepare("SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?")
.all(plan.id, `${targetYear}-%`);
expect(entries).toHaveLength(0);
});
});
// ── getEntries / toggleEntry ──────────────────────────────────────────────────
describe('getEntries', () => {
it('VACAY-SVC-031: returns empty entries and companyHolidays for a new plan+year', () => {
const { plan } = setupUserWithPlan();
const yr = new Date().getFullYear().toString();
const result = getEntries(plan.id, yr);
expect(result.entries).toEqual([]);
expect(result.companyHolidays).toEqual([]);
});
});
describe('toggleEntry', () => {
it('VACAY-SVC-032: adds an entry on first call (action: added)', () => {
const { user, plan } = setupUserWithPlan();
const result = toggleEntry(user.id, plan.id, '2025-08-01', undefined);
expect(result.action).toBe('added');
const row = testDb
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
.get(user.id, plan.id, '2025-08-01');
expect(row).toBeDefined();
});
it('VACAY-SVC-033: removes the entry on second call (action: removed)', () => {
const { user, plan } = setupUserWithPlan();
toggleEntry(user.id, plan.id, '2025-08-02', undefined);
const result = toggleEntry(user.id, plan.id, '2025-08-02', undefined);
expect(result.action).toBe('removed');
const row = testDb
.prepare('SELECT * FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date = ?')
.get(user.id, plan.id, '2025-08-02');
expect(row).toBeUndefined();
});
});
// ── toggleCompanyHoliday ──────────────────────────────────────────────────────
describe('toggleCompanyHoliday', () => {
it('VACAY-SVC-034: adds a company holiday on first call (action: added)', () => {
const { plan } = setupUserWithPlan();
const result = toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined);
expect(result.action).toBe('added');
const row = testDb
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
.get(plan.id, '2025-12-25');
expect(row).toBeDefined();
});
it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', () => {
const { plan } = setupUserWithPlan();
toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined);
const result = toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined);
expect(result.action).toBe('removed');
const row = testDb
.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date = ?')
.get(plan.id, '2025-12-26');
expect(row).toBeUndefined();
});
it('VACAY-SVC-036: adding a company holiday removes any existing vacay_entry on that date', () => {
const { user, plan } = setupUserWithPlan();
// First add a personal entry on that date
toggleEntry(user.id, plan.id, '2025-05-01', undefined);
// Now declare it a company holiday — the personal entry should be wiped
toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined);
const personalEntry = testDb
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
.get(plan.id, '2025-05-01');
expect(personalEntry).toBeUndefined();
});
});
// ── acceptInvite / declineInvite / cancelInvite ───────────────────────────────
describe('acceptInvite', () => {
it('VACAY-SVC-037: changes membership status to accepted', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: invitee } = createUser(testDb);
getOwnPlan(invitee.id); // ensure own plan exists for data migration path
insertMember(ownerPlan.id, invitee.id, 'pending');
const result = acceptInvite(invitee.id, ownerPlan.id, undefined);
expect(result.error).toBeUndefined();
const row = testDb
.prepare('SELECT status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
.get(ownerPlan.id, invitee.id) as { status: string } | undefined;
expect(row?.status).toBe('accepted');
});
it('VACAY-SVC-038: returns 404 error when there is no pending invite', () => {
const { user } = createUser(testDb);
const result = acceptInvite(user.id, 99999, undefined);
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
it('VACAY-SVC-039: accepted member becomes visible via getActivePlan', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: invitee } = createUser(testDb);
getOwnPlan(invitee.id);
insertMember(ownerPlan.id, invitee.id, 'pending');
acceptInvite(invitee.id, ownerPlan.id, undefined);
const active = getActivePlan(invitee.id);
expect(active.id).toBe(ownerPlan.id);
});
});
describe('declineInvite', () => {
it('VACAY-SVC-040: removes the pending invite row', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: invitee } = createUser(testDb);
insertMember(ownerPlan.id, invitee.id, 'pending');
declineInvite(invitee.id, ownerPlan.id, undefined);
const row = testDb
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
.get(ownerPlan.id, invitee.id);
expect(row).toBeUndefined();
});
});
describe('cancelInvite', () => {
it('VACAY-SVC-041: removes the pending invite when owner cancels it', () => {
const { user: owner, plan: ownerPlan } = setupUserWithPlan();
const { user: target } = createUser(testDb);
insertMember(ownerPlan.id, target.id, 'pending');
cancelInvite(ownerPlan.id, target.id);
const row = testDb
.prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?')
.get(ownerPlan.id, target.id);
expect(row).toBeUndefined();
});
});
// ── getAvailableUsers ─────────────────────────────────────────────────────────
describe('getAvailableUsers', () => {
it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: unrelated } = createUser(testDb);
getOwnPlan(unrelated.id);
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).toContain(unrelated.id);
// Owner themselves should NOT appear (excluded by u.id != ?)
expect(available.map(u => u.id)).not.toContain(owner.id);
});
it('VACAY-SVC-043: excludes users who already have an accepted membership in any plan', () => {
const { user: owner, plan } = setupUserWithPlan();
const { user: alreadyFused } = createUser(testDb);
const { plan: otherPlan } = setupUserWithPlan();
insertMember(otherPlan.id, alreadyFused.id, 'accepted');
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).not.toContain(alreadyFused.id);
});
});
// ── getStats ──────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('VACAY-SVC-044: returns per-user stats with correct fields', () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
const stats = getStats(plan.id, yr);
expect(stats).toHaveLength(1);
expect(stats[0]).toMatchObject({
user_id: user.id,
year: yr,
vacation_days: 30,
used: 0,
remaining: 30,
});
});
it('VACAY-SVC-045: used reflects the actual number of entries for that user and year', () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined);
toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined);
const stats = getStats(plan.id, yr);
expect(stats[0].used).toBe(2);
expect(stats[0].remaining).toBe(28);
});
});
// ── applyHolidayCalendars ─────────────────────────────────────────────────────
describe('applyHolidayCalendars', () => {
it('VACAY-SVC-046: does nothing when holidays_enabled is 0 (fetch is never called)', async () => {
const { plan } = setupUserWithPlan();
// holidays_enabled defaults to 0
await applyHolidayCalendars(plan.id);
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it('VACAY-SVC-047: deletes matching vacay_entries for a global holiday date returned by the API', async () => {
const { user, plan } = setupUserWithPlan();
const yr = new Date().getFullYear();
// Enable holidays and add a calendar
testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id);
addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined);
// Add a vacay entry on the holiday date
const holidayDate = `${yr}-01-01`;
testDb
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(plan.id, user.id, holidayDate, '');
// Override fetch to return one global holiday matching that entry
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ date: holidayDate, global: true }],
}));
await applyHolidayCalendars(plan.id);
const remaining = testDb
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?')
.all(plan.id, holidayDate);
expect(remaining).toHaveLength(0);
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
// Prevent the module-level setInterval from running during tests
vi.useFakeTimers();
@@ -8,7 +8,14 @@ vi.stubGlobal('fetch', vi.fn());
afterAll(() => vi.unstubAllGlobals());
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
import {
estimateCondition,
cacheKey,
getWeather,
getDetailedWeather,
ApiError,
type WeatherResult,
} from '../../../src/services/weatherService';
// ── estimateCondition ────────────────────────────────────────────────────────
@@ -105,3 +112,585 @@ describe('cacheKey', () => {
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
});
});
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Build a minimal mock Response for fetch. */
function mockResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
/** ISO date string offset by `days` from now (fake-timer "now"). */
function dateOffset(days: number): string {
const d = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
return d.toISOString().slice(0, 10);
}
// ── getWeather ────────────────────────────────────────────────────────────────
describe('getWeather', () => {
// Use coordinates that are unique per describe block to avoid cross-test cache
// pollution. Each nested describe uses a distinct lat so the module-level Map
// never returns stale data from a sibling test.
beforeEach(() => {
vi.mocked(fetch).mockReset();
});
describe('with date — cache hit', () => {
it('returns cached result without calling fetch', async () => {
const date = dateOffset(2);
const forecastBody = {
daily: {
time: [date],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [0],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(forecastBody));
// First call populates the cache
const first = await getWeather('10.00', '20.00', date, 'en');
expect(fetch).toHaveBeenCalledTimes(1);
vi.mocked(fetch).mockReset();
// Second call with identical arguments should be served from cache
const second = await getWeather('10.00', '20.00', date, 'en');
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
});
describe('with date — forecast path (diffDays -1 .. +16)', () => {
it('returns a forecast WeatherResult for a date 3 days away', async () => {
const date = dateOffset(3);
const body = {
daily: {
time: [date],
temperature_2m_max: [25],
temperature_2m_min: [15],
weathercode: [1],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('11.00', '21.00', date, 'en');
expect(result.type).toBe('forecast');
expect(result.temp).toBe(20); // (25+15)/2
expect(result.temp_max).toBe(25);
expect(result.temp_min).toBe(15);
expect(result.main).toBe('Clear'); // WMO code 1
expect(result.description).toBe('Mainly clear');
});
it('uses German descriptions when lang is "de"', async () => {
const date = dateOffset(4);
const body = {
daily: {
time: [date],
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [3],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('11.01', '21.01', date, 'de');
expect(result.description).toBe('Bewolkt'); // German for code 3
});
it('falls back to "Clouds" for an unknown WMO code', async () => {
const date = dateOffset(5);
const body = {
daily: {
time: [date],
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [999], // not in WMO_MAP
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('11.02', '21.02', date, 'en');
expect(result.main).toBe('Clouds');
});
it('throws ApiError when response.ok is false', async () => {
const date = dateOffset(2);
const body = { reason: 'rate limited' };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, false, 429));
await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toThrow(ApiError);
await expect(getWeather('12.00', '22.00', date, 'en')).rejects.toMatchObject({
status: 429,
message: 'rate limited',
});
});
it('throws ApiError when data.error is true', async () => {
const date = dateOffset(2);
const body = { error: true, reason: 'invalid coordinates' };
// Need a fresh coordinate to avoid the cache from the previous test failure
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200));
await expect(getWeather('12.01', '22.01', date, 'en')).rejects.toThrow(ApiError);
});
it('falls through to climate path when date is not found in forecast data', async () => {
// The forecast API returns data but NOT for our target date; the code
// checks idx === -1 and falls into the diffDays > -1 climate branch.
const date = dateOffset(3);
const forecastBody = {
daily: {
time: ['1970-01-01'], // deliberately wrong date
temperature_2m_max: [10],
temperature_2m_min: [5],
weathercode: [0],
},
};
// Archive response for the climate fallback
const refDate = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);
const archiveBody = {
daily: {
time: ['some-date'],
temperature_2m_max: [18],
temperature_2m_min: [8],
precipitation_sum: [0],
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(forecastBody))
.mockResolvedValueOnce(mockResponse(archiveBody));
const result = await getWeather('13.00', '23.00', date, 'en');
expect(result.type).toBe('climate');
expect(fetch).toHaveBeenCalledTimes(2);
});
});
describe('with date — past date (diffDays < -1)', () => {
it('returns no_forecast error immediately without fetching', async () => {
const date = dateOffset(-5); // 5 days in the past
const result = await getWeather('14.00', '24.00', date, 'en');
expect(result.error).toBe('no_forecast');
expect(fetch).not.toHaveBeenCalled();
});
});
describe('with date — climate / archive path (diffDays > 16)', () => {
it('returns a climate WeatherResult for a far-future date', async () => {
const date = dateOffset(20);
const body = {
daily: {
time: ['2025-01-01', '2025-01-02'],
temperature_2m_max: [22, 24],
temperature_2m_min: [12, 14],
precipitation_sum: [0, 0.1],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('15.00', '25.00', date, 'en');
expect(result.type).toBe('climate');
expect(result.temp).toBe(18); // avg of (22+12)/2=17 and (24+14)/2=19 -> avg 18
expect(result.temp_max).toBe(23);
expect(result.temp_min).toBe(13);
});
it('throws ApiError when archive API response.ok is false', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'server error' }, false, 500));
await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toThrow(ApiError);
await expect(getWeather('15.01', '25.01', date, 'en')).rejects.toMatchObject({ status: 500 });
});
it('returns no_forecast when archive daily data is missing', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
const result = await getWeather('15.02', '25.02', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when archive daily.time is empty', async () => {
const date = dateOffset(20);
const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], precipitation_sum: [] } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('15.03', '25.03', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when all temperature entries are null', async () => {
const date = dateOffset(20);
const body = {
daily: {
time: ['2025-01-01'],
temperature_2m_max: [null],
temperature_2m_min: [null],
precipitation_sum: [0],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('15.04', '25.04', date, 'en');
expect(result.error).toBe('no_forecast');
});
});
describe('without date — current weather path', () => {
it('returns current WeatherResult', async () => {
const body = {
current: { temperature_2m: 18.7, weathercode: 2 },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('16.00', '26.00', undefined, 'en');
expect(result.type).toBe('current');
expect(result.temp).toBe(19); // Math.round(18.7)
expect(result.main).toBe('Clouds'); // WMO code 2
expect(result.description).toBe('Partly cloudy');
});
it('uses German descriptions when lang is "de"', async () => {
const body = { current: { temperature_2m: 10, weathercode: 45 } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getWeather('16.01', '26.01', undefined, 'de');
expect(result.description).toBe('Nebel');
});
it('returns cached current weather on second identical call', async () => {
const body = { current: { temperature_2m: 22, weathercode: 0 } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const first = await getWeather('16.02', '26.02', undefined, 'en');
vi.mocked(fetch).mockReset();
const second = await getWeather('16.02', '26.02', undefined, 'en');
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
it('throws ApiError when current weather API returns error', async () => {
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'bad request' }, false, 400));
await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toThrow(ApiError);
await expect(getWeather('16.03', '26.03', undefined, 'en')).rejects.toMatchObject({ status: 400 });
});
it('throws ApiError when data.error flag is set on current weather response', async () => {
const body = { error: true, reason: 'quota exceeded' };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body, true, 200));
await expect(getWeather('16.04', '26.04', undefined, 'en')).rejects.toThrow(ApiError);
});
});
});
// ── getDetailedWeather ────────────────────────────────────────────────────────
describe('getDetailedWeather', () => {
beforeEach(() => {
vi.mocked(fetch).mockReset();
});
describe('cache hit', () => {
it('returns cached result without calling fetch a second time', async () => {
const date = dateOffset(5);
const dailyBody = {
daily: {
time: [date],
temperature_2m_max: [28],
temperature_2m_min: [18],
weathercode: [0],
precipitation_sum: [0],
precipitation_probability_max: [0],
windspeed_10m_max: [10],
sunrise: [`${date}T06:00`],
sunset: [`${date}T20:00`],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(dailyBody));
const first = await getDetailedWeather('30.00', '40.00', date, 'en');
vi.mocked(fetch).mockReset();
const second = await getDetailedWeather('30.00', '40.00', date, 'en');
expect(fetch).not.toHaveBeenCalled();
expect(second).toEqual(first);
});
});
describe('forecast path (diffDays <= 16)', () => {
it('returns a detailed forecast WeatherResult with hourly data', async () => {
const date = dateOffset(6);
const body = {
daily: {
time: [date],
temperature_2m_max: [30],
temperature_2m_min: [20],
weathercode: [80],
precipitation_sum: [5],
precipitation_probability_max: [70],
windspeed_10m_max: [15],
sunrise: [`${date}T05:45`],
sunset: [`${date}T21:15`],
},
hourly: {
time: [`${date}T12:00`, `${date}T13:00`],
temperature_2m: [28, 29],
precipitation_probability: [60, 65],
precipitation: [1.2, 0.8],
weathercode: [80, 81],
windspeed_10m: [12, 14],
relativehumidity_2m: [70, 68],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('31.00', '41.00', date, 'en');
expect(result.type).toBe('forecast');
expect(result.temp).toBe(25); // (30+20)/2
expect(result.temp_max).toBe(30);
expect(result.temp_min).toBe(20);
expect(result.main).toBe('Rain'); // WMO code 80
expect(result.precipitation_sum).toBe(5);
expect(result.precipitation_probability_max).toBe(70);
expect(result.wind_max).toBe(15);
expect(result.sunrise).toBe('05:45');
expect(result.sunset).toBe('21:15');
expect(result.hourly).toHaveLength(2);
expect(result.hourly![0].temp).toBe(28);
expect(result.hourly![0].precipitation_probability).toBe(60);
expect(result.hourly![1].main).toBe('Rain'); // WMO code 81
});
it('returns no_forecast when daily data is missing', async () => {
const date = dateOffset(7);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
const result = await getDetailedWeather('31.01', '41.01', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when daily.time is empty', async () => {
const date = dateOffset(7);
const body = {
daily: {
time: [],
temperature_2m_max: [],
temperature_2m_min: [],
weathercode: [],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('31.02', '41.02', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('throws ApiError when forecast API returns !ok', async () => {
const date = dateOffset(8);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'not found' }, false, 404));
await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toThrow(ApiError);
await expect(getDetailedWeather('31.03', '41.03', date, 'en')).rejects.toMatchObject({ status: 404 });
});
it('throws ApiError when data.error flag is set', async () => {
const date = dateOffset(9);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'bad coords' }));
await expect(getDetailedWeather('31.04', '41.04', date, 'en')).rejects.toThrow(ApiError);
});
it('handles missing hourly block gracefully', async () => {
const date = dateOffset(10);
const body = {
daily: {
time: [date],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [0],
precipitation_sum: [0],
precipitation_probability_max: [0],
windspeed_10m_max: [5],
sunrise: [`${date}T06:00`],
sunset: [`${date}T20:00`],
},
// no hourly field
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('31.05', '41.05', date, 'en');
expect(result.type).toBe('forecast');
expect(result.hourly).toEqual([]);
});
});
describe('climate / archive path (diffDays > 16)', () => {
it('returns a detailed climate WeatherResult with hourly data', async () => {
const date = dateOffset(20);
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
const refYear = refDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
const body = {
daily: {
time: [refDateStr],
temperature_2m_max: [26],
temperature_2m_min: [16],
weathercode: [63],
precipitation_sum: [8],
windspeed_10m_max: [20],
sunrise: [`${refDateStr}T06:30`],
sunset: [`${refDateStr}T20:30`],
},
hourly: {
time: [`${refDateStr}T10:00`, `${refDateStr}T11:00`],
temperature_2m: [22, 24],
precipitation: [2, 1],
weathercode: [63, 61],
windspeed_10m: [18, 16],
relativehumidity_2m: [80, 75],
},
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.00', '42.00', date, 'en');
expect(result.type).toBe('climate');
expect(result.temp).toBe(21); // (26+16)/2
expect(result.temp_max).toBe(26);
expect(result.temp_min).toBe(16);
expect(result.main).toBe('Rain'); // WMO code 63
expect(result.description).toBe('Rain'); // WMO_DESCRIPTION_EN[63]
expect(result.precipitation_sum).toBe(8);
expect(result.wind_max).toBe(20);
expect(result.sunrise).toBe('06:30');
expect(result.sunset).toBe('20:30');
expect(result.hourly).toHaveLength(2);
expect(result.hourly![0].temp).toBe(22);
expect(result.hourly![0].precipitation).toBe(2);
expect(result.hourly![1].main).toBe('Rain'); // WMO code 61
});
it('uses German descriptions when lang is "de"', async () => {
const date = dateOffset(20);
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
const refYear = refDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
const body = {
daily: {
time: [refDateStr],
temperature_2m_max: [20],
temperature_2m_min: [10],
weathercode: [0],
precipitation_sum: [0],
windspeed_10m_max: [5],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.01', '42.01', date, 'de');
expect(result.description).toBe('Klar'); // German WMO_DESCRIPTION_DE[0]
});
it('returns no_forecast when archive daily data is missing', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({}));
const result = await getDetailedWeather('32.02', '42.02', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('returns no_forecast when archive daily.time is empty', async () => {
const date = dateOffset(20);
const body = { daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } };
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.03', '42.03', date, 'en');
expect(result.error).toBe('no_forecast');
});
it('throws ApiError when archive API returns !ok', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503));
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ reason: 'upstream error' }, false, 503));
await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toThrow(ApiError);
await expect(getDetailedWeather('32.04', '42.04', date, 'en')).rejects.toMatchObject({ status: 503 });
});
it('throws ApiError when archive data.error flag is set', async () => {
const date = dateOffset(20);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ error: true, reason: 'quota exceeded' }));
await expect(getDetailedWeather('32.05', '42.05', date, 'en')).rejects.toThrow(ApiError);
});
it('falls back to estimateCondition when archive weathercode is undefined', async () => {
// When daily.weathercode[0] is undefined, the code falls back to
// estimateCondition(avgTemp, precipitation_sum)
const date = dateOffset(20);
const refDate = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
const refYear = refDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(refDate.getMonth() + 1).padStart(2, '0')}-${String(refDate.getDate()).padStart(2, '0')}`;
const body = {
daily: {
time: [refDateStr],
temperature_2m_max: [20],
temperature_2m_min: [10],
// weathercode intentionally omitted — will be undefined
precipitation_sum: [10], // > 5 mm and temp > 0 -> 'Rain'
windspeed_10m_max: [5],
},
hourly: { time: [], temperature_2m: [] },
};
vi.mocked(fetch).mockResolvedValueOnce(mockResponse(body));
const result = await getDetailedWeather('32.06', '42.06', date, 'en');
// undefined code -> WMO_MAP[undefined] is undefined -> falls back to estimateCondition
// avgTemp = (20+10)/2 = 15, precip = 10 > 5 and temp 15 > 0 -> 'Rain'
expect(result.main).toBe('Rain');
});
});
});