Files
IdeA/frontend/src/features/web/PairingScreen.test.tsx
Blomios 3f1e132e88 feat(frontend): écran Appareils et parcours d'appairage nommé (#77 F1/F2)
Expose la gestion des appareils appairés introduite en B1-B4, sur une
surface unique partagée par le web et le desktop.

- Écran Appareils : liste, renommage, révocation unitaire ou globale,
  activité formatée, panneau de code éphémère.
- Le parcours d'appairage demande un nom d'appareil, pour qu'une
  révocation porte sur quelque chose d'identifiable par l'utilisateur.
- Gateways DeviceGateway en trois adapters (Tauri, HTTP, Mock), le port
  restant le seul contrat connu de la feature.

Les erreurs sont mappées localement et le message du serveur n'est jamais
affiché tel quel : un échec d'appairage ne doit pas devenir un oracle pour
qui teste des codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:27:06 +02:00

189 lines
6.7 KiB
TypeScript

/**
* #75 — the pairing code is uppercase hex (`3F7A9C21`), not digits:
* - the field must summon the text keyboard, not the numeric keypad;
* - a lowercase entry must reach `session.pair()` uppercased and space-free,
* because the server compares the code strictly.
*
* #77 lot F1 — the handshake becomes `{code, name}`:
* - dashes normalise away too, so a code retyped as `AB12-CD34` still pairs;
* - the device name is prefilled from a readable derivation, editable, and
* required; it is never a raw User-Agent.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { WebSession } from "@/adapters/http";
import type { FetchLike } from "@/adapters/http/httpInvoker";
import type { FlagStore } from "@/adapters/http/webSession";
import { PairingScreen } from "./PairingScreen";
function memStore(): FlagStore {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
const okFetch: FetchLike = async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => "{}",
});
/** A `/api/pair` that always fails with the given wire code. */
function failingFetch(status: number, code: string): FetchLike {
return async () => ({
ok: false,
status,
json: async () => ({ code, message: "server wording" }),
text: async () => "{}",
});
}
/** Pins the UA so the name prefill is deterministic. */
function stubUserAgent(ua: string): void {
Object.defineProperty(window.navigator, "userAgent", {
value: ua,
configurable: true,
});
}
const ORIGINAL_UA = window.navigator.userAgent;
function setup(fetchImpl: FetchLike = okFetch) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl, store: memStore() });
const pair = vi.spyOn(session, "pair");
const onPaired = vi.fn();
render(<PairingScreen session={session} onPaired={onPaired} />);
return { pair, onPaired };
}
const codeField = () => screen.getByLabelText("Code d'appairage");
const nameField = () => screen.getByLabelText("Nom de cet appareil") as HTMLInputElement;
const submit = () => screen.getByRole("button", { name: "Appairer" }) as HTMLButtonElement;
beforeEach(() => stubUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"));
afterEach(() => stubUserAgent(ORIGINAL_UA));
describe("PairingScreen code field", () => {
it("asks for the text keyboard, not the numeric keypad", () => {
setup();
expect(codeField().getAttribute("inputmode")).toBe("text");
expect(codeField().getAttribute("autocapitalize")).toBe("characters");
expect(codeField().getAttribute("autocomplete")).toBe("one-time-code");
});
it("describes the code without lying about its shape", () => {
setup();
expect(codeField().getAttribute("placeholder")).toBe("p. ex. AB12CD34");
expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy();
});
it("uppercases and strips spaces before pairing", async () => {
const { pair, onPaired } = setup();
fireEvent.change(codeField(), { target: { value: " ab12cd34 " } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
});
it("strips a dash the user retyped from the grouped display (#77)", async () => {
const { pair, onPaired } = setup();
fireEvent.change(codeField(), { target: { value: "AB12-CD34" } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "iPhone");
});
});
describe("PairingScreen device name", () => {
it("prefills a readable name, never the raw User-Agent", () => {
setup();
expect(nameField().value).toBe("iPhone");
expect(document.body.textContent).not.toContain("Mozilla/5.0");
});
it("sends the edited name with the code", async () => {
const { pair, onPaired } = setup();
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
fireEvent.change(nameField(), { target: { value: " Téléphone de Marie " } });
fireEvent.click(submit());
await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
expect(pair).toHaveBeenCalledWith("AB12CD34", "Téléphone de Marie");
});
it("caps the name at 40 characters", () => {
setup();
expect(nameField().maxLength).toBe(40);
});
it("requires both a code and a name", () => {
setup();
// A name alone (prefilled) is not enough.
expect(submit().disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
expect(submit().disabled).toBe(false);
// Clearing the name blocks it again: the list must never show a blank row.
fireEvent.change(nameField(), { target: { value: " " } });
expect(submit().disabled).toBe(true);
});
it("keeps submit disabled for a blank or separator-only code", () => {
setup();
fireEvent.change(codeField(), { target: { value: " " } });
expect(submit().disabled).toBe(true);
fireEvent.change(codeField(), { target: { value: " - " } });
expect(submit().disabled).toBe(true);
});
});
describe("PairingScreen error placement", () => {
async function submitAndFail(status: number, code: string) {
setup(failingFetch(status, code));
fireEvent.change(codeField(), { target: { value: "AB12CD34" } });
fireEvent.click(submit());
await screen.findByRole("alert");
}
it("shows a rejected name on the name field, not as a form error", async () => {
await submitAndFail(400, "invalid_name");
// Attached to the field: the fix is one edit away, and the code is still
// valid — a form-level error would read as "start over".
const message = screen.getByRole("alert");
expect(message.textContent).toBe(
"Nom d'appareil invalide (1 à 40 caractères). Le code reste valable.",
);
expect(nameField().getAttribute("aria-describedby")).toBe(message.id);
expect(nameField().getAttribute("aria-invalid")).toBe("true");
expect(screen.queryByTestId("pairing-error")).toBeNull();
// The code was never consumed, so it must not be painted as the culprit.
expect(codeField().getAttribute("aria-invalid")).not.toBe("true");
});
it("keeps a rejected code as a form error", async () => {
await submitAndFail(401, "invalid_or_expired");
expect(screen.getByTestId("pairing-error").textContent).toBe("Code invalide ou expiré.");
// The name is not at fault, so it must not be marked as such.
expect(nameField().getAttribute("aria-invalid")).not.toBe("true");
});
});