mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
c0c59b6d80
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
790 lines
27 KiB
TypeScript
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');
|
|
});
|
|
});
|