diff --git a/backend/tests/test_library_progress.py b/backend/tests/test_library_progress.py index aa1517ce..12228e39 100644 --- a/backend/tests/test_library_progress.py +++ b/backend/tests/test_library_progress.py @@ -6,9 +6,11 @@ there. Too early or too close to the end clears it. Nobody else's checkpoint, an cannot reach, is ever read or written. DB-backed (real Postgres). """ +import pytest from sqlalchemy import select from app.models import DownloadJob, DownloadShare, LibraryState +from app.routes._common import ProgressIn, kept_position def _done_job(db, user) -> DownloadJob: @@ -87,6 +89,18 @@ def test_too_early_or_near_the_end_clears_it(db, make_user, as_user): assert _checkpoint(c, job.id, 0) == 0 +# The edges themselves, one second either side — the same rows as the client's +# frontend/src/lib/resume.dom.test.ts: the rule must be applied alike on both sides, and the shared +# constants alone (resume.contract.test.ts) would not see a `>=` that became `>` on one side. +@pytest.mark.parametrize( + ("position", "duration", "kept"), + [(4, 3000, 0), (5, 3000, 5), (2990, 3000, 2990), (2991, 3000, 0)], +) +def test_the_kept_point_at_its_exact_edges(position, duration, kept): + body = ProgressIn(position_seconds=position, duration_seconds=duration) + assert kept_position(body) == kept + + def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_user): owner, friend = make_user(), make_user() job = _done_job(db, owner) diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index 9b68a2a6..b5a1e431 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -454,3 +454,31 @@ describe("uploadDownload's wire shape", () => { expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined(); }); }); + +// The library player re-reads its listings once its closing point is on the server: it cancelled +// their poll so an older answer could not put the point back, and only this callback brings them +// back. A beacon that never called it, or a slice that dropped it, left the lists stale with every +// test green. +describe("a beacon's settle callback", () => { + it("runs once the request is answered, and once it has failed", async () => { + const fetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockRejectedValueOnce(new TypeError("offline")); + const answered = vi.fn(); + const failed = vi.fn(); + beacon("/api/a", {}, answered); + beacon("/api/b", {}, failed); + await vi.waitFor(() => + expect([answered.mock.calls.length, failed.mock.calls.length]).toEqual([1, 1]), + ); + expect(fetch.mock.calls.map(([url]) => url).slice(-2)).toEqual(["/api/a", "/api/b"]); + }); + + it("is passed on by the library progress beacon", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + const settled = vi.fn(); + downloadsApi.saveDownloadProgressBeacon(7, 125.4, 600, settled); + await vi.waitFor(() => expect(settled).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend/src/lib/api/plexStream.contract.test.ts b/frontend/src/lib/api/plexStream.contract.test.ts index 3565d73b..641ad9a9 100644 --- a/frontend/src/lib/api/plexStream.contract.test.ts +++ b/frontend/src/lib/api/plexStream.contract.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { resolve } from "node:path"; import { afterEach, expect, it, vi } from "vitest"; import { api } from "../api"; @@ -10,10 +10,9 @@ import { api } from "../api"; afterEach(() => vi.restoreAllMocks()); function serverRoute(): RegExp { - // From the frontend root (vitest's cwd): this file runs under jsdom — `api` boots i18n, which reads - // localStorage — and there `import.meta.url` is not a file URL `readFileSync` takes. + // Anchored to this file, not the working directory, so the test holds wherever vitest starts. const source = readFileSync( - join(process.cwd(), "../backend/app/routes/plex/playback.py"), + resolve(__dirname, "../../../../backend/app/routes/plex/playback.py"), "utf8", ); const prefix = /APIRouter\(prefix="([^"]+)"/.exec(source)?.[1]; @@ -21,7 +20,9 @@ function serverRoute(): RegExp { // If either is missing the route was renamed or moved — re-point this test, the rule stands. expect(prefix, "router prefix").toBeDefined(); expect(path, "the session stop route").toBeDefined(); - const pattern = (prefix! + path!).replace(/\{[^}]+\}/g, "[^/]+"); + // Each path parameter becomes a group of its own NAME: two ids that swapped places still match a + // pattern of anonymous wildcards, and the server then finds no session to stop. + const pattern = (prefix! + path!).replace(/\{(\w+)\}/g, "(?<$1>[^/]+)"); return new RegExp(`^${pattern}$`); } @@ -29,8 +30,6 @@ it("sends the session stop to the route the server serves it on", () => { const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null)); api.plexStopSession("12345", "a1b2c3"); const url = String(fetch.mock.calls[0]![0]); - expect(url).toMatch(serverRoute()); - expect(url).toContain("/12345/"); - expect(url).toContain("/a1b2c3/"); + expect(url.match(serverRoute())?.groups).toEqual({ rating_key: "12345", session_id: "a1b2c3" }); expect(fetch.mock.calls[0]![1]).toMatchObject({ method: "POST", keepalive: true }); }); diff --git a/frontend/src/lib/resume.dom.test.ts b/frontend/src/lib/resume.dom.test.ts index aa071ace..5617cec7 100644 --- a/frontend/src/lib/resume.dom.test.ts +++ b/frontend/src/lib/resume.dom.test.ts @@ -154,6 +154,18 @@ describe("keptPosition (the server's rule)", () => { expect(keptPosition(2995, 3000)).toBe(0); expect(keptPosition(120, 0)).toBe(120); // an unknown length cannot be "near the end" }); + + // The edges themselves, one second either side: a `>=` that became `>` on one side only moves a + // rule both sides must apply alike, and the constants alone (resume.contract.test.ts) would not see + // it. backend/tests/test_library_progress.py holds the server to the same rows. + it.each([ + [4, 3000, 0], + [5, 3000, 5], + [2990, 3000, 2990], + [2991, 3000, 0], + ])("at %s s of %s keeps %s", (position, duration, kept) => { + expect(keptPosition(position, duration)).toBe(kept); + }); }); describe("a shared link's point", () => {