test(plex): cover create write-through + the 409->nameTaken mapping (F-F)
Backend: create-collection happy path (write-through + editable) and the create resync-failure 422 (honest reconcile detail), mirroring the rename/remove tests. Frontend: extract notifyCollectionEditError (the 409->nameTaken vs generic-fallback mapping that was inline in the New-collection create()) and unit-test it; wire create() through it.
This commit is contained in:
@@ -303,6 +303,54 @@ def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _resync_create(db, plex, lib, rk):
|
||||||
|
"""Create-path resync stub: the new rating_key has no local row yet, so materialize it."""
|
||||||
|
col = PlexCollection(rating_key=rk, library_id=lib.id, title="Fresh Coll", child_count=1)
|
||||||
|
db.add(col)
|
||||||
|
db.flush()
|
||||||
|
return col
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_writes_through_to_plex(db, monkeypatch):
|
||||||
|
_lib, user = _seed_editable(db)
|
||||||
|
stub = _CollStub()
|
||||||
|
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create)
|
||||||
|
try:
|
||||||
|
r = client.post(
|
||||||
|
"/api/plex/collections",
|
||||||
|
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "create_collection" in stub.calls
|
||||||
|
body = r.json()
|
||||||
|
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||||
|
assert body["editable"] is True # create marks the new collection editable
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_resync_failure_is_422(db, monkeypatch):
|
||||||
|
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||||
|
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||||
|
_lib, user = _seed_editable(db)
|
||||||
|
stub = _CollStub()
|
||||||
|
|
||||||
|
def _boom(db_, plex, lib_, rk):
|
||||||
|
raise PlexError("read failed")
|
||||||
|
|
||||||
|
client = _admin_client(db, user, monkeypatch, stub, resync=_boom)
|
||||||
|
try:
|
||||||
|
r = client.post(
|
||||||
|
"/api/plex/collections",
|
||||||
|
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert "reconcile" in r.json()["detail"].lower()
|
||||||
|
assert "create_collection" in stub.calls
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||||
# is never reached (stub.calls holds only the mutating call).
|
# is never reached (stub.calls holds only the mutating call).
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ import type { TFunction } from "i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import {
|
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
||||||
api,
|
|
||||||
HttpError,
|
|
||||||
isIntrinsicReadOnly,
|
|
||||||
type PlexCard,
|
|
||||||
type PlexCollection,
|
|
||||||
} from "../lib/api";
|
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import PosterTile from "./PosterTile";
|
import PosterTile from "./PosterTile";
|
||||||
@@ -102,13 +95,12 @@ function NewCollectionPanel({
|
|||||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||||
onCreated(col.id);
|
onCreated(col.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
|
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
|
||||||
// (notifyActionError would otherwise surface the server's raw English detail).
|
notifyCollectionEditError(
|
||||||
if (e instanceof HttpError && e.status === 409) {
|
e,
|
||||||
notify({ level: "error", message: t("plex.collection.nameTaken") });
|
t("plex.collection.editFailed"),
|
||||||
} else {
|
t("plex.collection.nameTaken"),
|
||||||
notifyActionError(e, t("plex.collection.editFailed"));
|
);
|
||||||
}
|
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// jsdom (not node): importing ../api pulls in i18n, which reads navigator.language at module load.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Spy on the toast sink so we can assert exactly what each error maps to.
|
||||||
|
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||||
|
|
||||||
|
import { HttpError } from "./api";
|
||||||
|
import { notify } from "./notifications";
|
||||||
|
import { notifyCollectionEditError } from "./actionErrors";
|
||||||
|
|
||||||
|
const notifyMock = vi.mocked(notify);
|
||||||
|
beforeEach(() => notifyMock.mockClear());
|
||||||
|
|
||||||
|
describe("notifyCollectionEditError", () => {
|
||||||
|
it("maps a backend 409 to the friendly nameTaken message (not the raw server detail)", () => {
|
||||||
|
notifyCollectionEditError(new HttpError(409, "already exists"), "EDIT_FAILED", "NAME_TAKEN");
|
||||||
|
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "NAME_TAKEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the server detail for a non-409 HttpError", () => {
|
||||||
|
notifyCollectionEditError(new HttpError(500, "server boom"), "EDIT_FAILED", "NAME_TAKEN");
|
||||||
|
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "server boom" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the generic edit-failed message for a non-HttpError", () => {
|
||||||
|
notifyCollectionEditError(new Error("network"), "EDIT_FAILED", "NAME_TAKEN");
|
||||||
|
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,3 +12,22 @@ export function notifyActionError(err: unknown, fallbackMessage: string): void {
|
|||||||
const detail = err instanceof HttpError ? err.detail : null;
|
const detail = err instanceof HttpError ? err.detail : null;
|
||||||
notify({ level: "error", message: detail || fallbackMessage });
|
notify({ level: "error", message: detail || fallbackMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection write errors. The backend enforces per-library name uniqueness and returns 409 on a
|
||||||
|
* duplicate; localize that specific case to the friendly `nameTaken` copy (notifyActionError would
|
||||||
|
* otherwise surface the server's raw English detail). Everything else falls back to the generic
|
||||||
|
* edit-failed handling (which still prefers the server's reason when present). Both args are already
|
||||||
|
* translated so this stays i18n-agnostic and unit-testable.
|
||||||
|
*/
|
||||||
|
export function notifyCollectionEditError(
|
||||||
|
err: unknown,
|
||||||
|
editFailedMessage: string,
|
||||||
|
nameTakenMessage: string,
|
||||||
|
): void {
|
||||||
|
if (err instanceof HttpError && err.status === 409) {
|
||||||
|
notify({ level: "error", message: nameTakenMessage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyActionError(err, editFailedMessage);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user