// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027 import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { server } from '../../../tests/helpers/msw/server'; import { http, HttpResponse } from 'msw'; import { useAuthStore } from '../../store/authStore'; import { buildUser } from '../../../tests/helpers/factories'; import MemoriesPanel from './MemoriesPanel'; // Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'), clearImageQueue: vi.fn(), })); const defaultProps = { tripId: 1, startDate: '2025-03-01', endDate: '2025-03-10', }; // Reusable provider object to configure a connected Immich instance const immichAddon = { id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true, config: { status_get: '/integrations/memories/immich/status' }, }; // Handlers that simulate a connected provider with no photos/links const connectedHandlers = [ http.get('/api/addons', () => HttpResponse.json({ addons: [immichAddon] }) ), http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true }) ), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] }) ), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }) ), ]; beforeEach(() => { resetAllStores(); // Seed a default logged-in user seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) }); }); describe('MemoriesPanel', () => { it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => { // Use a delayed response so loading stays true long enough to assert server.use( http.get('/api/addons', async () => { await new Promise(resolve => setTimeout(resolve, 200)); return HttpResponse.json({ addons: [] }); }), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] }) ), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }) ), ); render(); // Spinner is rendered synchronously — loading state starts as true expect(document.querySelector('.animate-spin')).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => { server.use( http.get('/api/addons', () => HttpResponse.json({ addons: [] })), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] }) ), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }) ), ); render(); // "Photo provider not connected" — no providers, falls back to generic label await screen.findByText('Photo provider not connected'); }); it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { asset_id: 'abc', provider: 'immich', user_id: 2, username: 'Alice', shared: 1, added_at: '2025-03-05T10:00:00Z', }, ], }) ), ); render(); // Alice's username is rendered as an avatar tooltip in the gallery await screen.findByText('Alice'); }); it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => { server.use(...connectedHandlers); render(); // Provider is connected so the gallery renders — but no photos → empty state await screen.findByText('No photos found'); }); it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [ { id: 1, provider: 'immich', album_id: 'a1', album_name: 'Holidays', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null, }, ], }) ), ); render(); await screen.findByText('Holidays'); }); it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => { let syncCalled = false; server.use( ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [ { id: 1, provider: 'immich', album_id: 'a1', album_name: 'Holidays', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null, }, ], }) ), http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => { syncCalled = true; return HttpResponse.json({ ok: true }); }), ); render(); await screen.findByText('Holidays'); const syncBtn = screen.getByTitle('Sync album'); await userEvent.click(syncBtn); await waitFor(() => expect(syncCalled).toBe(true)); }); it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => { let deleteCalled = false; server.use( ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [ { id: 1, provider: 'immich', album_id: 'a1', album_name: 'Holidays', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null, }, ], }) ), http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => { deleteCalled = true; return HttpResponse.json({ ok: true }); }), ); render(); await screen.findByText('Holidays'); // The unlink button is only shown when link.user_id === currentUser.id const unlinkBtn = screen.getByTitle('Unlink album'); await userEvent.click(unlinkBtn); await waitFor(() => expect(deleteCalled).toBe(true)); }); it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' }, { photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' }, ], }) ), ); render(); // Default sort is ascending ("Oldest first") const sortBtn = await screen.findByText('Oldest first'); await userEvent.click(sortBtn); // After toggle, button label switches to "Newest first" expect(screen.getByText('Newest first')).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => { server.use( ...connectedHandlers, http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }) ), ); render(); // Wait for the empty gallery to load await screen.findByText('No photos found'); // Both the header button and gallery CTA say "Add photos" — click the first const addBtns = screen.getAllByText('Add photos'); await userEvent.click(addBtns[0]); // Picker header is now visible await screen.findByText('Select photos from Immich'); }); it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => { server.use( ...connectedHandlers, http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }) ), ); render(); await screen.findByText('No photos found'); const addBtns = screen.getAllByText('Add photos'); await userEvent.click(addBtns[0]); await screen.findByText('Select photos from Immich'); // Click Cancel in the picker header await userEvent.click(screen.getByText('Cancel')); // Gallery is restored await screen.findByText('No photos found'); }); it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => { server.use( ...connectedHandlers, http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [] }) ), ); render(); await screen.findByText('No photos found'); await userEvent.click(screen.getByText('Link Album')); // Album picker header appears await screen.findByText('Select Immich Album'); }); it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 0, added_at: '2025-03-05T10:00:00Z', }, ], }) ), ); render(); // Share-toggle button appears with correct title (not shared → "Share photos") await screen.findByTitle('Share photos'); // "Private" label is shown on unshared own photos expect(screen.getByText('Private')).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => { let putCalled = false; server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 0, added_at: '2025-03-05T10:00:00Z', }, ], }) ), http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => { putCalled = true; return HttpResponse.json({ ok: true }); }), ); render(); const shareBtn = await screen.findByTitle('Share photos'); await userEvent.click(shareBtn); await waitFor(() => expect(putCalled).toBe(true)); }); it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => { let deleteCalled = false; server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T10:00:00Z', }, ], }) ), http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => { deleteCalled = true; return HttpResponse.json({ ok: true }); }), ); render(); // Wait for the share/stop-sharing button to confirm the gallery has rendered await screen.findByTitle('Stop sharing'); // The remove button is the second action button in the hover overlay — no title, just an X icon // Get all buttons and click the one after the share toggle const allBtns = screen.getAllByRole('button'); const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing'); // The remove button immediately follows the share button in the DOM await userEvent.click(allBtns[shareIdx + 1]); await waitFor(() => expect(deleteCalled).toBe(true)); }); it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => { server.use( ...connectedHandlers, http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [ { id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' }, ], }) ), ); render(); await screen.findByText('No photos found'); const [firstAddBtn] = screen.getAllByText('Add photos'); await userEvent.click(firstAddBtn); await screen.findByText('Select photos from Immich'); // Month group header appears after photos load await screen.findByText(/March.*2025|2025.*March/); }); it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => { server.use( ...connectedHandlers, http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [ { id: 'album1', albumName: 'Summer 2025', assetCount: 42 }, ], }) ), ); render(); await screen.findByText('No photos found'); await userEvent.click(screen.getByText('Link Album')); await screen.findByText('Summer 2025'); // Asset count is rendered next to the album name expect(screen.getByText(/42/)).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => { const immich2Addon = { id: 'immich2', name: 'Immich2', type: 'photo_provider', enabled: true, config: { status_get: '/integrations/memories/immich2/status' }, }; server.use( http.get('/api/addons', () => HttpResponse.json({ addons: [immichAddon, immich2Addon] }) ), http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })), http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })), http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })), http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })), ); render(); await screen.findByText('No photos found'); const [firstAddBtn] = screen.getAllByText('Add photos'); await userEvent.click(firstAddBtn); // With multiple providers the picker header uses the "multiple" translation await screen.findByText('Select Photos'); // Both provider name tabs are rendered inside the picker expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' }, ], }) ), ); render(); // Location dropdown shows "All locations" option when there are 2+ distinct cities await screen.findByText('All locations'); expect(screen.getByRole('combobox')).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => { let addPhotosCalled = false; server.use( ...connectedHandlers, http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [ { id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }, ], }) ), http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => { addPhotosCalled = true; return HttpResponse.json({ ok: true }); }), ); render(); await screen.findByText('No photos found'); const [firstAddBtn] = screen.getAllByText('Add photos'); await userEvent.click(firstAddBtn); await screen.findByText('Select photos from Immich'); // Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves) // img has alt="" so findByRole('img') won't work — use findByAltText instead const thumbnail = await screen.findByAltText(''); // Click the thumbnail — bubbles up to the parent div's onClick to select it await userEvent.click(thumbnail); // "1 selected" count appears and "Add 1 photos" button is active await screen.findByText(/1\s+selected/); await userEvent.click(screen.getByText('Add 1 photos')); // Confirm share dialog appears await screen.findByText('Share with trip members?'); // Click the confirm "Share photos" button to execute await userEvent.click(screen.getByText('Share photos')); await waitFor(() => expect(addPhotosCalled).toBe(true)); }); it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => { let searchCount = 0; server.use( ...connectedHandlers, http.post('/api/integrations/memories/immich/search', () => { searchCount++; return HttpResponse.json({ assets: [] }); }), ); render(); await screen.findByText('No photos found'); const [firstAddBtn] = screen.getAllByText('Add photos'); await userEvent.click(firstAddBtn); await screen.findByText('Select photos from Immich'); // Click "All photos" — triggers a second loadPickerPhotos(false) call await userEvent.click(screen.getByText('All photos')); await waitFor(() => expect(searchCount).toBeGreaterThan(1)); }); it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => { server.use( ...connectedHandlers, http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] }) ), ); render(); await screen.findByText('No photos found'); const [firstAddBtn] = screen.getAllByText('Add photos'); await userEvent.click(firstAddBtn); await screen.findByText('Select photos from Immich'); // "Trip dates" tab is absent when dates are not set expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument(); expect(screen.getByText('All photos')).toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => { server.use( http.get('/api/addons', () => HttpResponse.json({ addons: [ { id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} }, ], }) ), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] }) ), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] }) ), ); render(); // Provider name shown in the not-connected message when exactly 1 enabled provider await screen.findByText('MyApp not connected'); }); it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { asset_id: 'asset1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T10:00:00Z', }, ], }) ), http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [ { id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }, ], }) ), ); render(); // Gallery shows own photo — "Stop sharing" title confirms it's loaded await screen.findByTitle('Stop sharing'); // Open picker from the header button (only 1 "Add photos" button since photos > 0) await userEvent.click(screen.getByText('Add photos')); await screen.findByText('Select photos from Immich'); // The asset already in the gallery shows the "Added" overlay in the picker await screen.findByText('Added'); }); it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('photos')), http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [ { photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' }, ], }) ), ); render(); const select = await screen.findByRole('combobox'); // Change filter to a specific city await userEvent.selectOptions(select, 'Paris'); expect(select).toHaveValue('Paris'); }); it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => { server.use( ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [ { id: 1, provider: 'immich', album_id: 'a1', album_name: 'Holidays', user_id: 2, username: 'Alice', sync_enabled: 1, last_synced_at: null, }, ], }) ), ); render(); await screen.findByText('Holidays'); // Other user's username is shown in parentheses expect(screen.getByText('(Alice)')).toBeInTheDocument(); // Unlink button is NOT shown for another user's album link expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument(); }); it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => { let linkCalled = false; // Track whether POST has been made so the GET can return different data let albumLinked = false; server.use( ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }], }) ), http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => { linkCalled = true; albumLinked = true; return HttpResponse.json({ ok: true }); }), // Return empty before POST, linked album after POST http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => { if (!albumLinked) return HttpResponse.json({ links: [] }); return HttpResponse.json({ links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }], }); }), http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => HttpResponse.json({ ok: true }) ), ); render(); await screen.findByText('No photos found'); await userEvent.click(screen.getByText('Link Album')); await screen.findByText('Summer 2025'); // Click the album button to link it (album is not yet linked → button is enabled) await userEvent.click(screen.getByText('Summer 2025')); await waitFor(() => expect(linkCalled).toBe(true)); }); it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => { server.use( ...connectedHandlers, http.get('/api/integrations/memories/immich/albums', () => HttpResponse.json({ albums: [] }) ), ); render(); await screen.findByText('No photos found'); await userEvent.click(screen.getByText('Link Album')); await screen.findByText('Select Immich Album'); // Click Cancel to dismiss without linking await userEvent.click(screen.getByText('Cancel')); // Gallery is restored await screen.findByText('No photos found'); }); });