Files
silo-server/web/src/hooks/queries/admin/users.settings.test.ts
T
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

388 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { createElement } from "react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { installPolicyStorageMocks, jsonResponse } from "@/pages/admin-policy/policyTestUtils";
import { SETTING_KEYS } from "@/lib/settingsContract";
import {
useAdminDeviceOverrides,
useAdminUserDeviceSettings,
useAdminUserSettings,
useDeleteAdminUserSetting,
useDeleteAllAdminUserDeviceSettingsForDevice,
useUpdateAdminUserDeviceSetting,
useUpdateAdminUserSetting,
} from "./users";
// These hooks moved off the removed string-registry routes
// (/admin/users/{id}/settings, /device-settings, /profiles/{pid}/device-settings/…)
// onto the canonical values API. Every assertion here pins the new URL shape
// and the typed JSON bodies — against the old hooks each of these requests
// would 404 in production, which is exactly the breakage this file guards.
function createWrapper() {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return createElement(QueryClientProvider, { client }, children);
};
}
const valuesResponse = {
revision: 1,
values: [
{
key: "playback.subtitle_mode",
scope: "profile",
profile_id: "p1",
value: "always",
revision: 1,
updated_at: "2026-07-28T10:00:00Z",
},
{
key: "playback.auto_skip_intro",
scope: "profile",
profile_id: "p1",
value: true,
revision: 1,
},
{
key: "player.audio_sync_ms",
scope: "profile_device",
profile_id: "p1",
device_id: "tv-1",
value: 250,
revision: 1,
updated_at: "2026-07-28T11:00:00Z",
},
],
};
describe("admin canonical settings hooks", () => {
beforeEach(() => {
installPolicyStorageMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("lists non-device values from /settings/values with stringified values", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input) => {
expect(String(input)).toBe("/api/v1/admin/users/7/settings/values");
return jsonResponse(valuesResponse);
}),
);
const { result } = renderHook(() => useAdminUserSettings(7), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.data).toEqual([
{
key: "playback.subtitle_mode",
scope: "profile",
profile_id: "p1",
client_family: undefined,
library_id: undefined,
series_id: undefined,
value: "always",
updated_at: "2026-07-28T10:00:00Z",
},
{
key: "playback.auto_skip_intro",
scope: "profile",
profile_id: "p1",
client_family: undefined,
library_id: undefined,
series_id: undefined,
value: "true",
updated_at: undefined,
},
]);
});
it("derives device overrides from the same list, enriched with names", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url === "/api/v1/admin/users/7/settings/values") {
return jsonResponse(valuesResponse);
}
if (url === "/api/v1/admin/devices") {
return jsonResponse({
devices: [
{
user_id: 7,
device_id: "tv-1",
device_name: "Living Room TV",
device_platform: "tvos",
},
],
});
}
if (url === "/api/v1/admin/users/7/profiles") {
return jsonResponse([{ id: "p1", name: "Laura" }]);
}
throw new Error(`unexpected request: ${url}`);
}),
);
const { result } = renderHook(() => useAdminUserDeviceSettings(7), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.data.length).toBe(1));
expect(result.current.data).toEqual([
{
user_id: 7,
profile_id: "p1",
profile_name: "Laura",
device_id: "tv-1",
device_name: "Living Room TV",
device_platform: "tvos",
key: "player.audio_sync_ms",
value: "250",
updated_at: "2026-07-28T11:00:00Z",
},
]);
});
it("scopes the device panel's overrides to one device, from the canonical list", async () => {
// The device detail endpoint reports the legacy user_device_settings
// table, which nothing canonical writes to; the panel has to read the
// values API or an override written since the cutover never appears.
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input) => {
const url = String(input);
if (url === "/api/v1/admin/users/7/settings/values") {
return jsonResponse({
revision: 1,
values: [
...valuesResponse.values,
{
key: "player.hdr_enabled",
scope: "profile_device",
profile_id: "p1",
device_id: "phone-9",
value: false,
revision: 1,
},
],
});
}
if (url === "/api/v1/admin/devices") {
return jsonResponse({ devices: [] });
}
if (url === "/api/v1/admin/users/7/profiles") {
return jsonResponse([{ id: "p1", name: "Laura" }]);
}
throw new Error(`unexpected request: ${url}`);
}),
);
const { result } = renderHook(() => useAdminDeviceOverrides(7, "tv-1"), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.data.length).toBe(1));
// Only tv-1's row, and never a non-device-scoped row from the same list.
expect(result.current.data.map((setting) => setting.key)).toEqual(["player.audio_sync_ms"]);
expect(result.current.data[0]?.device_id).toBe("tv-1");
});
it("writes a user setting to the canonical route with a typed value", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
if (init?.method === "PUT") {
expect(url).toBe(
"/api/v1/admin/users/7/settings/values/playback.auto_skip_intro?scope=profile&profile_id=p1",
);
// Booleans travel typed, not as the "true" string the old registry stored.
expect(JSON.parse(String(init.body))).toEqual({ value: true });
return jsonResponse({ key: "playback.auto_skip_intro", scope: "profile", value: true });
}
return jsonResponse(valuesResponse);
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useUpdateAdminUserSetting(), { wrapper: createWrapper() });
result.current.mutate({
userId: 7,
key: "playback.auto_skip_intro",
identity: { scope: "profile", profileId: "p1" },
value: "true",
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it("carries a profile-client family from listing through mutation identity", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
const url = String(input);
if (init?.method === "PUT") {
expect(url).toBe(
"/api/v1/admin/users/7/settings/values/ui.card_presentation?scope=profile_client&profile_id=p1&client_family=tv",
);
return jsonResponse({
key: SETTING_KEYS.UI_CARD_PRESENTATION,
scope: "profile_client",
profile_id: "p1",
client_family: "tv",
value: { poster_size: "large", caption: "artwork" },
});
}
expect(url).toBe("/api/v1/admin/users/7/settings/values");
return jsonResponse({
revision: 5,
values: [
{
key: SETTING_KEYS.UI_CARD_PRESENTATION,
scope: "profile_client",
profile_id: "p1",
client_family: "tv",
value: { poster_size: "compact", caption: "title" },
revision: 1,
},
],
});
});
vi.stubGlobal("fetch", fetchMock);
const listed = renderHook(() => useAdminUserSettings(7), { wrapper: createWrapper() });
await waitFor(() => expect(listed.result.current.data.length).toBe(1));
expect(listed.result.current.data[0]).toMatchObject({
scope: "profile_client",
profile_id: "p1",
client_family: "tv",
});
const update = renderHook(() => useUpdateAdminUserSetting(), { wrapper: createWrapper() });
update.result.current.mutate({
userId: 7,
key: SETTING_KEYS.UI_CARD_PRESENTATION,
identity: { scope: "profile_client", profileId: "p1", clientFamily: "tv" },
value: JSON.stringify({ poster_size: "large", caption: "artwork" }),
});
await waitFor(() => expect(update.result.current.isSuccess).toBe(true));
});
it("deletes a user setting at its exact scope", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
expect(init?.method).toBe("DELETE");
expect(String(input)).toBe(
"/api/v1/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=p1",
);
return new Response(null, { status: 204 });
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useDeleteAdminUserSetting(), { wrapper: createWrapper() });
result.current.mutate({
userId: 7,
key: "playback.subtitle_mode",
identity: { scope: "profile", profileId: "p1" },
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it("resets navigation shortcuts with the permitted atomic empty document", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
expect(init?.method).toBe("PUT");
expect(String(input)).toBe(
"/api/v1/admin/users/7/settings/values/nav.shortcuts?scope=profile&profile_id=p1",
);
expect(JSON.parse(String(init?.body))).toEqual({ value: { items: [] } });
return jsonResponse({
key: SETTING_KEYS.NAV_SHORTCUTS,
scope: "profile",
profile_id: "p1",
value: { items: [] },
});
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useDeleteAdminUserSetting(), { wrapper: createWrapper() });
result.current.mutate({
userId: 7,
key: SETTING_KEYS.NAV_SHORTCUTS,
identity: { scope: "profile", profileId: "p1" },
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it("writes a device override at profile_device scope with a typed number", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
if (init?.method === "PUT") {
expect(String(input)).toBe(
"/api/v1/admin/users/7/settings/values/player.audio_sync_ms?scope=profile_device&profile_id=p1&device_id=tv-1",
);
expect(JSON.parse(String(init.body))).toEqual({ value: 250 });
return jsonResponse({ key: "player.audio_sync_ms", scope: "profile_device", value: 250 });
}
return jsonResponse(valuesResponse);
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useUpdateAdminUserDeviceSetting(), {
wrapper: createWrapper(),
});
result.current.mutate({
userId: 7,
profileId: "p1",
deviceId: "tv-1",
key: "player.audio_sync_ms",
value: "250",
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
});
it("resets a whole device profile via per-key deletes, tolerating 404s", async () => {
const deleted: string[] = [];
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
if (init?.method === "DELETE") {
const url = String(input);
deleted.push(url);
// The first key's row is already gone: that is the goal state, not a
// failure, so the loop must carry on to the second key.
if (url.includes("player.hdr_enabled")) {
return jsonResponse(
{ error: "not_found", message: "No value is set at this scope" },
404,
);
}
return new Response(null, { status: 204 });
}
return jsonResponse(valuesResponse);
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useDeleteAllAdminUserDeviceSettingsForDevice(), {
wrapper: createWrapper(),
});
result.current.mutate({
userId: 7,
profileId: "p1",
deviceId: "tv-1",
keys: ["player.hdr_enabled", "player.audio_sync_ms"],
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(deleted).toEqual([
"/api/v1/admin/users/7/settings/values/player.hdr_enabled?scope=profile_device&profile_id=p1&device_id=tv-1",
"/api/v1/admin/users/7/settings/values/player.audio_sync_ms?scope=profile_device&profile_id=p1&device_id=tv-1",
]);
});
});