/** * Unit tests for the DI-native CollectionsService (COLLECTIONS-SVC-001 … 092; * moved 1:1 from the legacy tests/unit/services/collectionsService.test.ts, * case IDs preserved — the 080/081 membership-lookup cases are new with the * fold, and the 090–092 band pins the post-fold quirk fixes: all-or-nothing * bulk writes and socket-id forwarding on the from-trip saves). Real in-memory SQLite (full schema + migrations) so the SQL — * owner/member visibility, the collection-scoped dedup, the fusion state * machine and the widened photo-cache reference check — is exercised * faithfully. Keeps its own clearCollections() reset (the shared * resetTestDb RESET_TABLES list has no collection tables). */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; const { testDb, dbMock, broadcastToUser } = 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 broadcastToUser = vi.fn(); const mock = { db, closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, canAccessTrip: (tripId: number | string, 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: () => false, }; return { testDb: db, dbMock: mock, broadcastToUser }; }); vi.mock('../../../src/db/database', () => dbMock); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, })); vi.mock('../../../src/websocket', () => ({ broadcastToUser, broadcast: vi.fn() })); const notifSend = vi.fn().mockResolvedValue(undefined); import fs from 'fs'; import path from 'path'; import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { createUser, createTrip, createPlace, createCategory, createTag, addTripMember } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { CollectionsService } from '../../../src/nest/collections/collections.service'; import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import { makeStorageFixture } from '../../helpers/storage-fixture'; import { notificationsStub } from '../../helpers/notifications'; const storageFx = makeStorageFixture(''); const svc = new CollectionsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub(notifSend), storageFx.storage); // The real cache: these cases assert what removeIfUnreferenced actually does // about collection_places (#1081), so a stub would assert nothing. const photoCache = new PlacePhotoCacheService(new DatabaseService(testDb), makeStorageFixture('photos/google/').storage); const removeIfUnreferenced = (id: string) => photoCache.removeIfUnreferenced(id); function clearCollections() { testDb.exec(` DELETE FROM collection_place_labels; DELETE FROM collection_labels; DELETE FROM collection_place_tags; DELETE FROM collection_places; DELETE FROM collection_members; DELETE FROM collections; DELETE FROM google_place_photo_meta; DELETE FROM place_tags; DELETE FROM places; DELETE FROM trip_members; DELETE FROM trips; DELETE FROM users; `); } beforeAll(async () => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => { clearCollections(); broadcastToUser.mockClear(); notifSend.mockClear(); }); afterAll(() => { testDb.close(); }); // ── Lists CRUD + visibility ────────────────────────────────────────────────── describe('collections CRUD + visibility', () => { it('COLLECTIONS-SVC-001: createCollection + listCollections is owner-scoped', () => { const a = createUser(testDb).user; const b = createUser(testDb).user; const col = svc.createCollection(a.id, { name: 'Tokyo' }); expect(col.is_owner).toBe(true); expect(col.owner_id).toBe(a.id); expect(svc.listCollections(a.id).collections).toHaveLength(1); expect(svc.listCollections(b.id).collections).toHaveLength(0); }); it('COLLECTIONS-SVC-002: getCollection 404 for a non-member', () => { const a = createUser(testDb).user; const b = createUser(testDb).user; const col = svc.createCollection(a.id, { name: 'Private' }); expect(() => svc.getCollection(b.id, col.id)).toThrow(); try { svc.getCollection(b.id, col.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } }); it('COLLECTIONS-SVC-003: updateCollection renames; reorder only touches visible rows', () => { const a = createUser(testDb).user; const col = svc.createCollection(a.id, { name: 'Old' }); const updated = svc.updateCollection(a.id, col.id, { name: 'New' }); expect(updated.name).toBe('New'); const b = createUser(testDb).user; const other = svc.createCollection(b.id, { name: 'B-list' }); // b's first list → sort_order 0 svc.reorderCollections(a.id, [other.id, col.id]); // a cannot see other → skipped; col → index 1 const otherRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(other.id) as { sort_order: number }; const colRow = testDb.prepare('SELECT sort_order FROM collections WHERE id = ?').get(col.id) as { sort_order: number }; expect(otherRow.sort_order).toBe(0); // untouched — not visible to a expect(colRow.sort_order).toBe(1); // reordered to its index in the visible-filtered list }); }); // ── Saved places + dedup ───────────────────────────────────────────────────── describe('saved places + dedup', () => { it('COLLECTIONS-SVC-010: savePlace sets owner_id=owner, saved_by=caller, no itinerary cols', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Shared' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); const res = svc.savePlace(member.id, { collection_id: col.id, name: 'Senso-ji', lat: 35.71, lng: 139.79 }); expect(res.place).toBeDefined(); const row = testDb.prepare('SELECT * FROM collection_places WHERE id = ?').get(res.place!.id) as Record; expect(row.owner_id).toBe(owner.id); expect(row.saved_by).toBe(member.id); expect('reservation_status' in row).toBe(false); expect('place_time' in row).toBe(false); }); it('COLLECTIONS-SVC-011: second identical save is a duplicate; force inserts', () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Dedup' }); svc.savePlace(u.id, { collection_id: col.id, name: 'Eiffel Tower' }); const dup = svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower' }); expect(dup.duplicate).toBe(true); expect(dup.duplicateOf?.name).toBe('Eiffel Tower'); const forced = svc.savePlace(u.id, { collection_id: col.id, name: 'eiffel tower', force: true }); expect(forced.place).toBeDefined(); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 2 }); }); it('COLLECTIONS-SVC-012: savePlace attaches tags', () => { const u = createUser(testDb).user; const tag = createTag(testDb, u.id, { name: 'food' }); const col = svc.createCollection(u.id, { name: 'Tagged' }); const res = svc.savePlace(u.id, { collection_id: col.id, name: 'Ramen', tag_ids: [tag.id] }); expect(res.place!.tags?.map((t) => t.name)).toContain('food'); }); it('COLLECTIONS-SVC-013: savePlace rejects an inaccessible collection (404)', () => { const a = createUser(testDb).user; const b = createUser(testDb).user; const col = svc.createCollection(a.id, { name: 'Locked' }); expect(() => svc.savePlace(b.id, { collection_id: col.id, name: 'X' })).toThrow(); }); it('COLLECTIONS-SVC-100: a NAMED candidate does not merge into a different place at the same coordinates', () => { // The wrong-city hazard: findDuplicateCollectionPlace used to fall through to // a coordinate match for a named candidate whose name did not match anything, // which would report two distinct places at one address (the restaurant and // the bar) as duplicates of each other. const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Berlin' }); svc.savePlace(u.id, { collection_id: col.id, name: 'Ground Floor Diner', lat: 52.52, lng: 13.405 }); const result = svc.savePlace(u.id, { collection_id: col.id, name: 'Rooftop Bar', lat: 52.52, lng: 13.405 }); expect(result.duplicate).toBeFalsy(); expect(result.place).toBeDefined(); }); it('COLLECTIONS-SVC-101: a provider id still recognises a renamed place a name/coords search would miss', () => { // google_place_id/google_ftid/osm_id are stored on every collection_places row // but were never read back for dedup, so a renamed place with no matching // name or coordinates could be saved again under its old provider id. const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Renames' }); svc.savePlace(u.id, { collection_id: col.id, name: 'Original Name', lat: 1, lng: 1, google_place_id: 'ChIJ_abc', }); const result = svc.savePlace(u.id, { collection_id: col.id, name: 'Renamed By User', lat: 2, lng: 2, google_place_id: 'ChIJ_abc', }); expect(result.duplicate).toBe(true); expect(result.duplicateOf?.name).toBe('Original Name'); }); it('COLLECTIONS-SVC-102: the bulk import recognises a renamed place by its provider id too', () => { // savePlace was not the only caller. The bulk copy carries the provider ids // into the row it writes, so asking without them would recognise less than // the row it just wrote already knows. const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Rome' }); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' }); testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id); svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); const out = svc.saveFromTripPlaces(u.id, col.id, trip.id, [place.id]); expect(out.copied).toBe(0); expect(out.skipped.map(s => s.name)).toEqual(['Trattoria da Enzo']); }); it('COLLECTIONS-SVC-103: the import picker marks that same place as already saved', () => { // The dialog and the import have to agree: a row shown as new that the import // then refuses is the drift this method exists to prevent. const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Rome' }); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo' }); testDb.prepare('UPDATE places SET google_ftid = ? WHERE id = ?').run('0x1:0x2', place.id); svc.savePlace(u.id, { collection_id: col.id, name: 'Dinner Tuesday', lat: 41.88, lng: 12.47, google_ftid: '0x1:0x2' }); const listed = svc.importablePlaces(u.id, col.id, trip.id).places.find(p => p.place_id === place.id); expect(listed?.already_in_list).toBe(true); }); }); // ── save-from-trip provenance + IDOR ───────────────────────────────────────── describe('saveFromTripPlace', () => { it('COLLECTIONS-SVC-014: records provenance from a readable trip', () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); const col = svc.createCollection(u.id, { name: 'From trip' }); const res = svc.saveFromTripPlace(u.id, col.id, trip.id, place.id); expect(res.place!.source_trip_id).toBe(trip.id); expect(res.place!.source_place_id).toBe(place.id); expect(res.place!.name).toBe('Louvre'); }); it('COLLECTIONS-SVC-015: rejects a trip the user cannot read (no IDOR)', () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, owner.id); const place = createPlace(testDb, trip.id, { name: 'Secret' }); const col = svc.createCollection(stranger.id, { name: 'Mine' }); expect(() => svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id)).toThrow(); try { svc.saveFromTripPlace(stranger.id, col.id, trip.id, place.id); } catch (e) { expect((e as { status: number }).status).toBe(404); } }); }); // ── status + move ──────────────────────────────────────────────────────────── describe('status + updatePlace move', () => { it('COLLECTIONS-SVC-016: setStatus cycles idea→want→visited', () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'S' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Place' }).place!; expect(p.status).toBe('idea'); expect(svc.setStatus(u.id, p.id, 'want').status).toBe('want'); expect(svc.setStatus(u.id, p.id, 'visited').status).toBe('visited'); }); it('COLLECTIONS-SVC-017: updatePlace moves to another list (asserts access on target, resets owner_id)', async () => { const owner = createUser(testDb).user; const a = svc.createCollection(owner.id, { name: 'A' }); const targetOwner = createUser(testDb).user; const b = svc.createCollection(targetOwner.id, { name: 'B' }); // owner is also an accepted member of b so the move target is visible to them testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(b.id, owner.id); const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'Movable' }).place!; const moved = await svc.updatePlace(owner.id, p.id, { collection_id: b.id }); expect(moved.collection_id).toBe(b.id); const row = testDb.prepare('SELECT owner_id FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number }; expect(row.owner_id).toBe(targetOwner.id); // reset to the target collection's owner }); it('COLLECTIONS-SVC-018: updatePlace move to an inaccessible target is rejected', async () => { const owner = createUser(testDb).user; const a = svc.createCollection(owner.id, { name: 'A' }); const stranger = createUser(testDb).user; const b = svc.createCollection(stranger.id, { name: 'B' }); const p = svc.savePlace(owner.id, { collection_id: a.id, name: 'X' }).place!; await expect(svc.updatePlace(owner.id, p.id, { collection_id: b.id })).rejects.toThrow(); }); // #1870: the address column was missing from the UPDATE set, so a typo or a // moved restaurant could only be fixed by deleting and re-adding the place. it('COLLECTIONS-SVC-019: updatePlace corrects the address and clears it with null', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Rome' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' }).place!; expect((await svc.updatePlace(u.id, p.id, { address: 'Via Nuova 1' })).address).toBe('Via Nuova 1'); expect((await svc.updatePlace(u.id, p.id, { address: null })).address).toBeNull(); }); it('COLLECTIONS-SVC-019b: an update without an address leaves the stored one alone', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Rome' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Trattoria', address: 'Via Vechia 1' }).place!; expect((await svc.updatePlace(u.id, p.id, { name: 'Trattoria da Enzo' })).address).toBe('Via Vechia 1'); }); }); // ── copy to trip ───────────────────────────────────────────────────────────── describe('copyToTrip', () => { it('COLLECTIONS-SVC-020: reduced INSERT (itinerary defaults), skips dups, copies tags', () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const tag = createTag(testDb, u.id, { name: 'must-see' }); const col = svc.createCollection(u.id, { name: 'Plan' }); const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'Colosseum', tag_ids: [tag.id] }).place!; // pre-existing trip place that should make a duplicate createPlace(testDb, trip.id, { name: 'Pantheon' }); const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'Pantheon' }).place!; const res = svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }); expect(res.copied).toBe(1); expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']); const inserted = testDb.prepare("SELECT * FROM places WHERE trip_id = ? AND name = 'Colosseum'").get(trip.id) as Record; expect(inserted.reservation_status).toBe('none'); // itinerary column took the table default expect(inserted.duration_minutes).toBe(60); const tagLink = testDb.prepare('SELECT COUNT(*) n FROM place_tags WHERE place_id = ?').get(inserted.id); expect(tagLink).toEqual({ n: 1 }); }); it('COLLECTIONS-SVC-021: rejects place_ids from a collection the user cannot see', () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); const hidden = svc.createCollection(owner.id, { name: 'Hidden' }); const p = svc.savePlace(owner.id, { collection_id: hidden.id, name: 'Secret' }).place!; const trip = createTrip(testDb, stranger.id); expect(() => svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] })).toThrow(); }); it('COLLECTIONS-SVC-022: rejects a trip the user cannot edit (403/404)', () => { const u = createUser(testDb).user; const owner2 = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, owner2.id); // u has no access const col = svc.createCollection(u.id, { name: 'C' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'X' }).place!; expect(() => svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] })).toThrow(); }); it('COLLECTIONS-SVC-023: a trip MEMBER can copy (place_edit allowed)', () => { const owner2 = createUser(testDb).user; const member = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, owner2.id); addTripMember(testDb, trip.id, member.id); const col = svc.createCollection(member.id, { name: 'C' }); const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Forum' }).place!; const res = svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }); expect(res.copied).toBe(1); }); }); // ── delete + delete-many ───────────────────────────────────────────────────── describe('delete places', () => { it('COLLECTIONS-SVC-024: deletePlace + deletePlacesMany assert access', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'D' }); const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!; const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!; await svc.deletePlace(u.id, p1.id); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 1 }); expect(await svc.deletePlacesMany(u.id, [p2.id])).toEqual([p2.id]); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); }); }); // ── Fusion state machine ───────────────────────────────────────────────────── describe('fusion invitations', () => { function setup() { const owner = createUser(testDb).user; const target = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Fusion' }); return { owner, target, col }; } it('COLLECTIONS-SVC-030: sendInvite — self 400, unknown 404, non-owner 403, happy path', async () => { const { owner, target, col } = setup(); expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, owner.id).status).toBe(400); expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, 99999).status).toBe(404); expect(svc.sendInvite(col.id, target.id, target.username, target.email, owner.id).status).toBe(403); // non-owner inviter const ok = svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); expect(ok.error).toBeUndefined(); expect(broadcastToUser).toHaveBeenCalledWith(target.id, expect.objectContaining({ type: 'collections:invite' })); // the notification send is fire-and-forget via a dynamic import — flush microtasks. await vi.waitFor(() => expect(notifSend).toHaveBeenCalledWith(expect.objectContaining({ event: 'collection_invite', targetId: target.id }))); }); it('COLLECTIONS-SVC-031: double-invite while pending → 400; existing member → 400', () => { const { owner, target, col } = setup(); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id).status).toBe(400); svc.acceptInvite(target.id, col.id, undefined); expect(svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id).error).toBe('Already a member'); }); it('COLLECTIONS-SVC-032: acceptInvite — 404 with no pending; flips to accepted → member now sees list', () => { const { owner, target, col } = setup(); expect(svc.acceptInvite(target.id, col.id, undefined).status).toBe(404); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); expect(svc.acceptInvite(target.id, col.id, undefined).error).toBeUndefined(); expect(svc.listCollections(target.id).collections.map((c) => c.id)).toContain(col.id); }); it('COLLECTIONS-SVC-033: accept-after-cancel → 404 (no orphan accept)', () => { const { owner, target, col } = setup(); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); svc.cancelInvite(col.id, owner.id, target.id); expect(svc.acceptInvite(target.id, col.id, undefined).status).toBe(404); }); it('COLLECTIONS-SVC-034: declineInvite removes the pending row', () => { const { owner, target, col } = setup(); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); svc.declineInvite(target.id, col.id, undefined); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); }); it('COLLECTIONS-SVC-035: cancelInvite is owner-only', () => { const { owner, target, col } = setup(); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); expect(() => svc.cancelInvite(col.id, target.id, target.id)).toThrow(); // non-owner svc.cancelInvite(col.id, owner.id, target.id); // owner ok expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); }); it('COLLECTIONS-SVC-036: leaveCollection — member ok, owner blocked (400)', () => { const { owner, target, col } = setup(); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); svc.acceptInvite(target.id, col.id, undefined); svc.leaveCollection(target.id, col.id, undefined); expect(svc.listCollections(target.id).collections.map((c) => c.id)).not.toContain(col.id); expect(() => svc.leaveCollection(owner.id, col.id, undefined)).toThrow(); try { svc.leaveCollection(owner.id, col.id, undefined); } catch (e) { expect((e as { status: number }).status).toBe(400); } }); it('COLLECTIONS-SVC-037: availableUsers is scoped to THIS collection only (no one-fusion bug)', () => { const owner = createUser(testDb).user; const target = createUser(testDb).user; const colA = svc.createCollection(owner.id, { name: 'A' }); const colB = svc.createCollection(owner.id, { name: 'B' }); // target is accepted in A; must still be invitable to B svc.sendInvite(colA.id, owner.id, owner.username, owner.email, target.id); svc.acceptInvite(target.id, colA.id, undefined); const forB = svc.availableUsers(owner.id, colB.id).map((u) => u.id); expect(forB).toContain(target.id); const forA = svc.availableUsers(owner.id, colA.id).map((u) => u.id); expect(forA).not.toContain(target.id); // already a member of A }); it('COLLECTIONS-SVC-038: availableUsers excludes self + guests', () => { const owner = createUser(testDb).user; const normal = createUser(testDb).user; const guest = createUser(testDb).user; testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id); const col = svc.createCollection(owner.id, { name: 'C' }); const ids = svc.availableUsers(owner.id, col.id).map((u) => u.id); expect(ids).toContain(normal.id); expect(ids).not.toContain(owner.id); expect(ids).not.toContain(guest.id); }); it('COLLECTIONS-SVC-039: visibility = owner OR accepted member (pending does NOT grant access)', () => { const { owner, target, col } = setup(); svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); expect(() => svc.getCollection(target.id, col.id)).toThrow(); // pending, no access yet svc.acceptInvite(target.id, col.id, undefined); expect(svc.getCollection(target.id, col.id).collection.id).toBe(col.id); }); }); // ── deleteCollection snapshot + broadcast + cascade ────────────────────────── describe('deleteCollection', () => { it('COLLECTIONS-SVC-040: owner-only; snapshots accepted+pending, broadcasts collections:deleted, cascades', () => { const owner = createUser(testDb).user; const accepted = createUser(testDb).user; const pending = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Doomed' }); svc.savePlace(owner.id, { collection_id: col.id, name: 'P' }); svc.sendInvite(col.id, owner.id, owner.username, owner.email, accepted.id); svc.acceptInvite(accepted.id, col.id, undefined); svc.sendInvite(col.id, owner.id, owner.username, owner.email, pending.id); // a non-owner member cannot delete expect(() => svc.deleteCollection(accepted.id, col.id)).toThrow(); broadcastToUser.mockClear(); svc.deleteCollection(owner.id, col.id); const targets = broadcastToUser.mock.calls.map((c) => c[0]); expect(targets).toEqual(expect.arrayContaining([accepted.id, pending.id])); expect(targets).not.toContain(owner.id); expect(broadcastToUser.mock.calls.every((c) => (c[1] as { type: string }).type === 'collections:deleted')).toBe(true); expect(testDb.prepare('SELECT COUNT(*) n FROM collections WHERE id = ?').get(col.id)).toEqual({ n: 0 }); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_members WHERE collection_id = ?').get(col.id)).toEqual({ n: 0 }); }); }); // ── owner_id semantics: member account deletion keeps shared content ───────── describe('owner_id semantics', () => { it('COLLECTIONS-SVC-041: deleting a MEMBER account nulls saved_by but keeps the place', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Shared' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status) VALUES (?, ?, 'accepted')").run(col.id, member.id); const p = svc.savePlace(member.id, { collection_id: col.id, name: 'Kept' }).place!; testDb.prepare('DELETE FROM users WHERE id = ?').run(member.id); const row = testDb.prepare('SELECT owner_id, saved_by FROM collection_places WHERE id = ?').get(p.id) as { owner_id: number; saved_by: number | null }; expect(row).toBeDefined(); expect(row.owner_id).toBe(owner.id); expect(row.saved_by).toBeNull(); // ON DELETE SET NULL }); }); // ── Photo-cache guard ──────────────────────────────────────────────────────── describe('photo-cache widening', () => { it('COLLECTIONS-SVC-042: a collection_places row keeps a photo no places row references', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Photos' }); // cache meta for place_id 'gp-x', referenced ONLY by a collection_places row. testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-x', null, Date.now()); svc.savePlace(u.id, { collection_id: col.id, name: 'Cached', google_place_id: 'gp-x' }); await removeIfUnreferenced('gp-x'); // would evict if isReferenced ignored collection_places const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-x'); expect(meta).toBeDefined(); }); it('COLLECTIONS-SVC-043: an unreferenced photo is still reclaimable', async () => { testDb.prepare('INSERT INTO google_place_photo_meta (place_id, attribution, fetched_at) VALUES (?, ?, ?)').run('gp-orphan', null, Date.now()); await removeIfUnreferenced('gp-orphan'); const meta = testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('gp-orphan'); expect(meta).toBeUndefined(); }); }); // ── Labels ─────────────────────────────────────────────────────────────────── function addMember(colId: number, userId: number, role: 'viewer' | 'editor' | 'admin') { testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', ?)").run(colId, userId, role); } describe('collection labels', () => { it('COLLECTIONS-SVC-050: createLabel is returned by getCollection; duplicate name is 409', () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Germany' }); const label = svc.createLabel(u.id, col.id, 'Berlin', '#ff0000'); expect(label.name).toBe('Berlin'); expect(label.collection_id).toBe(col.id); expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(1); expect(() => svc.createLabel(u.id, col.id, 'berlin')).toThrow(); // case-insensitive dup try { svc.createLabel(u.id, col.id, 'berlin'); } catch (e) { expect((e as { status: number }).status).toBe(409); } }); it('COLLECTIONS-SVC-051: a viewer cannot manage labels (403); an editor can', () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Trip' }); addMember(col.id, viewer.id, 'viewer'); try { svc.createLabel(viewer.id, col.id, 'X'); } catch (e) { expect((e as { status: number }).status).toBe(403); } const editor = createUser(testDb).user; addMember(col.id, editor.id, 'editor'); expect(svc.createLabel(editor.id, col.id, 'Museums').id).toBeGreaterThan(0); }); it('COLLECTIONS-SVC-052: updatePlace label_ids sets labels; a label from another list is ignored', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'DE' }); const other = svc.createCollection(u.id, { name: 'Other' }); const l1 = svc.createLabel(u.id, col.id, 'Berlin'); const foreign = svc.createLabel(u.id, other.id, 'Paris'); const place = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!; await svc.updatePlace(u.id, place.id, { label_ids: [l1.id, foreign.id] }); const stored = svc.getCollection(u.id, col.id).places.find(p => p.id === place.id)!; expect(stored.label_ids).toEqual([l1.id]); }); it('COLLECTIONS-SVC-053: assignLabels bulk-adds then unassigns across places', () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'DE' }); const l = svc.createLabel(u.id, col.id, 'Coast'); const p1 = svc.savePlace(u.id, { collection_id: col.id, name: 'A' }).place!; const p2 = svc.savePlace(u.id, { collection_id: col.id, name: 'B' }).place!; expect(svc.assignLabels(u.id, [l.id], [p1.id, p2.id], false).changed).toBe(2); expect(svc.getCollection(u.id, col.id).places.every(p => p.label_ids?.includes(l.id))).toBe(true); svc.assignLabels(u.id, [l.id], [p1.id], true); const after = svc.getCollection(u.id, col.id).places; expect(after.find(p => p.id === p1.id)!.label_ids).toEqual([]); expect(after.find(p => p.id === p2.id)!.label_ids).toEqual([l.id]); }); it('COLLECTIONS-SVC-054: deleteLabel removes it and cascades its place assignments', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'DE' }); const l = svc.createLabel(u.id, col.id, 'Berlin'); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Gate' }).place!; await svc.updatePlace(u.id, p.id, { label_ids: [l.id] }); svc.deleteLabel(u.id, l.id); expect(svc.getCollection(u.id, col.id).collection.labels).toHaveLength(0); expect(svc.getCollection(u.id, col.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]); }); it('COLLECTIONS-SVC-055: moving a place to another list drops its labels', async () => { const u = createUser(testDb).user; const a = svc.createCollection(u.id, { name: 'A' }); const b = svc.createCollection(u.id, { name: 'B' }); const l = svc.createLabel(u.id, a.id, 'Berlin'); const p = svc.savePlace(u.id, { collection_id: a.id, name: 'Gate' }).place!; await svc.updatePlace(u.id, p.id, { label_ids: [l.id] }); await svc.updatePlace(u.id, p.id, { collection_id: b.id }); expect(svc.getCollection(u.id, b.id).places.find(x => x.id === p.id)!.label_ids).toEqual([]); }); }); // ── Custom saved-place image (#1136) ───────────────────────────────────────── describe('custom saved-place image', () => { function writePlaceImage(name: string): string { const filePath = path.join(storageFx.root, name); fs.writeFileSync(filePath, 'jpeg-bytes'); return filePath; } it('COLLECTIONS-SVC-060: updatePlace sets image_url', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Photos' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' }).place!; const updated = await svc.updatePlace(u.id, p.id, { image_url: '/uploads/places/col-set.jpg' }); expect(updated.image_url).toBe('/uploads/places/col-set.jpg'); }); it('COLLECTIONS-SVC-061: setPlaceImage stores the url and reclaims a replaced upload', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Photos' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic' }).place!; const fileA = writePlaceImage('col-replace-a.jpg'); await svc.setPlaceImage(u.id, p.id, '/uploads/places/col-replace-a.jpg'); expect(fs.existsSync(fileA)).toBe(true); const res = await svc.setPlaceImage(u.id, p.id, '/uploads/places/col-replace-b.jpg'); expect(res.image_url).toBe('/uploads/places/col-replace-b.jpg'); expect(fs.existsSync(fileA)).toBe(false); }); it('COLLECTIONS-SVC-062: deletePlace reclaims the uploaded image when unreferenced', async () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Photos' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Pic', image_url: '/uploads/places/col-delete.jpg' }).place!; const fileA = writePlaceImage('col-delete.jpg'); expect(fs.existsSync(fileA)).toBe(true); await svc.deletePlace(u.id, p.id); expect(fs.existsSync(fileA)).toBe(false); }); }); // ── Collaborative ratings (#1435) ──────────────────────────────────────────── describe('collaborative ratings (#1435)', () => { it('COLLECTIONS-SVC-070: setRating stores a vote, updates it, and clears with null', () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Rate' }); const p = svc.savePlace(u.id, { collection_id: col.id, name: 'Louvre' }).place!; let updated = svc.setRating(u.id, p.id, 5); expect(updated.rating_avg).toBe(5); expect(updated.rating_count).toBe(1); expect(updated.ratings?.find(r => r.user_id === u.id)?.rating).toBe(5); updated = svc.setRating(u.id, p.id, 3); // same user re-votes → replaces, not appends expect(updated.rating_avg).toBe(3); expect(updated.rating_count).toBe(1); updated = svc.setRating(u.id, p.id, null); // clear expect(updated.rating_avg).toBeNull(); expect(updated.rating_count).toBe(0); }); it('COLLECTIONS-SVC-071: every accepted member may vote; the value is the average', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Shared rate' }); // A viewer (read-only) member — still allowed to cast a personal vote. testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(col.id, member.id); const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Notre-Dame' }).place!; svc.setRating(owner.id, p.id, 5); const updated = svc.setRating(member.id, p.id, 2); expect(updated.rating_count).toBe(2); expect(updated.rating_avg).toBe(3.5); }); it('COLLECTIONS-SVC-072: a non-member cannot rate', () => { const owner = createUser(testDb).user; const outsider = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Private rate' }); const p = svc.savePlace(owner.id, { collection_id: col.id, name: 'Secret' }).place!; expect(() => svc.setRating(outsider.id, p.id, 4)).toThrow(); }); it('COLLECTIONS-SVC-073: saving a trip place copies only the saver + shared-member votes', () => { const owner = createUser(testDb).user; const shared = createUser(testDb).user; // member of BOTH the trip and the collection const tripOnly = createUser(testDb).user; // member of the trip only const col = svc.createCollection(owner.id, { name: 'From trip rated' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, shared.id); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, shared.id); addTripMember(testDb, trip.id, tripOnly.id); const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); const ins = testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)'); ins.run(place.id, owner.id, 5); ins.run(place.id, shared.id, 4); ins.run(place.id, tripOnly.id, 1); const saved = svc.savePlace(owner.id, { collection_id: col.id, name: 'Colosseum', source_trip_id: trip.id, source_place_id: place.id, }).place!; const votes = testDb.prepare('SELECT user_id, rating FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id) as { user_id: number; rating: number }[]; const voterIds = votes.map(v => v.user_id).sort((a, b) => a - b); expect(voterIds).toEqual([owner.id, shared.id].sort((a, b) => a - b)); expect(votes.find(v => v.user_id === tripOnly.id)).toBeUndefined(); }); it('COLLECTIONS-SVC-074: copying a saved place into a trip carries its ratings along', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Copyable' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, member.id); const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Trevi' }).place!; svc.setRating(owner.id, cp.id, 5); svc.setRating(member.id, cp.id, 3); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); // member is on the trip, so their vote carries const res = svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); expect(res.copied).toBe(1); const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; const votes = testDb.prepare('SELECT user_id, rating FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number; rating: number }[]; expect(votes).toHaveLength(2); expect(votes.find(v => v.user_id === owner.id)?.rating).toBe(5); expect(votes.find(v => v.user_id === member.id)?.rating).toBe(3); }); it('COLLECTIONS-SVC-075: savePlace does NOT harvest ratings from a source place the caller cannot access', () => { const attacker = createUser(testDb).user; const victim = createUser(testDb).user; const col = svc.createCollection(attacker.id, { name: 'Harvest attempt' }); // The victim is a member of the attacker's collection (so they'd be "eligible"). testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, victim.id); // A PRIVATE trip the attacker is not on, with the victim's vote on a place. const privateTrip = createTrip(testDb, victim.id); const secret = createPlace(testDb, privateTrip.id, { name: 'Secret spot' }); testDb.prepare('INSERT INTO place_ratings (place_id, user_id, rating) VALUES (?, ?, ?)').run(secret.id, victim.id, 5); const saved = svc.savePlace(attacker.id, { collection_id: col.id, name: 'x', source_trip_id: privateTrip.id, source_place_id: secret.id, }).place!; const stolen = testDb.prepare('SELECT * FROM collection_place_ratings WHERE collection_place_id = ?').all(saved.id); expect(stolen).toHaveLength(0); // no access to the source trip → nothing copied }); it('COLLECTIONS-SVC-076: copyToTrip carries only votes from members of the target trip', () => { const owner = createUser(testDb).user; const inTrip = createUser(testDb).user; // collection member AND trip member const notInTrip = createUser(testDb).user; // collection member only const col = svc.createCollection(owner.id, { name: 'Mixed membership' }); for (const u of [inTrip, notInTrip]) { testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(col.id, u.id); } const cp = svc.savePlace(owner.id, { collection_id: col.id, name: 'Pantheon' }).place!; svc.setRating(owner.id, cp.id, 5); svc.setRating(inTrip.id, cp.id, 4); svc.setRating(notInTrip.id, cp.id, 1); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, inTrip.id); svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as { id: number }; const ids = (testDb.prepare('SELECT user_id FROM place_ratings WHERE place_id = ?').all(newPlace.id) as { user_id: number }[]) .map(v => v.user_id).sort((a, b) => a - b); expect(ids).toEqual([owner.id, inTrip.id].sort((a, b) => a - b)); expect(ids).not.toContain(notInTrip.id); }); }); // ── Membership lookups ─────────────────────────────────────────────────────── describe('membership lookups', () => { it('COLLECTIONS-SVC-080: findMembership matches by google id and coords, never by bare name', () => { const u = createUser(testDb).user; const col = svc.createCollection(u.id, { name: 'Lookup' }); svc.savePlace(u.id, { collection_id: col.id, name: 'Starbucks', lat: 48.8584, lng: 2.2945, google_place_id: 'gp-1' }); expect(svc.findMembership(u.id, { google_place_id: 'gp-1' }).saved).toBe(true); expect(svc.findMembership(u.id, { lat: 48.8584, lng: 2.2945 }).saved).toBe(true); // A bare name is deliberately NOT a condition on its own — no false positives. expect(svc.findMembership(u.id, { name: 'Starbucks' })).toEqual({ saved: false, lists: [] }); // No lists at all short-circuits. const other = createUser(testDb).user; expect(svc.findMembership(other.id, { google_place_id: 'gp-1' })).toEqual({ saved: false, lists: [] }); }); it('COLLECTIONS-SVC-081: findMembershipForUser reports owner / accepted / pending / none', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const outsider = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'M' }); expect(svc.findMembershipForUser(owner.id, col.id)).toEqual({ is_member: true, is_owner: true, status: 'accepted' }); expect(svc.findMembershipForUser(outsider.id, col.id)).toEqual({ is_member: false, is_owner: false, status: null }); svc.sendInvite(col.id, owner.id, owner.username, owner.email, member.id); expect(svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: false, is_owner: false, status: 'pending' }); svc.acceptInvite(member.id, col.id, undefined); expect(svc.findMembershipForUser(member.id, col.id)).toEqual({ is_member: true, is_owner: false, status: 'accepted' }); }); }); // ── Post-fold quirk fixes (the trailing fix(server) commit) ───────────────── describe('atomic bulk writes (post-fold quirk fixes)', () => { it('COLLECTIONS-SVC-090: deletePlacesMany is all-or-nothing — a mid-list 403 deletes nothing', async () => { const u = createUser(testDb).user; const otherOwner = createUser(testDb).user; const mine = svc.createCollection(u.id, { name: 'Mine' }); const shared = svc.createCollection(otherOwner.id, { name: 'Shared' }); // u is an editor on the shared list — can add/edit but NOT delete (owner/admin only). testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'editor')").run(shared.id, u.id); const p1 = svc.savePlace(u.id, { collection_id: mine.id, name: 'Deletable' }).place!; const p2 = svc.savePlace(u.id, { collection_id: shared.id, name: 'Protected' }).place!; // The relocated legacy interleaved checks with deletes, so p1 was gone by // the time p2's 403 fired. Now every id is checked first: nothing deleted. await expect(svc.deletePlacesMany(u.id, [p1.id, p2.id])).rejects.toThrow('Only an admin can delete places from this list'); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p1.id)).toEqual({ n: 1 }); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_places WHERE id = ?').get(p2.id)).toEqual({ n: 1 }); }); it('COLLECTIONS-SVC-091: assignLabels permission-checks every list before writing anything', () => { const u = createUser(testDb).user; const otherOwner = createUser(testDb).user; const mine = svc.createCollection(u.id, { name: 'Mine' }); const readonly = svc.createCollection(otherOwner.id, { name: 'ReadOnly' }); testDb.prepare("INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'accepted', 'viewer')").run(readonly.id, u.id); const label = svc.createLabel(u.id, mine.id, 'Coast'); const pa = svc.savePlace(u.id, { collection_id: mine.id, name: 'A' }).place!; const pb = testDb.prepare('INSERT INTO collection_places (collection_id, owner_id, saved_by, name) VALUES (?, ?, ?, ?)') .run(readonly.id, otherOwner.id, otherOwner.id, 'B').lastInsertRowid as number; // The relocated legacy checked per list inside the write loop, so `mine` // was labeled before `readonly`'s 403 fired. Now all lists check first. expect(() => svc.assignLabels(u.id, [label.id], [pa.id, Number(pb)], false)).toThrow('You have read-only access to this list'); expect(testDb.prepare('SELECT COUNT(*) n FROM collection_place_labels WHERE collection_place_id = ?').get(pa.id)).toEqual({ n: 0 }); }); it('COLLECTIONS-SVC-092: from-trip saves forward the socket id so the origin client does not echo', () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); // Distinct coords — the factory default would coord-dedup against Louvre. const place2 = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }); const col = svc.createCollection(u.id, { name: 'From trip' }); broadcastToUser.mockClear(); svc.saveFromTripPlace(u.id, col.id, trip.id, place.id, undefined, 'sock-1'); expect(broadcastToUser).toHaveBeenCalledWith(u.id, expect.objectContaining({ type: 'collections:updated' }), 'sock-1'); broadcastToUser.mockClear(); svc.saveFromTripPlaces(u.id, col.id, trip.id, [place2.id], undefined, 'sock-2'); expect(broadcastToUser).toHaveBeenCalledWith(u.id, expect.objectContaining({ type: 'collections:updated' }), 'sock-2'); }); }); // ── Bulk "visited" from a trip (#1469) ─────────────────────────────────────── /** The stored status of a saved place, read straight off the row. */ function statusOf(placeId: number): string { return (testDb.prepare('SELECT status FROM collection_places WHERE id = ?').get(placeId) as { status: string }).status; } describe('bulk status', () => { it('COLLECTIONS-SVC-093: setStatusMany writes one status across lists and counts only real changes', () => { const u = createUser(testDb).user; const a = svc.createCollection(u.id, { name: 'A' }); const b = svc.createCollection(u.id, { name: 'B' }); const pa = svc.savePlace(u.id, { collection_id: a.id, name: 'Louvre' }).place!; const pb = svc.savePlace(u.id, { collection_id: b.id, name: 'Louvre' }).place!; svc.setStatus(u.id, pb.id, 'visited'); // pb is already visited, so only pa is a change. expect(svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 1 }); expect(statusOf(pa.id)).toBe('visited'); expect(svc.setStatusMany(u.id, [pa.id, pb.id], 'visited')).toEqual({ updated: 0 }); }); it('COLLECTIONS-SVC-094: setStatusMany is all-or-nothing — a read-only list stops the batch', () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const mine = svc.createCollection(viewer.id, { name: 'Mine' }); const readonly = svc.createCollection(owner.id, { name: 'Shared' }); addMember(readonly.id, viewer.id, 'viewer'); const p1 = svc.savePlace(viewer.id, { collection_id: mine.id, name: 'Louvre' }).place!; const p2 = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre' }).place!; expect(() => svc.setStatusMany(viewer.id, [p1.id, p2.id], 'visited')).toThrow('You have read-only access to this list'); expect(statusOf(p1.id)).toBe('idea'); }); it('COLLECTIONS-SVC-095: setStatusFromTrip marks every saved copy of the selected trip places', () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const louvre = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); const orsay = createPlace(testDb, trip.id, { name: 'Orsay', lat: 48.86, lng: 2.3266 }); const a = svc.createCollection(u.id, { name: 'Paris' }); const b = svc.createCollection(u.id, { name: 'Museums' }); svc.saveFromTripPlace(u.id, a.id, trip.id, louvre.id); svc.saveFromTripPlace(u.id, b.id, trip.id, louvre.id); svc.saveFromTripPlace(u.id, a.id, trip.id, orsay.id); expect(svc.setStatusFromTrip(u.id, trip.id, [louvre.id], 'visited')).toEqual({ updated: 2, places: 1 }); const statuses = testDb.prepare('SELECT status FROM collection_places ORDER BY id').all() as { status: string }[]; expect(statuses.map(s => s.status)).toEqual(['visited', 'visited', 'idea']); }); it('COLLECTIONS-SVC-096: a place renamed in the list is still found by its source link', async () => { const u = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Trattoria da Enzo', lat: 41.88, lng: 12.47 }); const col = svc.createCollection(u.id, { name: 'Rome' }); const saved = svc.saveFromTripPlace(u.id, col.id, trip.id, place.id).place!; // Renamed on both sides, and moved far enough that coordinates cannot match. await svc.updatePlace(u.id, saved.id, { name: 'Dinner spot', lat: 45, lng: 9 }); testDb.prepare('UPDATE places SET name = ? WHERE id = ?').run('Enzo', place.id); expect(svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 1, places: 1 }); }); it('COLLECTIONS-SVC-097: a trip the caller cannot see is a 404, and unsaved places are a no-op', () => { const u = createUser(testDb).user; const stranger = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, u.id); const place = createPlace(testDb, trip.id, { name: 'Louvre' }); expect(() => svc.setStatusFromTrip(stranger.id, trip.id, [place.id], 'visited')).toThrow('Trip not found'); expect(svc.setStatusFromTrip(u.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); }); it('COLLECTIONS-SVC-098: lists the caller may only read are skipped, not refused', () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; createCategory(testDb); const trip = createTrip(testDb, viewer.id); const place = createPlace(testDb, trip.id, { name: 'Louvre', lat: 48.8606, lng: 2.3376 }); const readonly = svc.createCollection(owner.id, { name: 'Shared' }); addMember(readonly.id, viewer.id, 'viewer'); const theirs = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', lat: 48.8606, lng: 2.3376 }).place!; expect(svc.setStatusFromTrip(viewer.id, trip.id, [place.id], 'visited')).toEqual({ updated: 0, places: 0 }); expect(statusOf(theirs.id)).toBe('idea'); }); it('COLLECTIONS-SVC-099: findMembership reports the per-list status and edit right', () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const readonly = svc.createCollection(owner.id, { name: 'Shared' }); addMember(readonly.id, viewer.id, 'viewer'); const saved = svc.savePlace(owner.id, { collection_id: readonly.id, name: 'Louvre', google_place_id: 'gp-9' }).place!; svc.setStatus(owner.id, saved.id, 'visited'); expect(svc.findMembership(viewer.id, { google_place_id: 'gp-9' }).lists).toEqual([ { collection_id: readonly.id, name: 'Shared', place_id: saved.id, status: 'visited', can_edit: false }, ]); expect(svc.findMembership(owner.id, { google_place_id: 'gp-9' }).lists[0].can_edit).toBe(true); }); }); // ── Export / import as a file (#2198) ──────────────────────────────────────── describe('exportCollection / importCollection (#2198)', () => { /** A list with two labels, a category and one place carrying both. */ function seedList(ownerId: number) { const cat = createCategory(testDb, { name: 'Restaurant' }); const col = svc.createCollection(ownerId, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); const must = svc.createLabel(ownerId, col.id, 'Must see', '#ff0000'); const rain = svc.createLabel(ownerId, col.id, 'Rainy day', '#00ff00'); const market = svc.savePlace(ownerId, { collection_id: col.id, name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459, address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/', phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', category_id: cat.id, price: 12, currency: 'EUR', }).place!; const view = svc.savePlace(ownerId, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327 }).place!; svc.assignLabels(ownerId, [must.id], [market.id], false); svc.assignLabels(ownerId, [rain.id], [view.id], false); return { col, cat, must, rain, market, view }; } it('COLLECTIONS-SVC-100: a file carries the list, its labels and each place with its label names', () => { const owner = createUser(testDb).user; const { col, market } = seedList(owner.id); const file = svc.exportCollection(owner.id, col.id); expect(file.format).toBe('trek.collection'); expect(file.version).toBe(1); expect(file).toMatchObject({ name: 'Lisbon', description: 'Three days', color: '#ef4444' }); expect(file.labels).toEqual([ { name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: '#00ff00' }, ]); expect(file.places).toHaveLength(2); const first = file.places[0] as Record; expect(first).toMatchObject({ name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459, address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/', phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', price: 12, currency: 'EUR', category: 'Restaurant', labels: ['Must see'], }); expect(market.id).toBeGreaterThan(0); }); it('COLLECTIONS-SVC-101: a file carries no ids, no members and no ratings', () => { const owner = createUser(testDb).user; const member = createUser(testDb).user; const { col, market } = seedList(owner.id); addMember(col.id, member.id, 'editor'); svc.setRating(member.id, market.id, 5); const file = svc.exportCollection(owner.id, col.id); const serialised = JSON.stringify(file); for (const forbidden of ['"id"', 'collection_id', 'owner_id', 'saved_by', 'source_trip_id', 'source_place_id', 'rating', 'user_id', 'category_id']) { expect(serialised, forbidden).not.toContain(forbidden); } }); it('COLLECTIONS-SVC-102: an instance-local image path does not leave the instance', () => { const owner = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Pictures' }); svc.savePlace(owner.id, { collection_id: col.id, name: 'Uploaded', image_url: '/uploads/places/secret.jpg' }); svc.savePlace(owner.id, { collection_id: col.id, name: 'Proxied', image_url: '/api/maps/place-photo/abc' }); svc.savePlace(owner.id, { collection_id: col.id, name: 'Remote', image_url: 'https://example.com/photo.jpg' }); const images = svc.exportCollection(owner.id, col.id).places.map(p => (p as { image_url?: string | null }).image_url); expect(images).toEqual([null, null, 'https://example.com/photo.jpg']); }); it('COLLECTIONS-SVC-103: a member may export, a stranger may not', () => { const owner = createUser(testDb).user; const viewer = createUser(testDb).user; const stranger = createUser(testDb).user; const { col } = seedList(owner.id); addMember(col.id, viewer.id, 'viewer'); expect(svc.exportCollection(viewer.id, col.id).places).toHaveLength(2); try { svc.exportCollection(stranger.id, col.id); throw new Error('should have thrown'); } catch (e) { expect((e as { status: number }).status).toBe(404); } }); it('COLLECTIONS-SVC-104: a file round-trips into an equivalent list of the importer', () => { const owner = createUser(testDb).user; const other = createUser(testDb).user; const { col } = seedList(owner.id); const file = svc.exportCollection(owner.id, col.id); const result = svc.importCollection(other.id, { file }); const created = result.collection as { id: number; owner_id: number; name: string }; expect(result).toMatchObject({ imported: 2, skipped: 0 }); expect(created.owner_id).toBe(other.id); expect(created.name).toBe('Lisbon'); // Re-exporting the copy gives the same file, bar the timestamp. const again = svc.exportCollection(other.id, created.id); expect({ ...again, exported_at: undefined }).toEqual({ ...file, exported_at: undefined }); }); it('COLLECTIONS-SVC-105: the importer may rename the list on the way in', () => { const owner = createUser(testDb).user; const { col } = seedList(owner.id); const file = svc.exportCollection(owner.id, col.id); const created = svc.importCollection(owner.id, { file, name: 'Lisbon (from Ana)' }).collection as { id: number; name: string }; expect(created.name).toBe('Lisbon (from Ana)'); // And the original is untouched. expect(svc.getCollection(owner.id, col.id).collection.name).toBe('Lisbon'); }); it('COLLECTIONS-SVC-106: a category arrives by name, matched case-insensitively, unknown names drop', () => { const owner = createUser(testDb).user; createCategory(testDb, { name: 'Restaurant' }); const created = svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Categories', places: [ { name: 'Exact', category: 'Restaurant' }, { name: 'Sloppy', category: ' rEsTaUrAnT ' }, { name: 'Unknown', category: 'Not a category here' }, { name: 'None' }, ], } }).collection as { id: number }; const places = svc.getCollection(owner.id, created.id).places; expect(places.map(p => p.category?.name ?? null)).toEqual(['Restaurant', 'Restaurant', null, null]); }); it('COLLECTIONS-SVC-107: labels are recreated and only names the file defines are assigned', () => { const owner = createUser(testDb).user; const created = svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Labelled', labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Must see', color: '#00ff00' }], places: [ { name: 'Tagged', labels: ['Must see'] }, { name: 'Sloppy case', labels: [' MUST SEE '] }, { name: 'Ghost label', labels: ['Never defined'] }, ], } }).collection as { id: number }; const detail = svc.getCollection(owner.id, created.id); // The duplicate name collapses to one label. expect(detail.collection.labels).toHaveLength(1); const labelId = detail.collection.labels![0].id; expect(detail.places.map(p => p.label_ids)).toEqual([[labelId], [labelId], []]); }); it('COLLECTIONS-SVC-108: a place the contract refuses is skipped, the rest still arrive', () => { const owner = createUser(testDb).user; const result = svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Mixed', places: [ { name: 'Good' }, { noName: true }, { name: '' }, { name: 'Also good' }, null, ], } }); expect(result).toMatchObject({ imported: 2, skipped: 3 }); const created = result.collection as { id: number }; expect(svc.getCollection(owner.id, created.id).places.map(p => p.name)).toEqual(['Good', 'Also good']); }); it('COLLECTIONS-SVC-109: a file cannot hand the importer ids, provenance or other people', () => { const owner = createUser(testDb).user; const victim = createUser(testDb).user; const trip = createTrip(testDb, victim.id); const place = createPlace(testDb, trip.id, { name: 'Private place' }); const created = svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Hostile', places: [{ name: 'Claims things', id: 9999, collection_id: 1, owner_id: victim.id, saved_by: victim.id, source_trip_id: trip.id, source_place_id: place.id, } as never], } }).collection as { id: number }; const row = testDb.prepare('SELECT * FROM collection_places WHERE collection_id = ?').get(created.id) as Record; expect(row.owner_id).toBe(owner.id); expect(row.saved_by).toBe(owner.id); expect(row.source_trip_id).toBeNull(); expect(row.source_place_id).toBeNull(); expect(row.id).not.toBe(9999); }); it('COLLECTIONS-SVC-110: a link the browser must not follow arrives as nothing, the place still does', () => { const owner = createUser(testDb).user; const created = svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Links', places: [ { name: 'Script', website: 'javascript:alert(1)' as never }, { name: 'Local image', image_url: '/uploads/x.jpg' as never }, { name: 'Fine', website: 'https://example.com', image_url: 'https://example.com/p.jpg' }, ], } }).collection as { id: number }; const places = svc.getCollection(owner.id, created.id).places; expect(places.map(p => p.name)).toEqual(['Script', 'Local image', 'Fine']); expect(places[0].website).toBeNull(); expect(places[1].image_url).toBeNull(); expect(places[2]).toMatchObject({ website: 'https://example.com', image_url: 'https://example.com/p.jpg' }); }); it('COLLECTIONS-SVC-111: an import writes nothing at all when one of its writes fails', () => { const owner = createUser(testDb).user; const before = svc.listCollections(owner.id).collections.length; const insert = testDb.prepare.bind(testDb); const spy = vi.spyOn(testDb, 'prepare').mockImplementation((sql: string) => { if (sql.includes('INSERT INTO collection_places')) throw new Error('disk is full'); return insert(sql); }); try { expect(() => svc.importCollection(owner.id, { file: { format: 'trek.collection', version: 1, name: 'Doomed', places: [{ name: 'Never arrives' }], } })).toThrow('disk is full'); } finally { spy.mockRestore(); } expect(svc.listCollections(owner.id).collections).toHaveLength(before); }); }); // ── The same list as GPX (#2301) ───────────────────────────────────────────── describe('exportCollectionGpx / readCollectionGpx (#2301)', () => { const fixture = (name: string) => fs.readFileSync(path.join(__dirname, '../../fixtures/gpx', name), 'utf8'); it('COLLECTIONS-SVC-120: a list that goes out as GPX comes back as the same list', () => { const owner = createUser(testDb).user; const other = createUser(testDb).user; const cat = createCategory(testDb, { name: 'Restaurant' }); const col = svc.createCollection(owner.id, { name: 'Lisbon', description: 'Three days', color: '#ef4444' }); const must = svc.createLabel(owner.id, col.id, 'Must see', '#ff0000'); svc.createLabel(owner.id, col.id, 'Rainy day', '#00ff00'); const market = svc.savePlace(owner.id, { collection_id: col.id, name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459, address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/', phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', category_id: cat.id, price: 12.5, currency: 'EUR', links: [{ url: 'https://menu.example/', label: 'Menu' }], }).place!; svc.savePlace(owner.id, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327, status: 'visited' }); svc.assignLabels(owner.id, [must.id], [market.id], false); const original = svc.exportCollection(owner.id, col.id); const exported = svc.exportCollectionGpx(owner.id, col.id); const read = svc.readCollectionGpx({ gpx: exported.gpx, file_name: 'lisbon.gpx' }); const result = svc.importCollection(other.id, { file: read.file }); expect(exported).toMatchObject({ name: 'Lisbon', waypoints: 2, omitted: 0 }); expect(read).toMatchObject({ skipped: 0, track_points: 0 }); expect(result).toMatchObject({ imported: 2, skipped: 0 }); // Names, coordinates, notes, website, category, labels, status and the rest: // exported again from the copy, the file is the one the original gave. const again = svc.exportCollection(other.id, (result.collection as { id: number }).id); expect({ ...again, exported_at: undefined }).toEqual({ ...original, exported_at: undefined }); }); it('COLLECTIONS-SVC-121: a place without coordinates is left out and counted, and a stranger gets nothing', () => { const owner = createUser(testDb).user; const stranger = createUser(testDb).user; const col = svc.createCollection(owner.id, { name: 'Mixed' }); svc.savePlace(owner.id, { collection_id: col.id, name: 'Pinned', lat: 1, lng: 2 }); svc.savePlace(owner.id, { collection_id: col.id, name: 'Somewhere vague' }); const result = svc.exportCollectionGpx(owner.id, col.id); expect(result).toMatchObject({ name: 'Mixed', waypoints: 1, omitted: 1 }); expect(result.gpx).toContain('Pinned'); expect(result.gpx).not.toContain('Somewhere vague'); expect(() => svc.exportCollectionGpx(stranger.id, col.id)).toThrow(expect.objectContaining({ status: 404 })); }); it('COLLECTIONS-SVC-122: OsmAnd favourites become a list, their groups matched against the palette', () => { const owner = createUser(testDb).user; createCategory(testDb, { name: 'Restaurant' }); const read = svc.readCollectionGpx({ gpx: fixture('osmand-favourites.gpx'), file_name: 'favourites.gpx' }); const created = svc.importCollection(owner.id, { file: read.file, name: 'Lisbon favourites' }).collection as { id: number }; const detail = svc.getCollection(owner.id, created.id); expect(detail.collection.name).toBe('Lisbon favourites'); expect(detail.places.map(p => [p.name, p.category?.name ?? null, p.address ?? null])).toEqual([ ['Time Out Market', 'Restaurant', 'Av. 24 de Julho 49, 1200-479 Lisboa'], // No "Viewpoints" in this palette, and a file does not get to add one. ['Miradouro de Santa Luzia', null, null], ['Pastéis de Belém', null, 'R. de Belém 84 92, 1300-085 Lisboa'], ]); }); it('COLLECTIONS-SVC-123: reading a GPX writes nothing, whatever is in it', () => { const owner = createUser(testDb).user; const before = testDb.prepare('SELECT COUNT(*) AS n FROM collections').get() as { n: number }; svc.readCollectionGpx({ gpx: fixture('garmin-sym.gpx') }); expect(() => svc.readCollectionGpx({ gpx: fixture('hostile-doctype.gpx') })).toThrow(expect.objectContaining({ code: 'unreadable' })); expect(testDb.prepare('SELECT COUNT(*) AS n FROM collections').get()).toEqual(before); expect(svc.listCollections(owner.id).collections).toHaveLength(0); }); });