Files
TREK/server/tests/integration/memories-unified.test.ts
T
Maurice 247433fb2a feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106)
* fix(journey): authorize reads of the journey share link

GET /api/journeys/:id/share-link now requires journey access (canAccessJourney),
matching the create/delete share-link routes and the get_journey_share_link MCP
tool. Returns no link when the caller lacks access to the journey.

* feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile

Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/
Splitwise-style cost tracker: multiple payers per expense, equal split across
chosen members, settle-up with persisted history + undo, 12 fixed categories,
per-expense currency with live FX conversion to a user-set display currency
(Settings -> Display), and locale-correct money formatting. Adds a desktop and a
dedicated mobile layout. A migration backfills existing budget items (single
payer, split members, currency). Closes #551 (per-expense currency).

Also switches the app font to self-hosted Poppins (Geist for secondary subtext),
replacing the Google Fonts CDN dependency.

* fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge

- Dark mode used a warm oklch palette that read brownish; switch to the
  neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a
  subtle backdrop-blur glass on cards.
- Costs now uses the full available page width on desktop instead of a 1280px cap.
- Render the expense count next to the Expenses title as a badge.
- Adapt budget/journey unit tests to the new payer-based settlement model and the
  Costs rename (category default 'other', Costs tab/CostsPanel).

* fix(costs): drop the entry-count badge, always show row edit/delete actions

Removes the count badge next to the Expenses title and makes the per-row
edit/delete actions permanently visible (no longer hover-only) on desktop too.

* feat(costs): currency-native money formatting, custom select/date, rename addon to Costs

