feat(stats): add library entry deletion and app-wide delete progress (#174)

This commit is contained in:
2026-07-29 02:00:16 -07:00
committed by GitHub
parent 0d7084c8aa
commit 6d2a72e13b
40 changed files with 2013 additions and 125 deletions
@@ -1100,12 +1100,56 @@ describe('stats server API routes', () => {
assert.deepEqual(assignments, [{ animeId: 1, body }]);
});
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
it('GET /api/stats/anime/:animeId/cover returns revalidated cover art', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1/cover');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/jpeg');
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');
assert.equal(res.headers.get('cache-control'), 'no-cache');
assert.ok(res.headers.get('etag'));
});
it('GET /api/stats/anime/:animeId/cover answers 304 for a matching ETag', async () => {
const app = createStatsApp(createMockTracker());
const first = await app.request('/api/stats/anime/1/cover');
const etag = first.headers.get('etag');
assert.ok(etag);
const cached = await app.request('/api/stats/anime/1/cover', {
headers: { 'If-None-Match': `W/${etag}, "other"` },
});
assert.equal(cached.status, 304);
assert.equal(cached.headers.get('etag'), etag);
});
it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => {
// A relinked AniList entry swaps the bytes behind the same cover URL.
let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
const app = createStatsApp(
createMockTracker({
getAnimeCoverArt: async () => ({
videoId: 1,
anilistId: 21858,
coverUrl: 'https://example.com/cover.jpg',
coverBlob,
titleRomaji: 'Little Witch Academia',
titleEnglish: 'Little Witch Academia',
episodesTotal: 25,
fetchedAtMs: Date.now(),
}),
}),
);
const first = await app.request('/api/stats/anime/1/cover');
const staleEtag = first.headers.get('etag');
assert.ok(staleEtag);
coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const res = await app.request('/api/stats/anime/1/cover', {
headers: { 'If-None-Match': staleEtag },
});
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/png');
assert.notEqual(res.headers.get('etag'), staleEtag);
});
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
@@ -2873,6 +2917,39 @@ Aligned English subtitle
assert.equal(deleteCalls, 0);
});
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
let deletedAnimeId: number | null = null;
const app = createStatsApp(
createMockTracker({
deleteAnime: async (animeId: number) => {
deletedAnimeId = animeId;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });
assert.equal(res.status, 200);
assert.equal(deletedAnimeId, 7);
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
let deleteCalls = 0;
const app = createStatsApp(
createMockTracker({
deleteAnime: async () => {
deleteCalls += 1;
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });
assert.equal(res.status, 400);
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });