Files
TREK/server/tests/unit/nest/share.service.test.ts
T
2026-07-26 21:24:57 +02:00

434 lines
21 KiB
TypeScript

/**
* Unit tests for the DI-native ShareService — SHARE-SVC-001 through
* SHARE-SVC-028 (the SHARE-001..026 range belongs to the HTTP parity suite in
* tests/integration/share.test.ts; these pin the service SQL directly, plus
* the share.bridge delegation). 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: () => {},
}));
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import {
createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment,
} from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { ShareService } from '../../../src/nest/share/share.service';
import { createOrUpdateShareLink, getShareLink, deleteShareLink } from '../../../src/nest/share/share.bridge';
import type { User } from '../../../src/types';
const svc = new ShareService(new DatabaseService(testDb));
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
checkPermission.mockReset();
serveFilePath.mockReset();
});
afterAll(() => {
testDb.close();
});
function shareRow(tripId: number | string) {
return testDb.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
}
/** Owner + trip + a share link with all five flags on unless overridden. */
function seedSharedTrip(flags: Record<string, boolean> = {}) {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { token } = svc.createOrUpdate(String(trip.id), user.id, {
share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true,
...flags,
});
return { user, trip, token };
}
// ── verifyTripAccess / canManage ──────────────────────────────────────────────
describe('verifyTripAccess and canManage', () => {
it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
expect(svc.verifyTripAccess(String(trip.id), owner.id)?.id).toBe(trip.id);
expect(svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
});
it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', () => {
checkPermission.mockReturnValue(true);
const trip = { id: 5, user_id: 1 } as any;
const owner = { id: 1, role: 'user' } as User;
const member = { id: 2, role: 'user' } as User;
expect(svc.canManage(trip, owner)).toBe(true);
expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 1, false);
svc.canManage(trip, member);
expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 2, true);
});
});
// ── createOrUpdate ────────────────────────────────────────────────────────────
describe('createOrUpdate', () => {
it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const before = Date.now();
const result = svc.createOrUpdate(String(trip.id), user.id, {});
expect(result.created).toBe(true);
expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
const row = shareRow(trip.id);
expect(row.token).toBe(result.token);
expect(row.created_by).toBe(user.id);
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
.toEqual([1, 1, 0, 0, 0]);
const ninetyDays = 90 * 24 * 60 * 60 * 1000;
const expires = new Date(row.expires_at).getTime();
expect(expires).toBeGreaterThanOrEqual(before + ninetyDays - 5000);
expect(expires).toBeLessThanOrEqual(Date.now() + ninetyDays + 5000);
});
it('SHARE-SVC-004: explicit flags override the defaults in both directions', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {
share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true,
});
const row = shareRow(trip.id);
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
.toEqual([0, 0, 1, 1, 1]);
});
it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const first = svc.createOrUpdate(String(trip.id), user.id, {});
const second = svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
expect(second).toEqual({ token: first.token, created: false });
expect(shareRow(trip.id).share_budget).toBe(1);
});
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and never touches expires_at', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2099-01-01T00:00:00.000Z', trip.id);
svc.createOrUpdate(String(trip.id), user.id, {});
const row = shareRow(trip.id);
// Omitted share_packing fell back to its default (off) — destructuring
// defaults apply on every call, not only on create.
expect(row.share_packing).toBe(0);
expect(row.expires_at).toBe('2099-01-01T00:00:00.000Z');
});
});
// ── get / remove ──────────────────────────────────────────────────────────────
describe('get and remove', () => {
it('SHARE-SVC-007: get returns null when no link exists', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(svc.get(String(trip.id))).toBeNull();
});
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const { token } = svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
const info = svc.get(String(trip.id));
expect(info).toEqual({
token,
created_at: expect.any(String),
share_map: true,
share_bookings: true,
share_packing: true,
share_budget: false,
share_collab: false,
});
});
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {});
svc.remove(String(trip.id));
expect(shareRow(trip.id)).toBeUndefined();
expect(() => svc.remove(String(trip.id))).not.toThrow();
});
});
// ── getSharedTripData ─────────────────────────────────────────────────────────
describe('getSharedTripData', () => {
it('SHARE-SVC-010: returns null for an unknown token', () => {
expect(svc.getSharedTripData('nope')).toBeNull();
});
it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', () => {
const { trip, token } = seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
expect(svc.getSharedTripData(token)).toBeNull();
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
expect(svc.getSharedTripData(token)).not.toBeNull();
});
it('SHARE-SVC-012: returns null when the trip row is gone', () => {
const { trip, token } = seedSharedTrip();
// The token normally cascades away with its trip; orphan it deliberately
// to pin the defensive `if (!trip) return null` branch.
testDb.exec('PRAGMA foreign_keys = OFF');
testDb.prepare('DELETE FROM trips WHERE id = ?').run(trip.id);
testDb.exec('PRAGMA foreign_keys = ON');
expect(svc.getSharedTripData(token)).toBeNull();
});
it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', () => {
const { trip, token } = seedSharedTrip();
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
const data = svc.getSharedTripData(token)!;
expect(data).not.toBeNull();
expect(data.trip).toEqual(expect.objectContaining({ id: trip.id, title: trip.title }));
// Explicit column list — no owner id or internal fields on the trip row.
expect(Object.keys(data.trip).sort()).toEqual(
['cover_image', 'currency', 'description', 'end_date', 'id', 'start_date', 'title'],
);
expect(data.permissions).toEqual({
share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true,
});
expect(data.days).toHaveLength(1);
const entries = data.assignments[day.id];
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual(expect.objectContaining({ day_id: day.id, notes: 'go early' }));
expect(entries[0].place).toEqual(expect.objectContaining({
id: place.id, name: 'Louvre', tags: [],
}));
expect(entries[0].place.category).toEqual(expect.objectContaining({ id: place.category_id }));
expect(data.collab).toEqual([]);
});
it('SHARE-SVC-014: COALESCEs assignment times over place times', () => {
const { trip, token } = seedSharedTrip();
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id);
const a = createDayAssignment(testDb, day.id, place.id);
let entry = svc.getSharedTripData(token)!.assignments[day.id][0];
expect(entry.place.place_time).toBe('09:00');
expect(entry.place.end_time).toBe('10:00');
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', a.id);
entry = svc.getSharedTripData(token)!.assignments[day.id][0];
expect(entry.place.place_time).toBe('14:00');
expect(entry.place.end_time).toBe('15:00');
});
it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', () => {
const { trip, token } = seedSharedTrip();
const day = createDay(testDb, trip.id);
const p1 = createPlace(testDb, trip.id, { name: 'First' });
const p2 = createPlace(testDb, trip.id, { name: 'Second' });
testDb.prepare(
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
).run(day.id, p2.id);
testDb.prepare(
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
).run(day.id, p1.id);
const entries = svc.getSharedTripData(token)!.assignments[day.id];
expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']);
});
it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', () => {
const { trip, token } = seedSharedTrip();
const day = createDay(testDb, trip.id);
const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id);
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)')
.run(r1.lastInsertRowid, day.id);
const reservations = svc.getSharedTripData(token)!.reservations;
const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid);
const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid);
expect(flight.day_positions).toEqual({ [day.id]: 3 });
expect(hotel.day_positions).toBeNull();
});
it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', () => {
const { user, trip, token } = seedSharedTrip();
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id);
const packing = svc.getSharedTripData(token)!.packing;
expect(packing.map((p: any) => p.name)).toEqual(['Common']);
});
it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', () => {
const { user, trip, token } = seedSharedTrip();
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id);
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id);
const collab = svc.getSharedTripData(token)!.collab;
expect(collab).toHaveLength(1);
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
expect(svc.getSharedTripData(token)!.collab).toEqual([]);
});
it('SHARE-SVC-019: withholds each section when its flag is off', () => {
const { trip, token } = seedSharedTrip({
share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false,
});
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, day.id, place.id);
testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id);
testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id);
const data = svc.getSharedTripData(token)!;
expect(data.days).toEqual([]);
expect(data.assignments).toEqual({});
expect(data.dayNotes).toEqual({});
expect(data.places).toEqual([]);
expect(data.reservations).toEqual([]);
expect(data.accommodations).toEqual([]);
expect(data.packing).toEqual([]);
expect(data.budget).toEqual([]);
expect(data.collab).toEqual([]);
});
it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', () => {
const { user, trip, token } = seedSharedTrip();
expect(svc.getSharedTripData(token)!.baseCurrency).toBe('EUR');
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id);
expect(svc.getSharedTripData(token)!.baseCurrency).toBe('USD');
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id);
expect(svc.getSharedTripData(token)!.baseCurrency).toBe('CHF');
});
it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', () => {
const { trip, token } = seedSharedTrip();
const proxied = createPlace(testDb, trip.id, { name: 'Proxied' });
const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' });
createPlace(testDb, trip.id, { name: 'Bare' });
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id);
const byName = Object.fromEntries(
svc.getSharedTripData(token)!.places.map((p: any) => [p.name, p.image_url]),
);
expect(byName['Proxied']).toBe(`/api/shared/${token}/place-photo/ChIJabc/bytes`);
expect(byName['Uploaded']).toBe('/uploads/pic.jpg');
expect(byName['Bare']).toBeNull();
});
});
// ── getSharedPlacePhotoPath ───────────────────────────────────────────────────
describe('getSharedPlacePhotoPath', () => {
it('SHARE-SVC-022: returns null for an unknown or expired token', () => {
expect(svc.getSharedPlacePhotoPath('nope', 'ChIJabc')).toBeNull();
const { trip, token } = seedSharedTrip();
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull();
expect(serveFilePath).not.toHaveBeenCalled();
});
it('SHARE-SVC-023: returns null when the owner disabled the map section', () => {
const { trip, token } = seedSharedTrip({ share_map: false });
const place = createPlace(testDb, trip.id);
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id);
expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull();
expect(serveFilePath).not.toHaveBeenCalled();
});
it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', () => {
const { token } = seedSharedTrip();
expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull();
expect(serveFilePath).not.toHaveBeenCalled();
});
it('SHARE-SVC-025: resolves via serveFilePath when the encoded placeId matches the stored URL', () => {
const { trip, token } = seedSharedTrip();
const place = createPlace(testDb, trip.id);
// Wikimedia pseudo-IDs contain characters that must round-trip encoded.
const placeId = 'coords:48.8,2.3';
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?')
.run(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, place.id);
serveFilePath.mockReturnValue('/cache/abc.jpg');
expect(svc.getSharedPlacePhotoPath(token, placeId)).toBe('/cache/abc.jpg');
expect(serveFilePath).toHaveBeenCalledWith(placeId);
});
});
// ── share.bridge delegation ───────────────────────────────────────────────────
describe('share.bridge delegation', () => {
it('SHARE-SVC-026: createOrUpdateShareLink delegates to ShareService.createOrUpdate', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = createOrUpdateShareLink(String(trip.id), user.id, { share_budget: true });
expect(result.created).toBe(true);
expect(shareRow(trip.id).share_budget).toBe(1);
});
it('SHARE-SVC-027: getShareLink delegates to ShareService.get', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getShareLink(String(trip.id))).toBeNull();
const { token } = svc.createOrUpdate(String(trip.id), user.id, {});
expect(getShareLink(String(trip.id))?.token).toBe(token);
});
it('SHARE-SVC-028: deleteShareLink delegates to ShareService.remove', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
svc.createOrUpdate(String(trip.id), user.id, {});
deleteShareLink(String(trip.id));
expect(shareRow(trip.id)).toBeUndefined();
});
});