- Format every amount in its own currency convention (symbol position, grouping
  and decimal separators) regardless of app language, via a currency->locale map
  (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the
  app locale, so EUR showed the symbol in front under an English UI.
- Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the
  add/edit expense modal instead of the native <select>/<input type=date>.
- Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only;
  id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs.

* feat(auth): configurable session duration via SESSION_DURATION

Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling
how long a session stays valid before re-login. It drives both the trek_session
JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid
values warn at startup and fall back to the default (24h — unchanged). The MFA
challenge token and MCP OAuth tokens keep their own TTL.

Implements the request from discussion #946. Documented in the env-var wiki page,
.env.example and docker-compose.yml.
2026-06-05 01:38:25 +02:00

366 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unified Memories integration tests (UNIFIED-001 UNIFIED-020).
* Covers the provider-agnostic /unified/trips/:tripId/photos and
* /unified/trips/:tripId/album-links routes.
*
* No real HTTP is made — safeFetch is mocked to never be called.
* The broadcast WebSocket call is no-op mocked.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async () => {
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
return {
...actual,
checkSsrf: vi.fn().mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: '93.184.216.34' }),
safeFetch: vi.fn().mockRejectedValue(new Error('safeFetch should not be called in unified tests')),
};
});
import { buildApp } from '../../src/bootstrap';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
let nestApp: INestApplication;
let app: Application;
const BASE = '/api/integrations/memories/unified';
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb);
resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function photosUrl(tripId: number) { return `${BASE}/trips/${tripId}/photos`; }
function albumLinksUrl(tripId: number, linkId?: number) {
return linkId ? `${BASE}/trips/${tripId}/album-links/${linkId}` : `${BASE}/trips/${tripId}/album-links`;
}
// ── Unified Photo Management ─────────────────────────────────────────────────
describe('Unified photo management', () => {
it('UNIFIED-001 — GET photos lists own + shared photos from other members', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// owner has a private photo; member has a shared photo
addTripPhoto(testDb, trip.id, owner.id, 'asset-own', 'immich', { shared: false });
addTripPhoto(testDb, trip.id, member.id, 'asset-shared', 'immich', { shared: true });
const res = await request(app)
.get(photosUrl(trip.id))
.set('Cookie', authCookie(owner.id));
expect(res.status).toBe(200);
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
expect(ids).toContain('asset-own');
expect(ids).toContain('asset-shared');
});
it('UNIFIED-002 — GET photos excludes other members\' private photos', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
addTripPhoto(testDb, trip.id, member.id, 'asset-private', 'immich', { shared: false });
const res = await request(app)
.get(photosUrl(trip.id))
.set('Cookie', authCookie(owner.id));
expect(res.status).toBe(200);
const ids = (res.body.photos as any[]).map((p: any) => p.asset_id);
expect(ids).not.toContain('asset-private');
});
it('UNIFIED-003 — GET photos returns 404 for non-member', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.get(photosUrl(trip.id))
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
it('UNIFIED-004 — POST photos adds photos from selections', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(photosUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({
shared: true,
selections: [{ provider: 'immich', asset_ids: ['asset-a', 'asset-b'] }],
});
expect(res.status).toBe(200);
expect(res.body.added).toBe(2);
const rows = testDb.prepare(`
SELECT tkp.asset_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ?
`).all(trip.id) as any[];
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
});
it('UNIFIED-005 — POST photos with empty selections returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(photosUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ selections: [] });
expect(res.status).toBe(400);
});
it('UNIFIED-006 — POST photos with invalid provider returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(photosUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ selections: [{ provider: 'nonexistent', asset_ids: ['asset-x'] }] });
expect(res.status).toBe(400);
});
it('UNIFIED-007 — PUT photos/sharing toggles shared flag', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
const trekRef = testDb.prepare(`
SELECT tp.photo_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tkp.asset_id = ?
`).get(trip.id, 'asset-tog') as any;
const res = await request(app)
.put(`${photosUrl(trip.id)}/sharing`)
.set('Cookie', authCookie(user.id))
.send({ photo_id: trekRef.photo_id, shared: true });
expect(res.status).toBe(200);
const row = testDb.prepare(`
SELECT tp.shared FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tkp.asset_id = ?
`).get('asset-tog') as any;
expect(row.shared).toBe(1);
});
it('UNIFIED-008 — PUT photos/sharing on non-member trip returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.put(`${photosUrl(trip.id)}/sharing`)
.set('Cookie', authCookie(stranger.id))
.send({ provider: 'immich', asset_id: 'any', shared: true });
expect(res.status).toBe(404);
});
it('UNIFIED-009 — DELETE photos removes own photo', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
const trekRef = testDb.prepare(`
SELECT tp.photo_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tkp.asset_id = ?
`).get(trip.id, 'asset-del') as any;
const res = await request(app)
.delete(photosUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ photo_id: trekRef.photo_id });
expect(res.status).toBe(200);
const row = testDb.prepare(`
SELECT tp.* FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tkp.asset_id = ?
`).get('asset-del');
expect(row).toBeUndefined();
});
it('UNIFIED-010 — DELETE photos on non-member trip returns 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const res = await request(app)
.delete(photosUrl(trip.id))
.set('Cookie', authCookie(stranger.id))
.send({ provider: 'immich', asset_id: 'any' });
expect(res.status).toBe(404);
});
});
// ── Unified Album-Link Management ────────────────────────────────────────────
describe('Unified album-link management', () => {
it('UNIFIED-011 — POST album-links with missing provider returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(albumLinksUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ album_id: 'album-abc', album_name: 'Test' }); // no provider
expect(res.status).toBe(400);
});
it('UNIFIED-012 — POST album-links with missing album_id returns 400', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res = await request(app)
.post(albumLinksUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ provider: 'immich', album_name: 'Test' }); // no album_id
expect(res.status).toBe(400);
});
it('UNIFIED-013 — POST album-links duplicate link returns 409', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await request(app)
.post(albumLinksUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' });
const res = await request(app)
.post(albumLinksUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ provider: 'immich', album_id: 'album-dup', album_name: 'Dup' });
expect(res.status).toBe(409);
});
it('UNIFIED-014 — GET album-links only returns links for enabled providers', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-enabled');
// Disable the immich provider
testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich');
const res = await request(app)
.get(albumLinksUrl(trip.id))
.set('Cookie', authCookie(user.id));
// Re-enable for future tests
testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich');
expect(res.status).toBe(400); // no providers enabled → error
});
});
// ── Auth checks ───────────────────────────────────────────────────────────────
describe('Unified auth checks', () => {
it('UNIFIED-020 — GET photos without auth returns 401', async () => {
const res = await request(app).get(`${BASE}/trips/1/photos`);
expect(res.status).toBe(401);
});
it('UNIFIED-020 — POST photos without auth returns 401', async () => {
const res = await request(app).post(`${BASE}/trips/1/photos`);
expect(res.status).toBe(401);
});
it('UNIFIED-020 — PUT photos/sharing without auth returns 401', async () => {
const res = await request(app).put(`${BASE}/trips/1/photos/sharing`);
expect(res.status).toBe(401);
});
it('UNIFIED-020 — DELETE photos without auth returns 401', async () => {
const res = await request(app).delete(`${BASE}/trips/1/photos`);
expect(res.status).toBe(401);
});
it('UNIFIED-020 — GET album-links without auth returns 401', async () => {
const res = await request(app).get(`${BASE}/trips/1/album-links`);
expect(res.status).toBe(401);
});
it('UNIFIED-020 — POST album-links without auth returns 401', async () => {
const res = await request(app).post(`${BASE}/trips/1/album-links`);
expect(res.status).toBe(401);
});
it('UNIFIED-020 — DELETE album-links without auth returns 401', async () => {
const res = await request(app).delete(`${BASE}/trips/1/album-links/1`);
expect(res.status).toBe(401);
});
});