mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
978 lines
41 KiB
TypeScript
978 lines
41 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native ShareService — SHARE-SVC-001 through
|
||
* SHARE-SVC-025 (the SHARE-001..026 range belongs to the HTTP parity suite in
|
||
* tests/integration/share.test.ts; these pin the service SQL directly; the
|
||
* 026–028 bridge-delegation cases died with share.bridge when the legacy
|
||
* share-link tools moved to share.mcp.ts). Uses a real in-memory SQLite DB so
|
||
* SQL logic is exercised faithfully.
|
||
*/
|
||
import { db as testDb } from '../../../src/db/database';
|
||
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||
import { ShareService, publicReservationMetadata } from '../../../src/nest/share/share.service';
|
||
import type { User } from '../../../src/types';
|
||
import {
|
||
createUser,
|
||
createTrip,
|
||
addTripMember,
|
||
createDay,
|
||
createPlace,
|
||
createDayAssignment,
|
||
createDayAccommodation,
|
||
createDayNote,
|
||
createReservation,
|
||
} from '../../helpers/factories';
|
||
import { shareServiceRepoArgs } from '../../helpers/share-repos';
|
||
import { resetTestDb } from '../../helpers/test-db';
|
||
import type { TestOrm } from '../../helpers/test-orm';
|
||
import {
|
||
sharedTestOrm,
|
||
createTestUnitOfWork,
|
||
createTestAppSettingsRepo,
|
||
createTestSettingsRepo,
|
||
createTestTagsRepo,
|
||
createTestPlaceRatingsRepo,
|
||
createTestAssignmentParticipantsRepo,
|
||
} from '../../helpers/test-uow';
|
||
|
||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||
|
||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||
|
||
vi.mock('../../../src/db/database', async () => {
|
||
const { createSnapshotTestDb } = await import('../../helpers/db-mock');
|
||
const db = createSnapshotTestDb();
|
||
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 mock;
|
||
});
|
||
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-secret',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
|
||
const checkPermission = vi.fn();
|
||
const permissionsStub = { checkPermission } as unknown as PermissionsService;
|
||
|
||
// Injected stub since the photo-cache fold (was a path mock of the module).
|
||
const serveKey = vi.fn();
|
||
const photoCacheStub = { serveKey } as unknown as PlacePhotoCacheService;
|
||
|
||
let svc: ShareService;
|
||
let t: TestOrm;
|
||
|
||
// `t`, `uow` and the settings repositories all derive from the SAME
|
||
// `sharedTestOrm(testDb)` (task-2-review.md I2) — see settings.service.test.ts's
|
||
// own comment on this pattern.
|
||
beforeAll(async () => {
|
||
t = await sharedTestOrm(testDb);
|
||
svc = new ShareService(
|
||
new SettingsService(
|
||
await createTestUnitOfWork(testDb),
|
||
await createTestAppSettingsRepo(testDb),
|
||
await createTestSettingsRepo(testDb),
|
||
),
|
||
permissionsStub,
|
||
new QueryHelpersService(
|
||
await createTestTagsRepo(testDb),
|
||
await createTestPlaceRatingsRepo(testDb),
|
||
await createTestAssignmentParticipantsRepo(testDb),
|
||
),
|
||
photoCacheStub,
|
||
await createTestUnitOfWork(testDb),
|
||
...(await shareServiceRepoArgs(testDb)),
|
||
);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
t.clear();
|
||
checkPermission.mockReset();
|
||
serveKey.mockReset();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await t.close();
|
||
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. */
|
||
async function seedSharedTrip(flags: Record<string, boolean> = {}) {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const { token } = await 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', async () => {
|
||
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((await svc.verifyTripAccess(String(trip.id), owner.id))?.id).toBe(trip.id);
|
||
expect(await svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
|
||
expect(await svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
|
||
});
|
||
|
||
it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', async () => {
|
||
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(await svc.canManage(trip, owner)).toBe(true);
|
||
expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 1, false);
|
||
await 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', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const before = Date.now();
|
||
const result = await 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', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
await 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', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const first = await svc.createOrUpdate(String(trip.id), user.id, {});
|
||
const second = await 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 renews the 90-day expiry', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
|
||
// Simulate a legacy pre-TTL row (NULL expiry): an explicit update moves it
|
||
// onto the 90-day clock.
|
||
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
|
||
const before = Date.now();
|
||
await 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);
|
||
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);
|
||
});
|
||
});
|
||
|
||
// ── get / remove ──────────────────────────────────────────────────────────────
|
||
|
||
describe('get and remove', () => {
|
||
it('SHARE-SVC-007: get returns null when no link exists', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
expect(await svc.get(String(trip.id))).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
const { token } = await svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
|
||
const info = await 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,
|
||
share_travel_only: false,
|
||
share_hide_images: false,
|
||
});
|
||
});
|
||
|
||
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', async () => {
|
||
const { user } = createUser(testDb);
|
||
const trip = createTrip(testDb, user.id);
|
||
await svc.createOrUpdate(String(trip.id), user.id, {});
|
||
await svc.remove(String(trip.id));
|
||
expect(shareRow(trip.id)).toBeUndefined();
|
||
await expect(svc.remove(String(trip.id))).resolves.toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ── getSharedTripData ─────────────────────────────────────────────────────────
|
||
|
||
describe('getSharedTripData', () => {
|
||
it('SHARE-SVC-010: returns null for an unknown token', async () => {
|
||
expect(await svc.getSharedTripData('nope')).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
|
||
expect(await svc.getSharedTripData(token)).toBeNull();
|
||
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
|
||
expect(await svc.getSharedTripData(token)).not.toBeNull();
|
||
});
|
||
|
||
// R5's six-case matrix (valid, revoked, unknown, wrong-case, NUL-byte,
|
||
// expired) — SHARE-SVC-010/011 above already pin unknown/expired; the
|
||
// remaining four are named separately here, per the plan's "a failure in
|
||
// one branch must not be masked by another's passing case" discipline.
|
||
it('SHARE-SVC-039 (R5, valid): a freshly created token returns the shared payload', async () => {
|
||
const { token } = await seedSharedTrip();
|
||
expect(await svc.getSharedTripData(token)).not.toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-040 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
expect(await svc.getSharedTripData(token)).not.toBeNull();
|
||
testDb.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(trip.id);
|
||
expect(await svc.getSharedTripData(token)).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-041 (R5, wrong-case): a differently-cased token never matches — no case-folding introduced (mutation proof: a COLLATE NOCASE companion would flip this red)', async () => {
|
||
const { token } = await seedSharedTrip();
|
||
const flipped = token === token.toUpperCase() ? token.toLowerCase() : token.toUpperCase();
|
||
expect(flipped).not.toBe(token);
|
||
expect(await svc.getSharedTripData(flipped)).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-042 (R5, NUL-byte): a token with an embedded NUL byte never matches', async () => {
|
||
const { token } = await seedSharedTrip();
|
||
expect(await svc.getSharedTripData(`${token}\0x`)).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-012: returns null when the trip row is gone', async () => {
|
||
const { trip, token } = await 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(await svc.getSharedTripData(token)).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', async () => {
|
||
const { trip, token } = await 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 = (await 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,
|
||
share_travel_only: false,
|
||
share_hide_images: false,
|
||
});
|
||
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', async () => {
|
||
const { trip, token } = await 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 = (await 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 = (await 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', async () => {
|
||
const { trip, token } = await 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 = (await 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', async () => {
|
||
const { trip, token } = await 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 = (await 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)', async () => {
|
||
const { user, trip, token } = await 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 = (await 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', async () => {
|
||
const { user, trip, token } = await 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 = (await svc.getSharedTripData(token))!.collab;
|
||
expect(collab).toHaveLength(1);
|
||
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
|
||
|
||
await svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
|
||
expect((await svc.getSharedTripData(token))!.collab).toEqual([]);
|
||
});
|
||
|
||
it('SHARE-SVC-019: withholds each section when its flag is off', async () => {
|
||
const { trip, token } = await 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 = (await 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-019b: travel and stays only keeps transport, hotels and the stay stops (#1712)', async () => {
|
||
const { trip, token } = await seedSharedTrip({ share_travel_only: true });
|
||
const day = createDay(testDb, trip.id);
|
||
const museum = createPlace(testDb, trip.id, { name: 'Museum' });
|
||
const hotel = createPlace(testDb, trip.id, { name: 'Hotel' });
|
||
const stay = createDayAccommodation(testDb, trip.id, hotel.id, day.id, day.id);
|
||
createDayAssignment(testDb, day.id, museum.id);
|
||
const stayStop = createDayAssignment(testDb, day.id, hotel.id);
|
||
testDb.prepare('UPDATE day_assignments SET accommodation_id = ? WHERE id = ?').run(stay.id, stayStop.id);
|
||
createDayNote(testDb, day.id, trip.id);
|
||
for (const type of ['flight', 'hotel', 'restaurant', 'tour'])
|
||
createReservation(testDb, trip.id, { type, title: type });
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
expect(data.permissions.share_travel_only).toBe(true);
|
||
expect(data.assignments[day.id].map((a: any) => a.place.name)).toEqual(['Hotel']);
|
||
expect(data.dayNotes).toEqual({});
|
||
expect(data.places.map((p: any) => p.name)).toEqual(['Hotel']);
|
||
expect(data.reservations.map((r: any) => r.type).sort()).toEqual(['flight', 'hotel']);
|
||
});
|
||
|
||
it('SHARE-SVC-019c: without photos drops every place image and refuses the photo route (#1712)', async () => {
|
||
const { trip, token } = await seedSharedTrip({ share_hide_images: true });
|
||
const day = createDay(testDb, trip.id);
|
||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||
testDb.prepare("UPDATE places SET image_url = '/api/maps/place-photo/abc/bytes' WHERE id = ?").run(place.id);
|
||
createDayAssignment(testDb, day.id, place.id);
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
expect(data.places[0].image_url).toBeNull();
|
||
expect(data.assignments[day.id][0].place.image_url).toBeNull();
|
||
serveKey.mockReturnValue('photos-google/abc');
|
||
await expect(svc.getSharedPlacePhotoKey(token, 'abc')).resolves.toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', async () => {
|
||
const { user, trip, token } = await seedSharedTrip();
|
||
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('EUR');
|
||
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id);
|
||
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('USD');
|
||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id);
|
||
expect((await svc.getSharedTripData(token))!.baseCurrency).toBe('CHF');
|
||
});
|
||
|
||
it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', async () => {
|
||
const { trip, token } = await 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(
|
||
(await 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();
|
||
});
|
||
|
||
it('SHARE-SVC-027: cartoApiKey resolves owner setting → admin instance default → empty (#2054)', async () => {
|
||
const { user, token } = await seedSharedTrip();
|
||
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('');
|
||
testDb
|
||
.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_carto_api_key', 'instance-key')")
|
||
.run();
|
||
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('instance-key');
|
||
testDb
|
||
.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'carto_api_key', ' owner-key ')")
|
||
.run(user.id);
|
||
expect((await svc.getSharedTripData(token))!.cartoApiKey).toBe('owner-key');
|
||
});
|
||
|
||
it('SHARE-SVC-029: staged bookings stay out of the public payload, confirmation number included', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO reservations (trip_id, title, type, status, confirmation_number, ingest_state)
|
||
VALUES (?, 'Parked Flight', 'flight', 'confirmed', 'SECRET1', 'staged')`,
|
||
)
|
||
.run(trip.id);
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO reservations (trip_id, title, type, status, confirmation_number)
|
||
VALUES (?, 'Booked Flight', 'flight', 'confirmed', 'OPEN1')`,
|
||
)
|
||
.run(trip.id);
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
|
||
expect((data.reservations as any[]).map((r) => r.title)).toEqual(['Booked Flight']);
|
||
// SELECT * hands out notes, url and metadata too, so check the whole payload.
|
||
expect(JSON.stringify(data)).not.toContain('SECRET1');
|
||
});
|
||
|
||
it('SHARE-SVC-030: every booking that predates the column stays in the payload', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
for (let i = 0; i < 5; i++) {
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO reservations (trip_id, title, type, status)
|
||
VALUES (?, ?, 'flight', 'confirmed')`,
|
||
)
|
||
.run(trip.id, `Booking ${i}`);
|
||
}
|
||
|
||
expect((await svc.getSharedTripData(token))!.reservations as any[]).toHaveLength(5);
|
||
});
|
||
|
||
it('SHARE-SVC-031: accommodations backed only by a staged booking are withheld, unlinked ones are kept', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const place = createPlace(testDb, trip.id, { name: 'Hotel Bellevue' });
|
||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||
const stay = (): number =>
|
||
testDb
|
||
.prepare(
|
||
`
|
||
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id) VALUES (?, ?, ?, ?)
|
||
`,
|
||
)
|
||
.run(trip.id, place.id, day.id, day.id).lastInsertRowid as number;
|
||
|
||
const stagedStay = stay();
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO reservations (trip_id, title, type, status, accommodation_id, ingest_state)
|
||
VALUES (?, 'Parked Hotel', 'hotel', 'confirmed', ?, 'staged')`,
|
||
)
|
||
.run(trip.id, String(stagedStay));
|
||
const unlinkedStay = stay();
|
||
|
||
const rows = (await svc.getSharedTripData(token))!.accommodations as any[];
|
||
|
||
expect(rows.map((a) => a.id)).toEqual([unlinkedStay]);
|
||
});
|
||
});
|
||
|
||
// ── #2320: what the public payload carries, and what it withholds ───────────
|
||
|
||
describe('getSharedTripData redaction (#2320)', () => {
|
||
it('SHARE-SVC-032: a booking travels without its confirmation, import trail or travellers', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO reservations
|
||
(trip_id, title, type, status, confirmation_number, notes, url, external_source, external_id, sync_enabled, needs_review)
|
||
VALUES (?, 'Night train', 'train', 'confirmed', 'PNR9XY', 'Bring the tickets', 'https://bahn.example/booking/1', 'kitinerary', 'ext-42', 1, 0)`,
|
||
)
|
||
.run(trip.id);
|
||
|
||
const [row] = (await svc.getSharedTripData(token))!.reservations as any[];
|
||
|
||
expect(Object.keys(row).sort()).toEqual([
|
||
'accommodation_id',
|
||
'created_at',
|
||
'day_id',
|
||
'day_positions',
|
||
'end_day_id',
|
||
'endpoints',
|
||
'id',
|
||
'location',
|
||
'metadata',
|
||
'notes',
|
||
'place_id',
|
||
'reservation_end_time',
|
||
'reservation_time',
|
||
'status',
|
||
'title',
|
||
'trip_id',
|
||
'type',
|
||
'url',
|
||
]);
|
||
expect(row.notes).toBe('Bring the tickets');
|
||
expect(row.url).toBe('https://bahn.example/booking/1');
|
||
expect(JSON.stringify(await svc.getSharedTripData(token))).not.toContain('PNR9XY');
|
||
expect(JSON.stringify(await svc.getSharedTripData(token))).not.toContain('ext-42');
|
||
});
|
||
|
||
it('SHARE-SVC-033: booking metadata keeps the journey and drops the ticket', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const metadata = JSON.stringify({
|
||
airline: 'LH',
|
||
flight_number: 'LH400',
|
||
departure_airport: 'FRA',
|
||
arrival_airport: 'JFK',
|
||
seat: '14A',
|
||
price: 812,
|
||
passenger_name: 'Ada Lovelace',
|
||
confirmation_number: 'LOC123',
|
||
legs: [
|
||
{
|
||
from: 'FRA',
|
||
to: 'BER',
|
||
airline: 'LH',
|
||
flight_number: 'LH170',
|
||
dep_time: '08:00',
|
||
arr_time: '09:05',
|
||
confirmation_number: 'LEG1',
|
||
seat: '3C',
|
||
},
|
||
{
|
||
from: 'BER',
|
||
to: 'JFK',
|
||
airline: 'LH',
|
||
flight_number: 'LH400',
|
||
dep_time: '11:00',
|
||
arr_time: '14:10',
|
||
confirmation_number: 'LEG2',
|
||
},
|
||
],
|
||
});
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO reservations (trip_id, title, type, status, metadata)
|
||
VALUES (?, 'To New York', 'flight', 'confirmed', ?)`,
|
||
)
|
||
.run(trip.id, metadata);
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
const [row] = data.reservations as any[];
|
||
const meta = JSON.parse(row.metadata);
|
||
|
||
expect(meta).toEqual({
|
||
airline: 'LH',
|
||
flight_number: 'LH400',
|
||
departure_airport: 'FRA',
|
||
arrival_airport: 'JFK',
|
||
legs: [
|
||
{ from: 'FRA', to: 'BER', airline: 'LH', flight_number: 'LH170', dep_time: '08:00', arr_time: '09:05' },
|
||
{ from: 'BER', to: 'JFK', airline: 'LH', flight_number: 'LH400', dep_time: '11:00', arr_time: '14:10' },
|
||
],
|
||
});
|
||
const whole = JSON.stringify(data);
|
||
for (const secret of ['14A', '812', 'Ada Lovelace', 'LOC123', 'LEG1', 'LEG2', '3C']) {
|
||
expect(whole, secret).not.toContain(secret);
|
||
}
|
||
});
|
||
|
||
it('SHARE-SVC-034: a booking carries its ordered stops', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const id = testDb
|
||
.prepare(
|
||
`INSERT INTO reservations (trip_id, title, type, status)
|
||
VALUES (?, 'Coach', 'bus', 'confirmed')`,
|
||
)
|
||
.run(trip.id).lastInsertRowid;
|
||
const ins = testDb.prepare(`INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||
ins.run(id, 'to', 1, 'Lyon', null, 45.76, 4.84);
|
||
ins.run(id, 'from', 0, 'Paris', 'PAR', 48.85, 2.35);
|
||
|
||
const [row] = (await svc.getSharedTripData(token))!.reservations as any[];
|
||
|
||
expect(row.endpoints.map((e: any) => [e.role, e.name])).toEqual([
|
||
['from', 'Paris'],
|
||
['to', 'Lyon'],
|
||
]);
|
||
expect(Object.keys(row.endpoints[0]).sort()).toEqual([
|
||
'code',
|
||
'lat',
|
||
'lng',
|
||
'local_date',
|
||
'local_time',
|
||
'name',
|
||
'role',
|
||
'sequence',
|
||
'timezone',
|
||
]);
|
||
});
|
||
|
||
it('SHARE-SVC-035: only http(s) links reach the page, on bookings and places alike', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||
for (const [title, url] of [
|
||
['ok', 'https://example.com/x'],
|
||
['js', 'javascript:alert(1)'],
|
||
['data', 'data:text/html,hi'],
|
||
['junk', 'not a url'],
|
||
['blank', ' '],
|
||
]) {
|
||
testDb
|
||
.prepare(`INSERT INTO reservations (trip_id, title, type, status, url) VALUES (?, ?, 'other', 'confirmed', ?)`)
|
||
.run(trip.id, title, url);
|
||
}
|
||
const place = createPlace(testDb, trip.id, { name: 'Museum' });
|
||
testDb
|
||
.prepare('UPDATE places SET website = ?, phone = ? WHERE id = ?')
|
||
.run('javascript:alert(2)', '+43 1 234', place.id);
|
||
createDayAssignment(testDb, day.id, place.id, {});
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
const urls = Object.fromEntries((data.reservations as any[]).map((r) => [r.title, r.url]));
|
||
expect(urls).toEqual({ ok: 'https://example.com/x', js: null, data: null, junk: null, blank: null });
|
||
expect(data.assignments[day.id][0].place.website).toBeNull();
|
||
expect(data.assignments[day.id][0].place.phone).toBe('+43 1 234');
|
||
expect((data.places as any[])[0].website).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-036: an assignment carries the place notes, duration and contact, the pool carries no booking notes', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||
testDb
|
||
.prepare(
|
||
`UPDATE places SET description = 'Big museum', address = 'Rue de Rivoli', notes = 'Skip the pyramid queue',
|
||
duration_minutes = 180, website = 'https://louvre.fr', phone = '+33 1', reservation_notes = 'Booked under Ada', google_place_id = 'ChIJ123' WHERE id = ?`,
|
||
)
|
||
.run(place.id);
|
||
createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
const entry = data.assignments[day.id][0];
|
||
expect(entry.notes).toBe('go early');
|
||
expect(entry.place).toEqual(
|
||
expect.objectContaining({
|
||
description: 'Big museum',
|
||
address: 'Rue de Rivoli',
|
||
notes: 'Skip the pyramid queue',
|
||
duration_minutes: 180,
|
||
website: 'https://louvre.fr',
|
||
phone: '+33 1',
|
||
}),
|
||
);
|
||
const pool = (data.places as any[])[0];
|
||
expect(pool.notes).toBe('Skip the pyramid queue');
|
||
expect(pool).not.toHaveProperty('reservation_notes');
|
||
expect(pool).not.toHaveProperty('google_place_id');
|
||
expect(JSON.stringify(data)).not.toContain('Booked under Ada');
|
||
expect(JSON.stringify(data)).not.toContain('ChIJ123');
|
||
});
|
||
|
||
it('SHARE-SVC-046 (SH12 full-key parity): every allow-listed column of a fully-populated place reaches the public payload, nothing else does', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const place = createPlace(testDb, trip.id, { name: 'Fully Populated' });
|
||
testDb
|
||
.prepare(
|
||
`UPDATE places SET
|
||
description = 'A description', address = '1 Rue Test', price = 12.5, currency = 'EUR',
|
||
place_time = '09:00', end_time = '10:00', duration_minutes = 60, notes = 'A public note',
|
||
image_url = '/uploads/pic.jpg', website = 'https://example.com', phone = '+33 1 23',
|
||
transport_mode = 'walk',
|
||
reservation_status = 'confirmed', reservation_notes = 'Private booking note', reservation_datetime = '2026-01-01T09:00',
|
||
google_place_id = 'ChIJ-private', google_ftid = 'ftid-private', osm_id = 'osm-private', amap_poi_id = 'amap-private',
|
||
route_geometry = '{"type":"LineString"}', route_color = '#ff0000', stop_type = 'hotel', fill_percent = 42,
|
||
source = 'import'
|
||
WHERE id = ?`,
|
||
)
|
||
.run(place.id);
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
const pool = (data.places as any[])[0];
|
||
|
||
// The full allow-list (20 `places` columns + the 3 flat category-join
|
||
// columns), and nothing more.
|
||
expect(Object.keys(pool).sort()).toEqual([
|
||
'address',
|
||
'category_color',
|
||
'category_icon',
|
||
'category_id',
|
||
'category_name',
|
||
'created_at',
|
||
'currency',
|
||
'description',
|
||
'duration_minutes',
|
||
'end_time',
|
||
'id',
|
||
'image_url',
|
||
'lat',
|
||
'lng',
|
||
'name',
|
||
'notes',
|
||
'phone',
|
||
'place_time',
|
||
'price',
|
||
'transport_mode',
|
||
'trip_id',
|
||
'updated_at',
|
||
'website',
|
||
]);
|
||
expect(pool.notes).toBe('A public note');
|
||
expect(pool.address).toBe('1 Rue Test');
|
||
expect(pool.duration_minutes).toBe(60);
|
||
|
||
// Every non-allow-listed column, whole-payload-wide.
|
||
const whole = JSON.stringify(data);
|
||
for (const secret of [
|
||
'Private booking note',
|
||
'2026-01-01T09:00',
|
||
'ChIJ-private',
|
||
'ftid-private',
|
||
'osm-private',
|
||
'amap-private',
|
||
'LineString',
|
||
'#ff0000',
|
||
]) {
|
||
expect(whole, secret).not.toContain(secret);
|
||
}
|
||
});
|
||
|
||
it('SHARE-SVC-037: a stay carries its desk times and note, not its confirmation', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
const place = createPlace(testDb, trip.id, { name: 'Hotel' });
|
||
const day = createDay(testDb, trip.id, { date: '2026-09-01' });
|
||
testDb
|
||
.prepare(
|
||
`INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
|
||
VALUES (?, ?, ?, ?, '15:00', '11:00', 'HOTEL-777', 'Ask for a quiet room')`,
|
||
)
|
||
.run(trip.id, place.id, day.id, day.id);
|
||
|
||
const data = (await svc.getSharedTripData(token))!;
|
||
const [stay] = data.accommodations as any[];
|
||
expect(stay).toEqual(
|
||
expect.objectContaining({
|
||
check_in: '15:00',
|
||
check_out: '11:00',
|
||
notes: 'Ask for a quiet room',
|
||
place_name: 'Hotel',
|
||
}),
|
||
);
|
||
expect(stay).not.toHaveProperty('confirmation');
|
||
expect(JSON.stringify(data)).not.toContain('HOTEL-777');
|
||
});
|
||
|
||
it('SHARE-SVC-038: metadata that is not an object comes back as nothing', async () => {
|
||
expect(publicReservationMetadata(null)).toBeNull();
|
||
expect(publicReservationMetadata('not json')).toBeNull();
|
||
expect(publicReservationMetadata('[1,2]')).toBeNull();
|
||
expect(publicReservationMetadata('"str"')).toBeNull();
|
||
expect(publicReservationMetadata({ airline: 'OS', legs: [null, 'x', { from: 'VIE', seat: '1A' }] })).toBe(
|
||
JSON.stringify({ airline: 'OS', legs: [{ from: 'VIE' }] }),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ── getSharedPlacePhotoKey ───────────────────────────────────────────────────
|
||
|
||
describe('getSharedPlacePhotoKey', () => {
|
||
it('SHARE-SVC-022: returns null for an unknown or expired token', async () => {
|
||
expect(await svc.getSharedPlacePhotoKey('nope', 'ChIJabc')).toBeNull();
|
||
const { trip, token } = await seedSharedTrip();
|
||
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
|
||
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
|
||
expect(serveKey).not.toHaveBeenCalled();
|
||
});
|
||
|
||
// R5's six-case matrix, this method's own named cases (SHARE-SVC-022 above
|
||
// already pins unknown/expired — same predicate, same {@link
|
||
// ShareTokensRepository.findTripAndShareMapByToken}, proven separately here
|
||
// since a regression in this method's own call site would not otherwise be
|
||
// caught).
|
||
it('SHARE-SVC-043 (R5, revoked): a deleted token returns null — distinct from merely unknown', async () => {
|
||
const { trip, token } = await seedSharedTrip();
|
||
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(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).not.toBeNull();
|
||
testDb.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(trip.id);
|
||
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-044 (R5, wrong-case): a differently-cased token never matches (mutation proof: a COLLATE NOCASE companion would flip this red)', async () => {
|
||
const { token } = await seedSharedTrip();
|
||
const flipped = token === token.toUpperCase() ? token.toLowerCase() : token.toUpperCase();
|
||
expect(flipped).not.toBe(token);
|
||
expect(await svc.getSharedPlacePhotoKey(flipped, 'ChIJabc')).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-045 (R5, NUL-byte): a token with an embedded NUL byte never matches', async () => {
|
||
const { token } = await seedSharedTrip();
|
||
expect(await svc.getSharedPlacePhotoKey(`${token}\0x`, 'ChIJabc')).toBeNull();
|
||
});
|
||
|
||
it('SHARE-SVC-023: returns null when the owner disabled the map section', async () => {
|
||
const { trip, token } = await 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(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
|
||
expect(serveKey).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', async () => {
|
||
const { token } = await seedSharedTrip();
|
||
expect(await svc.getSharedPlacePhotoKey(token, 'ChIJabc')).toBeNull();
|
||
expect(serveKey).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('SHARE-SVC-025: resolves via serveKey when the encoded placeId matches the stored URL', async () => {
|
||
const { trip, token } = await 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);
|
||
serveKey.mockReturnValue('abc.jpg');
|
||
expect(await svc.getSharedPlacePhotoKey(token, placeId)).toBe('abc.jpg');
|
||
expect(serveKey).toHaveBeenCalledWith(placeId);
|
||
});
|
||
});
|
||
|
||
// SHARE-SVC-026..028 (share.bridge delegation) were deleted with the bridge —
|
||
// its last consumer, the legacy share-link tools in src/mcp/tools/trips.ts,
|
||
// moved to the DI-discovered share.mcp.ts.
|