From 9b1d2d430d9d2bbc329f6486f1f7bacf7cca67d4 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Fri, 16 Oct 2026 03:11:59 +0300 Subject: [PATCH] fix(languages): send the user's id with the language calls so the pill and save row work (E-03, E-05) The server's user_access functions read the account from args.userId and answer "User ID is required" without it. get-language-state, set-active-language and keep-phrase-pending sent no userId, so against a real server the popup pill, the language list and the save window language row never showed. Co-Authored-By: Claude Opus 5.5 --- src/common/language/api.ts | 18 +++++++++--- tests/language-api.test.ts | 55 +++++++++++++++++++++++++++++++++++ tests/popup-languages.test.ts | 2 +- 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 tests/language-api.test.ts diff --git a/src/common/language/api.ts b/src/common/language/api.ts index 1f97134..a18e154 100644 --- a/src/common/language/api.ts +++ b/src/common/language/api.ts @@ -1,4 +1,4 @@ -import { functionProvider } from "@modular-rest/client"; +import { authentication, functionProvider } from "@modular-rest/client"; import type { KeepPhrasePendingReply, LangCode, @@ -17,14 +17,24 @@ import type { * * Deliberately NOT wrapped in withAuthRetry: a missing function answers with an * error body, and a recovery there would log a registered user out. + * + * The `user_access` functions read the account from `args.userId`: modular-rest does + * not add it, and without it the server answers "User ID is required", so the popup + * pill and the save window language row never showed against a real server. */ +/** The signed-in user's id for `user_access` functions (the server checks it is theirs). */ +function caller(): { userId?: string } { + const id = authentication.user?.id; + return id ? { userId: id } : {}; +} + /** `get-language-state`, or null when the server does not have it (yet). */ export async function fetchLanguageState(): Promise { try { const state = await functionProvider.run({ name: "get-language-state", - args: {}, + args: { ...caller() }, }); return isLanguageState(state) ? state : null; } catch (_e) { @@ -52,7 +62,7 @@ export async function fetchLanguageCapabilities(): Promise { const reply = await functionProvider.run<{ state: LanguageState }>({ name: "set-active-language", - args: { language }, + args: { ...caller(), language }, }); if (!reply || !isLanguageState(reply.state)) { throw new Error("The server did not return the new language state."); @@ -70,7 +80,7 @@ export async function keepPhrasePending( ): Promise { return functionProvider.run({ name: "keep-phrase-pending", - args: { phrase, language }, + args: { ...caller(), phrase, language }, }); } diff --git a/tests/language-api.test.ts b/tests/language-api.test.ts new file mode 100644 index 0000000..2bac051 --- /dev/null +++ b/tests/language-api.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The server's `user_access` functions read the account from args.userId and answer +// "User ID is required" without it, so every call that acts on the learner must send it. +const run = vi.fn(); +const auth = { user: { id: "u1", type: "user" } as { id: string; type: string } | null }; + +vi.mock("@modular-rest/client", () => ({ + functionProvider: { run: (...a: unknown[]) => run(...a) }, + authentication: { + get user() { + return auth.user; + }, + }, +})); + +import { fetchLanguageCapabilities, fetchLanguageState, keepPhrasePending, setActiveLanguage } from "../src/common/language/api"; + +const STATE = { translationLanguage: "fa", learningLanguage: "de", languages: [], onboardingState: "done", plan: "learner", maxLanguages: 10, pending: [] }; + +describe("language API client", () => { + beforeEach(() => { + run.mockReset(); + auth.user = { id: "u1", type: "user" }; + }); + + it("get-language-state sends the signed-in user's id", async () => { + run.mockResolvedValue(STATE); + await expect(fetchLanguageState()).resolves.toEqual(STATE); + expect(run).toHaveBeenCalledWith({ name: "get-language-state", args: { userId: "u1" } }); + }); + + it("set-active-language and keep-phrase-pending send the user's id", async () => { + run.mockResolvedValueOnce({ state: STATE }); + await setActiveLanguage("de"); + expect(run).toHaveBeenLastCalledWith({ name: "set-active-language", args: { userId: "u1", language: "de" } }); + + run.mockResolvedValueOnce({ phraseId: "p1", expiresAt: "2026-11-15" }); + await keepPhrasePending({ phrase: "Hallo" }, "de"); + expect(run).toHaveBeenLastCalledWith({ name: "keep-phrase-pending", args: { userId: "u1", phrase: { phrase: "Hallo" }, language: "de" } }); + }); + + it("get-language-capabilities stays anonymous", async () => { + run.mockResolvedValue({ learning: [], own: [] }); + await fetchLanguageCapabilities(); + expect(run).toHaveBeenCalledWith({ name: "get-language-capabilities", args: {} }); + }); + + it("sends no userId while nobody is signed in (the server then refuses, and the reader answers null)", async () => { + auth.user = null; + run.mockRejectedValue(new Error("User ID is required")); + await expect(fetchLanguageState()).resolves.toBeNull(); + expect(run).toHaveBeenCalledWith({ name: "get-language-state", args: {} }); + }); +}); diff --git a/tests/popup-languages.test.ts b/tests/popup-languages.test.ts index 01b447b..ebf7c2f 100644 --- a/tests/popup-languages.test.ts +++ b/tests/popup-languages.test.ts @@ -131,7 +131,7 @@ describe("LanguageSwitcher (popup list)", () => { await w.find("[data-testid='language-row-en']").trigger("click"); await flushPromises(); - expect(run).toHaveBeenCalledWith({ name: "set-active-language", args: { language: "en" } }); + expect(run).toHaveBeenCalledWith({ name: "set-active-language", args: { userId: "u1", language: "en" } }); expect(useLanguageStore().state?.learningLanguage).toBe("en"); const sent = (globalThis as any).chrome.tabs.sendMessage.mock.calls; expect(sent).toHaveLength(2);