From 73959f9dde343934205fc3ee4327dc209c17740c Mon Sep 17 00:00:00 2001 From: tomdebone Date: Thu, 10 Sep 2026 22:58:25 +0200 Subject: [PATCH] feat(tauri-app): Einladungscode im Client, Produktions-URLs als Default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Dinge, die einen ausgelieferten Client heute unbrauchbar gemacht hätten. Erstens: Seit die öffentliche Instanz Einladungscodes verlangt, bekam jeder Registrierungsversuch aus dem Client 400 InvalidInviteCode, ohne dass es ein Feld für einen Code gegeben hätte. auth_register und create_account nehmen ihn jetzt entgegen, der LoginScreen zeigt das Feld nur im Registrieren-Modus und leert es beim Moduswechsel — sonst würde ein getippter Code im ausgeblendeten Feld überleben und beim nächsten Versuch stillschweigend mitgehen. Ein leeres Feld wird weggelassen, nicht abgelehnt: ob ein Code nötig ist, entscheidet der Server (PDS_INVITE_REQUIRED), und eine lokale Dev-PDS verlangt keinen. "Kein Code angegeben" und "mein Code ist der Leerstring" sind zwei verschiedene Aussagen; nur die erste ist je wahr. Deshalb fliegt der Schlüssel per skip_serializing_if ganz aus dem Body. Der Server unterscheidet bei der Ablehnung bewusst nicht zwischen fehlend, falsch, gesperrt und verbraucht — sonst wären Codes enumerierbar. Die Meldung im Client nennt deshalb beide plausiblen Auswege, statt einen zu raten. Zweitens: Die Basis-URLs fielen ohne Umgebungsvariable auf http://127.0.0.1:2583 bzw. :2584 zurück. Ein gebautes Paket hätte also gegen nichts gesprochen. Beide zeigen jetzt auf https://tweet.maarcade.com — dieselbe Origin für PDS und AppView, der Proxy trennt nach Pfadpräfix. Die Variablen überschreiben weiterhin, damit Entwicklung gegen localhost möglich bleibt; eine leer gesetzte Variable gilt dabei als ungesetzt, weil eine leere Basis-URL sonst als kryptischer Relative-URL-Fehler weit weg von der Ursache auftaucht. Der Settings-View spiegelte dieselben alten Defaults und hätte falsche Backends angezeigt — mitgezogen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX --- crates/tauri-app/src-tauri/src/lib.rs | 157 +++++++++++- crates/tauri-app/src-tauri/src/pds_client.rs | 37 +++ crates/tauri-app/src/App.svelte | 10 +- crates/tauri-app/src/lib/api/client.ts | 58 ++++- crates/tauri-app/src/lib/api/invite.test.ts | 138 +++++++++++ .../src/lib/components/LoginScreen.svelte | 73 +++++- .../src/lib/components/LoginScreen.test.ts | 231 ++++++++++++++++++ 7 files changed, 691 insertions(+), 13 deletions(-) create mode 100644 crates/tauri-app/src/lib/api/invite.test.ts create mode 100644 crates/tauri-app/src/lib/components/LoginScreen.test.ts diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index c9e002a..ec1e27e 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -34,15 +34,32 @@ async fn pds_describe(state: tauri::State<'_, AppState>) -> Result, handle: String, password: String, + invite_code: Option, ) -> Result { let sess = state .pds - .create_account(&handle, &password) + .create_account(&handle, &password, invite_code.as_deref()) .await .map_err(|e| e.to_string())?; let s = AccountSession { @@ -768,16 +785,74 @@ async fn show_notification( Ok(()) } +/// Default PDS base URL — the public instance. +/// +/// This is what a *shipped* build talks to. It used to be +/// `http://127.0.0.1:2583`, which meant a packaged `.app` handed to +/// anyone but the developer pointed at a server that does not exist on +/// their machine: every call failed with a connection error and the +/// login screen could not even render `describeServer`. A default is +/// the configuration of the people who never set one, so it has to be +/// the production deployment. +/// +/// No trailing slash: [`PdsHttpClient`] builds its endpoints as +/// `{base}/xrpc/com.atproto.…`, so the base must end at the host. +const DEFAULT_PDS_URL: &str = "https://tweet.maarcade.com"; + +/// Default AppView base URL. Same host as the PDS — the reverse proxy +/// in front of `tweet.maarcade.com` routes by path prefix: `/xrpc/…` +/// to the PDS, `/api/…` to the AppView. [`AppViewClient`] appends +/// `/api/…` to this base (see the `format!("{}/api/…", self.base_url)` +/// calls in `appview_client.rs`), so the two clients can and must +/// share the one origin. +const DEFAULT_APPVIEW_URL: &str = "https://tweet.maarcade.com"; + +/// Resolve one base URL from its environment variable, falling back to +/// the compiled-in default. +/// +/// **The environment always wins.** Development runs against a local +/// stack — `MAARCADETWEET_PDS_URL=http://127.0.0.1:2583` and +/// `MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584`, which is what +/// `scripts/` and the dev docker-compose set up — and pointing the +/// desktop client at it must stay a matter of exporting two variables, +/// never of rebuilding. Only an *unset* variable takes the production +/// default. +/// +/// A variable set to whitespace (or the empty string) counts as unset: +/// an empty base URL would silently produce request URLs like +/// `/xrpc/…` with no host, and `reqwest` would reject them as a +/// relative-URL error far away from the actual mistake. Trailing +/// slashes are trimmed because both clients append an absolute path to +/// this string, and `https://host//api/x` is not the same route to +/// every proxy. +/// +/// Takes the already-performed lookup rather than the variable name so +/// it stays a pure function — testable without mutating the process +/// environment, and without a Tauri runtime. +fn base_url_or_default(from_env: Result, default: &str) -> String { + let configured = from_env.ok(); + let trimmed = configured + .as_deref() + .map(|v| v.trim().trim_end_matches('/')) + .filter(|v| !v.is_empty()); + trimmed.unwrap_or(default).to_string() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); - let pds_url = std::env::var("MAARCADETWEET_PDS_URL") - .unwrap_or_else(|_| "http://127.0.0.1:2583".to_string()); - let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL") - .unwrap_or_else(|_| "http://127.0.0.1:2584".to_string()); + let pds_url = base_url_or_default( + std::env::var("MAARCADETWEET_PDS_URL"), + DEFAULT_PDS_URL, + ); + let appview_url = base_url_or_default( + std::env::var("MAARCADETWEET_APPVIEW_URL"), + DEFAULT_APPVIEW_URL, + ); + tracing::info!(%pds_url, %appview_url, "resolved backend base URLs"); let state = AppState { pds: PdsHttpClient::new(pds_url.clone()), @@ -1056,3 +1131,75 @@ async fn profile_set( .map_err(|e| e.to_string())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::env::VarError; + + /// The whole point of the change: a build with nothing configured + /// must talk to the public instance, not to a loopback port that + /// only exists on a developer's laptop. Pinned as a literal so a + /// well-meant "let's default back to localhost for dev" has to + /// argue with a red test first. + #[test] + fn unset_env_falls_back_to_the_public_instance() { + assert_eq!( + base_url_or_default(Err(VarError::NotPresent), DEFAULT_PDS_URL), + "https://tweet.maarcade.com" + ); + assert_eq!( + base_url_or_default(Err(VarError::NotPresent), DEFAULT_APPVIEW_URL), + "https://tweet.maarcade.com" + ); + // Both services live behind the same origin — the proxy splits + // them by path prefix (`/xrpc/` vs `/api/`), which the clients + // append themselves. + assert_eq!(DEFAULT_PDS_URL, DEFAULT_APPVIEW_URL); + assert!(!DEFAULT_PDS_URL.ends_with('/')); + } + + /// Development against a local stack has to keep working by + /// exporting a variable, so a set value always beats the default. + #[test] + fn env_var_overrides_the_default() { + assert_eq!( + base_url_or_default(Ok("http://127.0.0.1:2583".into()), DEFAULT_PDS_URL), + "http://127.0.0.1:2583" + ); + assert_eq!( + base_url_or_default(Ok("http://127.0.0.1:2584".into()), DEFAULT_APPVIEW_URL), + "http://127.0.0.1:2584" + ); + } + + /// An empty or whitespace-only variable is a misconfiguration, not + /// a request for an empty base URL: `reqwest` would answer the + /// resulting host-less URL with a relative-URL error nowhere near + /// the cause. Trailing slashes go because both clients append an + /// absolute path (`{base}/xrpc/…`, `{base}/api/…`). + #[test] + fn blank_env_is_ignored_and_trailing_slashes_are_trimmed() { + assert_eq!( + base_url_or_default(Ok("".into()), DEFAULT_PDS_URL), + DEFAULT_PDS_URL + ); + assert_eq!( + base_url_or_default(Ok(" ".into()), DEFAULT_PDS_URL), + DEFAULT_PDS_URL + ); + assert_eq!( + base_url_or_default(Ok("http://127.0.0.1:2584/".into()), DEFAULT_APPVIEW_URL), + "http://127.0.0.1:2584" + ); + assert_eq!( + base_url_or_default(Ok(" https://tweet.maarcade.com// ".into()), DEFAULT_PDS_URL), + "https://tweet.maarcade.com" + ); + // A non-UTF-8 variable is as unusable as an unset one. + assert_eq!( + base_url_or_default(Err(VarError::NotUnicode("\u{fffd}".into())), DEFAULT_PDS_URL), + DEFAULT_PDS_URL + ); + } +} diff --git a/crates/tauri-app/src-tauri/src/pds_client.rs b/crates/tauri-app/src-tauri/src/pds_client.rs index 63de5b7..581a5ad 100644 --- a/crates/tauri-app/src-tauri/src/pds_client.rs +++ b/crates/tauri-app/src-tauri/src/pds_client.rs @@ -26,6 +26,27 @@ pub struct CreateAccountReq { pub handle: String, pub email: Option, pub password: String, + /// Invite code, required by the PDS whenever it runs with + /// `PDS_INVITE_REQUIRED=true` (the public instance at + /// `https://tweet.maarcade.com` does). Rejected codes come back as + /// `400 {"error":"InvalidInviteCode", …}`. + /// + /// **Wire name.** The server's `CreateAccountReq` + /// (`crates/pds-server/src/routes/types.rs`) is snake_case with an + /// `#[serde(alias = "inviteCode")]` for off-the-shelf atproto + /// clients. We are not one of those: every body this client sends + /// is snake_case (`refresh_jwt` in `refresh_session`, `handle` / + /// `password` right here), so `invite_code` is the field name that + /// matches the rest of the file. The alias exists for other people. + /// + /// **Skipped when `None`.** Same reasoning as `validate` below — + /// a PDS with the invite gate *off* must keep accepting our + /// registrations, and sending `"invite_code": null` (let alone + /// `""`) would be claiming the user supplied something. Omitting + /// the key leaves the server's `Option` at `None`, which is exactly + /// "the user gave no code". + #[serde(skip_serializing_if = "Option::is_none")] + pub invite_code: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -130,15 +151,31 @@ impl PdsHttpClient { Ok(r) } + /// `com.atproto.server.createAccount`. + /// + /// `invite_code` is `None` when the user left the field empty; the + /// key is then left out of the body entirely (see + /// [`CreateAccountReq::invite_code`]) so a PDS running without the + /// invite gate still registers the account. A code that only + /// contains whitespace is treated as absent for the same reason — + /// the server's `invite::normalize` trims before looking it up, so + /// `" "` could never match a real code anyway, and passing it on + /// would only turn "you forgot the field" into "your code is + /// wrong". pub async fn create_account( &self, handle: &str, password: &str, + invite_code: Option<&str>, ) -> Result { let body = CreateAccountReq { handle: handle.to_string(), email: None, password: password.to_string(), + invite_code: invite_code + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string), }; let r = self .client diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index 3ef281d..3848797 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -545,17 +545,23 @@ // Used in the Settings view to show which backends the client is // talking to. Kept as plain helpers so they can be swapped for a // `pds_describe`/`appview_describe` Tauri command later. + // The fallbacks mirror `DEFAULT_PDS_URL` / `DEFAULT_APPVIEW_URL` in + // `src-tauri/src/lib.rs`: both services sit behind the one public + // origin, split by path prefix (`/xrpc/` → PDS, `/api/` → AppView). + // If those constants ever move, move these with them — a Settings + // pane that names the wrong backend is worse than one that names + // none. function pdsBase(): string { if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) { return (import.meta as any).env.VITE_PDS_URL as string; } - return "http://127.0.0.1:2583"; + return "https://tweet.maarcade.com"; } function appviewBase(): string { if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) { return (import.meta as any).env.VITE_APPVIEW_URL as string; } - return "http://127.0.0.1:2584"; + return "https://tweet.maarcade.com"; } diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index fabc48c..c537e39 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -142,6 +142,21 @@ export function isAuthFailure(e: unknown): boolean { ); } +/// True when the PDS refused a registration because of the invite +/// code. The public instance runs with `PDS_INVITE_REQUIRED=true` and +/// answers `400 {"error":"InvalidInviteCode","message":…}` — the same +/// code for a missing, misspelled, disabled and already-spent code, on +/// purpose: the server does not tell an unauthenticated caller which +/// of those it was, since that would make invite codes enumerable. +/// +/// Matched on the string for the same reason as [`isTokenInvalid`]: +/// `pds_client.rs` bails with `createAccount failed: {status} {body}` +/// and `lib.rs` stringifies that into the command's `Err(String)`, so +/// the code travels verbatim across the IPC boundary. +function isInvalidInviteCode(e: unknown): boolean { + return errorText(e).includes("InvalidInviteCode"); +} + /// User-facing copy for a failed call, in the app's German UI voice. /// /// An auth failure gets a sentence naming the actual remedy. The raw @@ -155,6 +170,18 @@ export function errorMessage(e: unknown): string { if (isAuthFailure(e)) { return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden."; } + if (isInvalidInviteCode(e)) { + // Deliberately covers "no code given" too: the raw body a user + // would otherwise read is `createAccount failed: 400 Bad Request + // {"error":"InvalidInviteCode","message":"a valid invite code is + // required to create an account on this server"}`. Since the + // server refuses to say *which* way the code was wrong, the copy + // names both plausible fixes rather than guessing one. + return ( + "Einladungscode ungültig oder bereits verbraucht — " + + "bitte prüfen oder einen neuen Code anfordern." + ); + } return String(e); } @@ -208,11 +235,38 @@ function createSessionStore() { set(s); return s; }, - async register(handle: string, password: string) { + /// Create an account on the configured PDS. + /// + /// `inviteCode` is optional because the invite gate lives on the + /// *server* (`PDS_INVITE_REQUIRED`): the public instance at + /// `https://tweet.maarcade.com` requires a code, a local dev PDS + /// normally does not. The client therefore never refuses a + /// registration for a missing code on its own — it would break + /// development against localhost — it just forwards what the user + /// typed and lets the PDS decide. + /// + /// An empty (or whitespace-only) field is *omitted*, not sent as + /// `""`. Those are two different statements: "I gave no code" vs. + /// "my code is the empty string". The first is legitimate against + /// an open server; the second is never true and would only turn + /// into a confusing `InvalidInviteCode` on a server that has the + /// gate switched off. Dropping the key leaves the Rust + /// `Option` at `None`, and `create_account` then leaves + /// the field out of the JSON body entirely. + async register(handle: string, password: string, inviteCode?: string) { if (!isTauri()) { throw new Error("register requires the Tauri desktop runtime"); } - const s = await safeInvoke("auth_register", { handle, password }); + const code = inviteCode?.trim(); + const s = await safeInvoke("auth_register", { + handle, + password, + // Explicit `null` rather than a dropped key, matching how + // `createPost` passes its optional `embed` / `reply`: it + // deserialises into the Rust `Option` as `None` + // without depending on how `invoke` treats `undefined`. + inviteCode: code ? code : null, + }); set(s); return s; }, diff --git a/crates/tauri-app/src/lib/api/invite.test.ts b/crates/tauri-app/src/lib/api/invite.test.ts new file mode 100644 index 0000000..5e296fb --- /dev/null +++ b/crates/tauri-app/src/lib/api/invite.test.ts @@ -0,0 +1,138 @@ +// Unit tests for the invite-code half of the registration path. +// +// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is +// mocked so no Tauri shell is needed, and every assertion is about the +// exact command name + argument bag we hand the Rust IPC layer. That +// argument bag is the contract — `invoke` maps camelCase JS keys onto +// the snake_case Rust command parameters (`inviteCode` → `invite_code` +// on `auth_register`), so a typo here surfaces at runtime as a null +// argument, not at compile time. +// +// Covered: +// * `session.register` — the code is forwarded as `inviteCode`, +// alongside the unchanged `handle` / `password`; +// * the empty / whitespace-only field — must reach the shell as +// `null` ("no code given"), never as `""` ("my code is the empty +// string"), because a PDS without the invite gate has to keep +// accepting registrations; +// * `errorMessage` — the PDS's `InvalidInviteCode` body becomes +// German copy instead of the raw wire string. +// +// Run with: +// npx vitest run src/lib/api/invite.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), + isTauri: () => true, +})); + +/// What the Rust `auth_register` command answers with on success. +const SESSION = { + did: "did:plc:alice", + handle: "alice.tweet.maarcade.com", + access_jwt: "acc", + refresh_jwt: "ref", +}; + +beforeEach(() => { + invokeMock.mockReset(); +}); + +describe("session.register", () => { + it("forwards the invite code as `inviteCode`", async () => { + const { session } = await import("./client"); + invokeMock.mockResolvedValueOnce(SESSION); + + const s = await session.register( + "alice.tweet.maarcade.com", + "hunter2hunter2", + "mt-7k3qw-z9d2m", + ); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith("auth_register", { + handle: "alice.tweet.maarcade.com", + password: "hunter2hunter2", + inviteCode: "mt-7k3qw-z9d2m", + }); + expect(s.did).toBe("did:plc:alice"); + }); + + it("trims the surrounding whitespace off a pasted code", async () => { + const { session } = await import("./client"); + invokeMock.mockResolvedValueOnce(SESSION); + + // Copying a code out of a chat message routinely drags a space or + // a newline along. The server trims too (`invite::normalize`), but + // sending the untrimmed string would mean the *client* and the + // server disagree about whether the field is empty. + await session.register("alice.test", "pw", " mt-7k3qw-z9d2m\n"); + + expect(invokeMock.mock.calls[0][1]).toMatchObject({ + inviteCode: "mt-7k3qw-z9d2m", + }); + }); + + it("sends null — never an empty string — when the field is blank", async () => { + const { session } = await import("./client"); + + // Three ways the UI can hand us "nothing": the argument omitted + // entirely (login-shaped call), an untouched input, and an input + // holding only whitespace. All three mean "the user gave no code" + // and must arrive at the Rust `Option` as `None`, so that + // a PDS running without `PDS_INVITE_REQUIRED` still registers the + // account instead of rejecting a blank code. + for (const blank of [undefined, "", " "]) { + invokeMock.mockReset(); + invokeMock.mockResolvedValueOnce(SESSION); + + await session.register("alice.test", "pw", blank); + + const args = invokeMock.mock.calls[0][1] as Record; + expect(args.inviteCode).toBeNull(); + expect(args.inviteCode).not.toBe(""); + // The rest of the bag is unaffected. + expect(args.handle).toBe("alice.test"); + expect(args.password).toBe("pw"); + } + }); +}); + +describe("errorMessage for InvalidInviteCode", () => { + /// Exactly what crosses the IPC boundary when the PDS refuses the + /// code: `pds_client.rs` bails with `createAccount failed: {status} + /// {body}` and `lib.rs` stringifies that into `Err(String)`, which + /// `invoke` rejects with as a bare JS string. + const RAW = + 'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' + + '"message":"a valid invite code is required to create an account on this server"}'; + + it("replaces the raw 400 body with copy the user can act on", async () => { + const { errorMessage } = await import("./client"); + const msg = errorMessage(RAW); + + expect(msg).toContain("Einladungscode"); + // None of the wire noise survives into the UI. + expect(msg).not.toContain("InvalidInviteCode"); + expect(msg).not.toContain("400"); + // Both shapes a rejected `invoke` can produce — a bare string and + // an Error — go through the same `errorText` normalisation. + expect(errorMessage(new Error(RAW))).toBe(msg); + }); + + it("leaves unrelated registration failures verbatim", async () => { + const { errorMessage } = await import("./client"); + // A taken handle is a different 400 and has its own message worth + // showing; the invite branch must not swallow it. + expect( + errorMessage( + 'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}', + ), + ).toContain("HandleNotAvailable"); + expect(errorMessage("createAccount failed: 500 db down")).toContain("500"); + }); +}); diff --git a/crates/tauri-app/src/lib/components/LoginScreen.svelte b/crates/tauri-app/src/lib/components/LoginScreen.svelte index 61db7b1..51763ad 100644 --- a/crates/tauri-app/src/lib/components/LoginScreen.svelte +++ b/crates/tauri-app/src/lib/components/LoginScreen.svelte @@ -1,6 +1,11 @@ @@ -172,6 +228,15 @@ font-size: var(--fs-50); letter-spacing: var(--tracking-label); } + /* Sub-label under the invite field. Same dim mono voice as the + `// pds: …` server meta line above the form, so it reads as a + comment on the field rather than as a second input label. */ + .hint { + color: var(--text-dim); + font-family: var(--font-mono); + font-size: var(--fs-50); + opacity: 0.8; + } .form input { background: var(--bg); border: 1px solid var(--line-2); diff --git a/crates/tauri-app/src/lib/components/LoginScreen.test.ts b/crates/tauri-app/src/lib/components/LoginScreen.test.ts new file mode 100644 index 0000000..19594c6 --- /dev/null +++ b/crates/tauri-app/src/lib/components/LoginScreen.test.ts @@ -0,0 +1,231 @@ +// Regression guard for the invite-code field on the login screen. +// +// The public instance runs the PDS with `PDS_INVITE_REQUIRED=true`, so +// `createAccount` without a code is refused with `400 +// {"error":"InvalidInviteCode", …}`. Three things have to hold for the +// screen to be usable against it: +// +// 1. the field exists in "register" mode and *not* in "login" mode — +// a code is meaningless when signing in, and an input that is +// present but ignored invites people to fill it in; +// 2. what the user typed reaches `session.register` as its third +// argument, and an untouched field does not become a blank code; +// 3. a rejected code renders as German copy, not as the raw wire +// body, which is where the user would otherwise read +// `createAccount failed: 400 Bad Request {"error":…}`. +// +// Setup follows `NotificationsView.test.ts`: the component is mounted +// against jsdom with `../api/client` partially mocked — the real +// `errorMessage` is kept, since the error copy is part of what we are +// asserting on, and only the calls that would need a Tauri runtime are +// stubbed. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mount, unmount, tick } from "svelte"; + +const registerMock = vi.fn(); +const loginMock = vi.fn(); +const describeServerMock = vi.fn(); + +vi.mock("../api/client", async () => { + const actual = + await vi.importActual("../api/client"); + return { + ...actual, + // Keep the real `errorMessage` from `actual` — the German copy for + // `InvalidInviteCode` is exactly what test 3 checks. + describeServer: (...args: unknown[]) => describeServerMock(...args), + session: { + ...actual.session, + register: (...args: unknown[]) => registerMock(...args), + login: (...args: unknown[]) => loginMock(...args), + }, + }; +}); + +import LoginScreen from "./LoginScreen.svelte"; + +let target: HTMLDivElement; +let app: ReturnType | null = null; + +const SESSION = { + did: "did:plc:alice", + handle: "alice.tweet.maarcade.com", + access_jwt: "acc", + refresh_jwt: "ref", +}; + +beforeEach(() => { + target = document.createElement("div"); + document.body.appendChild(target); + registerMock.mockReset(); + loginMock.mockReset(); + describeServerMock.mockReset(); + // `onMount` calls this; a resolved stub keeps the meta line quiet. + describeServerMock.mockResolvedValue({ did: "did:web:tweet.maarcade.com" }); +}); + +afterEach(() => { + if (app) unmount(app); + app = null; + target.remove(); +}); + +/// Let `onMount`, the mocked promises and Svelte's flush settle. +async function flush(turns = 6) { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + await tick(); + } +} + +/// The screen has no test ids; the inputs are addressed the way a user +/// would, by the label text beside them. +function fieldByLabel(label: string): HTMLInputElement | null { + for (const el of target.querySelectorAll("label.field")) { + if (el.querySelector(".key")?.textContent?.trim() === label) { + return el.querySelector("input"); + } + } + return null; +} + +function typeInto(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +/// Flip login ⇄ register via the ghost button under the form. +async function toggleMode() { + const buttons = [...target.querySelectorAll("button.btn--ghost")]; + buttons[buttons.length - 1].dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await tick(); +} + +async function submitForm() { + target + .querySelector("button.btn--primary")! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flush(); +} + +async function mountScreen() { + app = mount(LoginScreen, { target, props: { onLogin: vi.fn() } }); + await flush(); +} + +describe("LoginScreen invite field", () => { + it("shows the code field only while registering", async () => { + await mountScreen(); + + // "login" is the default mode — no invite field, and nothing in + // the tab order either, since `{#if}` removes it from the DOM. + expect(fieldByLabel("einladungscode")).toBeNull(); + + await toggleMode(); + expect(fieldByLabel("einladungscode")).not.toBeNull(); + + await toggleMode(); + expect(fieldByLabel("einladungscode")).toBeNull(); + }); + + it("passes the typed code to session.register as the third argument", async () => { + registerMock.mockResolvedValue(SESSION); + await mountScreen(); + await toggleMode(); + + typeInto(fieldByLabel("handle")!, "alice.tweet.maarcade.com"); + typeInto(fieldByLabel("password")!, "hunter2hunter2"); + typeInto(fieldByLabel("einladungscode")!, "mt-7k3qw-z9d2m"); + await submitForm(); + + expect(loginMock).not.toHaveBeenCalled(); + expect(registerMock).toHaveBeenCalledTimes(1); + expect(registerMock).toHaveBeenCalledWith( + "alice.tweet.maarcade.com", + "hunter2hunter2", + "mt-7k3qw-z9d2m", + ); + }); + + it("does not turn an untouched field into a blank code", async () => { + registerMock.mockResolvedValue(SESSION); + await mountScreen(); + await toggleMode(); + + typeInto(fieldByLabel("handle")!, "alice.test"); + typeInto(fieldByLabel("password")!, "hunter2hunter2"); + // Invite field deliberately left alone. The submit must still go + // through: whether a code is required is the *server's* call + // (`PDS_INVITE_REQUIRED`), and a dev PDS on localhost runs without + // the gate. What must not happen is a blank code travelling on as + // if the user had entered one. + await submitForm(); + + expect(registerMock).toHaveBeenCalledTimes(1); + const code = registerMock.mock.calls[0][2]; + expect(code === "" || code === undefined).toBe(true); + expect(code?.trim?.() ?? "").toBe(""); + }); + + it("clears a typed code when switching back to login", async () => { + loginMock.mockResolvedValue(SESSION); + await mountScreen(); + await toggleMode(); + + typeInto(fieldByLabel("handle")!, "alice.test"); + typeInto(fieldByLabel("password")!, "hunter2hunter2"); + typeInto(fieldByLabel("einladungscode")!, "mt-stale-code0"); + + // Back to login, then to register again: a stale code lingering in + // the hidden field would be submitted silently on the next try. + await toggleMode(); + await toggleMode(); + expect(fieldByLabel("einladungscode")!.value).toBe(""); + }); + + it("renders German copy when the PDS rejects the code", async () => { + // Verbatim what reaches the component: `pds_client.rs` bails with + // `createAccount failed: {status} {body}`, `lib.rs` stringifies it + // into the command's `Err(String)`, and `invoke` rejects with that + // bare string. + registerMock.mockRejectedValue( + 'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' + + '"message":"a valid invite code is required to create an account on this server"}', + ); + await mountScreen(); + await toggleMode(); + + typeInto(fieldByLabel("handle")!, "alice.test"); + typeInto(fieldByLabel("password")!, "hunter2hunter2"); + typeInto(fieldByLabel("einladungscode")!, "mt-wrong-code0"); + await submitForm(); + + const err = target.querySelector(".err"); + expect(err).not.toBeNull(); + expect(err!.textContent).toContain("Einladungscode"); + // The wire noise the user would otherwise be shown is gone. + expect(err!.textContent).not.toContain("InvalidInviteCode"); + expect(err!.textContent).not.toContain("400 Bad Request"); + }); + + it("still shows an unrelated failure verbatim", async () => { + // The invite branch must not swallow every registration error — + // a taken handle has its own message worth reading. + registerMock.mockRejectedValue( + 'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}', + ); + await mountScreen(); + await toggleMode(); + + typeInto(fieldByLabel("handle")!, "alice.test"); + typeInto(fieldByLabel("password")!, "hunter2hunter2"); + await submitForm(); + + expect(target.querySelector(".err")!.textContent).toContain( + "HandleNotAvailable", + ); + }); +});