Files
minstrel/web/src/routes/admin/duplicates/duplicates.test.ts
T
bvandeusenandClaude Opus 5.5 7c89e59536
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m49s
release / integration (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / android (push) Successful in 6m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: admin bugs from the audit: a playback-error delete no longer reports failure, a failed Overview load no longer reads as all-clear, and email settings can't be saved over with defaults (M503 #5604)
- Playback errors: Delete file stops resolving the error row the track
  deletion already took with it (ON DELETE CASCADE), which 404'd into
  "Delete failed".
- Overview: a queue that fails to load says so with Try again, and its tile
  shows a dash, not 0. Delete via Lidarr shows only for an album Lidarr
  holds while Lidarr is connected. An approve refreshes the Collection.
- Quarantine: Delete via Lidarr is in the row menu, so a phone reaches it;
  "1 report", not "1 reports".
- Duplicates: the Keep badge follows the copy picked.
- Integrations: provider save failures and load errors are said, not
  swallowed or shown as "No providers registered"; the Lidarr test catches
  a failed call; SMTP fills once and Save stays off until it has loaded.
  Lidarr's blurb says what it is still used for.
- Requests: release profiles point at Collection › Following.
- Library: an empty library sends an admin to Admin to run a scan, and
  tells anyone else the admin scans it, instead of opening Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:40:11 -04:00

394 lines
15 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import type {
AdminDuplicatesResponse,
ResolvedDuplicateActivity,
ResolvedDuplicatesResponse
} from '#lib/api/types.js';
vi.mock('#lib/api/admin.js', () => ({
createDuplicatesQuery: vi.fn(),
createResolvedDuplicatesQuery: vi.fn(),
runDuplicateSweep: vi.fn().mockResolvedValue({ started: true }),
dismissDuplicateGroup: vi.fn().mockResolvedValue(undefined),
mergeDuplicateGroup: vi.fn().mockResolvedValue({
survivor_track_id: 'www-01',
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
}),
// The page embeds FingerprintSettingsCard, which loads its own settings from
// this module; the card has its own suite.
getFingerprintSettings: vi.fn().mockResolvedValue({
enabled: true,
chromaprint_length_sec: 120,
acoustic_max_bit_error_rate: 0.15,
backfill_concurrency: 2,
sweep_interval_hours: 1,
auto_resolve: true
}),
updateFingerprintSettings: vi.fn()
}));
import AdminDuplicatesPage from './+page.svelte';
import {
createDuplicatesQuery,
createResolvedDuplicatesQuery,
dismissDuplicateGroup,
mergeDuplicateGroup,
runDuplicateSweep
} from '#lib/api/admin.js';
const HOUR = 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const finishedSweep = {
state: 'finished' as const,
started_at: ago(2 * HOUR),
finished_at: ago(HOUR),
candidates: 1200,
groups_found: 1,
oversize_clusters: 0,
error_message: null
};
const allFingerprinted = { total: 1200, fingerprinted: 1200, rejected: 0, pending: 0, enabled: true };
function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][number]['members'][number]> = {}) {
return {
track_id: id,
title: 'WWW',
artist_name: 'Moe Shop',
album_id: 'al-1',
album_title: 'WWW',
file_path: `/music/Moe Shop/WWW (2020)/${id}.mp3`,
file_format: 'mp3',
file_size: 6_900_000,
duration_sec: 215,
added_at: ago(500 * HOUR),
like_count: 0,
play_count: 0,
disc_number: null,
track_number: null,
...extra
};
}
function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesResponse {
return {
sweep: finishedSweep,
fingerprints: allFingerprinted,
view: 'review',
counts: { review: 1, cross_release: 4, resolved: 2 },
total: 1,
limit: 25,
offset: 0,
groups: [
{
id: 'g-1',
tier: 'exact',
worst_bit_error_rate: null,
detected_at: ago(HOUR),
survivor_track_id: 'www-01',
survivor_reason: 'in the library longest',
class: 'review',
resolve_note: null,
members: [member('www-01', { like_count: 2, play_count: 14 }), member('www-02')]
}
],
...over
};
}
// The page reads only these fields of an infinite query.
function infinite<T>(pages: T[]) {
return readable({
data: { pages },
isPending: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
isFetchNextPageError: false,
fetchNextPage: () => {},
refetch: () => {}
});
}
function resolvedPage(items: ResolvedDuplicateActivity[]): ResolvedDuplicatesResponse {
return { total: items.length, limit: 25, offset: 0, items };
}
function renderWith(data: AdminDuplicatesResponse, resolved: ResolvedDuplicateActivity[] = []) {
vi.mocked(createDuplicatesQuery).mockReturnValue(
infinite([data]) as unknown as ReturnType<typeof createDuplicatesQuery>
);
vi.mocked(createResolvedDuplicatesQuery).mockReturnValue(
infinite([resolvedPage(resolved)]) as unknown as ReturnType<typeof createResolvedDuplicatesQuery>
);
return render(AdminDuplicatesPage);
}
function text(el: HTMLElement): string {
return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
}
afterEach(() => vi.clearAllMocks());
describe('admin duplicates', () => {
test('shows each group with its members, what they carry, and the copy to keep', () => {
renderWith(response());
expect(screen.getAllByTestId('duplicate-group')).toHaveLength(1);
expect(screen.getAllByTestId('duplicate-member')).toHaveLength(2);
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Identical audio');
expect(text(screen.getByTestId('survivor-badge'))).toBe('Keep · in the library longest');
const history = screen.getAllByTestId('member-history').map(text);
expect(history).toEqual(['2 likes · 14 plays', 'no likes or plays']);
});
// The badge must sit on the proposed survivor, not merely appear somewhere.
test('the keep badge is on the survivor row', () => {
renderWith(response());
const rows = screen.getAllByTestId('duplicate-member');
expect(rows[0].querySelector('[data-testid="survivor-badge"]')).not.toBeNull();
expect(rows[1].querySelector('[data-testid="survivor-badge"]')).toBeNull();
});
test('picking another copy moves the keep badge to it', async () => {
renderWith(response());
const rows = screen.getAllByTestId('duplicate-member');
await fireEvent.click(rows[1].querySelector('input[type="radio"]') as HTMLInputElement);
const after = screen.getAllByTestId('duplicate-member');
expect(after[0].querySelector('[data-testid="survivor-badge"]')).toBeNull();
expect(text(after[1].querySelector('[data-testid="survivor-badge"]') as HTMLElement)).toBe('Keep · your pick');
});
test('an acoustic group reads as a match percentage', () => {
const r = response();
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
renderWith(r);
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Same recording · 96% match');
});
// #3885: identical audio titled "WWW" and "WWW (instrumental)". One file
// claimed to be the vocal and wasn't, and nothing said the vocal was missing.
describe('identical audio under different titles', () => {
function www() {
const r = response();
r.groups[0] = {
...r.groups[0],
members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })]
};
return r;
}
test('says the copies are titled differently and names both titles', () => {
renderWith(www());
const notice = text(screen.getByTestId('title-mismatch'));
expect(notice).toContain('titled “WWW” and “WWW (instrumental)”');
expect(notice).toContain("isn't in your library");
});
test('three different titles are all named', () => {
const r = www();
r.groups[0].members.push(member('www-03', { title: 'WWW (Radio Edit)' }));
renderWith(r);
expect(text(screen.getByTestId('title-mismatch'))).toContain(
'“WWW”, “WWW (instrumental)” and “WWW (Radio Edit)”'
);
});
test('titles differing only in case or spacing are the same title', () => {
const r = response();
r.groups[0] = {
...r.groups[0],
members: [
member('www-01', { title: 'WWW (Instrumental)' }),
member('www-02', { title: ' www (instrumental) ' })
]
};
renderWith(r);
expect(screen.queryByTestId('title-mismatch')).toBeNull();
});
test('matching titles raise nothing', () => {
renderWith(response());
expect(screen.queryByTestId('title-mismatch')).toBeNull();
});
// Across encodings a "Remastered" suffix is routine, not a mislabel.
test('an acoustic group with different titles raises nothing', () => {
const r = www();
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
renderWith(r);
expect(screen.queryByTestId('title-mismatch')).toBeNull();
});
});
// Three states all show zero groups. Each must say which it is.
test('empty while still fingerprinting says so', () => {
renderWith(
response({
groups: [],
total: 0,
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: true }
})
);
expect(text(screen.getByTestId('empty-state'))).toContain('still being fingerprinted');
});
// Switched off, the backlog never shrinks. "Still being fingerprinted" would
// promise work nothing is doing (#3913).
test('with fingerprinting off the page says so instead of promising progress', () => {
renderWith(
response({
groups: [],
total: 0,
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: false }
})
);
const empty = text(screen.getByTestId('empty-state'));
expect(empty).toContain('Fingerprinting is off');
expect(empty).not.toContain('still being fingerprinted');
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off');
});
test('the fingerprinting settings are on this page', async () => {
renderWith(response());
expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy();
});
test('empty before any sweep says the sweep has not run', () => {
renderWith(
response({
groups: [],
total: 0,
sweep: { ...finishedSweep, state: 'never', started_at: null, finished_at: null, candidates: null }
})
);
expect(text(screen.getByTestId('empty-state'))).toContain("hasn't run yet");
});
test('empty after a sweep says nothing needs review', () => {
renderWith(response({ groups: [], total: 0 }));
expect(text(screen.getByTestId('empty-state'))).toContain('Nothing needs review');
});
test('Not duplicates dismisses that group', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('button', { name: 'Not duplicates' }));
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
});
test('Sweep now starts a sweep', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('button', { name: 'Sweep now' }));
expect(runDuplicateSweep).toHaveBeenCalledTimes(1);
});
// A merge deletes files. The first click must only arm it.
test('Merge needs a second click, and keeps the proposed copy by default', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
expect(mergeDuplicateGroup).not.toHaveBeenCalled();
expect(text(screen.getByTestId('merge-confirm'))).toContain('The other 1 file will be deleted from disk');
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', {
survivor_track_id: 'www-01',
unmonitor: false
});
});
test('choosing another copy to keep sends that copy', async () => {
renderWith(response());
const radios = screen.getAllByRole('radio');
expect((radios[0] as HTMLInputElement).checked).toBe(true);
await fireEvent.click(radios[1]);
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', {
survivor_track_id: 'www-02',
unmonitor: false
});
});
test('Cancel disarms the merge', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.queryByTestId('merge-confirm')).toBeNull();
expect(screen.getByRole('button', { name: 'Merge…' })).toBeTruthy();
});
// M498: the resolver's three outcomes are three tabs, each with its size.
describe('tabs', () => {
test('each tab shows its count, and Across releases asks for that view', async () => {
renderWith(response());
expect(screen.getByRole('tab', { name: /needs review/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getAllByTestId('tab-count').map(text)).toEqual(['1', '4', '2']);
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('review');
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release');
});
// Copies across releases are linked as one song; dismissing one says they
// are not, which is what the button names.
test('Across releases dismisses as Not the same song', async () => {
renderWith(response());
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
await fireEvent.click(screen.getByRole('button', { name: 'Not the same song' }));
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
});
// Release changes stopped with Lidarr (M502), but the ones made before stay
// in the history.
test('Resolved automatically lists merges and past release changes', async () => {
renderWith(response(), [
{
id: 'a-1',
action: 'duplicate_merge',
created_at: ago(HOUR),
details: {
survivor_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz.mp3',
survivor_reason: 'the copy that is not a video rip',
removed: [{ track_id: 't-2', file_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz (Official Video).mp3' }]
}
},
{
id: 'a-2',
action: 'lidarr_release_change',
created_at: ago(2 * HOUR),
details: {
album_title: 'Humanz',
artist_name: 'Gorillaz',
from_release: '14x12" Vinyl, Digital Media (68 tracks)',
to_release: 'Digital Media (40 tracks)'
}
}
]);
await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i }));
const items = screen.getAllByTestId('resolved-item').map(text);
expect(items[0]).toContain('Kept 03 - Saturnz Barz.mp3 (the copy that is not a video rip) and removed 1 file.');
expect(items[1]).toContain('Lidarr now monitors Digital Media (40 tracks) of Humanz by Gorillaz');
expect(screen.queryByTestId('duplicate-group')).toBeNull();
});
});
describe("the resolver's note", () => {
test('shows above the group it explains', () => {
const r = response();
r.groups[0] = { ...r.groups[0], resolve_note: 'Identical audio filed under different titles.' };
renderWith(r);
expect(text(screen.getByTestId('resolve-note'))).toBe('Identical audio filed under different titles.');
});
test('a merge confirms and sends the copy chosen to keep', async () => {
renderWith(response());
await fireEvent.click(screen.getAllByRole('radio')[1]);
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02', unmonitor: false });
});
});
});