- keep getCurrentPageId off the legacy shell bridge surface - leave page-id lookup on the router side where it is actually used - align the bridge tests and type definitions with the slimmer API
273 lines
9.9 KiB
TypeScript
273 lines
9.9 KiB
TypeScript
import { createMemoryHistory } from '@tanstack/react-router';
|
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
|
|
|
|
import { createAppQueryClient } from '@/app/query-client';
|
|
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
|
|
|
function createResponse(body: unknown, ok = true, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
|
return {
|
|
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
|
|
isPageAllowed: vi.fn(() => true),
|
|
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
|
|
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
|
|
setActivePageChrome: vi.fn(),
|
|
activateLegacyPath: vi.fn(),
|
|
showReactHost: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderIssuesRoute(initialEntries = ['/issues']) {
|
|
const queryClient = createAppQueryClient();
|
|
const history = createMemoryHistory({ initialEntries });
|
|
const router = createAppRouter({ history, queryClient });
|
|
|
|
return {
|
|
history,
|
|
router,
|
|
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
|
|
};
|
|
}
|
|
|
|
const workflowActions = {
|
|
openDownloadMissingAlbum: vi.fn(),
|
|
openAddToWishlistAlbum: vi.fn(),
|
|
};
|
|
|
|
describe('issues route', () => {
|
|
beforeEach(() => {
|
|
workflowActions.openDownloadMissingAlbum.mockReset();
|
|
workflowActions.openAddToWishlistAlbum.mockReset();
|
|
window.SoulSyncWebShellBridge = createShellBridge();
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input instanceof Request ? input.url : String(input);
|
|
if (url.includes('/api/issues/counts')) {
|
|
return createResponse({
|
|
success: true,
|
|
counts: {
|
|
open: 2,
|
|
in_progress: 1,
|
|
resolved: 0,
|
|
dismissed: 0,
|
|
total: 3,
|
|
},
|
|
});
|
|
}
|
|
if (url.includes('/api/issues?')) {
|
|
return createResponse({
|
|
success: true,
|
|
total: 1,
|
|
issues: [
|
|
{
|
|
id: 7,
|
|
profile_id: 2,
|
|
entity_type: 'album',
|
|
entity_id: '15',
|
|
category: 'wrong_metadata',
|
|
title: 'Bad tags',
|
|
description: 'Album title is wrong',
|
|
status: 'open',
|
|
priority: 'normal',
|
|
snapshot_data: {
|
|
title: 'Album Name',
|
|
artist_name: 'Artist',
|
|
thumb_url: 'https://example.com/thumb.jpg',
|
|
spotify_album_id: 'abc123',
|
|
track_number: 1,
|
|
duration: 245,
|
|
format: 'FLAC',
|
|
bitrate: 1411,
|
|
},
|
|
created_at: '2026-04-03 10:30:00',
|
|
reporter_name: 'Ada',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.includes('/api/issues/7')) {
|
|
return createResponse({
|
|
success: true,
|
|
issue: {
|
|
id: 7,
|
|
profile_id: 2,
|
|
entity_type: 'album',
|
|
entity_id: '15',
|
|
category: 'wrong_metadata',
|
|
title: 'Bad tags',
|
|
description: 'Album title is wrong',
|
|
status: 'open',
|
|
priority: 'normal',
|
|
snapshot_data: {
|
|
title: 'Album Name',
|
|
artist_name: 'Artist',
|
|
thumb_url: 'https://example.com/thumb.jpg',
|
|
spotify_album_id: 'abc123',
|
|
track_number: 1,
|
|
duration: 245,
|
|
format: 'FLAC',
|
|
bitrate: 1411,
|
|
},
|
|
created_at: '2026-04-03 10:30:00',
|
|
reporter_name: 'Ada',
|
|
},
|
|
});
|
|
}
|
|
if (url.includes('/api/spotify/album/abc123')) {
|
|
return createResponse({
|
|
id: 'abc123',
|
|
name: 'Album Name',
|
|
album_type: 'album',
|
|
images: [{ url: 'https://example.com/thumb.jpg' }],
|
|
total_tracks: 1,
|
|
artists: [{ name: 'Artist' }],
|
|
tracks: [{ id: 'track-1', name: 'Track 1' }],
|
|
});
|
|
}
|
|
return createResponse({ success: true });
|
|
}) as unknown as typeof fetch,
|
|
);
|
|
vi.stubGlobal('SoulSyncWorkflowActions', workflowActions);
|
|
vi.stubGlobal('showToast', vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
window.SoulSyncWebShellBridge = undefined;
|
|
});
|
|
|
|
it('renders stats and list items through the app router', async () => {
|
|
renderIssuesRoute();
|
|
await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2'));
|
|
expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags');
|
|
});
|
|
|
|
it('loads the detail modal from the route search state', async () => {
|
|
renderIssuesRoute(['/issues?issueId=7']);
|
|
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
|
expect(await screen.findByTitle('Spotify Album')).toHaveAttribute(
|
|
'href',
|
|
'https://open.spotify.com/album/abc123',
|
|
);
|
|
});
|
|
|
|
it('stores filters in route search state', async () => {
|
|
renderIssuesRoute();
|
|
const status = await screen.findByRole('combobox', { name: /status/i });
|
|
fireEvent.change(status, { target: { value: 'resolved' } });
|
|
await waitFor(() => expect(status).toHaveValue('resolved'));
|
|
});
|
|
|
|
it('opens and closes the detail modal', async () => {
|
|
const { history } = renderIssuesRoute();
|
|
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
|
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
|
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
|
|
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
|
|
});
|
|
|
|
it('closes the detail modal with Escape', async () => {
|
|
const { history } = renderIssuesRoute();
|
|
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
|
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
|
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
|
|
|
|
fireEvent.keyDown(document, { key: 'Escape' });
|
|
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
|
|
});
|
|
|
|
it('focuses the detail modal close button on open', async () => {
|
|
renderIssuesRoute();
|
|
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
|
|
|
const closeButton = await screen.findByRole('button', {
|
|
name: /close issue detail/i,
|
|
});
|
|
|
|
await waitFor(() => expect(closeButton).toHaveFocus());
|
|
});
|
|
|
|
it('invokes the shared workflow adapter for admin downloads', async () => {
|
|
renderIssuesRoute();
|
|
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
|
|
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
|
|
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
virtualPlaylistId: 'issue_download_abc123',
|
|
playlistName: '[Artist] Album Name',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('opens the global React issue composer through the domain bridge', async () => {
|
|
const fetchMock = vi.mocked(fetch);
|
|
renderIssuesRoute();
|
|
await waitFor(() => expect(window.SoulSyncIssueDomain).toBeDefined());
|
|
|
|
act(() => {
|
|
window.SoulSyncIssueDomain?.openReportIssue({
|
|
entityType: 'album',
|
|
entityId: 15,
|
|
entityName: 'Album Name',
|
|
artistName: 'Artist',
|
|
});
|
|
});
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
|
|
const titleInput = screen.getByLabelText(/title/i);
|
|
const descriptionInput = screen.getByLabelText(/details/i);
|
|
const submitButton = screen.getByRole('button', { name: /submit issue/i });
|
|
const form = submitButton.closest('form');
|
|
|
|
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
|
|
fireEvent.change(titleInput, { target: { value: '' } });
|
|
expect(submitButton).toBeDisabled();
|
|
fireEvent.submit(form!);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Please provide a title for the issue');
|
|
});
|
|
|
|
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
|
|
fireEvent.blur(titleInput);
|
|
fireEvent.change(descriptionInput, {
|
|
target: { value: 'Detailed reproduction notes' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /high/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
|
|
expect(titleInput).toHaveValue('Custom report title');
|
|
expect(descriptionInput).toHaveValue('Detailed reproduction notes');
|
|
expect(screen.getByRole('button', { name: /high/i })).toHaveAttribute('aria-pressed', 'true');
|
|
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
fetchMock.mock.calls.some(
|
|
([request]) => request instanceof Request && request.method === 'POST',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('does not render track details for album issues', async () => {
|
|
renderIssuesRoute(['/issues?issueId=7']);
|
|
|
|
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
|
expect(screen.queryByText('Track Details')).not.toBeInTheDocument();
|
|
});
|
|
});
|