mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge remote-tracking branch 'origin/dev' into feat/places-kmz-kml-import
# Conflicts: # server/tests/integration/places.test.ts
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ get: vi.fn(), all: vi.fn() }) },
|
||||
db: { prepare: vi.fn(() => ({ get: vi.fn(), all: vi.fn() })) },
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||
|
||||
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
|
||||
import { db } from '../../../src/db/database';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
function makeReq(overrides: {
|
||||
@@ -82,6 +84,56 @@ describe('authenticate', () => {
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-003: calls next() and sets req.user for a valid JWT', () => {
|
||||
const mockUser = { id: 1, username: 'alice', email: 'alice@example.com', role: 'user' };
|
||||
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => mockUser), all: vi.fn() } as any);
|
||||
|
||||
const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' });
|
||||
const req = makeReq({ cookies: { trek_session: token } });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect((req as any).user).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('AUTH-MW-004: returns 401 for a valid JWT when user does not exist in DB', () => {
|
||||
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => undefined), all: vi.fn() } as any);
|
||||
|
||||
const token = jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
|
||||
authenticate(makeReq({ cookies: { trek_session: token } }), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-005: returns 401 for an expired JWT', () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 },
|
||||
'test-secret',
|
||||
{ algorithm: 'HS256' }
|
||||
);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
authenticate(makeReq({ cookies: { trek_session: expiredToken } }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-006: returns 401 for a JWT signed with the wrong secret', () => {
|
||||
const tamperedToken = jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
authenticate(makeReq({ cookies: { trek_session: tamperedToken } }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminOnly ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Unit tests for requireTripAccess and requireTripOwner middleware.
|
||||
* TRIP-ACCESS-001 through TRIP-ACCESS-010.
|
||||
* canAccessTrip and isOwner are mocked; no DB required.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const mockCanAccessTrip = vi.fn();
|
||||
const mockIsOwner = vi.fn();
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
canAccessTrip: (...args: any[]) => mockCanAccessTrip(...args),
|
||||
isOwner: (...args: any[]) => mockIsOwner(...args),
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
|
||||
|
||||
import { requireTripAccess, requireTripOwner } from '../../../src/middleware/tripAccess';
|
||||
|
||||
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
const res = { status } as unknown as Response;
|
||||
return { res, status, json };
|
||||
}
|
||||
|
||||
function makeReq(params: Record<string, string> = {}, userId = 1): Request {
|
||||
return {
|
||||
params,
|
||||
user: { id: userId },
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanAccessTrip.mockReset();
|
||||
mockIsOwner.mockReset();
|
||||
});
|
||||
|
||||
// ── requireTripAccess ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireTripAccess', () => {
|
||||
it('TRIP-ACCESS-001: returns 400 when no tripId param', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripAccess(makeReq({}), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-002: returns 404 when canAccessTrip returns null (not a member)', () => {
|
||||
mockCanAccessTrip.mockReturnValue(null);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripAccess(makeReq({ tripId: '42' }), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-003: calls next and attaches trip when user has access', () => {
|
||||
const fakeTrip = { id: 42, user_id: 1 };
|
||||
mockCanAccessTrip.mockReturnValue(fakeTrip);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
const req = makeReq({ tripId: '42' }, 1);
|
||||
requireTripAccess(req, res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect((req as any).trip).toEqual(fakeTrip);
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-004: accepts req.params.id as fallback when tripId is absent', () => {
|
||||
const fakeTrip = { id: 7, user_id: 2 };
|
||||
mockCanAccessTrip.mockReturnValue(fakeTrip);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripAccess(makeReq({ id: '7' }), res, next);
|
||||
expect(mockCanAccessTrip).toHaveBeenCalledWith(7, expect.any(Number));
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-005: passes numeric tripId to canAccessTrip', () => {
|
||||
mockCanAccessTrip.mockReturnValue({ id: 99, user_id: 3 });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripAccess(makeReq({ tripId: '99' }, 3), res, next);
|
||||
expect(mockCanAccessTrip).toHaveBeenCalledWith(99, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireTripOwner ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireTripOwner', () => {
|
||||
it('TRIP-ACCESS-006: returns 400 when no tripId param', () => {
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripOwner(makeReq({}), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-007: returns 403 when user is not the owner', () => {
|
||||
mockIsOwner.mockReturnValue(false);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status, json } = makeRes();
|
||||
requireTripOwner(makeReq({ tripId: '10' }, 2), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-008: calls next when user is the owner', () => {
|
||||
mockIsOwner.mockReturnValue(true);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripOwner(makeReq({ tripId: '10' }, 1), res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-009: accepts req.params.id as fallback when tripId is absent', () => {
|
||||
mockIsOwner.mockReturnValue(true);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripOwner(makeReq({ id: '5' }, 1), res, next);
|
||||
expect(mockIsOwner).toHaveBeenCalledWith(5, 1);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('TRIP-ACCESS-010: passes numeric tripId to isOwner', () => {
|
||||
mockIsOwner.mockReturnValue(true);
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res } = makeRes();
|
||||
requireTripOwner(makeReq({ tripId: '77' }, 4), res, next);
|
||||
expect(mockIsOwner).toHaveBeenCalledWith(77, 4);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ const mockDb = vi.hoisted(() => {
|
||||
|
||||
vi.mock('../../../src/db/database', () => mockDb);
|
||||
|
||||
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
|
||||
import { calculateSettlement } from '../../../src/services/budgetService';
|
||||
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -65,22 +65,6 @@ beforeEach(() => {
|
||||
setupDb([], []);
|
||||
});
|
||||
|
||||
// ── avatarUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('avatarUrl', () => {
|
||||
it('returns /uploads/avatars/<filename> when avatar is set', () => {
|
||||
expect(avatarUrl({ avatar: 'photo.jpg' })).toBe('/uploads/avatars/photo.jpg');
|
||||
});
|
||||
|
||||
it('returns null when avatar is null', () => {
|
||||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when avatar is undefined', () => {
|
||||
expect(avatarUrl({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── calculateSettlement ──────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateSettlement', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Unit tests for inAppNotificationActions — NOTIF-ACT-001 through NOTIF-ACT-008.
|
||||
* Pure Map registry — no DB or external dependencies.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAction } from '../../../src/services/inAppNotificationActions';
|
||||
|
||||
describe('getAction — built-in registrations', () => {
|
||||
it('NOTIF-ACT-001 — test_approve is pre-registered', () => {
|
||||
const handler = getAction('test_approve');
|
||||
expect(handler).toBeDefined();
|
||||
expect(typeof handler).toBe('function');
|
||||
});
|
||||
|
||||
it('NOTIF-ACT-002 — test_deny is pre-registered', () => {
|
||||
const handler = getAction('test_deny');
|
||||
expect(handler).toBeDefined();
|
||||
expect(typeof handler).toBe('function');
|
||||
});
|
||||
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -48,17 +48,6 @@ const sampleParticipants: Participant[] = [
|
||||
];
|
||||
|
||||
describe('formatAssignmentWithPlace', () => {
|
||||
it('returns correct top-level shape', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
|
||||
expect(result).toHaveProperty('id', 1);
|
||||
expect(result).toHaveProperty('day_id', 10);
|
||||
expect(result).toHaveProperty('order_index', 0);
|
||||
expect(result).toHaveProperty('notes', 'assignment note');
|
||||
expect(result).toHaveProperty('created_at');
|
||||
expect(result).toHaveProperty('place');
|
||||
expect(result).toHaveProperty('participants');
|
||||
});
|
||||
|
||||
it('nests place fields correctly from flat row', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
const { place } = result;
|
||||
@@ -100,24 +89,4 @@ describe('formatAssignmentWithPlace', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow({ category_id: 0 as any }), [], []);
|
||||
expect(result.place.category).toBeNull();
|
||||
});
|
||||
|
||||
it('includes provided tags in place.tags', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), sampleTags, []);
|
||||
expect(result.place.tags).toEqual(sampleTags);
|
||||
});
|
||||
|
||||
it('defaults place.tags to [] when empty array provided', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.place.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes provided participants', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], sampleParticipants);
|
||||
expect(result.participants).toEqual(sampleParticipants);
|
||||
});
|
||||
|
||||
it('defaults participants to [] when empty array provided', () => {
|
||||
const result = formatAssignmentWithPlace(makeRow(), [], []);
|
||||
expect(result.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Capture Agent constructor options so we can test the lookup callback
|
||||
const { agentCapture } = vi.hoisted(() => ({ agentCapture: { options: null as any } }));
|
||||
|
||||
// Mock dns/promises to avoid real DNS lookups in unit tests
|
||||
vi.mock('dns/promises', () => ({
|
||||
default: { lookup: vi.fn() },
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock undici Agent so we can inspect the connect.lookup option
|
||||
vi.mock('undici', () => ({
|
||||
Agent: class MockAgent {
|
||||
options: any;
|
||||
constructor(opts: any) {
|
||||
this.options = opts;
|
||||
agentCapture.options = opts;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import dns from 'dns/promises';
|
||||
import { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||
import { checkSsrf, SsrfBlockedError, safeFetch, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
|
||||
|
||||
const mockLookup = vi.mocked(dns.lookup);
|
||||
|
||||
@@ -142,4 +156,94 @@ describe('checkSsrf', () => {
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS resolution failure', () => {
|
||||
it('returns allowed:false when dns.lookup throws', async () => {
|
||||
mockLookup.mockRejectedValue(new Error('ENOTFOUND nxdomain.example'));
|
||||
const result = await checkSsrf('http://nxdomain.example.com');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.isPrivate).toBe(false);
|
||||
expect(result.error).toBe('Could not resolve hostname');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('SsrfBlockedError', () => {
|
||||
it('is an instance of Error', () => {
|
||||
const err = new SsrfBlockedError('blocked');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('has name SsrfBlockedError', () => {
|
||||
const err = new SsrfBlockedError('test message');
|
||||
expect(err.name).toBe('SsrfBlockedError');
|
||||
});
|
||||
|
||||
it('has the correct message', () => {
|
||||
const err = new SsrfBlockedError('my message');
|
||||
expect(err.message).toBe('my message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeFetch', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('throws SsrfBlockedError for a blocked URL (invalid URL)', async () => {
|
||||
await expect(safeFetch('not-a-valid-url')).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it('throws SsrfBlockedError for a loopback URL', async () => {
|
||||
mockLookup.mockResolvedValue({ address: '127.0.0.1', family: 4 });
|
||||
await expect(safeFetch('http://localhost')).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it('calls fetch with the resolved URL when allowed', async () => {
|
||||
mockLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
const result = await safeFetch('https://example.com');
|
||||
expect(mockFetch).toHaveBeenCalledOnce();
|
||||
expect(result.status).toBe(200);
|
||||
});
|
||||
|
||||
it('throws SsrfBlockedError with fallback message when error is undefined', async () => {
|
||||
// non-http protocol → error:'Only HTTP and HTTPS URLs are allowed'
|
||||
await expect(safeFetch('ftp://example.com')).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPinnedDispatcher', () => {
|
||||
it('returns an object (Agent instance)', () => {
|
||||
const dispatcher = createPinnedDispatcher('93.184.216.34');
|
||||
expect(dispatcher).toBeDefined();
|
||||
expect(typeof dispatcher).toBe('object');
|
||||
});
|
||||
|
||||
it('pinned lookup callback calls back with the resolved IPv4 address', () => {
|
||||
createPinnedDispatcher('93.184.216.34');
|
||||
const lookup = agentCapture.options?.connect?.lookup;
|
||||
expect(typeof lookup).toBe('function');
|
||||
const cb = vi.fn();
|
||||
lookup('example.com', {}, cb);
|
||||
expect(cb).toHaveBeenCalledWith(null, '93.184.216.34', 4);
|
||||
});
|
||||
|
||||
it('pinned lookup callback uses family 6 for IPv6 address', () => {
|
||||
createPinnedDispatcher('2001:4860:4860::8888');
|
||||
const lookup = agentCapture.options?.connect?.lookup;
|
||||
const cb = vi.fn();
|
||||
lookup('example.com', {}, cb);
|
||||
expect(cb).toHaveBeenCalledWith(null, '2001:4860:4860::8888', 6);
|
||||
});
|
||||
|
||||
it('returns array format when opts.all is true', () => {
|
||||
createPinnedDispatcher('93.184.216.34');
|
||||
const lookup = agentCapture.options?.connect?.lookup;
|
||||
const cb = vi.fn();
|
||||
lookup('example.com', { all: true }, cb);
|
||||
expect(cb).toHaveBeenCalledWith(null, [{ address: '93.184.216.34', family: 4 }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user