Add comprehensive backend test suite (#339)

* add test suite, mostly covers integration testing, tests are only backend side

* workflow runs the correct script

* workflow runs the correct script

* workflow runs the correct script

* unit tests incoming

* Fix multer silent rejections and error handler info leak

- Revert cb(null, false) to cb(new Error(...)) in auth.ts, collab.ts,
  and files.ts so invalid uploads return an error instead of silently
  dropping the file
- Error handler in app.ts now always returns 500 / "Internal server
  error" instead of forwarding err.message to the client

* Use statusCode consistently for multer errors and error handler

- Error handler in app.ts reads err.statusCode to forward the correct
  HTTP status while keeping the response body generic
This commit is contained in:
Julien G.
2026-04-03 13:17:53 +02:00
committed by GitHub
parent d48714d17a
commit 905c7d460b
74 changed files with 12821 additions and 311 deletions

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { encrypt_api_key, decrypt_api_key, maybe_encrypt_api_key } from '../../../src/services/apiKeyCrypto';
describe('apiKeyCrypto', () => {
const PLAINTEXT_KEY = 'my-secret-api-key-12345';
const ENC_PREFIX = 'enc:v1:';
// SEC-008 — Encrypted API keys not returned in plaintext
describe('encrypt_api_key', () => {
it('SEC-008: returns encrypted string with enc:v1: prefix', () => {
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
expect(encrypted).toMatch(/^enc:v1:/);
});
it('different calls produce different ciphertext (random IV)', () => {
const enc1 = encrypt_api_key(PLAINTEXT_KEY);
const enc2 = encrypt_api_key(PLAINTEXT_KEY);
expect(enc1).not.toBe(enc2);
});
it('encrypted value does not contain the plaintext', () => {
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
expect(encrypted).not.toContain(PLAINTEXT_KEY);
});
});
describe('decrypt_api_key', () => {
it('SEC-008: decrypts an encrypted key back to original', () => {
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
const decrypted = decrypt_api_key(encrypted);
expect(decrypted).toBe(PLAINTEXT_KEY);
});
it('returns null for null input', () => {
expect(decrypt_api_key(null)).toBeNull();
});
it('returns null for empty string', () => {
expect(decrypt_api_key('')).toBeNull();
});
it('returns plaintext as-is if not prefixed (legacy)', () => {
expect(decrypt_api_key('plain-legacy-key')).toBe('plain-legacy-key');
});
it('returns null for tampered ciphertext', () => {
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
const tampered = encrypted.replace(ENC_PREFIX, ENC_PREFIX) + 'TAMPER';
expect(decrypt_api_key(tampered)).toBeNull();
});
});
describe('maybe_encrypt_api_key', () => {
it('encrypts a new plaintext value', () => {
const result = maybe_encrypt_api_key('my-key');
expect(result).toMatch(/^enc:v1:/);
});
it('returns null for empty/falsy values', () => {
expect(maybe_encrypt_api_key('')).toBeNull();
expect(maybe_encrypt_api_key(null)).toBeNull();
expect(maybe_encrypt_api_key(undefined)).toBeNull();
});
it('returns already-encrypted value as-is (no double-encryption)', () => {
const encrypted = encrypt_api_key(PLAINTEXT_KEY);
const result = maybe_encrypt_api_key(encrypted);
expect(result).toBe(encrypted);
});
});
});

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest';
// Prevent file I/O side effects at module load time
vi.mock('fs', () => ({
default: {
mkdirSync: vi.fn(),
existsSync: vi.fn(() => false),
statSync: vi.fn(() => ({ size: 0 })),
appendFileSync: vi.fn(),
renameSync: vi.fn(),
},
mkdirSync: vi.fn(),
existsSync: vi.fn(() => false),
statSync: vi.fn(() => ({ size: 0 })),
appendFileSync: vi.fn(),
renameSync: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(), run: vi.fn() }) },
}));
import { getClientIp } from '../../../src/services/auditLog';
import type { Request } from 'express';
function makeReq(options: {
xff?: string | string[];
remoteAddress?: string;
} = {}): Request {
return {
headers: {
...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}),
},
socket: { remoteAddress: options.remoteAddress ?? undefined },
} as unknown as Request;
}
describe('getClientIp', () => {
it('returns first IP from comma-separated X-Forwarded-For string', () => {
expect(getClientIp(makeReq({ xff: '1.2.3.4, 5.6.7.8, 9.10.11.12' }))).toBe('1.2.3.4');
});
it('returns single IP when X-Forwarded-For has no comma', () => {
expect(getClientIp(makeReq({ xff: '10.0.0.1' }))).toBe('10.0.0.1');
});
it('returns first element when X-Forwarded-For is an array', () => {
expect(getClientIp(makeReq({ xff: ['203.0.113.1', '10.0.0.1'] }))).toBe('203.0.113.1');
});
it('trims whitespace from extracted IP', () => {
expect(getClientIp(makeReq({ xff: ' 192.168.1.1 , 10.0.0.1' }))).toBe('192.168.1.1');
});
it('falls back to req.socket.remoteAddress when no X-Forwarded-For', () => {
expect(getClientIp(makeReq({ remoteAddress: '172.16.0.1' }))).toBe('172.16.0.1');
});
it('returns null when no forwarded header and no socket address', () => {
expect(getClientIp(makeReq({}))).toBeNull();
});
it('returns null for empty string X-Forwarded-For', () => {
const req = {
headers: { 'x-forwarded-for': '' },
socket: { remoteAddress: undefined },
} as unknown as Request;
expect(getClientIp(req)).toBeNull();
});
});

View File

