Files
Oxicloud/frontend/src/routes/music/page.test.ts
T
Bradley Nelson e3823ce470 test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
2026-06-22 00:05:06 -06:00

120 lines
3.7 KiB
TypeScript

import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { ui, promptDialog, confirmDialog } = vi.hoisted(() => ({
ui: { notify: vi.fn() },
promptDialog: vi.fn(),
confirmDialog: vi.fn()
}));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/stores/dialogs.svelte', () => ({ promptDialog, confirmDialog }));
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
vi.mock('$lib/api/endpoints/music', () => ({
addTracks: vi.fn(),
createPlaylist: vi.fn(),
deletePlaylist: vi.fn(),
listPlaylists: vi.fn(),
listShares: vi.fn(async () => []),
listTracks: vi.fn(async () => []),
removeShare: vi.fn(),
removeTrack: vi.fn(),
renamePlaylist: vi.fn(),
reorderTracks: vi.fn(),
sharePlaylist: vi.fn(),
updatePlaylist: vi.fn(),
uploadCoverImage: vi.fn()
}));
import {
listPlaylists,
listTracks,
createPlaylist,
renamePlaylist,
deletePlaylist
} from '$lib/api/endpoints/music';
import MusicPage from './+page.svelte';
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
const playlist = {
id: 'p1',
name: 'Roadtrip',
description: 'songs',
owner_id: 'me',
is_public: false,
cover_file_id: null,
track_count: 1,
total_duration_secs: 180,
created_at: 0,
updated_at: 0
};
const track = {
id: 't1',
playlist_id: 'p1',
file_id: 'f1',
position: 0,
added_at: 0,
file_name: 'song.mp3',
file_size: 100,
mime_type: 'audio/mpeg',
title: 'Song',
artist: 'Artist',
album: 'Album',
duration_secs: 180
};
beforeEach(() => {
vi.clearAllMocks();
m(listTracks).mockResolvedValue([track]);
});
it('renders an empty state when there are no playlists', async () => {
m(listPlaylists).mockResolvedValue([]);
render(MusicPage);
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
await screen.findByTestId('music-create-playlist-empty-btn');
});
it('selects the first playlist and loads its tracks', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
render(MusicPage);
await waitFor(() => expect(listTracks).toHaveBeenCalledWith('p1'));
await waitFor(() => expect(screen.getAllByText('Roadtrip').length).toBeGreaterThan(0));
});
it('creates a playlist from the header button', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
promptDialog.mockResolvedValue('Chill');
m(createPlaylist).mockResolvedValue({ ...playlist, id: 'p2', name: 'Chill' });
render(MusicPage);
await screen.findByTestId('music-create-playlist-btn');
await fireEvent.click(screen.getByTestId('music-create-playlist-btn'));
await waitFor(() => expect(createPlaylist).toHaveBeenCalledWith('Chill'));
});
it('surfaces a load error', async () => {
m(listPlaylists).mockRejectedValue(new Error('boom'));
render(MusicPage);
await waitFor(() => expect(listPlaylists).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText('boom')).toBeTruthy());
});
it('renames the current playlist', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
promptDialog.mockResolvedValue('Renamed');
m(renamePlaylist).mockResolvedValue(undefined);
render(MusicPage);
await fireEvent.click(await screen.findByTestId('music-rename-playlist-btn'));
await waitFor(() => expect(renamePlaylist).toHaveBeenCalledWith('p1', 'Renamed'));
});
it('deletes the current playlist after confirmation', async () => {
m(listPlaylists).mockResolvedValue([playlist]);
confirmDialog.mockResolvedValue(true);
m(deletePlaylist).mockResolvedValue(undefined);
render(MusicPage);
await fireEvent.click(await screen.findByTestId('music-delete-playlist-btn'));
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
});