Per-user reading progress and uploader attribution. Schema (migration 0011): `read_progress` table (one row per (user, manga); chapter_id nullable on chapter delete) and nullable `uploaded_by` columns on mangas + chapters with partial indexes scoped to non-null rows. Endpoints (all `/me/*`, auth-scoped): - PUT `/v1/me/read-progress` upserts. FK violations + cross-manga chapter ids both surface as 4xx (404 / 422) so the API can't be used to write logically invalid rows. - GET `/v1/me/read-progress` paged newest-first list. - GET `/v1/me/read-progress/:manga_id` enriched with chapter_number for the manga page's Continue CTA. - DELETE `/v1/me/read-progress/:manga_id` idempotent. - GET `/v1/me/uploads` interleaved manga + chapter uploads as a tagged union; limit-only pagination. Existing manga + chapter upload handlers stamp `uploaded_by`. Frontend: - Reader emits progress on mount + page change (debounce) and via IntersectionObserver in continuous mode. High-water mark is seeded from the persisted server value so re-opening a chapter doesn't regress to page 1. Tab close survives via `sendBeacon` (fallback `keepalive` fetch); SPA navigation flushes via regular fetch. - Manga detail page shows "Continue reading Chapter N — page M" above the chapters list, working even for mangas with >50 chapters. - New `/profile/history` tab with reading history (clear-per-row, inline error on failure) and uploads (mangas + chapters mixed chronologically with type-aware rendering). 171 backend tests (incl. 16 history tests covering ownership, FK race, cross-link guard, chapter SET NULL behaviour) and 97 frontend tests + svelte-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
|
import { listMyUploads, listMyUploadsOrEmpty } from './uploads';
|
|
|
|
function ok(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
function envelope(status: number, code: string, message: string): Response {
|
|
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
describe('uploads api client', () => {
|
|
let fetchSpy: MockInstance<typeof globalThis.fetch>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('listMyUploads returns the discriminated union of entries', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
ok({
|
|
items: [
|
|
{
|
|
kind: 'manga',
|
|
manga: {
|
|
id: 'm1',
|
|
title: 'A',
|
|
status: 'ongoing',
|
|
alt_titles: [],
|
|
description: null,
|
|
cover_image_path: null,
|
|
created_at: '2026-05-17T12:00:00Z',
|
|
updated_at: '2026-05-17T12:00:00Z'
|
|
},
|
|
created_at: '2026-05-17T12:00:00Z'
|
|
},
|
|
{
|
|
kind: 'chapter',
|
|
manga_id: 'm1',
|
|
manga_title: 'A',
|
|
manga_cover_image_path: null,
|
|
chapter: {
|
|
id: 'c1',
|
|
manga_id: 'm1',
|
|
number: 1,
|
|
title: null,
|
|
page_count: 3,
|
|
created_at: '2026-05-17T13:00:00Z'
|
|
},
|
|
created_at: '2026-05-17T13:00:00Z'
|
|
}
|
|
],
|
|
page: { limit: 50, offset: 0, total: 2 }
|
|
})
|
|
);
|
|
const r = await listMyUploads();
|
|
expect(r.items[0].kind).toBe('manga');
|
|
expect(r.items[1].kind).toBe('chapter');
|
|
// Discriminant pattern-match (compile-time check via the union).
|
|
if (r.items[1].kind === 'chapter') {
|
|
expect(r.items[1].chapter.number).toBe(1);
|
|
}
|
|
});
|
|
|
|
it('listMyUploadsOrEmpty returns empty page on 401', async () => {
|
|
fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'login required'));
|
|
const r = await listMyUploadsOrEmpty();
|
|
expect(r.items).toEqual([]);
|
|
});
|
|
});
|