@@ -0,0 +1,299 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(), all: vi.fn(), run: vi.fn() }) },
canAccessTrip: vi.fn(),
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
vi.mock('../../../src/services/mfaCrypto', () => ({ encryptMfaSecret: vi.fn(), decryptMfaSecret: vi.fn() }));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: vi.fn((v) => v),
maybe_encrypt_api_key: vi.fn((v) => v),
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() }));
import {
utcSuffix,
stripUserForClient,
maskKey,
avatarUrl,
normalizeBackupCode,
hashBackupCode,
generateBackupCodes,
parseBackupCodeHashes,
} from '../../../src/services/authService';
import type { User } from '../../../src/types';
// ── utcSuffix ────────────────────────────────────────────────────────────────
describe('utcSuffix', () => {
it('returns null for null', () => {
expect(utcSuffix(null)).toBeNull();
});
it('returns null for undefined', () => {
expect(utcSuffix(undefined)).toBeNull();
});
it('returns null for empty string', () => {
expect(utcSuffix('')).toBeNull();
});
it('returns timestamp unchanged when already ending with Z', () => {
expect(utcSuffix('2024-01-01T12:00:00Z')).toBe('2024-01-01T12:00:00Z');
});
it('replaces space with T and appends Z for SQLite-style datetime', () => {
expect(utcSuffix('2024-01-01 12:00:00')).toBe('2024-01-01T12:00:00Z');
});
it('appends Z when T is present but Z is missing', () => {
expect(utcSuffix('2024-06-15T08:30:00')).toBe('2024-06-15T08:30:00Z');
});
});
// ── stripUserForClient ───────────────────────────────────────────────────────
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
username: 'alice',
email: 'alice@example.com',
role: 'user',
password_hash: 'supersecret',
maps_api_key: 'maps-key',
openweather_api_key: 'weather-key',
unsplash_api_key: 'unsplash-key',
mfa_secret: 'totpsecret',
mfa_backup_codes: '["hash1","hash2"]',
mfa_enabled: 0,
must_change_password: 0,
avatar: null,
created_at: '2024-01-01 00:00:00',
updated_at: '2024-06-01 00:00:00',
last_login: null,
...overrides,
} as unknown as User;
}
describe('stripUserForClient', () => {
it('SEC-008: omits password_hash', () => {
const result = stripUserForClient(makeUser());
expect(result).not.toHaveProperty('password_hash');
});
it('SEC-008: omits maps_api_key', () => {
const result = stripUserForClient(makeUser());
expect(result).not.toHaveProperty('maps_api_key');
});
it('SEC-008: omits openweather_api_key', () => {
const result = stripUserForClient(makeUser());
expect(result).not.toHaveProperty('openweather_api_key');
});
it('SEC-008: omits unsplash_api_key', () => {
const result = stripUserForClient(makeUser());
expect(result).not.toHaveProperty('unsplash_api_key');
});
it('SEC-008: omits mfa_secret', () => {
const result = stripUserForClient(makeUser());
expect(result).not.toHaveProperty('mfa_secret');
});
it('SEC-008: omits mfa_backup_codes', () => {
const result = stripUserForClient(makeUser());
expect(result).not.toHaveProperty('mfa_backup_codes');
});
it('preserves non-sensitive fields', () => {
const result = stripUserForClient(makeUser({ username: 'alice', email: 'alice@example.com', role: 'user' }));
expect(result.id).toBe(1);
expect(result.username).toBe('alice');
expect(result.email).toBe('alice@example.com');
expect(result.role).toBe('user');
});
it('normalizes mfa_enabled integer 1 to true', () => {
const result = stripUserForClient(makeUser({ mfa_enabled: 1 } as any));
expect(result.mfa_enabled).toBe(true);
});
it('normalizes mfa_enabled integer 0 to false', () => {
const result = stripUserForClient(makeUser({ mfa_enabled: 0 } as any));
expect(result.mfa_enabled).toBe(false);
});
it('normalizes mfa_enabled boolean true to true', () => {
const result = stripUserForClient(makeUser({ mfa_enabled: true } as any));
expect(result.mfa_enabled).toBe(true);
});
it('normalizes must_change_password integer 1 to true', () => {
const result = stripUserForClient(makeUser({ must_change_password: 1 } as any));
expect(result.must_change_password).toBe(true);
});
it('normalizes must_change_password integer 0 to false', () => {
const result = stripUserForClient(makeUser({ must_change_password: 0 } as any));
expect(result.must_change_password).toBe(false);
});
it('converts created_at through utcSuffix', () => {
const result = stripUserForClient(makeUser({ created_at: '2024-01-01 00:00:00' }));
expect(result.created_at).toBe('2024-01-01T00:00:00Z');
});
it('converts updated_at through utcSuffix', () => {
const result = stripUserForClient(makeUser({ updated_at: '2024-06-01 12:00:00' }));
expect(result.updated_at).toBe('2024-06-01T12:00:00Z');
});
it('passes null last_login through as null', () => {
const result = stripUserForClient(makeUser({ last_login: null }));
expect(result.last_login).toBeNull();
});
});
// ── maskKey ──────────────────────────────────────────────────────────────────
describe('maskKey', () => {
it('returns null for null', () => {
expect(maskKey(null)).toBeNull();
});
it('returns null for undefined', () => {
expect(maskKey(undefined)).toBeNull();
});
it('returns null for empty string', () => {
expect(maskKey('')).toBeNull();
});
it('returns -------- for keys with 8 or fewer characters', () => {
expect(maskKey('abcd1234')).toBe('--------');
expect(maskKey('short')).toBe('--------');
expect(maskKey('a')).toBe('--------');
});
it('returns ---- + last 4 chars for keys longer than 8 characters', () => {
expect(maskKey('abcdefghijkl')).toBe('----ijkl');
expect(maskKey('sk-test-12345678')).toBe('----5678');
});
});
// ── 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();
});
});
// ── normalizeBackupCode ──────────────────────────────────────────────────────
describe('normalizeBackupCode', () => {
it('uppercases the input', () => {
expect(normalizeBackupCode('abcd1234')).toBe('ABCD1234');
});
it('strips non-alphanumeric characters', () => {
expect(normalizeBackupCode('AB-CD 12!34')).toBe('ABCD1234');
});
it('handles code with dashes (normal backup code format)', () => {
expect(normalizeBackupCode('A1B2-C3D4')).toBe('A1B2C3D4');
});
it('returns empty string for empty input', () => {
expect(normalizeBackupCode('')).toBe('');
});
});
// ── hashBackupCode ───────────────────────────────────────────────────────────
describe('hashBackupCode', () => {
it('returns a 64-character hex string', () => {
const hash = hashBackupCode('A1B2-C3D4');
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});
it('is deterministic: same input always produces same output', () => {
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('A1B2-C3D4'));
});
it('normalizes before hashing: dashed and plain form produce the same hash', () => {
expect(hashBackupCode('A1B2-C3D4')).toBe(hashBackupCode('a1b2c3d4'));
});
});
// ── generateBackupCodes ──────────────────────────────────────────────────────
describe('generateBackupCodes', () => {
it('returns 10 codes by default', () => {
const codes = generateBackupCodes();
expect(codes).toHaveLength(10);
});
it('respects a custom count', () => {
expect(generateBackupCodes(5)).toHaveLength(5);
expect(generateBackupCodes(20)).toHaveLength(20);
});
it('each code matches the XXXX-XXXX uppercase hex pattern', () => {
const codes = generateBackupCodes();
for (const code of codes) {
expect(code).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}$/);
}
});
it('generates no duplicate codes', () => {
const codes = generateBackupCodes(10);
expect(new Set(codes).size).toBe(10);
});
});
// ── parseBackupCodeHashes ────────────────────────────────────────────────────
describe('parseBackupCodeHashes', () => {
it('returns [] for null', () => {
expect(parseBackupCodeHashes(null)).toEqual([]);
});
it('returns [] for undefined', () => {
expect(parseBackupCodeHashes(undefined)).toEqual([]);
});
it('returns [] for empty string', () => {
expect(parseBackupCodeHashes('')).toEqual([]);
});
it('returns [] for invalid JSON', () => {
expect(parseBackupCodeHashes('not-json')).toEqual([]);
});
it('returns [] for JSON that is not an array', () => {
expect(parseBackupCodeHashes('{"key":"value"}')).toEqual([]);
});
it('filters out non-string entries', () => {
expect(parseBackupCodeHashes('[1, "abc", null, true]')).toEqual(['abc']);
});
it('returns all strings from a valid JSON string array', () => {
expect(parseBackupCodeHashes('["hash1","hash2","hash3"]')).toEqual(['hash1', 'hash2', 'hash3']);
});
});

