@remit/web-client 0.0.229 → 0.0.230

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.229",
3
+ "version": "0.0.230",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Walking the config import wizard against a mocked instance.
3
+ *
4
+ * Every state here is one the components could already render in Storybook; the
5
+ * question this file answers is whether the app can ever reach them. Each of
6
+ * these was unreachable at one point: the 409 came back with its code on the
7
+ * thrown wrapper's body rather than on the error, an account whose password had
8
+ * just been verified stayed red because `connectionState` is written later by a
9
+ * worker, and "Check the file" was live before there was a document to send.
10
+ *
11
+ * Held with the real caches wired to `lib/query-error-handler.ts`, because
12
+ * "does the fatal overlay swallow this" is a question about what those two
13
+ * handlers pass, and nothing below them can see it. The overlay itself is not
14
+ * mounted — `getCurrentFatalError` is the same signal without a second app's
15
+ * worth of chrome in the tree.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, describe, it } from "node:test";
20
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
21
+ import { createElement } from "react";
22
+ import { ApiError } from "../../lib/api";
23
+ import "../../lib/client";
24
+ import { shouldEscalate, softErrorStatuses } from "../../lib/error-classifier";
25
+ import { __resetFatalError, getCurrentFatalError } from "../../lib/fatal-error";
26
+ import {
27
+ handleMutationCacheError,
28
+ handleQueryCacheError,
29
+ } from "../../lib/query-error-handler";
30
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
31
+ import {
32
+ type HttpCall,
33
+ type HttpMock,
34
+ mockFetch,
35
+ } from "../../test-support/http";
36
+ import { StepWelcome } from "../onboarding/OnboardingWizard";
37
+ import { ConfigImportWizard } from "./ConfigImportWizard";
38
+
39
+ let harness: DomHarness | undefined;
40
+ let http: HttpMock | undefined;
41
+
42
+ afterEach(() => {
43
+ harness?.close();
44
+ harness = undefined;
45
+ http?.restore();
46
+ http = undefined;
47
+ __resetFatalError();
48
+ });
49
+
50
+ const ACCOUNT_ID = "acct-imported";
51
+
52
+ const escalatingClient = (): QueryClient =>
53
+ new QueryClient({
54
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
55
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
56
+ defaultOptions: {
57
+ queries: { retry: false },
58
+ mutations: { retry: false },
59
+ },
60
+ });
61
+
62
+ /** The flat body the API emits, at the status it emits it with. */
63
+ const refusal = (status: number, body: unknown): Response =>
64
+ new Response(JSON.stringify(body), {
65
+ status,
66
+ headers: { "content-type": "application/json" },
67
+ });
68
+
69
+ const report = (overrides: Record<string, unknown> = {}) => ({
70
+ valid: true,
71
+ schemaVersion: 1,
72
+ applied: false,
73
+ items: [{ section: "accounts", key: "a@example.test", verdict: "created" }],
74
+ errors: [],
75
+ warnings: [],
76
+ accountsNeedingCredentials: [],
77
+ ...overrides,
78
+ });
79
+
80
+ const importedAccount = (): Record<string, unknown> => ({
81
+ accountId: ACCOUNT_ID,
82
+ accountConfigId: "cfg-1",
83
+ username: "a@example.test",
84
+ email: "a@example.test",
85
+ authType: "password",
86
+ imapHost: "imap.example.test",
87
+ imapPort: 993,
88
+ imapTls: true,
89
+ imapStartTls: false,
90
+ smtpEnabled: false,
91
+ smtpHost: "",
92
+ smtpPort: 587,
93
+ smtpTls: false,
94
+ smtpStartTls: false,
95
+ smtpUsername: "",
96
+ isActive: false,
97
+ // The server keeps saying this: only the imap worker clears it, on a
98
+ // connection that has not happened yet.
99
+ connectionState: "credentials_missing",
100
+ createdAt: 0,
101
+ updatedAt: 0,
102
+ folderAppointments: [],
103
+ });
104
+
105
+ interface Backend {
106
+ onImport?: (call: HttpCall) => unknown;
107
+ accounts?: Array<Record<string, unknown>>;
108
+ }
109
+
110
+ const done: string[] = [];
111
+
112
+ const start = (backend: Backend = {}): DomHarness => {
113
+ done.length = 0;
114
+ http = mockFetch((call) => {
115
+ if (call.path === "/config/import") {
116
+ return backend.onImport?.(call) ?? report();
117
+ }
118
+ if (call.path === "/config") {
119
+ return { accounts: backend.accounts ?? [], mailboxes: [] };
120
+ }
121
+ if (call.path === "/accounts/test-connection") {
122
+ return { imapSuccess: true, smtpSuccess: true };
123
+ }
124
+ return {};
125
+ });
126
+ harness = createDomHarness({ queryClient: escalatingClient() });
127
+ harness.renderApp(
128
+ createElement(ConfigImportWizard, {
129
+ onDone: (outcome: string) => done.push(outcome),
130
+ }),
131
+ );
132
+ return harness;
133
+ };
134
+
135
+ /**
136
+ * Hand the picker a file. jsdom has no `DataTransfer`, so the drop path cannot
137
+ * be driven from here; the input behind "Choose file" is the same entry point
138
+ * and takes a `files` list that only needs `item`.
139
+ */
140
+ const pickFile = async (dom: DomHarness, name: string, text: string) => {
141
+ const input = dom.query<HTMLInputElement>('input[type="file"]');
142
+ assert.ok(input, "expected the file input");
143
+ const file = new File([text], name, { type: "application/json" });
144
+ Object.defineProperty(input, "files", {
145
+ configurable: true,
146
+ value: { 0: file, length: 1, item: (i: number) => (i === 0 ? file : null) },
147
+ });
148
+ dom.dispatch(input, new dom.window.Event("change", { bubbles: true }));
149
+ await dom.flush();
150
+ await dom.wait(10);
151
+ await dom.flush();
152
+ };
153
+
154
+ const CONFIG = JSON.stringify({ kind: "reader.config", schemaVersion: 1 });
155
+
156
+ const checkButton = (dom: DomHarness): HTMLButtonElement =>
157
+ dom.byText("button", "Check the file") as HTMLButtonElement;
158
+
159
+ describe("ConfigImportWizard — a file that cannot be sent", () => {
160
+ it("keeps the check disabled and says why, for a file that is not JSON", async () => {
161
+ const dom = start();
162
+ await pickFile(dom, "photos.zip", "PKnot json");
163
+
164
+ assert.match(dom.text(), /That file is not JSON/);
165
+ assert.equal(checkButton(dom).disabled, true);
166
+ // Nothing left the browser: the refusal happened before the request.
167
+ assert.equal(http?.to("/config/import").length, 0);
168
+ });
169
+
170
+ it("arms the check only once a document has parsed", async () => {
171
+ const dom = start();
172
+ assert.equal(checkButton(dom).disabled, true);
173
+
174
+ await pickFile(dom, "config.json", CONFIG);
175
+ assert.equal(checkButton(dom).disabled, false);
176
+ });
177
+ });
178
+
179
+ describe("ConfigImportWizard — an instance that is not empty", () => {
180
+ const conflicted = () =>
181
+ refusal(409, {
182
+ code: "config_not_empty",
183
+ message: "already holds configuration",
184
+ details: { accounts: "2", filters: "14" },
185
+ });
186
+
187
+ it("offers abort or merge on the 409 rather than a connection error", async () => {
188
+ const dom = start({ onImport: conflicted });
189
+ await pickFile(dom, "config.json", CONFIG);
190
+ dom.click(checkButton(dom));
191
+ await dom.flush();
192
+
193
+ assert.match(dom.text(), /This instance already has configuration/);
194
+ assert.match(dom.text(), /2 accounts, 14 rules are set up here already/);
195
+ });
196
+
197
+ /**
198
+ * The other half of reaching that screen: the refusal must not also take the
199
+ * app to the fatal page over it. That decision is `shouldEscalate` reading
200
+ * the mutation's meta, and it is pinned here rather than through the wizard
201
+ * because under this loader `lib/client.ts` and the generated SDK resolve
202
+ * `client.gen` through two specifiers (`…/client.gen.ts` and `./client.gen.js`)
203
+ * and so hold two client instances — the error interceptor registers on one
204
+ * and the request goes through the other, which never happens under Vite,
205
+ * where both specifiers resolve to the same module.
206
+ */
207
+ it("keeps the 409 off the fatal page, and still escalates a 401", () => {
208
+ const meta = softErrorStatuses(409);
209
+ assert.equal(
210
+ shouldEscalate(new ApiError("not empty", 409, {}), meta, "user"),
211
+ false,
212
+ );
213
+ assert.equal(
214
+ shouldEscalate(new ApiError("signed out", 401, {}), meta, "user"),
215
+ true,
216
+ );
217
+ assert.equal(
218
+ shouldEscalate(new ApiError("broken", 500, {}), meta, "user"),
219
+ true,
220
+ );
221
+ });
222
+
223
+ it("does not record an import when the reader aborts the 409", async () => {
224
+ const dom = start({ onImport: conflicted });
225
+ await pickFile(dom, "config.json", CONFIG);
226
+ dom.click(checkButton(dom));
227
+ await dom.flush();
228
+
229
+ dom.click(dom.byText("button", "Stop, keep this instance"));
230
+ await dom.flush();
231
+ dom.click(dom.byText("button", "Continue"));
232
+ await dom.flush();
233
+
234
+ assert.deepEqual(done, ["abandoned"]);
235
+ });
236
+ });
237
+
238
+ describe("ConfigImportWizard — a session that has lapsed", () => {
239
+ it("escalates a 401 rather than keeping it in the wizard", async () => {
240
+ const dom = start({
241
+ onImport: () => refusal(401, { message: "signed out" }),
242
+ });
243
+ await pickFile(dom, "config.json", CONFIG);
244
+ dom.click(checkButton(dom));
245
+ await dom.flush();
246
+
247
+ assert.ok(
248
+ getCurrentFatalError(),
249
+ "expected the 401 to escalate rather than stay in the wizard",
250
+ );
251
+ });
252
+ });
253
+
254
+ describe("ConfigImportWizard — the credentials the file does not carry", () => {
255
+ const applied = () => {
256
+ let call = 0;
257
+ return () => {
258
+ call += 1;
259
+ return call === 1
260
+ ? report({ accountsNeedingCredentials: [ACCOUNT_ID] })
261
+ : report({
262
+ applied: true,
263
+ accountsNeedingCredentials: [ACCOUNT_ID],
264
+ importId: "imp-1",
265
+ });
266
+ };
267
+ };
268
+
269
+ it("marks the account ready once its password is verified and stored", async () => {
270
+ const dom = start({
271
+ onImport: applied(),
272
+ accounts: [importedAccount()],
273
+ });
274
+ await pickFile(dom, "config.json", CONFIG);
275
+ dom.click(checkButton(dom));
276
+ await dom.flush();
277
+ dom.click(dom.byText("button", "Import 1 changes"));
278
+ await dom.flush();
279
+ await dom.wait(20);
280
+ await dom.flush();
281
+
282
+ assert.match(dom.text(), /These accounts need credentials/);
283
+ assert.match(dom.text(), /credentials needed/);
284
+
285
+ dom.click(dom.byText("button", "Enter password"));
286
+ await dom.flush();
287
+ const password = dom.query<HTMLInputElement>("#credentials-password");
288
+ assert.ok(password, "expected the password field");
289
+ dom.type(password, "mailbox-password");
290
+ dom.click(dom.byText("button", "Test and continue"));
291
+ await dom.flush();
292
+ // The test step stages IMAP then SMTP before it settles, then hands over.
293
+ await dom.wait(500);
294
+ await dom.flush();
295
+ await dom.wait(900);
296
+ await dom.flush();
297
+
298
+ const [stored] = http?.to(`/accounts/${ACCOUNT_ID}`) ?? [];
299
+ assert.ok(stored, "expected the password to be stored on the account");
300
+ assert.equal(stored.body?.password, "mailbox-password");
301
+
302
+ // GET /config still says `credentials_missing`, and the row is green
303
+ // anyway: this wizard verified the credential itself.
304
+ assert.match(dom.text(), /These accounts need credentials/);
305
+ assert.match(dom.text(), /ready/);
306
+ assert.doesNotMatch(dom.text(), /credentials needed/);
307
+ });
308
+ });
309
+
310
+ describe("The onboarding welcome step", () => {
311
+ it("offers the import beside adding an account, where /onboarding mounts it", () => {
312
+ harness = createDomHarness();
313
+ const imported: string[] = [];
314
+ harness.renderApp(
315
+ createElement(StepWelcome, {
316
+ onStart: () => undefined,
317
+ onImportConfig: () => imported.push("import"),
318
+ }),
319
+ );
320
+
321
+ assert.match(harness.text(), /Add your first account/);
322
+ harness.click(harness.byText("button", "Import a config file"));
323
+ assert.deepEqual(imported, ["import"]);
324
+ });
325
+
326
+ it("offers no import where the wizard is embedded to add an account", () => {
327
+ harness = createDomHarness();
328
+ harness.renderApp(createElement(StepWelcome, { onStart: () => undefined }));
329
+
330
+ assert.match(harness.text(), /Add your first account/);
331
+ assert.equal(
332
+ harness
333
+ .queryAll("button")
334
+ .some((button) => button.textContent === "Import a config file"),
335
+ false,
336
+ );
337
+ });
338
+ });