mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
The first pass shipped the tagging checkboxes and nothing else, which was a regression dressed as an absorption: an album with no people.json has an empty roster, so the checkboxes rendered an empty list and there was no way to add anybody. The old page gated a setup form on `configured` for exactly that reason. So does this one now. Restored, behaviour-for-behaviour: - The roster grammar, in src/api/setup.ts. One person per line, aliases by comma, `=` for a combination with `+` meaning both and `|` meaning either. Keys are reused for anyone already listed and otherwise handed out one past the highest in use -- never the count, or a key deleted by hand gets reissued and inherits somebody else's photos. Mirrors pack_people.next_key. - The add-person box, for the usual case of noticing a new face in the photo already on screen. - `--untagged`, which was a no-op: it is a view filter, and anything seen this session stays navigable, or tagging the photo you are looking at removes it from the list you are walking. DOCS-web-ui.md claimed group ids were "picked, never typed" while settings rendered a plain text box. That was the mitigation cited for accepting an .env editor at all, so the claim is now backed by a <select> filled from /api/groups -- and a failed listing leaves it empty rather than unhandled. parseSetup returns a result instead of throwing, and readCombos has one return type: a try/catch in the view carried an "and if it was some other error" branch that could not happen, and an untestable branch is an untested one. Same reason the album view splits into roster.tsx and tagger.tsx. Still 100% on all four metrics with no exclude list, and verified against the real album: 185 items, 4 groups in the picker, guard still answering 421. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
130 lines
5.1 KiB
TypeScript
130 lines
5.1 KiB
TypeScript
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
|
|
import { ENV, GROUP } from "../test/fixtures.ts";
|
|
import { serve } from "../test/http.ts";
|
|
import { Settings } from "./settings.tsx";
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("says it is reading before the answer arrives", () => {
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
expect(screen.getByText("Reading .env…")).toBeInTheDocument();
|
|
});
|
|
|
|
it("offers group ids to pick rather than a box to retype one into", async () => {
|
|
// A group id is 44 characters of base64. Picking is the mitigation that
|
|
// makes editing GROUP_IDS from a browser reasonable at all.
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
const picker = await screen.findByLabelText("GROUP_IDS");
|
|
expect(picker.tagName).toBe("SELECT");
|
|
expect(screen.getByRole("option", { name: GROUP.name })).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows an empty picker when the listing could not be read", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn((input: string) => {
|
|
if (input === "/api/groups") return Promise.reject(new Error("down"));
|
|
return Promise.resolve({
|
|
json: () => Promise.resolve(ENV),
|
|
ok: true,
|
|
status: 200,
|
|
} as Response);
|
|
}),
|
|
);
|
|
render(<Settings />);
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("GROUP_IDS")).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByRole("option", { name: GROUP.name })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("will not let a key the server refuses even be typed into", async () => {
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText("PHONE_NUMBER")).toBeDisabled();
|
|
});
|
|
expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeEnabled();
|
|
});
|
|
|
|
it("locks the group fields while no listing has been read", async () => {
|
|
// parse_group_ids only checks syntax. Writing one blind with the container
|
|
// down turns a scoping mistake into a unit that will not start.
|
|
serve({ "/api/env": { body: { ...ENV, groups_known: false } }, "/api/groups": { body: { groups: [] } } });
|
|
render(<Settings />);
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("groups-locked")).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByLabelText("GROUP_IDS")).toBeDisabled();
|
|
});
|
|
|
|
it("asks for a restart once something has been written", async () => {
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("note")).toHaveTextContent("Restart the bot");
|
|
});
|
|
});
|
|
|
|
it("shows the server's refusal verbatim", async () => {
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
|
serve({
|
|
"/api/env": { body: { detail: "COOLDOWN_SECONDS must be an integer" }, status: 400 },
|
|
});
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("note")).toHaveTextContent("must be an integer");
|
|
});
|
|
});
|
|
|
|
it("reports a failure that is not the server's own sentence", async () => {
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(() => Promise.reject(new Error("network is down"))),
|
|
);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("note")).toHaveTextContent("could not save");
|
|
});
|
|
});
|
|
|
|
it("has nothing to save until something is edited", async () => {
|
|
serve({ "/api/env": { body: ENV }, "/api/groups": { body: { groups: [GROUP] } } });
|
|
render(<Settings />);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
});
|
|
});
|
|
it("writes the group id the picker was set to, not one that was typed", async () => {
|
|
const fetcher = serve({
|
|
"/api/env": { body: ENV },
|
|
"/api/groups": { body: { groups: [GROUP] } },
|
|
});
|
|
render(<Settings />);
|
|
await userEvent.selectOptions(await screen.findByLabelText("GROUP_IDS"), GROUP.id);
|
|
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("note")).toBeInTheDocument();
|
|
});
|
|
// A GET is called with one argument, so the init is genuinely absent for
|
|
// the two reads this view makes on mount.
|
|
type Call = [string, undefined | { body: string; method: string }];
|
|
const calls = fetcher.mock.calls as Call[];
|
|
const put = calls.find((call) => call[1]?.method === "PUT");
|
|
expect(JSON.parse(put?.[1]?.body ?? "{}")).toEqual({ values: { GROUP_IDS: GROUP.id } });
|
|
});
|