mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move share backend to proper DI
This commit is contained in:
+1
-1
@@ -18,7 +18,7 @@ Wave 2 — cross-cutting leverage (parallel to wave 3, high payoff)
|
||||
|
||||
Wave 3 — domain services with existing modules, low fan-in
|
||||
|
||||
7. dayNoteService (done 2026-07), tripInviteService (done 2026-07), assignmentService (done 2026-07), shareService, settingsService, fileService, collabService, packingService (done 2026-07) — each maps 1:1 to a Nest module that's already a thin delegate; none is imported by more than one
|
||||
7. dayNoteService (done 2026-07), tripInviteService (done 2026-07), assignmentService (done 2026-07), shareService (done 2026-07), settingsService, fileService, collabService, packingService (done 2026-07) — each maps 1:1 to a Nest module that's already a thin delegate; none is imported by more than one
|
||||
other legacy service.
|
||||
|
||||
Wave 4 — the coupled cluster (order matters here)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../../services/tripService';
|
||||
import {
|
||||
createOrUpdateShareLink, getShareLink, deleteShareLink,
|
||||
} from '../../services/shareService';
|
||||
} from '../../nest/share/share.bridge';
|
||||
import { isAddonEnabled, getCollabFeatures } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { countMessages, listPolls } from '../../services/collabService';
|
||||
|
||||
@@ -27,8 +27,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
|
||||
categories, tags, notifications, atlas.
|
||||
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
|
||||
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
|
||||
todo, packing, day-notes, trip-invite, assignments — see the migration recipe
|
||||
below.
|
||||
todo, packing, day-notes, trip-invite, assignments, share — see the migration
|
||||
recipe below.
|
||||
|
||||
## Cross-cutting Foundation pieces
|
||||
|
||||
@@ -110,9 +110,13 @@ surface, no plugin-host import and no bridge — the SQL folded straight into
|
||||
`trip-invite.service.ts`); assignments followed (a 7-tool `assignments.mcp.ts`,
|
||||
the plugin-host swap, and a bridge kept only for the two legacy registrars —
|
||||
places and reservations — that borrow its existence checks; the batch loaders
|
||||
stay in `services/queryHelpers.ts`, shared with the unmigrated day/share/place
|
||||
services). Repeat these steps per
|
||||
service (next up: shareService). This is a **pure relocation** — byte-identical
|
||||
stay in `services/queryHelpers.ts`, shared with the unmigrated day/place
|
||||
services); share followed (never imported by the plugin host, and its three MCP
|
||||
tools stay in the legacy trips registrar — their `trips:share` scope gate has
|
||||
no declarative `access: { group, mode }` equivalent — so the port is the SQL
|
||||
fold plus a 3-export `share.bridge.ts` for `mcp/tools/trips.ts`). Repeat these
|
||||
steps per
|
||||
service (next up: settingsService). This is a **pure relocation** — byte-identical
|
||||
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
|
||||
bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it
|
||||
injects domain services via `PluginHostDepsFactory`, so a migrated domain adds
|
||||
|
||||
@@ -100,9 +100,10 @@ import for one injected service (+ module export/import) — **no more bridge
|
||||
files for the plugin host, ever**. The factory's import list shrinks
|
||||
monotonically as the recipe in `src/nest/README.md` proceeds (packing and
|
||||
day-notes swapped in 2026-07; trip-invite migrated without touching the
|
||||
factory — it was never imported here; assignments swapped in 2026-07; next
|
||||
factory swap: whichever Wave-3 domain the factory imports migrates next —
|
||||
shareService/fileService/collabService are all imported here).
|
||||
factory — it was never imported here; assignments swapped in 2026-07; share
|
||||
migrated 2026-07 without touching the factory — it was never imported here
|
||||
either; next factory swap: whichever Wave-3 domain the factory imports migrates
|
||||
next — fileService/collabService are both imported here).
|
||||
|
||||
### Test impact (as landed)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ShareService } from './share.service';
|
||||
import type { SharePermissions } from './share.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the share domain — for code running OUTSIDE the
|
||||
* Nest container (the legacy share-link tools in src/mcp/tools/trips.ts; those
|
||||
* stay in the trips registrar because their `trips:share` scope gate has no
|
||||
* declarative `access: { group, mode }` equivalent yet). Exports only the
|
||||
* legacy services/shareService names still consumed outside the container,
|
||||
* 1:1, so repointing a consumer is an import-path-only diff. Inside the
|
||||
* container, inject ShareService instead.
|
||||
* Delete this file when the legacy MCP trips registrar migrates.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton.
|
||||
*/
|
||||
const share = new ShareService(new DatabaseService(db));
|
||||
|
||||
export function createOrUpdateShareLink(tripId: string, createdBy: number, permissions: SharePermissions) {
|
||||
return share.createOrUpdate(tripId, createdBy, permissions);
|
||||
}
|
||||
|
||||
export function getShareLink(tripId: string) {
|
||||
return share.get(tripId);
|
||||
}
|
||||
|
||||
export function deleteShareLink(tripId: string) {
|
||||
return share.remove(tripId);
|
||||
}
|
||||
@@ -1,15 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { TripAccess } from '../database/database.service';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import { loadTagsByPlaceIds } from '../../services/queryHelpers';
|
||||
import { serveFilePath } from '../../services/placePhotoCache';
|
||||
import { getUserSettings } from '../../services/settingsService';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/shareService';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing share service. Trip access, the
|
||||
* 'share_manage' permission and the token SQL reuse the legacy code unchanged.
|
||||
* Place photo proxy URLs (`/api/maps/place-photo/<id>/bytes`) are served by the
|
||||
* JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip
|
||||
* viewer. Rewrite them to the public, token-scoped equivalent
|
||||
* (`/api/shared/<token>/place-photo/<id>/bytes`) so thumbnails load in a shared
|
||||
* link. A simple prefix swap keeps the already-encoded placeId segment intact, so
|
||||
* the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through.
|
||||
*/
|
||||
function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null {
|
||||
if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) {
|
||||
return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`;
|
||||
}
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
export interface SharePermissions {
|
||||
share_map?: boolean;
|
||||
share_bookings?: boolean;
|
||||
share_packing?: boolean;
|
||||
share_budget?: boolean;
|
||||
share_collab?: boolean;
|
||||
}
|
||||
|
||||
export interface ShareTokenInfo {
|
||||
token: string;
|
||||
created_at: string;
|
||||
share_map: boolean;
|
||||
share_bookings: boolean;
|
||||
share_packing: boolean;
|
||||
share_budget: boolean;
|
||||
share_collab: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public share links — the legacy shareService SQL folded in over the injected
|
||||
* DatabaseService (byte-identical statements and coercions). Trip access and
|
||||
* the 'share_manage' permission gate create/delete; the shared read is public.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
@@ -23,11 +62,248 @@ export class ShareService {
|
||||
return checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id);
|
||||
}
|
||||
|
||||
createOrUpdate(tripId: string, userId: number, permissions: Parameters<typeof svc.createOrUpdateShareLink>[2]) {
|
||||
return svc.createOrUpdateShareLink(tripId, userId, permissions);
|
||||
/**
|
||||
* Creates a new share link or updates the permissions on an existing one.
|
||||
* Returns an object with the token string and whether it was newly created.
|
||||
*/
|
||||
createOrUpdate(tripId: string, userId: number, permissions: SharePermissions): { token: string; created: boolean } {
|
||||
const {
|
||||
share_map = true,
|
||||
share_bookings = true,
|
||||
share_packing = false,
|
||||
share_budget = false,
|
||||
share_collab = false,
|
||||
} = permissions;
|
||||
|
||||
const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId);
|
||||
if (existing) {
|
||||
this.dbs.run(
|
||||
'UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?',
|
||||
share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId,
|
||||
);
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
// New share links default to a 90-day TTL. Existing tokens that were
|
||||
// created before the expires_at migration keep NULL here and remain
|
||||
// valid indefinitely until the owner rotates them; that preserves
|
||||
// behaviour for anyone who's already sharing a link.
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
this.dbs.run(
|
||||
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
tripId, token, userId, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt,
|
||||
);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns share token info for a trip, or null if no share link exists.
|
||||
*/
|
||||
get(tripId: string): ShareTokenInfo | null {
|
||||
const row = this.dbs.get<any>('SELECT * FROM share_tokens WHERE trip_id = ?', tripId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
token: row.token,
|
||||
created_at: row.created_at,
|
||||
share_map: !!row.share_map,
|
||||
share_bookings: !!row.share_bookings,
|
||||
share_packing: !!row.share_packing,
|
||||
share_budget: !!row.share_budget,
|
||||
share_collab: !!row.share_collab,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the share token for a trip.
|
||||
*/
|
||||
remove(tripId: string): void {
|
||||
this.dbs.run('DELETE FROM share_tokens WHERE trip_id = ?', tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full public trip data for a share token, filtered by the token's
|
||||
* permission flags. Returns null if the token is invalid or the trip is gone.
|
||||
*/
|
||||
getSharedTripData(token: string): Record<string, any> | null {
|
||||
const shareRow = this.dbs.get<any>(
|
||||
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
|
||||
token,
|
||||
);
|
||||
if (!shareRow) return null;
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
// Trip
|
||||
const trip = this.dbs.get(
|
||||
'SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?',
|
||||
tripId,
|
||||
);
|
||||
if (!trip) return null;
|
||||
|
||||
// Days with assignments
|
||||
const days = this.dbs.all<any>('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC', tripId);
|
||||
const dayIds = days.map(d => d.id);
|
||||
|
||||
let assignments: Record<number, any[]> = {};
|
||||
let dayNotes: Record<number, any[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const ph = dayIds.map(() => '?').join(',');
|
||||
const allAssignments = this.dbs.all<any>(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`, ...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const byDay: Record<number, any[]> = {};
|
||||
for (const a of allAssignments as any[]) {
|
||||
if (!byDay[a.day_id]) byDay[a.day_id] = [];
|
||||
byDay[a.day_id].push({
|
||||
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
|
||||
place: {
|
||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||
image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
|
||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||
tags: tagsByPlace[a.place_id] || [],
|
||||
}
|
||||
});
|
||||
}
|
||||
assignments = byDay;
|
||||
|
||||
const allNotes = this.dbs.all<any>(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`, ...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||
notesByDay[n.day_id].push(n);
|
||||
}
|
||||
dayNotes = notesByDay;
|
||||
}
|
||||
|
||||
// Places
|
||||
const places = this.dbs.all<any>(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`, tripId).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
|
||||
|
||||
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||
const reservations = this.dbs.all<any>('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC', tripId);
|
||||
|
||||
const dayPositions = this.dbs.all<{ reservation_id: number; day_id: number; position: number }>(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`, tripId);
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
}
|
||||
|
||||
// Accommodations
|
||||
const accommodations = this.dbs.all(`
|
||||
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
|
||||
FROM day_accommodations a JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
`, tripId);
|
||||
|
||||
// Packing — a public viewer is neither owner nor recipient, so only Common items
|
||||
// may surface; never a co-member's private/personal packing items (#858).
|
||||
const packing = this.dbs.all('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC', tripId);
|
||||
|
||||
// Budget
|
||||
const budget = this.dbs.all('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC', tripId);
|
||||
|
||||
// Categories
|
||||
const categories = this.dbs.all('SELECT * FROM categories');
|
||||
|
||||
const permissions = {
|
||||
share_map: !!shareRow.share_map,
|
||||
share_bookings: !!shareRow.share_bookings,
|
||||
share_packing: !!shareRow.share_packing,
|
||||
share_budget: !!shareRow.share_budget,
|
||||
share_collab: !!shareRow.share_collab,
|
||||
};
|
||||
|
||||
// Collab messages (only if owner chose to share)
|
||||
const collabMessages = permissions.share_collab
|
||||
? this.dbs.all('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at', tripId)
|
||||
: [];
|
||||
|
||||
// Display currency the share owner sees in their Costs view. A public viewer has
|
||||
// no logged-in user, so the owner's per-user `default_currency` (with the admin
|
||||
// instance default already merged in by getUserSettings) is embedded in the
|
||||
// payload and used by the client to convert every expense — otherwise guests
|
||||
// fall back to the trip's base currency and see the wrong totals (#1361).
|
||||
// getUserSettings merges admin defaults under the user's own settings, so this
|
||||
// honours per-user → admin-default; we then fall back to trip currency → EUR.
|
||||
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
|
||||
if (shareRow.created_by != null) {
|
||||
const ownerDefault = getUserSettings(shareRow.created_by)['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Honour every share flag server-side — the client gates these too, but it must
|
||||
// not rely on that (mirrors journeyShareService). share_map covers the whole
|
||||
// itinerary: days, their assignments/notes, and the place list with coordinates,
|
||||
// addresses and notes. Withhold it when the owner disabled the map.
|
||||
return {
|
||||
trip, baseCurrency, categories, permissions,
|
||||
days: permissions.share_map ? days : [],
|
||||
assignments: permissions.share_map ? assignments : {},
|
||||
dayNotes: permissions.share_map ? dayNotes : {},
|
||||
places: permissions.share_map ? places : [],
|
||||
reservations: permissions.share_bookings ? reservations : [],
|
||||
accommodations: permissions.share_bookings ? accommodations : [],
|
||||
packing: permissions.share_packing ? packing : [],
|
||||
budget: permissions.share_budget ? budget : [],
|
||||
collab: collabMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for a cached place photo requested through a public
|
||||
* share link. Validates that the token is valid + unexpired and that the place
|
||||
* actually belongs to that token's trip (matched via the stored proxy URL, which
|
||||
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
|
||||
* depending on google_place_id). Returns null — never throws — so the caller
|
||||
* answers a plain 404, mirroring the authenticated bytes endpoint.
|
||||
*/
|
||||
getSharedPlacePhotoPath(token: string, placeId: string): string | null {
|
||||
const shareRow = this.dbs.get<{ trip_id: string; share_map: number }>(
|
||||
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
|
||||
token,
|
||||
);
|
||||
if (!shareRow) return null;
|
||||
// Place photos belong to the map/itinerary section — withhold them when the
|
||||
// owner disabled the map, matching getSharedTripData which no longer returns
|
||||
// the places (and thus their ids) in that case.
|
||||
if (!shareRow.share_map) return null;
|
||||
|
||||
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
|
||||
const place = this.dbs.get('SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?', shareRow.trip_id, expectedUrl);
|
||||
if (!place) return null;
|
||||
|
||||
return serveFilePath(placeId);
|
||||
}
|
||||
get(tripId: string) { return svc.getShareLink(tripId); }
|
||||
remove(tripId: string) { return svc.deleteShareLink(tripId); }
|
||||
getSharedTripData(token: string) { return svc.getSharedTripData(token); }
|
||||
getSharedPlacePhotoPath(token: string, placeId: string) { return svc.getSharedPlacePhotoPath(token, placeId); }
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import crypto from 'crypto';
|
||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||
import { serveFilePath } from './placePhotoCache';
|
||||
import { getUserSettings } from './settingsService';
|
||||
|
||||
const PLACE_PHOTO_PROXY_PREFIX = '/api/maps/place-photo/';
|
||||
|
||||
/**
|
||||
* Place photo proxy URLs (`/api/maps/place-photo/<id>/bytes`) are served by the
|
||||
* JWT-guarded MapsController, so they 401 for an unauthenticated shared-trip
|
||||
* viewer. Rewrite them to the public, token-scoped equivalent
|
||||
* (`/api/shared/<token>/place-photo/<id>/bytes`) so thumbnails load in a shared
|
||||
* link. A simple prefix swap keeps the already-encoded placeId segment intact, so
|
||||
* the URL round-trips. Non-proxy URLs (data:, /uploads/, null) pass through.
|
||||
*/
|
||||
function rewritePlacePhotoUrl(url: string | null | undefined, token: string): string | null {
|
||||
if (typeof url === 'string' && url.startsWith(PLACE_PHOTO_PROXY_PREFIX)) {
|
||||
return `/api/shared/${token}/place-photo/${url.slice(PLACE_PHOTO_PROXY_PREFIX.length)}`;
|
||||
}
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
interface SharePermissions {
|
||||
share_map?: boolean;
|
||||
share_bookings?: boolean;
|
||||
share_packing?: boolean;
|
||||
share_budget?: boolean;
|
||||
share_collab?: boolean;
|
||||
}
|
||||
|
||||
interface ShareTokenInfo {
|
||||
token: string;
|
||||
created_at: string;
|
||||
share_map: boolean;
|
||||
share_bookings: boolean;
|
||||
share_packing: boolean;
|
||||
share_budget: boolean;
|
||||
share_collab: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new share link or updates the permissions on an existing one.
|
||||
* Returns an object with the token string and whether it was newly created.
|
||||
*/
|
||||
export function createOrUpdateShareLink(
|
||||
tripId: string,
|
||||
createdBy: number,
|
||||
permissions: SharePermissions
|
||||
): { token: string; created: boolean } {
|
||||
const {
|
||||
share_map = true,
|
||||
share_bookings = true,
|
||||
share_packing = false,
|
||||
share_budget = false,
|
||||
share_collab = false,
|
||||
} = permissions;
|
||||
|
||||
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
||||
if (existing) {
|
||||
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
|
||||
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
// New share links default to a 90-day TTL. Existing tokens that were
|
||||
// created before the expires_at migration keep NULL here and remain
|
||||
// valid indefinitely until the owner rotates them; that preserves
|
||||
// behaviour for anyone who's already sharing a link.
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns share token info for a trip, or null if no share link exists.
|
||||
*/
|
||||
export function getShareLink(tripId: string): ShareTokenInfo | null {
|
||||
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
if (!row) return null;
|
||||
return {
|
||||
token: row.token,
|
||||
created_at: row.created_at,
|
||||
share_map: !!row.share_map,
|
||||
share_bookings: !!row.share_bookings,
|
||||
share_packing: !!row.share_packing,
|
||||
share_budget: !!row.share_budget,
|
||||
share_collab: !!row.share_collab,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the share token for a trip.
|
||||
*/
|
||||
export function deleteShareLink(tripId: string): void {
|
||||
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the full public trip data for a share token, filtered by the token's
|
||||
* permission flags. Returns null if the token is invalid or the trip is gone.
|
||||
*/
|
||||
export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
const shareRow = db.prepare(
|
||||
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as any;
|
||||
if (!shareRow) return null;
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
// Trip
|
||||
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
|
||||
if (!trip) return null;
|
||||
|
||||
// Days with assignments
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
|
||||
const dayIds = days.map(d => d.id);
|
||||
|
||||
let assignments: Record<number, any[]> = {};
|
||||
let dayNotes: Record<number, any[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const ph = dayIds.map(() => '?').join(',');
|
||||
const allAssignments = db.prepare(`
|
||||
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
|
||||
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
COALESCE(da.assignment_end_time, p.end_time) as end_time,
|
||||
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
|
||||
c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
|
||||
|
||||
const byDay: Record<number, any[]> = {};
|
||||
for (const a of allAssignments as any[]) {
|
||||
if (!byDay[a.day_id]) byDay[a.day_id] = [];
|
||||
byDay[a.day_id].push({
|
||||
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
|
||||
place: {
|
||||
id: a.place_id, name: a.place_name, description: a.place_description,
|
||||
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
|
||||
price: a.price, place_time: a.place_time, end_time: a.end_time,
|
||||
image_url: rewritePlacePhotoUrl(a.image_url, token), transport_mode: a.transport_mode,
|
||||
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
|
||||
tags: tagsByPlace[a.place_id] || [],
|
||||
}
|
||||
});
|
||||
}
|
||||
assignments = byDay;
|
||||
|
||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
|
||||
const notesByDay: Record<number, any[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||
notesByDay[n.day_id].push(n);
|
||||
}
|
||||
dayNotes = notesByDay;
|
||||
}
|
||||
|
||||
// Places
|
||||
const places = (db.prepare(`
|
||||
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
||||
FROM places p LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||
`).all(tripId) as any[]).map((p) => ({ ...p, image_url: rewritePlacePhotoUrl(p.image_url, token) }));
|
||||
|
||||
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||
|
||||
const dayPositions = db.prepare(`
|
||||
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||
FROM reservation_day_positions rdp
|
||||
JOIN reservations r ON rdp.reservation_id = r.id
|
||||
WHERE r.trip_id = ?
|
||||
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||
|
||||
const posMap = new Map<number, Record<number, number>>();
|
||||
for (const dp of dayPositions) {
|
||||
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||
}
|
||||
for (const r of reservations) {
|
||||
r.day_positions = posMap.get(r.id) || null;
|
||||
}
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
|
||||
FROM day_accommodations a JOIN places p ON a.place_id = p.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Packing — a public viewer is neither owner nor recipient, so only Common items
|
||||
// may surface; never a co-member's private/personal packing items (#858).
|
||||
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? AND is_private = 0 ORDER BY sort_order ASC').all(tripId);
|
||||
|
||||
// Budget
|
||||
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
|
||||
|
||||
// Categories
|
||||
const categories = db.prepare('SELECT * FROM categories').all();
|
||||
|
||||
const permissions = {
|
||||
share_map: !!shareRow.share_map,
|
||||
share_bookings: !!shareRow.share_bookings,
|
||||
share_packing: !!shareRow.share_packing,
|
||||
share_budget: !!shareRow.share_budget,
|
||||
share_collab: !!shareRow.share_collab,
|
||||
};
|
||||
|
||||
// Collab messages (only if owner chose to share)
|
||||
const collabMessages = permissions.share_collab
|
||||
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at').all(tripId)
|
||||
: [];
|
||||
|
||||
// Display currency the share owner sees in their Costs view. A public viewer has
|
||||
// no logged-in user, so the owner's per-user `default_currency` (with the admin
|
||||
// instance default already merged in by getUserSettings) is embedded in the
|
||||
// payload and used by the client to convert every expense — otherwise guests
|
||||
// fall back to the trip's base currency and see the wrong totals (#1361).
|
||||
// getUserSettings merges admin defaults under the user's own settings, so this
|
||||
// honours per-user → admin-default; we then fall back to trip currency → EUR.
|
||||
let baseCurrency = (trip as { currency?: string }).currency || 'EUR';
|
||||
if (shareRow.created_by != null) {
|
||||
const ownerDefault = getUserSettings(shareRow.created_by)['default_currency'];
|
||||
if (typeof ownerDefault === 'string' && ownerDefault.trim()) {
|
||||
baseCurrency = ownerDefault.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Honour every share flag server-side — the client gates these too, but it must
|
||||
// not rely on that (mirrors journeyShareService). share_map covers the whole
|
||||
// itinerary: days, their assignments/notes, and the place list with coordinates,
|
||||
// addresses and notes. Withhold it when the owner disabled the map.
|
||||
return {
|
||||
trip, baseCurrency, categories, permissions,
|
||||
days: permissions.share_map ? days : [],
|
||||
assignments: permissions.share_map ? assignments : {},
|
||||
dayNotes: permissions.share_map ? dayNotes : {},
|
||||
places: permissions.share_map ? places : [],
|
||||
reservations: permissions.share_bookings ? reservations : [],
|
||||
accommodations: permissions.share_bookings ? accommodations : [],
|
||||
packing: permissions.share_packing ? packing : [],
|
||||
budget: permissions.share_budget ? budget : [],
|
||||
collab: collabMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for a cached place photo requested through a public
|
||||
* share link. Validates that the token is valid + unexpired and that the place
|
||||
* actually belongs to that token's trip (matched via the stored proxy URL, which
|
||||
* covers both Google `placeId` and Wikimedia `coords:` pseudo-IDs without
|
||||
* depending on google_place_id). Returns null — never throws — so the caller
|
||||
* answers a plain 404, mirroring the authenticated bytes endpoint.
|
||||
*/
|
||||
export function getSharedPlacePhotoPath(token: string, placeId: string): string | null {
|
||||
const shareRow = db.prepare(
|
||||
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as { trip_id: string; share_map: number } | undefined;
|
||||
if (!shareRow) return null;
|
||||
// Place photos belong to the map/itinerary section — withhold them when the
|
||||
// owner disabled the map, matching getSharedTripData which no longer returns
|
||||
// the places (and thus their ids) in that case.
|
||||
if (!shareRow.share_map) return null;
|
||||
|
||||
const expectedUrl = `${PLACE_PHOTO_PROXY_PREFIX}${encodeURIComponent(placeId)}/bytes`;
|
||||
const place = db.prepare(
|
||||
'SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?'
|
||||
).get(shareRow.trip_id, expectedUrl);
|
||||
if (!place) return null;
|
||||
|
||||
return serveFilePath(placeId);
|
||||
}
|
||||
@@ -1,111 +1,158 @@
|
||||
/**
|
||||
* Share-link e2e — exercises the migrated /api/trips/:tripId/share-link and the
|
||||
* public /api/shared/:token endpoints through the real JwtAuthGuard against a
|
||||
* temp SQLite db. The share service + permission check are mocked; this focuses
|
||||
* on auth, trip-access 404, permission 403, the create-201-vs-update-200 split
|
||||
* temp SQLite db. ShareService runs its real SQL via DatabaseModule (the
|
||||
* DATABASE_CONNECTION factory picks up the mocked db singleton); trip access
|
||||
* resolves through a real-SQL canAccessTrip over the temp db. Only the
|
||||
* permission check and the photo-cache path lookup stay mocked. Focuses on
|
||||
* auth, trip-access 404, permission 403, the create-201-vs-update-200 split
|
||||
* and the unguarded public read.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { sessionCookie } from './harness';
|
||||
|
||||
const { db, canAccessTrip } = vi.hoisted(() => {
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp, canAccessTrip: vi.fn() };
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
// Real-SQL trip access over the temp db — ShareService.verifyTripAccess and
|
||||
// DatabaseModule both read the mocked singleton.
|
||||
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,
|
||||
getPlaceWithTags: () => null,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
}));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { shareSvc } = vi.hoisted(() => ({
|
||||
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn(), getSharedPlacePhotoPath: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/shareService', () => shareSvc);
|
||||
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
|
||||
vi.mock('../../src/services/placePhotoCache', () => ({ serveFilePath }));
|
||||
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { ShareModule } from '../../src/nest/share/share.module';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
|
||||
describe('Share-link e2e (real auth guard + temp SQLite)', () => {
|
||||
describe('Share-link e2e (real auth guard + real SQL over temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let tripId: number;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, ShareModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
// Mirror the production APP_PIPE (app.module.ts): the DTO-typed body
|
||||
// validates by metatype, exactly as it does under buildApp().
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
// seedUser() omits password_hash, which the real schema requires NOT NULL.
|
||||
db.prepare(
|
||||
"INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')",
|
||||
).run();
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||
db.exec('DELETE FROM share_tokens');
|
||||
db.exec('DELETE FROM places');
|
||||
db.exec('DELETE FROM trip_members');
|
||||
db.exec('DELETE FROM trips');
|
||||
tripId = Number(db.prepare('INSERT INTO trips (user_id, title) VALUES (1, ?)').run('Trip').lastInsertRowid);
|
||||
checkPermission.mockReturnValue(true);
|
||||
serveFilePath.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function tokenRow() {
|
||||
return db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
}
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/share-link')).status).toBe(401);
|
||||
expect((await request(server).get(`/api/trips/${tripId}/share-link`)).status).toBe(401);
|
||||
});
|
||||
|
||||
it('201 on first create, 200 on a subsequent update', async () => {
|
||||
shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: true });
|
||||
const created = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({ share_map: true });
|
||||
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_map: true });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toEqual({ token: 't' });
|
||||
expect(created.body).toEqual({ token: tokenRow().token });
|
||||
|
||||
shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: false });
|
||||
const updated = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({});
|
||||
const updated = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body).toEqual({ token: 't' });
|
||||
expect(updated.body).toEqual({ token: created.body.token });
|
||||
});
|
||||
|
||||
it('403 without share_manage', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({});
|
||||
const res = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
expect(tokenRow()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/share-link').set('Cookie', sessionCookie(1));
|
||||
const res = await request(server).get('/api/trips/999999/share-link').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('GET returns the stored flags, DELETE revokes the link', async () => {
|
||||
await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({ share_budget: true });
|
||||
const info = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
|
||||
expect(info.status).toBe(200);
|
||||
expect(info.body).toEqual(expect.objectContaining({ token: tokenRow().token, share_budget: true, share_packing: false }));
|
||||
|
||||
const removed = await request(server).delete(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
|
||||
expect(removed.status).toBe(200);
|
||||
expect(removed.body).toEqual({ success: true });
|
||||
expect(tokenRow()).toBeUndefined();
|
||||
|
||||
const after = await request(server).get(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1));
|
||||
expect(after.body).toEqual({ token: null });
|
||||
});
|
||||
|
||||
it('public shared read is unguarded (200, no cookie)', async () => {
|
||||
const res = await request(server).get('/api/shared/tok');
|
||||
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
|
||||
const res = await request(server).get(`/api/shared/${created.body.token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ trip: { id: 9 } });
|
||||
expect(res.body.trip).toEqual(expect.objectContaining({ id: tripId, title: 'Trip' }));
|
||||
expect(res.body.permissions).toEqual(expect.objectContaining({ share_map: true, share_budget: false }));
|
||||
});
|
||||
|
||||
it('public shared read 404 for an invalid token', async () => {
|
||||
shareSvc.getSharedTripData.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/shared/bad');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Invalid or expired link' });
|
||||
@@ -119,17 +166,19 @@ describe('Share-link e2e (real auth guard + temp SQLite)', () => {
|
||||
afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } });
|
||||
|
||||
it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => {
|
||||
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(photoFile);
|
||||
const res = await request(server).get('/api/shared/tok/place-photo/ChIJabc/bytes');
|
||||
const created = await request(server).post(`/api/trips/${tripId}/share-link`).set('Cookie', sessionCookie(1)).send({});
|
||||
db.prepare('INSERT INTO places (trip_id, name, image_url) VALUES (?, ?, ?)')
|
||||
.run(tripId, 'Louvre', '/api/maps/place-photo/ChIJabc/bytes');
|
||||
serveFilePath.mockReturnValueOnce(photoFile);
|
||||
const res = await request(server).get(`/api/shared/${created.body.token}/place-photo/ChIJabc/bytes`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect(res.headers['cache-control']).toContain('immutable');
|
||||
expect(Buffer.from(res.body)).toEqual(photoBytes);
|
||||
expect(shareSvc.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'ChIJabc');
|
||||
expect(serveFilePath).toHaveBeenCalledWith('ChIJabc');
|
||||
});
|
||||
|
||||
it('404 when the token/place does not resolve to a cached photo', async () => {
|
||||
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||
|
||||
@@ -1,77 +1,433 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
/**
|
||||
* Unit tests for the DI-native ShareService — SHARE-SVC-001 through
|
||||
* SHARE-SVC-028 (the SHARE-001..026 range belongs to the HTTP parity suite in
|
||||
* tests/integration/share.test.ts; these pin the service SQL directly, plus
|
||||
* the share.bridge delegation). Uses a real in-memory SQLite DB so SQL logic
|
||||
* is exercised faithfully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
// The wrapper delegates to legacy helpers; mock them so no real DB is loaded.
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../../src/db/database', () => ({ canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
|
||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
const mock = {
|
||||
db,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
getPlaceWithTags: () => null,
|
||||
canAccessTrip: (tripId: any, userId: number) =>
|
||||
db.prepare(`
|
||||
SELECT t.id, t.user_id FROM trips t
|
||||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||||
`).get(userId, tripId, userId),
|
||||
isOwner: (tripId: any, userId: number) =>
|
||||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||||
};
|
||||
return { testDb: db, dbMock: mock };
|
||||
});
|
||||
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
vi.mock('../../../src/config', () => ({
|
||||
JWT_SECRET: 'test-secret',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { share } = vi.hoisted(() => ({
|
||||
share: {
|
||||
createOrUpdateShareLink: vi.fn(),
|
||||
getShareLink: vi.fn(),
|
||||
deleteShareLink: vi.fn(),
|
||||
getSharedTripData: vi.fn(),
|
||||
getSharedPlacePhotoPath: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/shareService', () => share);
|
||||
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
|
||||
vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import {
|
||||
createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment,
|
||||
} from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { ShareService } from '../../../src/nest/share/share.service';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { createOrUpdateShareLink, getShareLink, deleteShareLink } from '../../../src/nest/share/share.bridge';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
function svc() {
|
||||
return new ShareService({ canAccessTrip } as unknown as DatabaseService);
|
||||
const svc = new ShareService(new DatabaseService(testDb));
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
checkPermission.mockReset();
|
||||
serveFilePath.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function shareRow(tripId: number | string) {
|
||||
return testDb.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
/** Owner + trip + a share link with all five flags on unless overridden. */
|
||||
function seedSharedTrip(flags: Record<string, boolean> = {}) {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const { token } = svc.createOrUpdate(String(trip.id), user.id, {
|
||||
share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true,
|
||||
...flags,
|
||||
});
|
||||
return { user, trip, token };
|
||||
}
|
||||
|
||||
describe('ShareService', () => {
|
||||
it('verifyTripAccess delegates to canAccessTrip', () => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(canAccessTrip).toHaveBeenCalledWith('5', 2);
|
||||
// ── verifyTripAccess / canManage ──────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess and canManage', () => {
|
||||
it('SHARE-SVC-001: verifyTripAccess returns the trip for owner and member, nothing for a stranger', () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
expect(svc.verifyTripAccess(String(trip.id), owner.id)?.id).toBe(trip.id);
|
||||
expect(svc.verifyTripAccess(String(trip.id), member.id)).toBeDefined();
|
||||
expect(svc.verifyTripAccess(String(trip.id), stranger.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('canManage forwards the ownership flag when the user owns the trip', () => {
|
||||
it('SHARE-SVC-002: canManage forwards the share_manage check with the ownership flag', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
const trip = { user_id: 1 } as never;
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(svc().canManage(trip, user)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('canManage marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const trip = { user_id: 2 } as never;
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(svc().canManage(trip, user)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('share_manage', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('createOrUpdate delegates to the legacy share service', () => {
|
||||
share.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true });
|
||||
const perms = { share_map: true };
|
||||
expect(svc().createOrUpdate('5', 2, perms)).toEqual({ token: 't', created: true });
|
||||
expect(share.createOrUpdateShareLink).toHaveBeenCalledWith('5', 2, perms);
|
||||
});
|
||||
|
||||
it('get / remove / getSharedTripData / getSharedPlacePhotoPath delegate', () => {
|
||||
share.getShareLink.mockReturnValue({ token: 't' });
|
||||
expect(svc().get('5')).toEqual({ token: 't' });
|
||||
expect(share.getShareLink).toHaveBeenCalledWith('5');
|
||||
|
||||
svc().remove('5');
|
||||
expect(share.deleteShareLink).toHaveBeenCalledWith('5');
|
||||
|
||||
share.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
|
||||
expect(svc().getSharedTripData('tok')).toEqual({ trip: { id: 9 } });
|
||||
expect(share.getSharedTripData).toHaveBeenCalledWith('tok');
|
||||
|
||||
share.getSharedPlacePhotoPath.mockReturnValue('/cache/p1.jpg');
|
||||
expect(svc().getSharedPlacePhotoPath('tok', 'p1')).toBe('/cache/p1.jpg');
|
||||
expect(share.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'p1');
|
||||
const trip = { id: 5, user_id: 1 } as any;
|
||||
const owner = { id: 1, role: 'user' } as User;
|
||||
const member = { id: 2, role: 'user' } as User;
|
||||
expect(svc.canManage(trip, owner)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 1, false);
|
||||
svc.canManage(trip, member);
|
||||
expect(checkPermission).toHaveBeenLastCalledWith('share_manage', 'user', 1, 2, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createOrUpdate ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createOrUpdate', () => {
|
||||
it('SHARE-SVC-003: creates a link with default flags (map/bookings on, rest off) and a 90-day expiry', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const before = Date.now();
|
||||
const result = svc.createOrUpdate(String(trip.id), user.id, {});
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.token).toMatch(/^[A-Za-z0-9_-]+$/); // base64url
|
||||
const row = shareRow(trip.id);
|
||||
expect(row.token).toBe(result.token);
|
||||
expect(row.created_by).toBe(user.id);
|
||||
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
|
||||
.toEqual([1, 1, 0, 0, 0]);
|
||||
const ninetyDays = 90 * 24 * 60 * 60 * 1000;
|
||||
const expires = new Date(row.expires_at).getTime();
|
||||
expect(expires).toBeGreaterThanOrEqual(before + ninetyDays - 5000);
|
||||
expect(expires).toBeLessThanOrEqual(Date.now() + ninetyDays + 5000);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-004: explicit flags override the defaults in both directions', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
svc.createOrUpdate(String(trip.id), user.id, {
|
||||
share_map: false, share_bookings: false, share_packing: true, share_budget: true, share_collab: true,
|
||||
});
|
||||
const row = shareRow(trip.id);
|
||||
expect([row.share_map, row.share_bookings, row.share_packing, row.share_budget, row.share_collab])
|
||||
.toEqual([0, 0, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-005: a second call updates the flags, keeps the token, and reports created:false', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const first = svc.createOrUpdate(String(trip.id), user.id, {});
|
||||
const second = svc.createOrUpdate(String(trip.id), user.id, { share_budget: true });
|
||||
expect(second).toEqual({ token: first.token, created: false });
|
||||
expect(shareRow(trip.id).share_budget).toBe(1);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-006: the update path re-applies the defaults for omitted flags and never touches expires_at', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
|
||||
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2099-01-01T00:00:00.000Z', trip.id);
|
||||
svc.createOrUpdate(String(trip.id), user.id, {});
|
||||
const row = shareRow(trip.id);
|
||||
// Omitted share_packing fell back to its default (off) — destructuring
|
||||
// defaults apply on every call, not only on create.
|
||||
expect(row.share_packing).toBe(0);
|
||||
expect(row.expires_at).toBe('2099-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
// ── get / remove ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('get and remove', () => {
|
||||
it('SHARE-SVC-007: get returns null when no link exists', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(svc.get(String(trip.id))).toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-008: get returns the token with the flags coerced to booleans', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const { token } = svc.createOrUpdate(String(trip.id), user.id, { share_packing: true });
|
||||
const info = svc.get(String(trip.id));
|
||||
expect(info).toEqual({
|
||||
token,
|
||||
created_at: expect.any(String),
|
||||
share_map: true,
|
||||
share_bookings: true,
|
||||
share_packing: true,
|
||||
share_budget: false,
|
||||
share_collab: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('SHARE-SVC-009: remove deletes the link and is a no-op when none exists', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
svc.createOrUpdate(String(trip.id), user.id, {});
|
||||
svc.remove(String(trip.id));
|
||||
expect(shareRow(trip.id)).toBeUndefined();
|
||||
expect(() => svc.remove(String(trip.id))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSharedTripData ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('getSharedTripData', () => {
|
||||
it('SHARE-SVC-010: returns null for an unknown token', () => {
|
||||
expect(svc.getSharedTripData('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-011: returns null for an expired token but honours NULL expiry (legacy rows)', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
|
||||
expect(svc.getSharedTripData(token)).toBeNull();
|
||||
testDb.prepare('UPDATE share_tokens SET expires_at = NULL WHERE trip_id = ?').run(trip.id);
|
||||
expect(svc.getSharedTripData(token)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-012: returns null when the trip row is gone', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
// The token normally cascades away with its trip; orphan it deliberately
|
||||
// to pin the defensive `if (!trip) return null` branch.
|
||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||
testDb.prepare('DELETE FROM trips WHERE id = ?').run(trip.id);
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
expect(svc.getSharedTripData(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-013: returns the trip projection, coerced permissions and grouped itinerary', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const day = createDay(testDb, trip.id, { date: '2025-06-01' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
createDayAssignment(testDb, day.id, place.id, { notes: 'go early' });
|
||||
const data = svc.getSharedTripData(token)!;
|
||||
expect(data).not.toBeNull();
|
||||
expect(data.trip).toEqual(expect.objectContaining({ id: trip.id, title: trip.title }));
|
||||
// Explicit column list — no owner id or internal fields on the trip row.
|
||||
expect(Object.keys(data.trip).sort()).toEqual(
|
||||
['cover_image', 'currency', 'description', 'end_date', 'id', 'start_date', 'title'],
|
||||
);
|
||||
expect(data.permissions).toEqual({
|
||||
share_map: true, share_bookings: true, share_packing: true, share_budget: true, share_collab: true,
|
||||
});
|
||||
expect(data.days).toHaveLength(1);
|
||||
const entries = data.assignments[day.id];
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(expect.objectContaining({ day_id: day.id, notes: 'go early' }));
|
||||
expect(entries[0].place).toEqual(expect.objectContaining({
|
||||
id: place.id, name: 'Louvre', tags: [],
|
||||
}));
|
||||
expect(entries[0].place.category).toEqual(expect.objectContaining({ id: place.category_id }));
|
||||
expect(data.collab).toEqual([]);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-014: COALESCEs assignment times over place times', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET place_time = ?, end_time = ? WHERE id = ?').run('09:00', '10:00', place.id);
|
||||
const a = createDayAssignment(testDb, day.id, place.id);
|
||||
let entry = svc.getSharedTripData(token)!.assignments[day.id][0];
|
||||
expect(entry.place.place_time).toBe('09:00');
|
||||
expect(entry.place.end_time).toBe('10:00');
|
||||
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('14:00', '15:00', a.id);
|
||||
entry = svc.getSharedTripData(token)!.assignments[day.id][0];
|
||||
expect(entry.place.place_time).toBe('14:00');
|
||||
expect(entry.place.end_time).toBe('15:00');
|
||||
});
|
||||
|
||||
it('SHARE-SVC-015: orders assignments by order_index, then created_at as tiebreaker', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const day = createDay(testDb, trip.id);
|
||||
const p1 = createPlace(testDb, trip.id, { name: 'First' });
|
||||
const p2 = createPlace(testDb, trip.id, { name: 'Second' });
|
||||
testDb.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
|
||||
).run(day.id, p2.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
|
||||
).run(day.id, p1.id);
|
||||
const entries = svc.getSharedTripData(token)!.assignments[day.id];
|
||||
expect(entries.map((e: any) => e.place.name)).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-016: attaches reservation day_positions, null when a reservation has none', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const day = createDay(testDb, trip.id);
|
||||
const r1 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
|
||||
const r2 = testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(trip.id);
|
||||
testDb.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, 3)')
|
||||
.run(r1.lastInsertRowid, day.id);
|
||||
const reservations = svc.getSharedTripData(token)!.reservations;
|
||||
const flight = reservations.find((r: any) => r.id === r1.lastInsertRowid);
|
||||
const hotel = reservations.find((r: any) => r.id === r2.lastInsertRowid);
|
||||
expect(flight.day_positions).toEqual({ [day.id]: 3 });
|
||||
expect(hotel.day_positions).toBeNull();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-017: excludes private packing items from the public payload (#858)', () => {
|
||||
const { user, trip, token } = seedSharedTrip();
|
||||
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Common', 0, ?)").run(trip.id, user.id);
|
||||
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, user.id);
|
||||
const packing = svc.getSharedTripData(token)!.packing;
|
||||
expect(packing.map((p: any) => p.name)).toEqual(['Common']);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-018: includes non-deleted collab messages only when share_collab is on', () => {
|
||||
const { user, trip, token } = seedSharedTrip();
|
||||
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Hello', 0)").run(trip.id, user.id);
|
||||
testDb.prepare("INSERT INTO collab_messages (trip_id, user_id, text, deleted) VALUES (?, ?, 'Gone', 1)").run(trip.id, user.id);
|
||||
const collab = svc.getSharedTripData(token)!.collab;
|
||||
expect(collab).toHaveLength(1);
|
||||
expect(collab[0]).toEqual(expect.objectContaining({ text: 'Hello', username: user.username }));
|
||||
|
||||
svc.createOrUpdate(String(trip.id), user.id, { share_collab: false });
|
||||
expect(svc.getSharedTripData(token)!.collab).toEqual([]);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-019: withholds each section when its flag is off', () => {
|
||||
const { trip, token } = seedSharedTrip({
|
||||
share_map: false, share_bookings: false, share_packing: false, share_budget: false, share_collab: false,
|
||||
});
|
||||
const day = createDay(testDb, trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAssignment(testDb, day.id, place.id);
|
||||
testDb.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')").run(trip.id);
|
||||
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Socks')").run(trip.id);
|
||||
testDb.prepare("INSERT INTO budget_items (trip_id, name, category, total_price) VALUES (?, 'Food', 'food', 10)").run(trip.id);
|
||||
const data = svc.getSharedTripData(token)!;
|
||||
expect(data.days).toEqual([]);
|
||||
expect(data.assignments).toEqual({});
|
||||
expect(data.dayNotes).toEqual({});
|
||||
expect(data.places).toEqual([]);
|
||||
expect(data.reservations).toEqual([]);
|
||||
expect(data.accommodations).toEqual([]);
|
||||
expect(data.packing).toEqual([]);
|
||||
expect(data.budget).toEqual([]);
|
||||
expect(data.collab).toEqual([]);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-020: baseCurrency falls back trip currency → EUR, with the owner default_currency winning (#1361)', () => {
|
||||
const { user, trip, token } = seedSharedTrip();
|
||||
expect(svc.getSharedTripData(token)!.baseCurrency).toBe('EUR');
|
||||
testDb.prepare('UPDATE trips SET currency = ? WHERE id = ?').run('USD', trip.id);
|
||||
expect(svc.getSharedTripData(token)!.baseCurrency).toBe('USD');
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ' CHF ')").run(user.id);
|
||||
expect(svc.getSharedTripData(token)!.baseCurrency).toBe('CHF');
|
||||
});
|
||||
|
||||
it('SHARE-SVC-021: rewrites place-photo proxy URLs to the token-scoped route, passing others through', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const proxied = createPlace(testDb, trip.id, { name: 'Proxied' });
|
||||
const uploaded = createPlace(testDb, trip.id, { name: 'Uploaded' });
|
||||
createPlace(testDb, trip.id, { name: 'Bare' });
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', proxied.id);
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/uploads/pic.jpg', uploaded.id);
|
||||
const byName = Object.fromEntries(
|
||||
svc.getSharedTripData(token)!.places.map((p: any) => [p.name, p.image_url]),
|
||||
);
|
||||
expect(byName['Proxied']).toBe(`/api/shared/${token}/place-photo/ChIJabc/bytes`);
|
||||
expect(byName['Uploaded']).toBe('/uploads/pic.jpg');
|
||||
expect(byName['Bare']).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getSharedPlacePhotoPath ───────────────────────────────────────────────────
|
||||
|
||||
describe('getSharedPlacePhotoPath', () => {
|
||||
it('SHARE-SVC-022: returns null for an unknown or expired token', () => {
|
||||
expect(svc.getSharedPlacePhotoPath('nope', 'ChIJabc')).toBeNull();
|
||||
const { trip, token } = seedSharedTrip();
|
||||
testDb.prepare('UPDATE share_tokens SET expires_at = ? WHERE trip_id = ?').run('2020-01-01T00:00:00.000Z', trip.id);
|
||||
expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull();
|
||||
expect(serveFilePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-023: returns null when the owner disabled the map section', () => {
|
||||
const { trip, token } = seedSharedTrip({ share_map: false });
|
||||
const place = createPlace(testDb, trip.id);
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run('/api/maps/place-photo/ChIJabc/bytes', place.id);
|
||||
expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull();
|
||||
expect(serveFilePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-024: returns null when no place in the trip carries the proxy URL', () => {
|
||||
const { token } = seedSharedTrip();
|
||||
expect(svc.getSharedPlacePhotoPath(token, 'ChIJabc')).toBeNull();
|
||||
expect(serveFilePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHARE-SVC-025: resolves via serveFilePath when the encoded placeId matches the stored URL', () => {
|
||||
const { trip, token } = seedSharedTrip();
|
||||
const place = createPlace(testDb, trip.id);
|
||||
// Wikimedia pseudo-IDs contain characters that must round-trip encoded.
|
||||
const placeId = 'coords:48.8,2.3';
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?')
|
||||
.run(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, place.id);
|
||||
serveFilePath.mockReturnValue('/cache/abc.jpg');
|
||||
expect(svc.getSharedPlacePhotoPath(token, placeId)).toBe('/cache/abc.jpg');
|
||||
expect(serveFilePath).toHaveBeenCalledWith(placeId);
|
||||
});
|
||||
});
|
||||
|
||||
// ── share.bridge delegation ───────────────────────────────────────────────────
|
||||
|
||||
describe('share.bridge delegation', () => {
|
||||
it('SHARE-SVC-026: createOrUpdateShareLink delegates to ShareService.createOrUpdate', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = createOrUpdateShareLink(String(trip.id), user.id, { share_budget: true });
|
||||
expect(result.created).toBe(true);
|
||||
expect(shareRow(trip.id).share_budget).toBe(1);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-027: getShareLink delegates to ShareService.get', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(getShareLink(String(trip.id))).toBeNull();
|
||||
const { token } = svc.createOrUpdate(String(trip.id), user.id, {});
|
||||
expect(getShareLink(String(trip.id))?.token).toBe(token);
|
||||
});
|
||||
|
||||
it('SHARE-SVC-028: deleteShareLink delegates to ShareService.remove', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
svc.createOrUpdate(String(trip.id), user.id, {});
|
||||
deleteShareLink(String(trip.id));
|
||||
expect(shareRow(trip.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user