fix(notifications): a confirm names its user and channel again; t() and a notice's text are kept apart by a contract
The value sweep wrapped two plain t() values in raw(), so the delete-user and the YouTube subscribe confirms read "[object Object]" where the email or the channel was. tsc cannot see that seam, so a syntax contract now refuses a notice value handed to t() and an already-said text kept in a notice. The raw() doc says what it is: text kept as fetched (a Plex title in the language it was fetched in). seasonTitleText's no-number branches are tested.
This commit is contained in:
@@ -74,6 +74,8 @@ describe("a season's name", () => {
|
|||||||
["Season 1", 1, "plex.seasonTitle(1)"],
|
["Season 1", 1, "plex.seasonTitle(1)"],
|
||||||
["Specials", 0, "plex.specials"],
|
["Specials", 0, "plex.specials"],
|
||||||
["Book One: Water", 1, "Book One: Water"],
|
["Book One: Water", 1, "Book One: Water"],
|
||||||
|
["Season 4", null, "Season 4"],
|
||||||
|
["", null, "plex.playlist.seasonUnknown"],
|
||||||
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
|
])("Plex's %j (season %s) reads %j", (title, n, shown) => {
|
||||||
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
|
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
|
||||||
expect(screen.getByText(shown)).toBeTruthy();
|
expect(screen.getByText(shown)).toBeTruthy();
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { readSources } from "./rawSources.testkit";
|
||||||
|
|
||||||
|
// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says
|
||||||
|
// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is
|
||||||
|
// unknown, and i18next's options take any value — so the slips are caught here, on the syntax:
|
||||||
|
// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]";
|
||||||
|
// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`)
|
||||||
|
// stays in the language it was raised in.
|
||||||
|
|
||||||
|
const isT = (e: ts.Expression) =>
|
||||||
|
(ts.isIdentifier(e) && e.text === "t") ||
|
||||||
|
(ts.isPropertyAccessExpression(e) && e.name.text === "t");
|
||||||
|
const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression =>
|
||||||
|
ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text);
|
||||||
|
const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression);
|
||||||
|
|
||||||
|
function mixedNotifText(name: string, src: string): string[] {
|
||||||
|
const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||||
|
const out: string[] = [];
|
||||||
|
const at = (n: ts.Node, what: string) =>
|
||||||
|
out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`);
|
||||||
|
const visit = (n: ts.Node) => {
|
||||||
|
if (isTCall(n)) {
|
||||||
|
for (const arg of n.arguments.slice(1)) {
|
||||||
|
if (!ts.isObjectLiteralExpression(arg)) continue;
|
||||||
|
for (const p of arg.properties) {
|
||||||
|
if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"]))
|
||||||
|
at(p, "a notice value handed to t()");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0]))
|
||||||
|
at(n, "a said text kept in a notice");
|
||||||
|
if (
|
||||||
|
ts.isPropertyAssignment(n) &&
|
||||||
|
ts.isIdentifier(n.name) &&
|
||||||
|
n.name.text === "detail" &&
|
||||||
|
isTCall(n.initializer)
|
||||||
|
)
|
||||||
|
at(n, "a said text kept as a notice's detail");
|
||||||
|
ts.forEachChild(n, visit);
|
||||||
|
};
|
||||||
|
visit(file);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a notice's text and a plain t() do not mix", () => {
|
||||||
|
it("holds across src", () => {
|
||||||
|
const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]);
|
||||||
|
expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app
|
||||||
|
expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"],
|
||||||
|
['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"],
|
||||||
|
['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"],
|
||||||
|
['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"],
|
||||||
|
['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"],
|
||||||
|
])("sees %s", (src, what) => {
|
||||||
|
expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })',
|
||||||
|
't("channels.discovery.confirmSubscribe", { name: nameOf(c) })',
|
||||||
|
"sayNotif(msg(key), t)",
|
||||||
|
])("lets %s through", (src) => {
|
||||||
|
expect(mixedNotifText("x.tsx", src)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,10 +12,11 @@ import { log, type Level } from "./log";
|
|||||||
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
|
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
|
||||||
// the reader's language each time it is shown. The text a caller translated at raise time was kept
|
// the reader's language each time it is shown. The text a caller translated at raise time was kept
|
||||||
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
|
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
|
||||||
// text that has no translation (a video's title, a name); `detail` is a server refusal, localized at
|
// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched
|
||||||
// display like the key. `notify` takes nothing else, and a value is no plain string either (a name
|
// in, since the card holds only that one); `detail` is a server refusal, localized at display like
|
||||||
// goes in as `raw`, a translated word as its own `msg`), so a pre-translated string does not
|
// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as
|
||||||
// type-check anywhere in a notice — only `raw(t(…))` would say it once, and that reads as what it is.
|
// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`,
|
||||||
|
// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses.
|
||||||
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
|
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
|
||||||
export type NotifValue = number | NotifText | { number: number } | { bytes: number };
|
export type NotifValue = number | NotifText | { number: number } | { bytes: number };
|
||||||
export type NotifText =
|
export type NotifText =
|
||||||
@@ -26,7 +27,7 @@ export type NotifText =
|
|||||||
/** The notice text for `t(key, values)`, said when it is shown. */
|
/** The notice text for `t(key, values)`, said when it is shown. */
|
||||||
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
|
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
|
||||||
values ? { key, values } : { key };
|
values ? { key, values } : { key };
|
||||||
/** Text with no translation of its own (a title, a name), shown as it is. */
|
/** Text kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */
|
||||||
export const raw = (text: string): NotifText => ({ raw: text });
|
export const raw = (text: string): NotifText => ({ raw: text });
|
||||||
|
|
||||||
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
|
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Subscribing changes the reader's real YouTube account, so the confirm names the channel it
|
||||||
|
// subscribes to — in both languages, through the real i18n. A notice's value (`raw`, `msg`) is said
|
||||||
|
// only by sayNotif: handed to a plain `t()` it reads "[object Object]".
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import i18n from "../../i18n";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) }));
|
||||||
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||||
|
|
||||||
|
import { useWatchedChannelActions } from "./useWatchedChannelActions";
|
||||||
|
import type { WatchedChannel } from "../../lib/api";
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
cleanup();
|
||||||
|
h.confirm.mockClear();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = { id: "UC1", title: "Kurzgesagt" } as WatchedChannel;
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("the subscribe confirm", () => {
|
||||||
|
it.each(["en", "hu"])("names the channel (%s)", async (lng) => {
|
||||||
|
await i18n.changeLanguage(lng);
|
||||||
|
const { result } = renderHook(() => useWatchedChannelActions(() => {}), { wrapper });
|
||||||
|
await act(() => result.current.onSubscribe(channel));
|
||||||
|
const said = h.confirm.mock.lastCall![0].message;
|
||||||
|
expect(said).toContain("Kurzgesagt");
|
||||||
|
expect(said).not.toContain("[object Object]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,7 +54,7 @@ export function useWatchedChannelActions(onOpenWizard: () => void) {
|
|||||||
const onSubscribe = async (c: WatchedChannel): Promise<boolean> => {
|
const onSubscribe = async (c: WatchedChannel): Promise<boolean> => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("channels.discovery.subscribe"),
|
title: t("channels.discovery.subscribe"),
|
||||||
message: t("channels.discovery.confirmSubscribe", { name: raw(nameOf(c)) }),
|
message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }),
|
||||||
confirmLabel: t("channels.discovery.subscribe"),
|
confirmLabel: t("channels.discovery.subscribe"),
|
||||||
});
|
});
|
||||||
if (ok) subscribe.mutate(c);
|
if (ok) subscribe.mutate(c);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Deleting a user cannot be undone, so its confirm names the account — in both languages, through
|
||||||
|
// the real i18n. A notice's value (`raw`, `msg`) is said only by sayNotif: handed to a plain `t()`
|
||||||
|
// it reads "[object Object]".
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import i18n from "../../i18n";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ confirm: vi.fn(async (_o: { message: string }) => false) }));
|
||||||
|
vi.mock("../../lib/useMe", () => ({
|
||||||
|
useMe: () => ({
|
||||||
|
id: 1,
|
||||||
|
email: "admin@x",
|
||||||
|
role: "admin",
|
||||||
|
pending_invites: 0,
|
||||||
|
pending_activations: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||||
|
|
||||||
|
import AdminUsers from "./AdminUsers";
|
||||||
|
import { api, type AdminUserRow } from "../../lib/api";
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
h.confirm.mockClear();
|
||||||
|
await i18n.changeLanguage("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: 2,
|
||||||
|
email: "someone@example.org",
|
||||||
|
role: "user",
|
||||||
|
is_suspended: false,
|
||||||
|
email_verified: true,
|
||||||
|
is_active: true,
|
||||||
|
} as unknown as AdminUserRow;
|
||||||
|
|
||||||
|
describe("the delete-user confirm", () => {
|
||||||
|
it.each(["en", "hu"])("names the account (%s)", async (lng) => {
|
||||||
|
await i18n.changeLanguage(lng);
|
||||||
|
vi.spyOn(api, "adminUsers").mockResolvedValue([row] as never);
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<AdminUsers />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: i18n.t("users.rowMenu") }));
|
||||||
|
fireEvent.click(await screen.findByText(i18n.t("users.delete.action")));
|
||||||
|
await waitFor(() => expect(h.confirm).toHaveBeenCalled());
|
||||||
|
const said = h.confirm.mock.lastCall![0].message;
|
||||||
|
expect(said).toContain("someone@example.org");
|
||||||
|
expect(said).not.toContain("[object Object]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -209,7 +209,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||||||
const onDelete = async (u: AdminUserRow) => {
|
const onDelete = async (u: AdminUserRow) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("users.delete.title"),
|
title: t("users.delete.title"),
|
||||||
message: t("users.delete.confirm", { email: raw(u.email) }),
|
message: t("users.delete.confirm", { email: u.email }),
|
||||||
confirmLabel: t("users.delete.action"),
|
confirmLabel: t("users.delete.action"),
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user