mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 15:21:46 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7a9210215 | |||
| d5d63aa979 | |||
| 84574020f2 | |||
| 1b7ea2c87d | |||
| 47b7678975 | |||
| da70388f4b | |||
| 6c1a795460 | |||
| 75d23eb6aa | |||
| 0c4de72356 | |||
| 5e8602c50a | |||
| 61b8070626 | |||
| 5caaeff67c | |||
| 92a1f9c448 | |||
| 58a8e97f94 | |||
| 815b725f87 |
@@ -1,8 +1,8 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that opens a new browser window and writes a full HTML document into it.
|
||||
// It does NOT render a React component. Tests verify window.open behaviour.
|
||||
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
|
||||
// Tests verify the overlay DOM structure and HTML content.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Mock window.open ─────────────────────────────────────────────────────────
|
||||
// ── Helpers to inspect the overlay ───────────────────────────────────────────
|
||||
|
||||
let mockWindow: {
|
||||
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
function getOverlay(): HTMLElement | null {
|
||||
return document.getElementById('journey-pdf-overlay');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindow = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
|
||||
});
|
||||
function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('journey-pdf-overlay')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
expect(document.body.contains(getOverlay())).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const iframe = getIframe();
|
||||
expect(iframe).not.toBeNull();
|
||||
const html = iframe!.srcdoc;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
|
||||
const overlay = getOverlay()!;
|
||||
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
|
||||
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
|
||||
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
|
||||
}
|
||||
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
||||
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
|
||||
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
|
||||
.print-bar .btn-save { background: white; color: #0f172a; }
|
||||
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<button class="btn-save" onclick="window.print()">Save as PDF</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Page 1: Cover -->
|
||||
<div class="cover-page">
|
||||
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) return
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
|
||||
// This avoids window.open() which Safari iOS blocks in async callbacks
|
||||
// and window.close() which doesn't work reliably in standalone PWA mode.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'journey-pdf-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="journey-pdf-save" style="min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
|
||||
<button id="journey-pdf-close" style="min-height:44px;padding:10px 16px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
|
||||
@@ -72,17 +72,18 @@ describe('BannerRenderer', () => {
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only last 2 shown)', async () => {
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Oldest notice' });
|
||||
it('FE-SN-BANNER-004: third banner is not rendered (only top 2 shown)', async () => {
|
||||
// Server returns notices highest-priority first; BannerRenderer takes slice(0,2)
|
||||
const n1 = makeBanner({ id: 'banner-1', titleKey: 'Highest notice' });
|
||||
const n2 = makeBanner({ id: 'banner-2', titleKey: 'Second notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Newest notice' });
|
||||
const n3 = makeBanner({ id: 'banner-3', titleKey: 'Lowest notice' });
|
||||
await act(async () => {
|
||||
render(<BannerRenderer notices={[n1, n2, n3]} />);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Oldest notice')).toBeNull();
|
||||
expect(screen.getByText('Highest notice')).toBeTruthy();
|
||||
expect(screen.getByText('Second notice')).toBeTruthy();
|
||||
expect(screen.getByText('Newest notice')).toBeTruthy();
|
||||
expect(screen.queryByText('Lowest notice')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-SN-BANNER-005: critical banner has aria-live="assertive"', async () => {
|
||||
|
||||
@@ -2011,6 +2011,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'وضع لا اتصال كامل كتطبيق PWA',
|
||||
'system_notice.v3_features.highlight_search': 'إكمال تلقائي في الوقت الفعلي',
|
||||
'system_notice.v3_features.highlight_import': 'استيراد أماكن من ملفات KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: ترقية OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'تمت إعادة تصميم تكامل MCP بالكامل. OAuth 2.1 هو الآن طريقة المصادقة الموصى بها. الرموز الثابتة (trek_…) مهملة وستُزال في إصدار مستقبلي.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 موصى به (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
|
||||
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Modo offline completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompleção de lugares em tempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares de arquivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: atualização OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'A integração MCP foi completamente reformulada. OAuth 2.1 agora é o método de autenticação recomendado. Tokens estáticos (trek_…) foram descontinuados e serão removidos em uma versão futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -2218,6 +2218,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Plný offline režim jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autodoplňování vyhledávání míst',
|
||||
'system_notice.v3_features.highlight_import': 'Import míst ze souborů KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizace OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrace MCP byla kompletně přepracována. OAuth 2.1 je nyní doporučenou metodou ověřování. Statické tokeny (trek_…) jsou zastaralé a budou v budoucí verzi odstraněny.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 doporučeno (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -2218,6 +2218,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Vollständiger Offline-Modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Echtzeit-Autovervollständigung für Orte',
|
||||
'system_notice.v3_features.highlight_import': 'Orte aus KMZ/KML-Dateien importieren',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-Upgrade',
|
||||
'system_notice.v3_mcp.body': 'Die MCP-Integration wurde vollständig überarbeitet. OAuth 2.1 ist jetzt die empfohlene Authentifizierungsmethode. Statische Tokens (trek_…) sind veraltet und werden in einer zukünftigen Version entfernt.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 empfohlen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -2240,6 +2240,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_search': 'Real-time place search autocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Import places from KMZ/KML files',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 upgrade',
|
||||
'system_notice.v3_mcp.body': 'The MCP integration has been fully overhauled. OAuth 2.1 is now the recommended auth method. Legacy static tokens (trek_\u2026) are deprecated and will be removed in a future release.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommended (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 fine-grained permission scopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
|
||||
|
||||
// System notices — onboarding
|
||||
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
||||
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
||||
|
||||
@@ -2220,6 +2220,14 @@ const es: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Modo sin conexión completo como PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocompletado de lugares en tiempo real',
|
||||
'system_notice.v3_features.highlight_import': 'Importar lugares desde archivos KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: actualización OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'La integración MCP ha sido completamente renovada. OAuth 2.1 es ahora el método de autenticación recomendado. Los tokens estáticos (trek_…) están obsoletos y se eliminarán en una versión futura.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recomendado (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const fr: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Mode hors ligne complet en PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autocomplétion des lieux en temps réel',
|
||||
'system_notice.v3_features.highlight_import': 'Importer des lieux depuis KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP : mise à niveau OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'intégration MCP a été entièrement repensée. OAuth 2.1 est désormais la méthode d'authentification recommandée. Les tokens statiques (trek_\u2026) sont dépréciés et seront supprimés dans une future version.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 recommandé (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -2215,6 +2215,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Teljes offline mód PWA-ként',
|
||||
'system_notice.v3_features.highlight_search': 'Valós idejű helykeresés-kiegészítés',
|
||||
'system_notice.v3_features.highlight_import': 'Helyek importálása KMZ/KML fájlokból',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1 frissítés',
|
||||
'system_notice.v3_mcp.body': 'Az MCP integráció teljesen megújult. Az OAuth 2.1 mostantól az ajánlott hitelesítési módszer. A statikus tokenek (trek_…) elavultak és egy jövőbeli kiadásban eltávolításra kerülnek.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 ajánlott (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -2256,6 +2256,14 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Mode offline penuh sebagai PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Pelengkapan otomatis tempat secara real-time',
|
||||
'system_notice.v3_features.highlight_import': 'Impor tempat dari file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: pembaruan OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integrasi MCP telah sepenuhnya diperbarui. OAuth 2.1 kini menjadi metode autentikasi yang direkomendasikan. Token statis (trek_…) sudah usang dan akan dihapus pada versi mendatang.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 direkomendasikan (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -2215,6 +2215,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Modalità offline completa come PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Completamento automatico luoghi in tempo reale',
|
||||
'system_notice.v3_features.highlight_import': 'Importa luoghi da file KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aggiornamento OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': "L'integrazione MCP è stata completamente rinnovata. OAuth 2.1 è ora il metodo di autenticazione consigliato. I token statici (trek_\u2026) sono deprecati e verranno rimossi in una versione futura.",
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 consigliato (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const nl: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Volledige offline modus als PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Realtime plaatsautocomplete',
|
||||
'system_notice.v3_features.highlight_import': 'Importeer plaatsen uit KMZ/KML-bestanden',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: OAuth 2.1-upgrade',
|
||||
'system_notice.v3_mcp.body': 'De MCP-integratie is volledig vernieuwd. OAuth 2.1 is nu de aanbevolen authenticatiemethode. Statische tokens (trek_…) zijn verouderd en worden verwijderd in een toekomstige versie.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 aanbevolen (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -2207,6 +2207,14 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Pełny tryb offline jako PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Autouzupełnianie wyszukiwania miejsc',
|
||||
'system_notice.v3_features.highlight_import': 'Import miejsc z plików KMZ/KML',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: aktualizacja OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Integracja MCP została całkowicie przeprojektowana. OAuth 2.1 jest teraz zalecaną metodą uwierzytelniania. Statyczne tokeny (trek_…) są przestarzałe i zostaną usunięte w przyszłej wersji.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 zalecany (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const ru: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': 'Полный офлайн-режим как PWA',
|
||||
'system_notice.v3_features.highlight_search': 'Автодополнение поиска мест в реальном времени',
|
||||
'system_notice.v3_features.highlight_import': 'Импорт мест из KMZ/KML-файлов',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP: обновление OAuth 2.1',
|
||||
'system_notice.v3_mcp.body': 'Интеграция MCP была полностью переработана. OAuth 2.1 теперь является рекомендуемым методом аутентификации. Статические токены (trek_…) устарели и будут удалены в будущей версии.',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 рекомендуется (mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
|
||||
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
|
||||
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -2214,6 +2214,14 @@ const zh: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': '作为 PWA 的完整离线模式',
|
||||
'system_notice.v3_features.highlight_search': '地点搜索实时自动补全',
|
||||
'system_notice.v3_features.highlight_import': '从 KMZ/KML 文件导入地点',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升级',
|
||||
'system_notice.v3_mcp.body': 'MCP 集成已全面重构。OAuth 2.1 现为推荐的身份验证方式。静态令牌(trek_…)已弃用,将在未来版本中移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 推荐(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
|
||||
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -2215,6 +2215,14 @@ const zhTw: Record<string, string> = {
|
||||
'system_notice.v3_features.highlight_offline': '作為 PWA 的完整離線模式',
|
||||
'system_notice.v3_features.highlight_search': '地點搜尋即時自動補全',
|
||||
'system_notice.v3_features.highlight_import': '從 KMZ/KML 檔案匯入地點',
|
||||
|
||||
// System notices — MCP OAuth 2.1 upgrade
|
||||
'system_notice.v3_mcp.title': 'MCP:OAuth 2.1 升級',
|
||||
'system_notice.v3_mcp.body': 'MCP 整合已全面重構。OAuth 2.1 現為建議的身份驗證方式。靜態令牌(trek_…)已棄用,將於未來版本移除。',
|
||||
'system_notice.v3_mcp.highlight_oauth': 'OAuth 2.1 建議(mcp-remote)',
|
||||
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
|
||||
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
|
||||
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
@@ -674,7 +674,10 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-027 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-027: Shows loading spinner before data loads', () => {
|
||||
it('renders a spinner while journey data is loading', () => {
|
||||
// Do NOT await the waitFor -- we check the loading state before data arrives
|
||||
// Pre-seed the store into a loading state (current: null, loading: true).
|
||||
// We can't rely on render() timing because RTL wraps in act(), which flushes
|
||||
// all microtasks including the MSW response before render() returns.
|
||||
useJourneyStore.setState({ loading: true, current: null });
|
||||
render(<JourneyDetailPage />);
|
||||
// The spinner has animate-spin class on a div
|
||||
const spinner = document.querySelector('.animate-spin');
|
||||
|
||||
@@ -1423,6 +1423,24 @@ function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading:
|
||||
)
|
||||
}
|
||||
|
||||
// ── Photo date grouping ───────────────────────────────────────────────────
|
||||
|
||||
function groupPhotosByDate(photos: any[]): { date: string; label: string; assets: any[] }[] {
|
||||
const map = new Map<string, any[]>()
|
||||
for (const asset of photos) {
|
||||
const key = asset.takenAt ? asset.takenAt.slice(0, 10) : '__unknown__'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(asset)
|
||||
}
|
||||
return [...map.entries()].map(([date, assets]) => ({
|
||||
date,
|
||||
label: date === '__unknown__'
|
||||
? 'Unknown date'
|
||||
: new Date(date + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }),
|
||||
assets,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Provider Picker ───────────────────────────────────────────────────────
|
||||
|
||||
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
@@ -1547,7 +1565,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
@@ -1732,51 +1750,60 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
||||
{photos.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{groupPhotosByDate(photos).map(group => (
|
||||
<div key={group.date}>
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
@@ -2000,7 +2027,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2384,7 +2411,7 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2481,7 +2508,7 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2738,7 +2765,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
|
||||
@@ -151,6 +151,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
logout: () => {
|
||||
disconnect()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
|
||||
@@ -314,6 +314,47 @@ describe('journeyStore', () => {
|
||||
expect(storedEntry?.photos[0].id).toBe(201);
|
||||
});
|
||||
|
||||
// ── loadJourney silent refresh ───────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => {
|
||||
const existing = buildJourneyDetail({ id: 5, title: 'Old' });
|
||||
useJourneyStore.setState({ current: existing, loading: false });
|
||||
|
||||
const loadingValues: boolean[] = [];
|
||||
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
|
||||
|
||||
const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' });
|
||||
server.use(
|
||||
http.get('/api/journeys/5', () => HttpResponse.json(refreshed))
|
||||
);
|
||||
|
||||
await useJourneyStore.getState().loadJourney(5);
|
||||
unsub();
|
||||
|
||||
expect(loadingValues.every(v => v === false)).toBe(true);
|
||||
expect(useJourneyStore.getState().current?.title).toBe('Refreshed');
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => {
|
||||
const existing = buildJourneyDetail({ id: 5 });
|
||||
useJourneyStore.setState({ current: existing, loading: false });
|
||||
|
||||
const loadingValues: boolean[] = [];
|
||||
const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading));
|
||||
|
||||
const other = buildJourneyDetail({ id: 99 });
|
||||
server.use(
|
||||
http.get('/api/journeys/99', () => HttpResponse.json(other))
|
||||
);
|
||||
|
||||
await useJourneyStore.getState().loadJourney(99);
|
||||
unsub();
|
||||
|
||||
expect(loadingValues).toContain(true);
|
||||
expect(useJourneyStore.getState().current?.id).toBe(99);
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
|
||||
@@ -124,7 +124,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadJourney: async (id) => {
|
||||
set({ loading: true, notFound: false })
|
||||
const cold = get().current?.id !== id
|
||||
if (cold) set({ loading: true, notFound: false })
|
||||
try {
|
||||
const data = await journeyApi.get(id)
|
||||
set({ current: data })
|
||||
@@ -134,7 +135,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
if (cold) set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface SystemNoticeState {
|
||||
fetching: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
dismiss: (id: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
|
||||
@@ -51,6 +52,10 @@ export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
set({ notices: [], loaded: false, fetching: false });
|
||||
},
|
||||
|
||||
dismiss(id: string) {
|
||||
// Optimistic: remove immediately
|
||||
const prev = get().notices;
|
||||
|
||||
@@ -1614,6 +1614,7 @@ function runMigrations(db: Database.Database): void {
|
||||
// Migration 102: Add check_in_end column for check-in time ranges
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 103: System notices — user tracking columns + dismissals table
|
||||
() => {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`);
|
||||
|
||||
@@ -60,16 +60,12 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to, size } = req.body;
|
||||
const { from, to, size, page } = req.body;
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const allAssets: any[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.assets) allAssets.push(...result.assets);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
res.json({ assets: allAssets });
|
||||
const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets || [], hasMore: !!result.hasMore });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,7 +63,7 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
WHERE jp.photo_id = ? AND je.journey_id = ?
|
||||
`).get(photoId, row.journey_id) as any;
|
||||
if (!photo) return null;
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
|
||||
@@ -30,9 +30,11 @@ function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean
|
||||
const userVersion = semver.valid(ctx.user.first_seen_version) ?? '0.0.0';
|
||||
const noticeVersion = semver.valid(condition.version);
|
||||
if (!noticeVersion) return false;
|
||||
// Strip prerelease/build metadata so '3.0.0-pre.42' is treated as '3.0.0'.
|
||||
const appVersion = semver.coerce(ctx.currentAppVersion)?.version ?? '0.0.0';
|
||||
return (
|
||||
semver.lt(userVersion, noticeVersion) &&
|
||||
semver.gte(semver.valid(ctx.currentAppVersion) ?? '0.0.0', noticeVersion)
|
||||
semver.gte(appVersion, noticeVersion)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,30 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
},
|
||||
|
||||
{
|
||||
// Page 3 — other highlights
|
||||
// Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled)
|
||||
id: 'v3-mcp',
|
||||
display: 'modal',
|
||||
severity: 'warn',
|
||||
icon: 'Bot',
|
||||
titleKey: 'system_notice.v3_mcp.title',
|
||||
bodyKey: 'system_notice.v3_mcp.body',
|
||||
highlights: [
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' },
|
||||
{ labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' },
|
||||
],
|
||||
dismissible: true,
|
||||
conditions: [
|
||||
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
|
||||
{ kind: 'addonEnabled', addonId: 'mcp' },
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 75,
|
||||
},
|
||||
|
||||
{
|
||||
// Page 4 — other highlights
|
||||
id: 'v3-features',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
|
||||
@@ -273,18 +273,19 @@ describe('Immich browse and search', () => {
|
||||
expect(res.body.buckets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('IMMICH-042 — POST /search returns mapped assets', async () => {
|
||||
it('IMMICH-042 — POST /search returns mapped assets with hasMore flag', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
.send({ page: 1, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' });
|
||||
expect(typeof res.body.hasMore).toBe('boolean');
|
||||
});
|
||||
|
||||
it('IMMICH-043 — POST /search when upstream throws returns 502', async () => {
|
||||
@@ -611,43 +612,77 @@ describe('Immich syncAlbumAssets', () => {
|
||||
|
||||
// ── searchPhotos pagination safety ────────────────────────────────────────────
|
||||
|
||||
describe('Immich searchPhotos pagination safety', () => {
|
||||
it('IMMICH-090 — searchPhotos stops at page 20 when hasMore is always true', async () => {
|
||||
describe('Immich searchPhotos pagination pass-through', () => {
|
||||
it('IMMICH-090 — POST /search proxies client page param and returns hasMore', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Return a full page of 1000 items on every call, so the loop would
|
||||
// run indefinitely without the page > 20 safety check.
|
||||
// Return a full page so hasMore=true (items.length >= size)
|
||||
const fullPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
items: Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `asset-p2-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Paris', country: 'France' },
|
||||
exifInfo: { city: 'Berlin', country: 'Germany' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
// Clear previous call history so the count only reflects this test
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
vi.mocked(safeFetch).mockResolvedValue(fullPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
.send({ page: 2, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.assets)).toBe(true);
|
||||
// 20 pages × 1000 items = 20000 assets total (safety limit)
|
||||
expect(res.body.assets.length).toBe(20000);
|
||||
// safeFetch should have been called exactly 20 times (the safety limit)
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(20);
|
||||
// Single page returned — not 20× aggregation
|
||||
expect(res.body.assets.length).toBe(50);
|
||||
expect(res.body.hasMore).toBe(true);
|
||||
// Immich was called exactly once
|
||||
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(1);
|
||||
// page=2 was forwarded to Immich
|
||||
const callBody = JSON.parse(vi.mocked(safeFetch).mock.calls[0][1]!.body as string);
|
||||
expect(callBody.page).toBe(2);
|
||||
});
|
||||
|
||||
it('IMMICH-091 — POST /search returns hasMore=false on last page', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setImmichCredentials(testDb, user.id, 'https://immich.example.com', 'test-api-key');
|
||||
|
||||
// Partial page → hasMore=false
|
||||
const partialPageResponse = {
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
json: () => Promise.resolve({
|
||||
assets: {
|
||||
items: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `asset-last-${i}`,
|
||||
fileCreatedAt: '2024-06-01T10:00:00.000Z',
|
||||
exifInfo: { city: 'Rome', country: 'Italy' },
|
||||
})),
|
||||
},
|
||||
}),
|
||||
body: null,
|
||||
} as any;
|
||||
|
||||
vi.mocked(safeFetch).mockResolvedValue(partialPageResponse);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`${IMMICH}/search`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ page: 5, size: 50 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assets.length).toBe(3);
|
||||
expect(res.body.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ afterAll(() => {
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
/** Insert a journey_photos row and return its id. */
|
||||
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
|
||||
function insertJourneyPhoto(
|
||||
entryId: number,
|
||||
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
||||
@@ -70,11 +70,13 @@ function insertJourneyPhoto(
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
||||
const trekId = trekResult.lastInsertRowid as number;
|
||||
const result = testDb.prepare(`
|
||||
testDb.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, NULL, 0, ?)
|
||||
`).run(entryId, trekId, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
// Return trek_photos.id — this is p.photo_id in the public API response
|
||||
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
|
||||
return trekId;
|
||||
}
|
||||
|
||||
// -- Tests --------------------------------------------------------------------
|
||||
@@ -237,6 +239,31 @@ describe('validateShareTokenForPhoto', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('JOURNEY-SHARE-016: resolves correctly when trek_photos.id differs from journey_photos.id (Immich bulk-sync scenario)', () => {
|
||||
// Simulate a user who has many trek_photos from Immich syncs before adding a journey photo.
|
||||
// trek_photos.id will be higher than journey_photos.id — the previous bug matched on jp.id
|
||||
// instead of jp.photo_id, causing a 404 for Immich photos in public shares.
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id);
|
||||
|
||||
// Pre-populate trek_photos to push the autoincrement higher
|
||||
for (let i = 0; i < 5; i++) {
|
||||
testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now());
|
||||
}
|
||||
|
||||
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
|
||||
const trekPhotoId = insertJourneyPhoto(entry.id, { assetId: 'journey-asset-xyz', ownerId: user.id });
|
||||
const { token } = createOrUpdateJourneyShareLink(journey.id, user.id, {});
|
||||
|
||||
// photoId = trek_photos.id (6), not journey_photos.id (1)
|
||||
const result = validateShareTokenForPhoto(token, trekPhotoId);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerId).toBe(user.id);
|
||||
expect(result!.journeyId).toBe(journey.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateShareTokenForAsset', () => {
|
||||
|
||||
@@ -40,6 +40,12 @@ describe('existingUserBeforeVersion', () => {
|
||||
it('fails when current app version < notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '1.5.0' })).toBe(false);
|
||||
});
|
||||
it('passes when current app version is a prerelease of the notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '2.0.0-pre.42' })).toBe(true);
|
||||
});
|
||||
it('passes when current app version is a prerelease beyond the notice version', () => {
|
||||
expect(evaluate(notice, { ...baseCtx, currentAppVersion: '2.1.0-pre.1' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateWindow', () => {
|
||||
|
||||
Reference in New Issue
Block a user