Files
TREK/client/src/components/Memories/MemoriesPanel.test.tsx
T
Maurice c0c59b6d80 feat: unified photo provider abstraction layer (#584)
Introduce trek_photos as central photo registry. Frontend uses
/api/photos/:id/:kind instead of provider-specific URLs. Adding
a new photo provider is now backend-only work.

- New trek_photos table (migration 98) with photo_id FK in
  trip_photos and journey_photos
- Unified /api/photos/:id/thumbnail|original|info endpoint
- photoResolverService for central resolution and streaming
- ProviderPicker: add "All Photos" tab, rename tabs, fix i18n
- Localize all hardcoded strings in JourneyDetailPage (14 langs)
- Fix date formatting to use browser locale instead of hardcoded 'en'
- Journey stats as styled tile cards
2026-04-13 20:08:31 +02:00

790 lines
27 KiB
TypeScript

// 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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
// "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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
// 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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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(<MemoriesPanel {...defaultProps} />);
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');
});
});