mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
093e069ccc
* refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
1624 lines
62 KiB
TypeScript
1624 lines
62 KiB
TypeScript
/**
|
|
* Unit tests for journeyService (JOURNEY-SVC-001 through JOURNEY-SVC-038).
|
|
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
|
|
*/
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
// -- DB setup -----------------------------------------------------------------
|
|
|
|
const { testDb, dbMock } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA busy_timeout = 5000');
|
|
const mock = {
|
|
db,
|
|
closeDb: () => {},
|
|
reinitialize: () => {},
|
|
getPlaceWithTags: () => null,
|
|
// Mirror the real canAccessTrip semantics against the test DB (owner or member
|
|
// → truthy access row, else undefined) so addTripToJourney's trip-access guard
|
|
// behaves as in production. (Was an unused `() => null` stub before the guard existed.)
|
|
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 };
|
|
});
|
|
|
|
vi.mock('../../../src/db/database', () => dbMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
|
|
|
import { createTables } from '../../../src/db/schema';
|
|
import { runMigrations } from '../../../src/db/migrations';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import {
|
|
createUser,
|
|
createTrip,
|
|
createJourney,
|
|
createJourneyEntry,
|
|
addJourneyContributor,
|
|
createPlace,
|
|
createDay,
|
|
createDayAssignment,
|
|
addTripPhoto,
|
|
} from '../../helpers/factories';
|
|
import {
|
|
canAccessJourney,
|
|
isOwner,
|
|
canEdit,
|
|
listJourneys,
|
|
createJourney as svcCreateJourney,
|
|
getJourneyFull,
|
|
updateJourney,
|
|
deleteJourney,
|
|
addTripToJourney,
|
|
removeTripFromJourney,
|
|
listEntries,
|
|
createEntry,
|
|
updateEntry,
|
|
deleteEntry,
|
|
addPhoto,
|
|
addProviderPhoto,
|
|
deletePhoto,
|
|
addContributor,
|
|
updateContributorRole,
|
|
removeContributor,
|
|
getSuggestions,
|
|
syncTripPlaces,
|
|
reorderEntries,
|
|
onPlaceCreated,
|
|
onPlaceUpdated,
|
|
onPlaceDeleted,
|
|
linkPhotoToEntry,
|
|
setPhotoProvider,
|
|
updatePhoto,
|
|
listUserTrips,
|
|
} from '../../../src/services/journeyService';
|
|
|
|
beforeAll(() => {
|
|
createTables(testDb);
|
|
runMigrations(testDb);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
});
|
|
|
|
afterAll(() => {
|
|
testDb.close();
|
|
});
|
|
|
|
// -- Access control -----------------------------------------------------------
|
|
|
|
describe('canAccessJourney', () => {
|
|
it('JOURNEY-SVC-001: returns journey for owner', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'My Journey' });
|
|
|
|
const result = canAccessJourney(journey.id, user.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe(journey.id);
|
|
expect(result!.title).toBe('My Journey');
|
|
});
|
|
|
|
it('JOURNEY-SVC-002: returns journey for contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
const result = canAccessJourney(journey.id, contrib.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe(journey.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-003: returns null for stranger', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = canAccessJourney(journey.id, stranger.id);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isOwner', () => {
|
|
it('JOURNEY-SVC-004: returns true for owner', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
expect(isOwner(journey.id, user.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-005: returns false for contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
expect(isOwner(journey.id, contrib.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-006: returns false for stranger', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
expect(isOwner(journey.id, stranger.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('canEdit', () => {
|
|
it('JOURNEY-SVC-007: owner can edit', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
expect(canEdit(journey.id, user.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-008: editor contributor can edit', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
expect(canEdit(journey.id, editor.id)).toBe(true);
|
|
});
|
|
|
|
it('JOURNEY-SVC-009: viewer contributor cannot edit', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
expect(canEdit(journey.id, viewer.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-010: stranger cannot edit', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
expect(canEdit(journey.id, stranger.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -- Journey CRUD -------------------------------------------------------------
|
|
|
|
describe('listJourneys', () => {
|
|
it('JOURNEY-SVC-011: returns owned journeys with counts', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Road Trip' });
|
|
createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', location_name: 'Paris' });
|
|
createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02', location_name: 'Lyon' });
|
|
|
|
const result = listJourneys(user.id);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].title).toBe('Road Trip');
|
|
expect(result[0].entry_count).toBe(2);
|
|
expect(result[0].place_count).toBe(2);
|
|
});
|
|
|
|
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: 'Shared Trip' });
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
const result = listJourneys(contrib.id);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].title).toBe('Shared Trip');
|
|
});
|
|
|
|
it('JOURNEY-SVC-013: does not include other users journeys', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: other } = createUser(testDb);
|
|
createJourney(testDb, owner.id, { title: 'Private' });
|
|
|
|
const result = listJourneys(other.id);
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
|
|
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
|
|
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
|
|
addTripToJourney(journey.id, trip1.id, user.id);
|
|
addTripToJourney(journey.id, trip2.id, user.id);
|
|
|
|
const result = listJourneys(user.id);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].trip_date_min).toBe('2025-06-01');
|
|
expect(result[0].trip_date_max).toBe('2026-03-20');
|
|
});
|
|
});
|
|
|
|
describe('createJourney (service)', () => {
|
|
it('JOURNEY-SVC-014: creates journey with contributor record', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const journey = svcCreateJourney(user.id, { title: 'New Journey', subtitle: 'Subtitle' });
|
|
|
|
expect(journey.title).toBe('New Journey');
|
|
expect(journey.subtitle).toBe('Subtitle');
|
|
expect(journey.user_id).toBe(user.id);
|
|
expect(journey.status).toBe('active');
|
|
|
|
// owner should be added as contributor
|
|
const contrib = testDb.prepare(
|
|
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
|
).get(journey.id, user.id) as { role: string } | undefined;
|
|
expect(contrib).toBeDefined();
|
|
expect(contrib!.role).toBe('owner');
|
|
});
|
|
|
|
it('JOURNEY-SVC-015: links trips when trip_ids provided', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Paris 2026' });
|
|
|
|
const journey = svcCreateJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] });
|
|
|
|
const link = testDb.prepare(
|
|
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
|
|
).get(journey.id, trip.id);
|
|
expect(link).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('getJourneyFull', () => {
|
|
it('JOURNEY-SVC-016: returns full journey with entries, trips, contributors', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Full Journey' });
|
|
createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Day 1',
|
|
entry_date: '2026-03-01',
|
|
story: 'Arrived!',
|
|
});
|
|
|
|
const result = getJourneyFull(journey.id, user.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.title).toBe('Full Journey');
|
|
expect(result!.entries).toHaveLength(1);
|
|
expect(result!.entries[0].title).toBe('Day 1');
|
|
expect(result!.contributors).toHaveLength(1);
|
|
expect(result!.stats.entries).toBe(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-017: returns null for unauthorized user', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = getJourneyFull(journey.id, stranger.id);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateJourney', () => {
|
|
it('JOURNEY-SVC-018: owner can update title and subtitle', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Old Title' });
|
|
|
|
const updated = updateJourney(journey.id, user.id, { title: 'New Title', subtitle: 'New Sub' });
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('New Title');
|
|
expect(updated!.subtitle).toBe('New Sub');
|
|
});
|
|
|
|
it('JOURNEY-SVC-019: editor contributor cannot update journey settings (#732)', () => {
|
|
// Post-#732: journey-level settings (title/cover/status) are owner-only.
|
|
// Editors keep access to entries and photos, but not the journey shell.
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id, { title: 'Original' });
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const updated = updateJourney(journey.id, editor.id, { title: 'Edited' });
|
|
|
|
expect(updated).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-020: viewer cannot update', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
const result = updateJourney(journey.id, viewer.id, { title: 'Hacked' });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-021: returns journey unchanged when no valid fields provided', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Same' });
|
|
|
|
const result = updateJourney(journey.id, user.id, {});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.title).toBe('Same');
|
|
});
|
|
|
|
it('JOURNEY-SVC-021b: accepts archived status', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
|
|
|
|
const result = updateJourney(journey.id, user.id, { status: 'archived' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.status).toBe('archived');
|
|
});
|
|
|
|
it('JOURNEY-SVC-021c: ignores invalid status value', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
|
|
|
|
const result = updateJourney(journey.id, user.id, { status: 'bogus' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.status).toBe('active');
|
|
});
|
|
});
|
|
|
|
describe('deleteJourney', () => {
|
|
it('JOURNEY-SVC-022: owner can delete', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const result = deleteJourney(journey.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-023: non-owner cannot delete', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const result = deleteJourney(journey.id, editor.id);
|
|
|
|
expect(result).toBe(false);
|
|
const row = testDb.prepare('SELECT * FROM journeys WHERE id = ?').get(journey.id);
|
|
expect(row).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// -- Trip management ----------------------------------------------------------
|
|
|
|
describe('addTripToJourney / removeTripFromJourney', () => {
|
|
it('JOURNEY-SVC-024: links a trip to a journey', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Linked Trip' });
|
|
|
|
const result = addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const link = testDb.prepare(
|
|
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
|
|
).get(journey.id, trip.id);
|
|
expect(link).toBeDefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
// A trip owned by someone else, that `user` is not a member of.
|
|
const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" });
|
|
|
|
const result = addTripToJourney(journey.id, foreignTrip.id, user.id);
|
|
|
|
expect(result).toBe(false);
|
|
const link = testDb.prepare(
|
|
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
|
|
).get(journey.id, foreignTrip.id);
|
|
expect(link).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Trip with Places',
|
|
start_date: '2026-03-01',
|
|
end_date: '2026-03-03',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day025.id, place.id);
|
|
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const skeletons = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
|
|
).all(journey.id, place.id);
|
|
expect(skeletons.length).toBe(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-026: owner can remove a trip from journey', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, { title: 'Remove Me' });
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const result = removeTripFromJourney(journey.id, trip.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const link = testDb.prepare(
|
|
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
|
|
).get(journey.id, trip.id);
|
|
expect(link).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-027: non-owner cannot remove a trip', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
const trip = createTrip(testDb, owner.id, { title: 'Stay Linked' });
|
|
addTripToJourney(journey.id, trip.id, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const result = removeTripFromJourney(journey.id, trip.id, editor.id);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -- Entries ------------------------------------------------------------------
|
|
|
|
describe('listEntries', () => {
|
|
it('JOURNEY-SVC-028: returns entries with photos for authorized user', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Morning Walk',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
const result = listEntries(journey.id, user.id);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result).toHaveLength(1);
|
|
expect(result![0].title).toBe('Morning Walk');
|
|
expect(result![0].photos).toEqual([]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-029: returns null for unauthorized user', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = listEntries(journey.id, stranger.id);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('createEntry', () => {
|
|
it('JOURNEY-SVC-030: creates entry for editor', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const entry = createEntry(journey.id, user.id, {
|
|
title: 'Beach Day',
|
|
entry_date: '2026-03-10',
|
|
story: 'Beautiful sunset',
|
|
mood: 'happy',
|
|
weather: 'sunny',
|
|
tags: ['beach', 'sunset'],
|
|
});
|
|
|
|
expect(entry).not.toBeNull();
|
|
expect(entry!.title).toBe('Beach Day');
|
|
expect(entry!.story).toBe('Beautiful sunset');
|
|
expect(entry!.mood).toBe('happy');
|
|
expect(entry!.type).toBe('entry');
|
|
expect(entry!.author_id).toBe(user.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-031: viewer cannot create entry', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
|
|
const entry = createEntry(journey.id, viewer.id, {
|
|
title: 'Should Fail',
|
|
entry_date: '2026-03-10',
|
|
});
|
|
|
|
expect(entry).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateEntry', () => {
|
|
it('JOURNEY-SVC-032: updates entry fields', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Old',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
const updated = updateEntry(entry.id, user.id, { title: 'Updated', mood: 'excited' });
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('Updated');
|
|
expect(updated!.mood).toBe('excited');
|
|
});
|
|
|
|
it('JOURNEY-SVC-033: promotes skeleton to entry when story is added', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
type: 'skeleton',
|
|
title: 'Placeholder',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
const updated = updateEntry(entry.id, user.id, { story: 'Now I have a story!' });
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.type).toBe('entry');
|
|
expect(updated!.story).toBe('Now I have a story!');
|
|
});
|
|
|
|
it('JOURNEY-SVC-034: returns null for non-existent entry', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = updateEntry(99999, user.id, { title: 'No Such Entry' });
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-034b: ignores injection column keys and mass-assignment attempts', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, {
|
|
title: 'Safe',
|
|
story: 'original',
|
|
entry_date: '2026-03-01',
|
|
});
|
|
|
|
// The keys come straight from the request body. A crafted key was previously
|
|
// interpolated as a raw SQL column name (`${key} = ?`), enabling subquery
|
|
// injection (full DB read) and mass-assignment of protected columns.
|
|
const malicious: Record<string, unknown> = {
|
|
title: 'Updated',
|
|
[`story = (SELECT password_hash FROM users WHERE id = ${user.id}), updated_at`]: 'x',
|
|
author_id: 999999,
|
|
};
|
|
|
|
const updated = updateEntry(entry.id, user.id, malicious as Parameters<typeof updateEntry>[2]);
|
|
|
|
expect(updated).not.toBeNull();
|
|
expect(updated!.title).toBe('Updated'); // legit field still applied
|
|
expect(updated!.story).toBe('original'); // injection key dropped — no hash leaked into story
|
|
expect(updated!.author_id).toBe(user.id); // mass-assignment blocked
|
|
});
|
|
});
|
|
|
|
describe('deleteEntry', () => {
|
|
it('JOURNEY-SVC-035: deletes entry for editor', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = deleteEntry(entry.id, user.id);
|
|
|
|
expect(result).toBe(true);
|
|
const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-036: returns false for non-existent entry', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
expect(deleteEntry(99999, user.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-037: viewer cannot delete entry', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
|
|
expect(deleteEntry(entry.id, viewer.id)).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id);
|
|
const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' });
|
|
|
|
// Create a filled entry that originated from a trip skeleton
|
|
const now = Date.now();
|
|
testDb.prepare(`
|
|
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?)
|
|
`).run(journey.id, trip.id, place.id, user.id, now, now);
|
|
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any;
|
|
|
|
const result = deleteEntry(entry.id, user.id);
|
|
expect(result).toBe(true);
|
|
|
|
// Entry should still exist but reverted to skeleton
|
|
const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any;
|
|
expect(reverted).toBeDefined();
|
|
expect(reverted.type).toBe('skeleton');
|
|
expect(reverted.story).toBeNull();
|
|
expect(reverted.mood).toBeNull();
|
|
expect(reverted.source_trip_id).toBe(trip.id);
|
|
expect(reverted.source_place_id).toBe(place.id);
|
|
expect(reverted.title).toBe('Tokyo Tower');
|
|
});
|
|
|
|
it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' });
|
|
|
|
const result = deleteEntry(entry.id, user.id);
|
|
expect(result).toBe(true);
|
|
|
|
const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// -- Photos -------------------------------------------------------------------
|
|
|
|
describe('addPhoto / addProviderPhoto / deletePhoto', () => {
|
|
it('JOURNEY-SVC-038: addPhoto creates a local photo on an entry', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/photo.jpg', '/uploads/thumb.jpg', 'Sunset');
|
|
|
|
expect(photo).not.toBeNull();
|
|
expect(photo!.file_path).toBe('/uploads/photo.jpg');
|
|
expect(photo!.thumbnail_path).toBe('/uploads/thumb.jpg');
|
|
expect(photo!.caption).toBe('Sunset');
|
|
expect(photo!.provider).toBe('local');
|
|
});
|
|
|
|
it('JOURNEY-SVC-039: addPhoto returns null for non-existent entry', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = addPhoto(99999, user.id, '/uploads/photo.jpg');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-040: addProviderPhoto creates a provider-backed photo', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const photo = addProviderPhoto(entry.id, user.id, 'immich', 'asset-123', 'My caption');
|
|
|
|
expect(photo).not.toBeNull();
|
|
expect(photo!.provider).toBe('immich');
|
|
expect(photo!.asset_id).toBe('asset-123');
|
|
expect(photo!.caption).toBe('My caption');
|
|
});
|
|
|
|
it('JOURNEY-SVC-041: addProviderPhoto skips duplicate asset', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset');
|
|
const duplicate = addProviderPhoto(entry.id, user.id, 'immich', 'dup-asset');
|
|
|
|
expect(duplicate).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-042: deletePhoto removes photo and returns it', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/delete-me.jpg');
|
|
|
|
const deleted = deletePhoto(photo!.id, user.id);
|
|
|
|
expect(deleted).not.toBeNull();
|
|
expect(deleted!.id).toBe(photo!.id);
|
|
const row = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-043: deletePhoto returns null for non-existent photo', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
expect(deletePhoto(99999, user.id)).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-044: viewer cannot add photo', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = addPhoto(entry.id, viewer.id, '/uploads/no.jpg');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- Contributors -------------------------------------------------------------
|
|
|
|
describe('addContributor / updateContributorRole / removeContributor', () => {
|
|
it('JOURNEY-SVC-045: owner can add contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: newContrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = addContributor(journey.id, owner.id, newContrib.id, 'editor');
|
|
|
|
expect(result).toBe(true);
|
|
const row = testDb.prepare(
|
|
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
|
).get(journey.id, newContrib.id) as { role: string } | undefined;
|
|
expect(row).toBeDefined();
|
|
expect(row!.role).toBe('editor');
|
|
});
|
|
|
|
it('JOURNEY-SVC-046: non-owner cannot add contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const { user: newUser } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
|
|
const result = addContributor(journey.id, editor.id, newUser.id, 'viewer');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-047: owner cannot add themselves as contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
const result = addContributor(journey.id, owner.id, owner.id, 'editor');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-048: owner can update contributor role', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'viewer');
|
|
|
|
const result = updateContributorRole(journey.id, owner.id, contrib.id, 'editor');
|
|
|
|
expect(result).toBe(true);
|
|
const row = testDb.prepare(
|
|
'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
|
).get(journey.id, contrib.id) as { role: string };
|
|
expect(row.role).toBe('editor');
|
|
});
|
|
|
|
it('JOURNEY-SVC-049: non-owner cannot update contributor role', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: editor } = createUser(testDb);
|
|
const { user: target } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, editor.id, 'editor');
|
|
addJourneyContributor(testDb, journey.id, target.id, 'viewer');
|
|
|
|
const result = updateContributorRole(journey.id, editor.id, target.id, 'editor');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-050: owner can remove contributor', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: contrib } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, contrib.id, 'editor');
|
|
|
|
const result = removeContributor(journey.id, owner.id, contrib.id);
|
|
|
|
expect(result).toBe(true);
|
|
const row = testDb.prepare(
|
|
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
|
).get(journey.id, contrib.id);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-051: removeContributor does not remove owner contributor record', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
|
|
// attempting to remove the owner's own contributor record should not work
|
|
// (the SQL filters role != 'owner')
|
|
removeContributor(journey.id, owner.id, owner.id);
|
|
|
|
const row = testDb.prepare(
|
|
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
|
).get(journey.id, owner.id);
|
|
expect(row).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// -- Suggestions --------------------------------------------------------------
|
|
|
|
describe('getSuggestions', () => {
|
|
it('JOURNEY-SVC-052: returns recently ended trips not yet in a journey', () => {
|
|
const { user } = createUser(testDb);
|
|
// Trip that ended 5 days ago (within 30-day window)
|
|
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
createTrip(testDb, user.id, {
|
|
title: 'Recent Trip',
|
|
start_date: tenDaysAgo,
|
|
end_date: fiveDaysAgo,
|
|
});
|
|
|
|
const suggestions = getSuggestions(user.id);
|
|
|
|
expect(suggestions.length).toBe(1);
|
|
expect((suggestions[0] as any).title).toBe('Recent Trip');
|
|
});
|
|
|
|
it('JOURNEY-SVC-053: excludes trips already linked to a journey', () => {
|
|
const { user } = createUser(testDb);
|
|
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Already Linked',
|
|
start_date: tenDaysAgo,
|
|
end_date: fiveDaysAgo,
|
|
});
|
|
const journey = createJourney(testDb, user.id);
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const suggestions = getSuggestions(user.id);
|
|
|
|
expect(suggestions.length).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-054: excludes trips ending in the future', () => {
|
|
const { user } = createUser(testDb);
|
|
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
createTrip(testDb, user.id, {
|
|
title: 'Future Trip',
|
|
start_date: '2026-04-01',
|
|
end_date: tomorrow,
|
|
});
|
|
|
|
const suggestions = getSuggestions(user.id);
|
|
|
|
expect(suggestions.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// -- syncTripPlaces ------------------------------------------------------------
|
|
|
|
describe('syncTripPlaces', () => {
|
|
it('JOURNEY-SVC-055: creates skeleton entries for each trip place', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Sync Trip',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-03',
|
|
});
|
|
const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
|
const place2 = createPlace(testDb, trip.id, { name: 'Louvre' });
|
|
const days055 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
|
|
createDayAssignment(testDb, days055[0].id, place1.id);
|
|
createDayAssignment(testDb, days055[1].id, place2.id);
|
|
|
|
syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
const skeletons = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'"
|
|
).all(journey.id) as any[];
|
|
expect(skeletons.length).toBe(2);
|
|
const names = skeletons.map((s: any) => s.title).sort();
|
|
expect(names).toEqual(['Eiffel Tower', 'Louvre']);
|
|
});
|
|
|
|
it('JOURNEY-SVC-056: skips places that already have skeleton entries', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Idempotent Trip',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-02',
|
|
});
|
|
const place056 = createPlace(testDb, trip.id, { name: 'Notre Dame' });
|
|
const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day056.id, place056.id);
|
|
|
|
syncTripPlaces(journey.id, trip.id, user.id);
|
|
syncTripPlaces(journey.id, trip.id, user.id); // second call
|
|
|
|
const skeletons = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'"
|
|
).all(journey.id);
|
|
expect(skeletons.length).toBe(1);
|
|
});
|
|
|
|
it('JOURNEY-SVC-057: uses day date for skeleton entry_date when available', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
// Trip with dates auto-creates days; grab an existing day to assign the place
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Dated Trip',
|
|
start_date: '2026-06-10',
|
|
end_date: '2026-06-12',
|
|
});
|
|
const day = testDb.prepare(
|
|
"SELECT * FROM days WHERE trip_id = ? AND date = '2026-06-11'"
|
|
).get(trip.id) as { id: number };
|
|
const place = createPlace(testDb, trip.id, { name: 'Colosseum' });
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
|
|
syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
const skeleton = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
|
|
).get(journey.id, place.id) as any;
|
|
expect(skeleton).toBeDefined();
|
|
expect(skeleton.entry_date).toBe('2026-06-11');
|
|
});
|
|
});
|
|
|
|
// -- onPlaceCreated / onPlaceUpdated / onPlaceDeleted -------------------------
|
|
|
|
describe('onPlaceCreated', () => {
|
|
it('JOURNEY-SVC-058: creates skeleton entry in linked journeys', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Webhook Trip',
|
|
start_date: '2026-07-01',
|
|
end_date: '2026-07-03',
|
|
});
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Create a new place after trip is linked
|
|
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' });
|
|
const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day058.id, place.id);
|
|
onPlaceCreated(trip.id, place.id);
|
|
|
|
const skeleton = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
|
|
).get(journey.id, place.id);
|
|
expect(skeleton).toBeDefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-059: does nothing if trip is not linked to any journey', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Unlinked Trip' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Remote Place' });
|
|
|
|
onPlaceCreated(trip.id, place.id);
|
|
|
|
const entries = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE source_place_id = ?"
|
|
).all(place.id);
|
|
expect(entries.length).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-060: does not duplicate if skeleton already exists', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Dup Trip',
|
|
start_date: '2026-07-01',
|
|
end_date: '2026-07-02',
|
|
});
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
|
|
const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day060.id, place.id);
|
|
onPlaceCreated(trip.id, place.id);
|
|
onPlaceCreated(trip.id, place.id); // second call
|
|
|
|
const entries = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
|
|
).all(journey.id, place.id);
|
|
expect(entries.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('onPlaceUpdated', () => {
|
|
it('JOURNEY-SVC-061: updates skeleton entry fields when place changes', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Update Place Trip',
|
|
start_date: '2026-08-01',
|
|
end_date: '2026-08-03',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
|
const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day061.id, place.id);
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Update the place name directly in DB
|
|
testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('New Name', 'New Address', place.id);
|
|
onPlaceUpdated(place.id);
|
|
|
|
const entry = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
|
|
).get(journey.id, place.id) as any;
|
|
expect(entry).toBeDefined();
|
|
expect(entry.title).toBe('New Name');
|
|
expect(entry.location_name).toBe('New Address');
|
|
});
|
|
|
|
it('JOURNEY-SVC-062: only updates location on filled entries, not title', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Filled Entry Trip',
|
|
start_date: '2026-08-01',
|
|
end_date: '2026-08-02',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Original Place' });
|
|
const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day062.id, place.id);
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Promote the skeleton to a full entry
|
|
const skeleton = testDb.prepare(
|
|
"SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
|
|
).get(journey.id, place.id) as { id: number };
|
|
updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' });
|
|
|
|
// Now update the place
|
|
testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Changed Place', 'Changed Addr', place.id);
|
|
onPlaceUpdated(place.id);
|
|
|
|
const entry = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE id = ?"
|
|
).get(skeleton.id) as any;
|
|
expect(entry.title).toBe('Custom Title'); // title unchanged
|
|
expect(entry.location_name).toBe('Changed Addr'); // location updated
|
|
});
|
|
|
|
it('JOURNEY-SVC-063: does nothing if place has no linked entries', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Orphan Trip' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Orphan Place' });
|
|
|
|
// Should not throw
|
|
onPlaceUpdated(place.id);
|
|
|
|
const entries = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE source_place_id = ?"
|
|
).all(place.id);
|
|
expect(entries.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('onPlaceDeleted', () => {
|
|
it('JOURNEY-SVC-064: deletes empty skeleton entries', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Delete Place Trip',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-02',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'To Be Deleted' });
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
onPlaceDeleted(place.id);
|
|
|
|
const entry = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE source_place_id = ?"
|
|
).get(place.id);
|
|
expect(entry).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-065: detaches filled entries and adds note instead of deleting', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Detach Trip',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-02',
|
|
});
|
|
const place = createPlace(testDb, trip.id, { name: 'Detach Place' });
|
|
const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
createDayAssignment(testDb, day065.id, place.id);
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Promote the skeleton to a filled entry
|
|
const skeleton = testDb.prepare(
|
|
"SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
|
|
).get(journey.id, place.id) as { id: number };
|
|
updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' });
|
|
|
|
onPlaceDeleted(place.id);
|
|
|
|
const entry = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE id = ?"
|
|
).get(skeleton.id) as any;
|
|
expect(entry).toBeDefined();
|
|
expect(entry.source_place_id).toBeNull();
|
|
expect(entry.source_trip_id).toBeNull();
|
|
expect(entry.story).toContain('original trip place was removed');
|
|
});
|
|
|
|
it('JOURNEY-SVC-066: does nothing for unlinked places', () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id, { title: 'Unlinked' });
|
|
const place = createPlace(testDb, trip.id, { name: 'Nowhere' });
|
|
|
|
// Should not throw
|
|
onPlaceDeleted(place.id);
|
|
});
|
|
});
|
|
|
|
// -- linkPhotoToEntry ----------------------------------------------------------
|
|
|
|
describe('linkPhotoToEntry', () => {
|
|
it('JOURNEY-SVC-067: moves photo from one entry to another', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry1 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const entry2 = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-02' });
|
|
|
|
const photo = addPhoto(entry1.id, user.id, '/uploads/link-test.jpg');
|
|
expect(photo).not.toBeNull();
|
|
|
|
const result = linkPhotoToEntry(entry2.id, photo!.id, user.id);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.entry_id).toBe(entry2.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-068: returns same photo if already on target entry', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/same-entry.jpg');
|
|
|
|
const result = linkPhotoToEntry(entry.id, photo!.id, user.id);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe(photo!.id);
|
|
expect(result!.entry_id).toBe(entry.id);
|
|
});
|
|
|
|
it('JOURNEY-SVC-069: returns null for non-existent entry', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = linkPhotoToEntry(99999, 1, user.id);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-070: returns null for non-existent photo', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = linkPhotoToEntry(entry.id, 99999, user.id);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-071: viewer cannot link photo', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, owner.id, '/uploads/owner-photo.jpg');
|
|
|
|
const result = linkPhotoToEntry(entry.id, photo!.id, viewer.id);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- setPhotoProvider ----------------------------------------------------------
|
|
|
|
describe('setPhotoProvider', () => {
|
|
it('JOURNEY-SVC-072: sets provider info on an existing photo', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/provider-test.jpg');
|
|
|
|
setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id);
|
|
|
|
const updated = testDb.prepare(`
|
|
SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id
|
|
FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
|
WHERE jp.id = ?
|
|
`).get(photo!.id) as any;
|
|
expect(updated.provider).toBe('immich');
|
|
expect(updated.asset_id).toBe('immich-asset-789');
|
|
expect(updated.owner_id).toBe(user.id);
|
|
});
|
|
});
|
|
|
|
// -- updatePhoto ---------------------------------------------------------------
|
|
|
|
describe('updatePhoto', () => {
|
|
it('JOURNEY-SVC-073: updates caption on photo', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/caption-test.jpg', undefined, 'Old caption');
|
|
|
|
const result = updatePhoto(photo!.id, user.id, { caption: 'New caption' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.caption).toBe('New caption');
|
|
});
|
|
|
|
it('JOURNEY-SVC-074: updates sort_order on photo', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/sort-test.jpg');
|
|
|
|
const result = updatePhoto(photo!.id, user.id, { sort_order: 10 });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.sort_order).toBe(10);
|
|
});
|
|
|
|
it('JOURNEY-SVC-075: returns null for non-existent photo', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const result = updatePhoto(99999, user.id, { caption: 'Nope' });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('JOURNEY-SVC-076: returns photo unchanged when no fields provided', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/noop-test.jpg', undefined, 'Stay');
|
|
|
|
const result = updatePhoto(photo!.id, user.id, {});
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.caption).toBe('Stay');
|
|
});
|
|
|
|
it('JOURNEY-SVC-077: viewer cannot update photo', () => {
|
|
const { user: owner } = createUser(testDb);
|
|
const { user: viewer } = createUser(testDb);
|
|
const journey = createJourney(testDb, owner.id);
|
|
addJourneyContributor(testDb, journey.id, viewer.id, 'viewer');
|
|
const entry = createJourneyEntry(testDb, journey.id, owner.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, owner.id, '/uploads/viewer-update.jpg');
|
|
|
|
const result = updatePhoto(photo!.id, viewer.id, { caption: 'Hacked' });
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- listUserTrips -------------------------------------------------------------
|
|
|
|
describe('listUserTrips', () => {
|
|
it('JOURNEY-SVC-078: returns all user trips', () => {
|
|
const { user } = createUser(testDb);
|
|
createTrip(testDb, user.id, { title: 'Trip A', start_date: '2026-01-01', end_date: '2026-01-03' });
|
|
createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-02-01', end_date: '2026-02-03' });
|
|
|
|
const trips = listUserTrips(user.id);
|
|
|
|
expect(trips.length).toBe(2);
|
|
// ordered by start_date DESC
|
|
expect((trips[0] as any).title).toBe('Trip B');
|
|
expect((trips[1] as any).title).toBe('Trip A');
|
|
});
|
|
|
|
it('JOURNEY-SVC-079: returns empty for user with no trips', () => {
|
|
const { user } = createUser(testDb);
|
|
|
|
const trips = listUserTrips(user.id);
|
|
|
|
expect(trips.length).toBe(0);
|
|
});
|
|
|
|
it('JOURNEY-SVC-080: does not return other users trips', () => {
|
|
const { user: user1 } = createUser(testDb);
|
|
const { user: user2 } = createUser(testDb);
|
|
createTrip(testDb, user1.id, { title: 'User1 Trip' });
|
|
|
|
const trips = listUserTrips(user2.id);
|
|
|
|
expect(trips.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// -- Edge cases ----------------------------------------------------------------
|
|
|
|
describe('Edge cases', () => {
|
|
it('JOURNEY-SVC-081: deleteEntry deletes photos along with the entry', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
const photo = addPhoto(entry.id, user.id, '/uploads/gallery-move.jpg');
|
|
|
|
const result = deleteEntry(entry.id, user.id);
|
|
expect(result).toBe(true);
|
|
|
|
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
|
|
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
|
|
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
|
|
expect(junctionRow).toBeUndefined();
|
|
});
|
|
|
|
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const result = updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' });
|
|
|
|
expect(result).not.toBeNull();
|
|
expect((result as any).cover_gradient).toBe('linear-gradient(to right, #ff0000, #0000ff)');
|
|
});
|
|
|
|
it('JOURNEY-SVC-083: updateJourney ignores unknown fields', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id, { title: 'Original' });
|
|
|
|
const result = updateJourney(journey.id, user.id, { bogus: 'field' } as any);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.title).toBe('Original');
|
|
});
|
|
|
|
it('JOURNEY-SVC-084: createEntry stores tags and pros_cons as JSON', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
|
|
const entry = createEntry(journey.id, user.id, {
|
|
entry_date: '2026-03-10',
|
|
tags: ['food', 'culture'],
|
|
pros_cons: { pros: ['Great view'], cons: ['Expensive'] },
|
|
});
|
|
|
|
expect(entry).not.toBeNull();
|
|
// Read raw from DB
|
|
const raw = testDb.prepare('SELECT tags, pros_cons FROM journey_entries WHERE id = ?').get(entry!.id) as any;
|
|
expect(JSON.parse(raw.tags)).toEqual(['food', 'culture']);
|
|
expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Great view'], cons: ['Expensive'] });
|
|
});
|
|
|
|
it('JOURNEY-SVC-085: updateEntry handles tags and pros_cons update', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
|
|
|
const result = updateEntry(entry.id, user.id, {
|
|
tags: ['beach', 'adventure'],
|
|
pros_cons: { pros: ['Fun'], cons: [] },
|
|
});
|
|
|
|
expect(result).not.toBeNull();
|
|
const raw = testDb.prepare('SELECT tags, pros_cons FROM journey_entries WHERE id = ?').get(entry.id) as any;
|
|
expect(JSON.parse(raw.tags)).toEqual(['beach', 'adventure']);
|
|
expect(JSON.parse(raw.pros_cons)).toEqual({ pros: ['Fun'], cons: [] });
|
|
});
|
|
|
|
it('JOURNEY-SVC-086: addTripToJourney syncs trip photos when present', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Photo Trip',
|
|
start_date: '2026-04-01',
|
|
end_date: '2026-04-03',
|
|
});
|
|
addTripPhoto(testDb, trip.id, user.id, 'immich-photo-1', 'immich', { shared: true });
|
|
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Trip photos now go straight into the journey gallery (no wrapper entry).
|
|
const photos = testDb.prepare(`
|
|
SELECT jp.*, tkp.asset_id FROM journey_photos jp
|
|
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
|
WHERE jp.journey_id = ?
|
|
`).all(journey.id);
|
|
expect(photos.length).toBe(1);
|
|
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
|
|
});
|
|
|
|
it('JOURNEY-SVC-087: removeTripFromJourney detaches filled entries, deletes skeletons', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Mixed Trip',
|
|
start_date: '2026-04-01',
|
|
end_date: '2026-04-03',
|
|
});
|
|
const place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' });
|
|
const place2 = createPlace(testDb, trip.id, { name: 'Filled Place' });
|
|
const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
|
|
createDayAssignment(testDb, days087[0].id, place1.id);
|
|
createDayAssignment(testDb, days087[1].id, place2.id);
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
// Promote one skeleton to a filled entry
|
|
const filled = testDb.prepare(
|
|
"SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
|
|
).get(journey.id, place2.id) as { id: number };
|
|
updateEntry(filled.id, user.id, { story: 'Now filled!' });
|
|
|
|
removeTripFromJourney(journey.id, trip.id, user.id);
|
|
|
|
// skeleton for place1 should be deleted
|
|
const skeletonRow = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
|
|
).get(journey.id, place1.id);
|
|
expect(skeletonRow).toBeUndefined();
|
|
|
|
// filled entry for place2 should be detached but still present
|
|
const filledRow = testDb.prepare(
|
|
"SELECT * FROM journey_entries WHERE id = ?"
|
|
).get(filled.id) as any;
|
|
expect(filledRow).toBeDefined();
|
|
expect(filledRow.source_trip_id).toBeNull();
|
|
expect(filledRow.source_place_id).toBeNull();
|
|
});
|
|
});
|
|
|
|
// -- Passphrase on addProviderPhoto -------------------------------------------
|
|
|
|
describe('addProviderPhoto — passphrase', () => {
|
|
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
|
|
|
|
const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
|
|
|
|
expect(photo).not.toBeNull();
|
|
|
|
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
|
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
|
|
expect(row?.passphrase).not.toBeNull();
|
|
expect(typeof row?.passphrase).toBe('string');
|
|
// stored value must be encrypted (not plaintext)
|
|
expect(row?.passphrase).not.toBe('secret-pp');
|
|
});
|
|
});
|
|
|
|
// -- reorderEntries (#846) ----------------------------------------------------
|
|
|
|
function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
|
|
const now = Date.now();
|
|
const res = testDb.prepare(`
|
|
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
|
|
VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
|
|
`).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
|
|
return { id: Number(res.lastInsertRowid) };
|
|
}
|
|
|
|
describe('reorderEntries', () => {
|
|
it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
|
|
const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
|
|
|
|
const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]);
|
|
expect(ok).toBe(true);
|
|
|
|
const entries = listEntries(journey.id, user.id)!;
|
|
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
|
|
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
|
|
});
|
|
|
|
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
|
|
const { user } = createUser(testDb);
|
|
const j1 = createJourney(testDb, user.id);
|
|
const j2 = createJourney(testDb, user.id);
|
|
const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
|
|
|
|
const ok = reorderEntries(j1.id, user.id, [entry.id]);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
|
|
const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
|
|
const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
|
|
|
|
reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
|
|
|
|
const entries = listEntries(journey.id, user.id)!;
|
|
const day2Entry = entries.find(e => e.id === day2.id)!;
|
|
expect(day2Entry.sort_order).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('syncTripPlaces sort_order', () => {
|
|
it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Order Trip',
|
|
start_date: '2026-09-01',
|
|
end_date: '2026-09-02',
|
|
});
|
|
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
|
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
|
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
|
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
|
|
createDayAssignment(testDb, day.id, p1.id);
|
|
createDayAssignment(testDb, day.id, p2.id);
|
|
createDayAssignment(testDb, day.id, p3.id);
|
|
|
|
syncTripPlaces(journey.id, trip.id, user.id);
|
|
|
|
const rows = testDb.prepare(
|
|
'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
|
|
).all(journey.id) as { sort_order: number }[];
|
|
const orders = rows.map(r => r.sort_order);
|
|
expect(new Set(orders).size).toBe(orders.length);
|
|
expect(orders).toEqual([0, 1, 2]);
|
|
});
|
|
});
|
|
|
|
describe('onPlaceCreated sort_order', () => {
|
|
it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => {
|
|
const { user } = createUser(testDb);
|
|
const journey = createJourney(testDb, user.id);
|
|
const trip = createTrip(testDb, user.id, {
|
|
title: 'Append Trip',
|
|
start_date: '2026-10-01',
|
|
end_date: '2026-10-02',
|
|
});
|
|
addTripToJourney(journey.id, trip.id, user.id);
|
|
|
|
const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
|
|
insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
|
|
|
|
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
|
|
createDayAssignment(testDb, day.id, place.id);
|
|
onPlaceCreated(trip.id, place.id);
|
|
|
|
const newEntry = testDb.prepare(
|
|
'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
|
|
).get(journey.id, place.id) as { sort_order: number } | undefined;
|
|
expect(newEntry).toBeDefined();
|
|
expect(newEntry!.sort_order).toBe(6);
|
|
});
|
|
});
|