View File

@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── DB mock setup ────────────────────────────────────────────────────────────
interface MockPrepared {
all: ReturnType<typeof vi.fn>;
get: ReturnType<typeof vi.fn>;
run: ReturnType<typeof vi.fn>;
}
const preparedMap: Record<string, MockPrepared> = {};
let defaultAll: ReturnType<typeof vi.fn>;
let defaultGet: ReturnType<typeof vi.fn>;
const mockDb = vi.hoisted(() => {
return {
db: {
prepare: vi.fn((sql: string) => {
return {
all: vi.fn(() => []),
get: vi.fn(() => undefined),
run: vi.fn(),
};
}),
},
canAccessTrip: vi.fn(() => true),
};
});
vi.mock('../../../src/db/database', () => mockDb);
import { calculateSettlement, avatarUrl } from '../../../src/services/budgetService';
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem;
}
function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } {
return {
budget_item_id,
user_id,
paid: paid ? 1 : 0,
username,
avatar: null,
} as BudgetItemMember & { budget_item_id: number };
}
function setupDb(items: BudgetItem[], members: (BudgetItemMember & { budget_item_id: number })[]) {
mockDb.db.prepare.mockImplementation((sql: string) => {
if (sql.includes('SELECT * FROM budget_items')) {
return { all: vi.fn(() => items), get: vi.fn(), run: vi.fn() };
}
if (sql.includes('budget_item_members')) {
return { all: vi.fn(() => members), get: vi.fn(), run: vi.fn() };
}
return { all: vi.fn(() => []), get: vi.fn(), run: vi.fn() };
});
}
beforeEach(() => {
vi.clearAllMocks();
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', () => {
it('returns empty balances and flows when trip has no items', () => {
setupDb([], []);
const result = calculateSettlement(1);
expect(result.balances).toEqual([]);
expect(result.flows).toEqual([]);
});
it('returns no flows when there are items but no members', () => {
setupDb([makeItem(1, 100)], []);
const result = calculateSettlement(1);
expect(result.flows).toEqual([]);
});
it('returns no flows when no one is marked as paid', () => {
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')],
);
const result = calculateSettlement(1);
expect(result.flows).toEqual([]);
});
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
// Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50.
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
);
const result = calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
const bob = result.balances.find(b => b.user_id === 2)!;
expect(alice.balance).toBe(50);
expect(bob.balance).toBe(-50);
expect(result.flows).toHaveLength(1);
expect(result.flows[0].from.user_id).toBe(2); // Bob owes
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
expect(result.flows[0].amount).toBe(50);
});
it('3 members, 1 payer: correct 3-way split', () => {
// Item: $90. Alice paid. Each of 3 owes $30. Alice net: +$60. Bob: -$30. Carol: -$30.
setupDb(
[makeItem(1, 90)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
);
const result = calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
const bob = result.balances.find(b => b.user_id === 2)!;
const carol = result.balances.find(b => b.user_id === 3)!;
expect(alice.balance).toBe(60);
expect(bob.balance).toBe(-30);
expect(carol.balance).toBe(-30);
expect(result.flows).toHaveLength(2);
});
it('all paid equally: all balances are zero, no flows', () => {
// Item: $60. 3 members, all paid equally (each paid $20, each owes $20). Net: 0.
// Actually with "paid" flag it means: paidPerPayer = item.total / numPayers.
// If all 3 paid: each gets +20 credit, each owes -20 = net 0 for everyone.
setupDb(
[makeItem(1, 60)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 1, 'bob'), makeMember(1, 3, 1, 'carol')],
);
const result = calculateSettlement(1);
for (const b of result.balances) {
expect(Math.abs(b.balance)).toBeLessThanOrEqual(0.01);
}
expect(result.flows).toHaveLength(0);
});
it('flow direction: from is debtor (owes), to is creditor (is owed)', () => {
// Alice paid $100 for 2 people. Bob owes Alice $50.
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
);
const result = calculateSettlement(1);
const flow = result.flows[0];
expect(flow.from.username).toBe('bob'); // debtor
expect(flow.to.username).toBe('alice'); // creditor
});
it('amounts are rounded to 2 decimal places', () => {
// Item: $10. 3 members, 1 payer. Share = 3.333... Each rounded to 3.33.
setupDb(
[makeItem(1, 10)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
);
const result = calculateSettlement(1);
for (const b of result.balances) {
const str = b.balance.toString();
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
expect(decimals).toBeLessThanOrEqual(2);
}
for (const flow of result.flows) {
const str = flow.amount.toString();
const decimals = str.includes('.') ? str.split('.')[1].length : 0;
expect(decimals).toBeLessThanOrEqual(2);
}
});
it('2 items with different payers: aggregates balances correctly', () => {
// Item 1: $100, Alice paid, [Alice, Bob] (Alice net: +50, Bob: -50)
// Item 2: $60, Bob paid, [Alice, Bob] (Bob net: +30, Alice: -30)
// Final: Alice: +50 - 30 = +20, Bob: -50 + 30 = -20
setupDb(
[makeItem(1, 100), makeItem(2, 60)],
[
makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'),
makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'),
],
);
const result = calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
const bob = result.balances.find(b => b.user_id === 2)!;
expect(alice.balance).toBe(20);
expect(bob.balance).toBe(-20);
expect(result.flows).toHaveLength(1);
expect(result.flows[0].amount).toBe(20);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cookieOptions } from '../../../src/services/cookie';
describe('cookieOptions', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('always sets httpOnly: true', () => {
expect(cookieOptions()).toHaveProperty('httpOnly', true);
});
it('always sets sameSite: strict', () => {
expect(cookieOptions()).toHaveProperty('sameSite', 'strict');
});
it('always sets path: /', () => {
expect(cookieOptions()).toHaveProperty('path', '/');
});
it('sets secure: false in test environment (COOKIE_SECURE=false from setup)', () => {
// setup.ts sets COOKIE_SECURE=false, so secure should be false
const opts = cookieOptions();
expect(opts.secure).toBe(false);
});
it('sets secure: true when NODE_ENV=production and COOKIE_SECURE is not false', () => {
vi.stubEnv('COOKIE_SECURE', 'true');
vi.stubEnv('NODE_ENV', 'production');
expect(cookieOptions().secure).toBe(true);
});
it('sets secure: false when COOKIE_SECURE=false even in production', () => {
vi.stubEnv('COOKIE_SECURE', 'false');
vi.stubEnv('NODE_ENV', 'production');
expect(cookieOptions().secure).toBe(false);
});
it('sets secure: true when FORCE_HTTPS=true', () => {
vi.stubEnv('COOKIE_SECURE', 'true');
vi.stubEnv('FORCE_HTTPS', 'true');
vi.stubEnv('NODE_ENV', 'development');
expect(cookieOptions().secure).toBe(true);
});
it('includes maxAge: 86400000 when clear is false (default)', () => {
expect(cookieOptions()).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
expect(cookieOptions(false)).toHaveProperty('maxAge', 24 * 60 * 60 * 1000);
});
it('omits maxAge when clear is true', () => {
const opts = cookieOptions(true);
expect(opts).not.toHaveProperty('maxAge');
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Reset module between tests that need a fresh token store
beforeEach(() => {
vi.resetModules();
});
describe('ephemeralTokens', () => {
async function getModule() {
return import('../../../src/services/ephemeralTokens');
}
// AUTH-030 — Resource token creation (single-use)
describe('createEphemeralToken', () => {
it('AUTH-030: creates a token and returns a hex string', async () => {
const { createEphemeralToken } = await getModule();
const token = createEphemeralToken(1, 'download');
expect(token).not.toBeNull();
expect(typeof token).toBe('string');
expect(token!.length).toBe(64); // 32 bytes hex
});
it('AUTH-030: different calls produce different tokens', async () => {
const { createEphemeralToken } = await getModule();
const t1 = createEphemeralToken(1, 'download');
const t2 = createEphemeralToken(1, 'download');
expect(t1).not.toBe(t2);
});
});
// AUTH-029 — WebSocket token expiry (single-use)
describe('consumeEphemeralToken', () => {
it('AUTH-030: token is consumed and returns userId on first use', async () => {
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
const token = createEphemeralToken(42, 'download')!;
const userId = consumeEphemeralToken(token, 'download');
expect(userId).toBe(42);
});
it('AUTH-030: token is single-use — second consume returns null', async () => {
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
const token = createEphemeralToken(42, 'download')!;
consumeEphemeralToken(token, 'download'); // first use
const second = consumeEphemeralToken(token, 'download'); // second use
expect(second).toBeNull();
});
it('AUTH-029: purpose mismatch returns null', async () => {
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
const token = createEphemeralToken(42, 'ws')!;
const result = consumeEphemeralToken(token, 'download');
expect(result).toBeNull();
});
it('AUTH-029: expired token returns null', async () => {
vi.useFakeTimers();
const { createEphemeralToken, consumeEphemeralToken } = await getModule();
const token = createEphemeralToken(42, 'ws')!; // 30s TTL
vi.advanceTimersByTime(31_000); // advance past expiry
const result = consumeEphemeralToken(token, 'ws');
expect(result).toBeNull();
vi.useRealTimers();
});
it('returns null for unknown token', async () => {
const { consumeEphemeralToken } = await getModule();
const result = consumeEphemeralToken('nonexistent-token', 'download');
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { encryptMfaSecret, decryptMfaSecret } from '../../../src/services/mfaCrypto';
describe('mfaCrypto', () => {
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; // typical base32 TOTP secret
// SEC-009 — Encrypted MFA secrets not exposed
describe('encryptMfaSecret', () => {
it('SEC-009: returns a base64 string (not the plaintext)', () => {
const encrypted = encryptMfaSecret(TOTP_SECRET);
expect(encrypted).not.toBe(TOTP_SECRET);
// Should be valid base64
expect(() => Buffer.from(encrypted, 'base64')).not.toThrow();
});
it('different calls produce different ciphertext (random IV)', () => {
const enc1 = encryptMfaSecret(TOTP_SECRET);
const enc2 = encryptMfaSecret(TOTP_SECRET);
expect(enc1).not.toBe(enc2);
});
it('encrypted value does not contain plaintext', () => {
const encrypted = encryptMfaSecret(TOTP_SECRET);
expect(encrypted).not.toContain(TOTP_SECRET);
});
});
describe('decryptMfaSecret', () => {
it('SEC-009: roundtrip — decrypt returns original secret', () => {
const encrypted = encryptMfaSecret(TOTP_SECRET);
const decrypted = decryptMfaSecret(encrypted);
expect(decrypted).toBe(TOTP_SECRET);
});
it('handles secrets of varying lengths', () => {
const short = 'ABC123';
const long = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP';
expect(decryptMfaSecret(encryptMfaSecret(short))).toBe(short);
expect(decryptMfaSecret(encryptMfaSecret(long))).toBe(long);
});
it('throws or returns garbage on tampered ciphertext', () => {
const encrypted = encryptMfaSecret(TOTP_SECRET);
const buf = Buffer.from(encrypted, 'base64');
buf[buf.length - 1] ^= 0xff; // flip last byte
const tampered = buf.toString('base64');
expect(() => decryptMfaSecret(tampered)).toThrow();
});
});
});

View File

@@ -0,0 +1,195 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(() => undefined), all: vi.fn(() => []) }) },
}));
vi.mock('../../../src/services/apiKeyCrypto', () => ({
decrypt_api_key: vi.fn((v) => v),
maybe_encrypt_api_key: vi.fn((v) => v),
}));
vi.mock('../../../src/services/auditLog', () => ({
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
writeAudit: vi.fn(),
getClientIp: vi.fn(),
}));
vi.mock('nodemailer', () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn() })) } }));
vi.mock('node-fetch', () => ({ default: vi.fn() }));
import { getEventText, buildEmailHtml, buildWebhookBody } from '../../../src/services/notifications';
afterEach(() => {
vi.unstubAllEnvs();
});
// ── getEventText ─────────────────────────────────────────────────────────────
describe('getEventText', () => {
const params = {
trip: 'Tokyo Adventure',
actor: 'Alice',
invitee: 'Bob',
booking: 'Hotel Sakura',
type: 'hotel',
count: '5',
preview: 'See you there!',
category: 'Clothing',
};
it('returns English title and body for lang=en', () => {
const result = getEventText('en', 'trip_invite', params);
expect(result.title).toBeTruthy();
expect(result.body).toBeTruthy();
expect(result.title).toContain('Tokyo Adventure');
expect(result.body).toContain('Alice');
});
it('returns German text for lang=de', () => {
const result = getEventText('de', 'trip_invite', params);
expect(result.title).toContain('Tokyo Adventure');
// German version uses "Einladung"
expect(result.title).toContain('Einladung');
});
it('falls back to English for unknown language code', () => {
const en = getEventText('en', 'trip_invite', params);
const unknown = getEventText('xx', 'trip_invite', params);
expect(unknown.title).toBe(en.title);
expect(unknown.body).toBe(en.body);
});
it('interpolates params into trip_invite correctly', () => {
const result = getEventText('en', 'trip_invite', params);
expect(result.title).toContain('Tokyo Adventure');
expect(result.body).toContain('Alice');
expect(result.body).toContain('Bob');
});
it('all 7 event types produce non-empty title and body in English', () => {
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
for (const event of events) {
const result = getEventText('en', event, params);
expect(result.title, `title for ${event}`).toBeTruthy();
expect(result.body, `body for ${event}`).toBeTruthy();
}
});
it('all 7 event types produce non-empty title and body in German', () => {
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
for (const event of events) {
const result = getEventText('de', event, params);
expect(result.title, `de title for ${event}`).toBeTruthy();
expect(result.body, `de body for ${event}`).toBeTruthy();
}
});
});
// ── buildWebhookBody ─────────────────────────────────────────────────────────
describe('buildWebhookBody', () => {
const payload = {
event: 'trip_invite',
title: 'Trip Invite',
body: 'Alice invited you',
tripName: 'Tokyo Adventure',
};
it('Discord URL produces embeds array format', () => {
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
expect(body).toHaveProperty('embeds');
expect(Array.isArray(body.embeds)).toBe(true);
expect(body.embeds[0]).toHaveProperty('title');
expect(body.embeds[0]).toHaveProperty('description', payload.body);
expect(body.embeds[0]).toHaveProperty('color');
expect(body.embeds[0]).toHaveProperty('footer');
expect(body.embeds[0]).toHaveProperty('timestamp');
});
it('Discord embed title is prefixed with compass emoji', () => {
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
expect(body.embeds[0].title).toContain('📍');
expect(body.embeds[0].title).toContain(payload.title);
});
it('Discord embed footer contains trip name when provided', () => {
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', payload));
expect(body.embeds[0].footer.text).toContain('Tokyo Adventure');
});
it('Discord embed footer defaults to TREK when no trip name', () => {
const noTrip = { ...payload, tripName: undefined };
const body = JSON.parse(buildWebhookBody('https://discord.com/api/webhooks/123/abc', noTrip));
expect(body.embeds[0].footer.text).toBe('TREK');
});
it('discordapp.com URL is also detected as Discord', () => {
const body = JSON.parse(buildWebhookBody('https://discordapp.com/api/webhooks/123/abc', payload));
expect(body).toHaveProperty('embeds');
});
it('Slack URL produces text field format', () => {
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
expect(body).toHaveProperty('text');
expect(body.text).toContain(payload.title);
expect(body.text).toContain(payload.body);
});
it('Slack text includes italic trip name when provided', () => {
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', payload));
expect(body.text).toContain('Tokyo Adventure');
});
it('Slack text omits trip name when not provided', () => {
const noTrip = { ...payload, tripName: undefined };
const body = JSON.parse(buildWebhookBody('https://hooks.slack.com/services/X/Y/Z', noTrip));
// Should not contain the trip name string
expect(body.text).not.toContain('Tokyo Adventure');
});
it('generic URL produces plain JSON with original fields plus timestamp and source', () => {
const body = JSON.parse(buildWebhookBody('https://mywebhook.example.com/hook', payload));
expect(body).toHaveProperty('event', payload.event);
expect(body).toHaveProperty('title', payload.title);
expect(body).toHaveProperty('body', payload.body);
expect(body).toHaveProperty('timestamp');
expect(body).toHaveProperty('source', 'TREK');
});
});
// ── buildEmailHtml ────────────────────────────────────────────────────────────
describe('buildEmailHtml', () => {
it('returns a string containing <!DOCTYPE html>', () => {
const html = buildEmailHtml('Test Subject', 'Test body text', 'en');
expect(html).toContain('<!DOCTYPE html>');
});
it('contains the subject text', () => {
const html = buildEmailHtml('My Email Subject', 'Some body', 'en');
expect(html).toContain('My Email Subject');
});
it('contains the body text', () => {
const html = buildEmailHtml('Subject', 'Hello world, this is the body!', 'en');
expect(html).toContain('Hello world, this is the body!');
});
it('uses English i18n strings for lang=en', () => {
const html = buildEmailHtml('Subject', 'Body', 'en');
expect(html).toContain('notifications enabled in TREK');
});
it('uses German i18n strings for lang=de', () => {
const html = buildEmailHtml('Subject', 'Body', 'de');
expect(html).toContain('TREK aktiviert');
});
it('falls back to English i18n for unknown language', () => {
const en = buildEmailHtml('Subject', 'Body', 'en');
const unknown = buildEmailHtml('Subject', 'Body', 'xx');
// Both should have the same footer text
expect(unknown).toContain('notifications enabled in TREK');
});
});

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { validatePassword } from '../../../src/services/passwordPolicy';
describe('validatePassword', () => {
// AUTH-006 — Registration with weak password
describe('length requirement', () => {
it('AUTH-006: rejects passwords shorter than 8 characters', () => {
expect(validatePassword('Ab1!')).toEqual({ ok: false, reason: expect.stringContaining('8 characters') });
expect(validatePassword('Ab1!456')).toEqual({ ok: false, reason: expect.stringContaining('8 characters') });
});
it('accepts passwords of exactly 8 characters that meet all requirements', () => {
expect(validatePassword('Ab1!abcd')).toEqual({ ok: true });
});
});
describe('complexity requirements', () => {
it('AUTH-006: rejects password missing uppercase letter', () => {
const result = validatePassword('abcd1234!');
expect(result.ok).toBe(false);
expect(result.reason).toContain('uppercase');
});
it('AUTH-006: rejects password missing lowercase letter', () => {
const result = validatePassword('ABCD1234!');
expect(result.ok).toBe(false);
expect(result.reason).toContain('lowercase');
});
it('AUTH-006: rejects password missing a number', () => {
const result = validatePassword('Abcdefg!');
expect(result.ok).toBe(false);
expect(result.reason).toContain('number');
});
it('AUTH-006: rejects password missing a special character', () => {
// 'TrekApp1' — has upper, lower, number, NO special char, NOT in blocklist
const result = validatePassword('TrekApp1');
expect(result.ok).toBe(false);
expect(result.reason).toContain('special character');
});
});
// AUTH-007 — Registration with common password
describe('common password blocklist', () => {
it('AUTH-007: rejects password matching exact blocklist entry (case-insensitive)', () => {
// 'password1' is in the blocklist. A capitalised+special variant still matches
// because the check is COMMON_PASSWORDS.has(password.toLowerCase()).
// However, 'Password1!' lowercased is 'password1!' which is NOT in the set.
// We must use a password whose lowercase is exactly in the set:
// 'Iloveyou1!' — lowercased: 'iloveyou1!' — NOT in set.
// Use a password whose *lowercase* IS in set: 'changeme' → 'Changeme' is 8 chars
// but lacks uppercase/number/special — test blocklist with full complex variants:
// 'ILoveyou1!' lowercased = 'iloveyou1!' — not in set.
// Just test exact matches that satisfy complexity: use blocklist entry itself.
// 'Iloveyou' is 8 chars, no number/special → fails complexity, not blocklist.
// Better: pick a blocklist entry that, when capitalised + special added, still matches.
// The check is: COMMON_PASSWORDS.has(password.toLowerCase())
// So 'FOOTBALL!' lowercased = 'football!' — not in set ('football' is in set).
// We need password.toLowerCase() to equal a set entry exactly:
// 'football' → add uppercase → 'Football' is still 8 chars, no number, no special → fails complexity first
// The blocklist check happens BEFORE complexity checks, after length + repetitive checks.
// So any 8+ char string whose lowercase is in the blocklist gets caught first.
// 'Password1' lowercased = 'password1' → in blocklist! ✓ (length ok, not repetitive)
expect(validatePassword('Password1')).toEqual({
ok: false,
reason: expect.stringContaining('common'),
});
});
it('AUTH-007: rejects "Changeme" whose lowercase is in the blocklist', () => {
// 'changeme' is in the set; 'Changeme'.toLowerCase() === 'changeme' ✓
expect(validatePassword('Changeme')).toEqual({
ok: false,
reason: expect.stringContaining('common'),
});
});
it('accepts a strong password that is not in the blocklist', () => {
expect(validatePassword('MyUniq!1Trek')).toEqual({ ok: true });
});
});
describe('repetitive password', () => {
it('rejects passwords made of a single repeated character', () => {
const result = validatePassword('AAAAAAAA');
expect(result.ok).toBe(false);
expect(result.reason).toContain('repetitive');
});
});
describe('valid passwords', () => {
it('accepts a strong unique password', () => {
expect(validatePassword('Tr3k!SecurePass')).toEqual({ ok: true });
});
it('accepts a strong password with special characters', () => {
expect(validatePassword('MyP@ss#2024')).toEqual({ ok: true });
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
// Mock database — permissions module queries app_settings at runtime
vi.mock('../../../src/db/database', () => ({
db: {
prepare: () => ({
all: () => [], // no custom permissions → fall back to defaults
run: vi.fn(),
}),
},
}));
import { checkPermission, getPermissionLevel, PERMISSION_ACTIONS } from '../../../src/services/permissions';
describe('permissions', () => {
describe('checkPermission — admin bypass', () => {
it('admin always passes regardless of permission level', () => {
for (const action of PERMISSION_ACTIONS) {
expect(checkPermission(action.key, 'admin', 1, 1, false)).toBe(true);
expect(checkPermission(action.key, 'admin', 99, 1, false)).toBe(true);
}
});
});
describe('checkPermission — everybody level', () => {
it('trip_create (everybody) allows any authenticated user', () => {
expect(checkPermission('trip_create', 'user', null, 42, false)).toBe(true);
});
});
describe('checkPermission — trip_owner level', () => {
const ownerId = 10;
const memberId = 20;
it('trip owner passes trip_owner check', () => {
expect(checkPermission('trip_delete', 'user', ownerId, ownerId, false)).toBe(true);
});
it('member fails trip_owner check', () => {
expect(checkPermission('trip_delete', 'user', ownerId, memberId, true)).toBe(false);
});
it('non-member non-owner fails trip_owner check', () => {
expect(checkPermission('trip_delete', 'user', ownerId, memberId, false)).toBe(false);
});
});
describe('checkPermission — trip_member level', () => {
const ownerId = 10;
const memberId = 20;
const outsiderId = 30;
it('trip owner passes trip_member check', () => {
expect(checkPermission('day_edit', 'user', ownerId, ownerId, false)).toBe(true);
});
it('trip member passes trip_member check', () => {
expect(checkPermission('day_edit', 'user', ownerId, memberId, true)).toBe(true);
});
it('outsider fails trip_member check', () => {
expect(checkPermission('day_edit', 'user', ownerId, outsiderId, false)).toBe(false);
});
});
describe('getPermissionLevel — defaults', () => {
it('returns default level for known actions (no DB overrides)', () => {
const defaults: Record<string, string> = {
trip_create: 'everybody',
trip_delete: 'trip_owner',
day_edit: 'trip_member',
budget_edit: 'trip_member',
};
for (const [key, expected] of Object.entries(defaults)) {
expect(getPermissionLevel(key)).toBe(expected);
}
});
it('returns trip_owner for unknown action key', () => {
expect(getPermissionLevel('nonexistent_action')).toBe('trip_owner');
});
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ all: () => [], get: vi.fn() }) },
}));
import { formatAssignmentWithPlace } from '../../../src/services/queryHelpers';
import type { AssignmentRow, Tag, Participant } from '../../../src/types';
function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
return {
id: 1,
day_id: 10,
place_id: 100,
order_index: 0,
notes: 'assignment note',
created_at: '2024-01-01T00:00:00Z',
place_name: 'Eiffel Tower',
place_description: 'Famous landmark',
lat: 48.8584,
lng: 2.2945,
address: 'Champ de Mars, Paris',
category_id: 5,
category_name: 'Sightseeing',
category_color: '#3b82f6',
category_icon: 'landmark',
price: 25.0,
place_currency: 'EUR',
place_time: '10:00',
end_time: '12:00',
duration_minutes: 120,
place_notes: 'Bring tickets',
image_url: 'https://example.com/img.jpg',
transport_mode: 'walk',
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
website: 'https://eiffel-tower.com',
phone: '+33 1 2345 6789',
...overrides,
} as AssignmentRow;
}
const sampleTags: Partial<Tag>[] = [
{ id: 1, name: 'Must-see', color: '#ef4444' },
];
const sampleParticipants: Participant[] = [
{ user_id: 42, username: 'alice', avatar: null },
];
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;
expect(place.id).toBe(100);
expect(place.name).toBe('Eiffel Tower');
expect(place.description).toBe('Famous landmark');
expect(place.lat).toBe(48.8584);
expect(place.lng).toBe(2.2945);
expect(place.address).toBe('Champ de Mars, Paris');
expect(place.price).toBe(25.0);
expect(place.currency).toBe('EUR');
expect(place.place_time).toBe('10:00');
expect(place.end_time).toBe('12:00');
expect(place.duration_minutes).toBe(120);
expect(place.notes).toBe('Bring tickets');
expect(place.image_url).toBe('https://example.com/img.jpg');
expect(place.transport_mode).toBe('walk');
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
expect(place.website).toBe('https://eiffel-tower.com');
expect(place.phone).toBe('+33 1 2345 6789');
});
it('constructs place.category object when category_id is present', () => {
const result = formatAssignmentWithPlace(makeRow(), [], []);
expect(result.place.category).toEqual({
id: 5,
name: 'Sightseeing',
color: '#3b82f6',
icon: 'landmark',
});
});
it('sets place.category to null when category_id is null', () => {
const result = formatAssignmentWithPlace(makeRow({ category_id: null as any }), [], []);
expect(result.place.category).toBeNull();
});
it('sets place.category to null when category_id is 0 (falsy)', () => {
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([]);
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
// Prevent the module-level setInterval from running during tests
vi.useFakeTimers();
// Mock node-fetch to prevent real HTTP requests
vi.mock('node-fetch', () => ({ default: vi.fn() }));
import { estimateCondition, cacheKey } from '../../../src/services/weatherService';
// ── estimateCondition ────────────────────────────────────────────────────────
describe('estimateCondition', () => {
describe('heavy precipitation (precipMm > 5)', () => {
it('returns Snow when temp <= 0', () => {
expect(estimateCondition(0, 6)).toBe('Snow');
expect(estimateCondition(-5, 10)).toBe('Snow');
});
it('returns Rain when temp > 0', () => {
expect(estimateCondition(1, 6)).toBe('Rain');
expect(estimateCondition(20, 50)).toBe('Rain');
});
it('boundary: precipMm = 5.01 and temp = 0 -> Snow', () => {
expect(estimateCondition(0, 5.01)).toBe('Snow');
});
it('boundary: precipMm = 5 is NOT heavy (exactly 5, not > 5) -> falls through', () => {
// precipMm = 5 fails the > 5 check, falls to > 1 check -> Snow or Drizzle
expect(estimateCondition(0, 5)).toBe('Snow'); // > 1 and temp <= 0
expect(estimateCondition(5, 5)).toBe('Drizzle'); // > 1 and temp > 0
});
});
describe('moderate precipitation (precipMm > 1)', () => {
it('returns Snow when temp <= 0', () => {
expect(estimateCondition(0, 2)).toBe('Snow');
expect(estimateCondition(-10, 1.5)).toBe('Snow');
});
it('returns Drizzle when temp > 0', () => {
expect(estimateCondition(5, 2)).toBe('Drizzle');
expect(estimateCondition(15, 3)).toBe('Drizzle');
});
});
describe('light precipitation (precipMm > 0.3)', () => {
it('returns Clouds regardless of temperature', () => {
expect(estimateCondition(-5, 0.5)).toBe('Clouds');
expect(estimateCondition(25, 0.5)).toBe('Clouds');
});
it('boundary: precipMm = 0.31 -> Clouds', () => {
expect(estimateCondition(20, 0.31)).toBe('Clouds');
});
it('boundary: precipMm = 0.3 is NOT light precipitation -> falls through', () => {
// precipMm = 0.3 fails the > 0.3 check, falls to temperature check
expect(estimateCondition(20, 0.3)).toBe('Clear'); // temp > 15
expect(estimateCondition(10, 0.3)).toBe('Clouds'); // temp <= 15
});
});
describe('dry conditions (precipMm <= 0.3)', () => {
it('returns Clear when temp > 15', () => {
expect(estimateCondition(16, 0)).toBe('Clear');
expect(estimateCondition(30, 0.1)).toBe('Clear');
});
it('returns Clouds when temp <= 15', () => {
expect(estimateCondition(15, 0)).toBe('Clouds');
expect(estimateCondition(10, 0)).toBe('Clouds');
expect(estimateCondition(-5, 0)).toBe('Clouds');
});
it('boundary: temp = 15 -> Clouds (not > 15)', () => {
expect(estimateCondition(15, 0)).toBe('Clouds');
});
});
});
// ── cacheKey ─────────────────────────────────────────────────────────────────
describe('cacheKey', () => {
it('rounds lat and lng to 2 decimal places', () => {
expect(cacheKey('48.8566', '2.3522', '2024-06-15')).toBe('48.86_2.35_2024-06-15');
});
it('uses "current" when date is undefined', () => {
expect(cacheKey('10.0', '20.0')).toBe('10.00_20.00_current');
});
it('handles negative coordinates', () => {
expect(cacheKey('-33.8688', '151.2093', '2024-01-01')).toBe('-33.87_151.21_2024-01-01');
});
it('pads to 2 decimal places for round numbers', () => {
expect(cacheKey('48', '2', '2024-01-01')).toBe('48.00_2.00_2024-01-01');
});
it('preserves the date string as-is', () => {
expect(cacheKey('0', '0', 'climate')).toBe('0.00_0.00_climate');
});
});