signal-bot/web/src/views/commands-aliases.test.tsx
Krzysztof kuhy Rudnicki 72e90acb47
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Let the operator add aliases from the Commands tab
A custom alias is data the command table is built from, so it cannot be a
live switch like the ones already on the tab. Instead the management page
writes aliases_custom.json in the bot's state directory and then asks the
bot to reload, in one step: whenever the answer is anything but `reloaded`
-- a word another command has, a name no command has, a bot still
connecting -- the file is restored byte for byte and the bot's own reason is
shown next to the field. What is on disk is therefore always what the bot
serves, and a typo cannot sit in the file poisoning every later reload.

The registry appends the operator's words to the shipped list at
registration, which keeps `!pomoc` on the shipped spelling and routes the
new words through the same clash check as aliases.json. An alias for a name
no command has fails the whole build (ConfigError -> `kept`), because a
silently ignored entry is how a file drifts. The tagger treats custom
aliases as reserved words too, so a person cannot be given one.

registry.py crossed the 250-line cap and now re-exports the record and the
error types from command.py; the bot's state directory lookup, previously
duplicated in pack.py and the suggestions export, is state_dir.py.

Proven end to end: !e2ekot is unknown, the file is written and reload.sh
runs, !e2ekot posts a cat, a file naming a command that does not exist is
refused with 409 and the previous table still answers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 16:20:05 +02:00

76 lines
3.3 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 { serve } from "../test/http.ts";
import { AliasField, wordsOf } from "./commands-aliases.tsx";
afterEach(() => {
vi.unstubAllGlobals();
});
const RELOADED = { commands: 28, name: "cat", status: "reloaded", triggers: ["!kotek", "!kicia"] };
function field(initial: string[] = []): void {
render(<AliasField initial={initial} name="cat" trigger="!kot" />);
}
it("splits on commas and whitespace alike, and keeps no empty word", () => {
expect(wordsOf("!kotek, !kicia")).toEqual(["!kotek", "!kicia"]);
expect(wordsOf(" kotek kicia ,, ")).toEqual(["kotek", "kicia"]);
expect(wordsOf("")).toEqual([]);
});
it("shows the words the command already has, on one line", () => {
field(["!kotek", "!kicia"]);
expect(screen.getByLabelText("!kot aliases")).toHaveValue("!kotek, !kicia");
});
it("sends the words typed and shows the line the bot took, with its count", async () => {
const fetcher = serve({ "/api/aliases/cat": { body: RELOADED } });
field();
await userEvent.type(screen.getByLabelText("!kot aliases"), "kotek kicia");
await userEvent.click(screen.getByRole("button", { name: "Aliases !kot" }));
await waitFor(() => {
expect(screen.getByTestId("aliases-note-cat")).toHaveTextContent("reloaded, 28 commands");
});
const [url, init] = fetcher.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/aliases/cat");
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({ triggers: ["kotek", "kicia"] });
// The bot's spelling wins: the "!" it added is what the field shows now.
expect(screen.getByLabelText("!kot aliases")).toHaveValue("!kotek, !kicia");
});
it("escapes a command name with a colon in it, as the pack's have", async () => {
const fetcher = serve({ "/api/aliases/pack%3Ap1": { body: { ...RELOADED, name: "pack:p1" } } });
render(<AliasField initial={[]} name="pack:p1" trigger="!alfa" />);
await userEvent.click(screen.getByRole("button", { name: "Aliases !alfa" }));
await waitFor(() => {
expect(screen.getByTestId("aliases-note-pack:p1")).toHaveTextContent("reloaded");
});
const [url] = fetcher.mock.calls[0] as [string];
expect(url).toBe("/api/aliases/pack%3Ap1");
});
it("shows the bot's own sentence when the reload is refused", async () => {
serve({ "/api/aliases/cat": { body: { detail: "trigger '!pies' is taken" }, status: 409 } });
field();
await userEvent.type(screen.getByLabelText("!kot aliases"), "pies");
await userEvent.click(screen.getByRole("button", { name: "Aliases !kot" }));
await waitFor(() => {
expect(screen.getByTestId("aliases-note-cat")).toHaveTextContent("trigger '!pies' is taken");
});
// Refused means not kept -- the field still says what was typed, so it can be fixed.
expect(screen.getByLabelText("!kot aliases")).toHaveValue("pies");
});
it("says 'could not save' when the failure is not the server's", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
field();
await userEvent.click(screen.getByRole("button", { name: "Aliases !kot" }));
await waitFor(() => {
expect(screen.getByTestId("aliases-note-cat")).toHaveTextContent("could not save");
});
});