@gusnips/react 0.6.0 → 0.6.1
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/dist/describe-error.d.ts +15 -1
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +2 -2
- package/dist/describe-error.js.map +1 -1
- package/package.json +1 -1
- package/src/describe-error.test.ts +38 -0
- package/src/describe-error.ts +18 -1
- package/src/guards.test.ts +129 -0
package/dist/describe-error.d.ts
CHANGED
|
@@ -146,6 +146,20 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
146
146
|
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
147
147
|
*/
|
|
148
148
|
knownMessageKeys: Record<ServerName, unknown>;
|
|
149
|
+
/**
|
|
150
|
+
* Transform `params` before they fill the server's sentence.
|
|
151
|
+
*
|
|
152
|
+
* The envelope carries what the SERVER knows, which is not always a word anyone should read: a
|
|
153
|
+
* role id (`admin`), an action verb (`approve_content`). The sentence needs whatever the app's
|
|
154
|
+
* own screens call those things, and the substitution happens here — inside the one lookup that
|
|
155
|
+
* reads `params` — so there is nowhere else an adopter could intervene. Without this seam the
|
|
156
|
+
* choice is to ship the raw id into `{{role}}`, or to stop using the server's sentence at all
|
|
157
|
+
* and re-resolve every key by hand, which is how i18n ended up inside a transport once already.
|
|
158
|
+
*
|
|
159
|
+
* Optional, and identity by default: an API whose params are already words passes nothing. It
|
|
160
|
+
* deliberately takes only the params — an arm that needs the error has `ctx` for that.
|
|
161
|
+
*/
|
|
162
|
+
localizeParams?: (params: Record<string, string | number> | undefined) => Record<string, unknown> | undefined;
|
|
149
163
|
/**
|
|
150
164
|
* Per-code copy. Everything not listed falls through to the default arm.
|
|
151
165
|
*
|
|
@@ -167,6 +181,6 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
167
181
|
}
|
|
168
182
|
export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
|
|
169
183
|
/** Build the describer. */
|
|
170
|
-
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
184
|
+
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
|
|
171
185
|
export {};
|
|
172
186
|
//# sourceMappingURL=describe-error.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAkB,MAAM,gBAAgB,CAAC;AAG1D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY;AACtB,mDAAmD;AACjD,OAAO;AACT,gEAAgE;GAC9D,QAAQ;AACV,gFAAgF;GAC9E,MAAM;AACR,iFAAiF;GAC/E,MAAM,CAAC;AAEX,+FAA+F;AAC/F,MAAM,WAAW,SAAS;IACxB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,mFAAmF;IACnF,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D;;;;;;;;GAQG;AACH,KAAK,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA2BvE;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,YAAY,CAAC;CACvB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;OAQG;IACH,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC1C;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,gGAAgG;IAChG,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,SAAS,CAAC;AAExD,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,EACV,iBAAsB,EACtB,gBAAgB,GACjB,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CA8F9F"}
|
|
1
|
+
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/describe-error.js
CHANGED
|
@@ -21,7 +21,7 @@ export function humanizeWait(t, secs, prefix) {
|
|
|
21
21
|
return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
|
|
22
22
|
}
|
|
23
23
|
/** Build the describer. */
|
|
24
|
-
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
24
|
+
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, localizeParams = (params) => params, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
25
25
|
const known = new Set(Object.keys(knownMessageKeys));
|
|
26
26
|
// Sound, and the reason nothing here needs a cast: the set was built from the keys of
|
|
27
27
|
// `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
|
|
@@ -32,7 +32,7 @@ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPref
|
|
|
32
32
|
if (named === undefined || !named.startsWith(messageKeyPrefix))
|
|
33
33
|
return null;
|
|
34
34
|
const name = named.slice(messageKeyPrefix.length);
|
|
35
|
-
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
35
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, localizeParams(error.params)) : null;
|
|
36
36
|
};
|
|
37
37
|
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
38
38
|
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAuHzC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAC1B,CAAoC,EACpC,IAAY,EACZ,MAAc;IAEd,IAAI,IAAI,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;IAC1F,IAAI,IAAI,GAAG,EAAE,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IACvF,OAAO,CAAC,CAAC,GAAG,MAAM,WAAW,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;AACrE,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -220,6 +220,44 @@ describe("the recovery kind", () => {
|
|
|
220
220
|
});
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The seventh migration's seam. The server names what IT knows — a role id, an action verb — and
|
|
225
|
+
* the sentence needs whatever the app's own screens call those. The substitution can only happen
|
|
226
|
+
* inside the lookup that reads `params`, which is why an arm cannot do it: by the time an arm
|
|
227
|
+
* runs, `ctx.says` is already resolved.
|
|
228
|
+
*/
|
|
229
|
+
describe("localizeParams", () => {
|
|
230
|
+
const options = {
|
|
231
|
+
t,
|
|
232
|
+
copyPrefix: "errors." as const,
|
|
233
|
+
formatWait: (secs: number) => humanizeWait(t, secs, "errors."),
|
|
234
|
+
messageKeyPrefix: "serverErrors." as const,
|
|
235
|
+
knownMessageKeys: { roleRequired: "" },
|
|
236
|
+
};
|
|
237
|
+
const refusal = new ApiError(403, {
|
|
238
|
+
code: "FORBIDDEN",
|
|
239
|
+
message: "Needs admin",
|
|
240
|
+
messageKey: "serverErrors.roleRequired",
|
|
241
|
+
params: { role: "admin" },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("translates a param before it fills the sentence", () => {
|
|
245
|
+
const describeError = createErrorDescriber({
|
|
246
|
+
...options,
|
|
247
|
+
localizeParams: (params) => ({
|
|
248
|
+
...params,
|
|
249
|
+
...(typeof params?.role === "string" && { role: t(`roles.${params.role}`) }),
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
expect(describeError(refusal).cause).toBe('serverErrors.roleRequired({"role":"roles.admin"})');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("is identity by default, so params already in words pass straight through", () => {
|
|
256
|
+
const describeError = createErrorDescriber(options);
|
|
257
|
+
expect(describeError(refusal).cause).toBe('serverErrors.roleRequired({"role":"admin"})');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
223
261
|
/**
|
|
224
262
|
* The second convention in the fleet: an API with no `messageKey` field at all, whose CODE
|
|
225
263
|
* names the sentence. One repo has 98 of them — `errors.FORECAST_NOT_FOUND` and its siblings —
|
package/src/describe-error.ts
CHANGED
|
@@ -197,6 +197,22 @@ export interface ErrorDescriberOptions<
|
|
|
197
197
|
* somebody. A name that is absent falls back to the server's English `message` instead.
|
|
198
198
|
*/
|
|
199
199
|
knownMessageKeys: Record<ServerName, unknown>;
|
|
200
|
+
/**
|
|
201
|
+
* Transform `params` before they fill the server's sentence.
|
|
202
|
+
*
|
|
203
|
+
* The envelope carries what the SERVER knows, which is not always a word anyone should read: a
|
|
204
|
+
* role id (`admin`), an action verb (`approve_content`). The sentence needs whatever the app's
|
|
205
|
+
* own screens call those things, and the substitution happens here — inside the one lookup that
|
|
206
|
+
* reads `params` — so there is nowhere else an adopter could intervene. Without this seam the
|
|
207
|
+
* choice is to ship the raw id into `{{role}}`, or to stop using the server's sentence at all
|
|
208
|
+
* and re-resolve every key by hand, which is how i18n ended up inside a transport once already.
|
|
209
|
+
*
|
|
210
|
+
* Optional, and identity by default: an API whose params are already words passes nothing. It
|
|
211
|
+
* deliberately takes only the params — an arm that needs the error has `ctx` for that.
|
|
212
|
+
*/
|
|
213
|
+
localizeParams?: (
|
|
214
|
+
params: Record<string, string | number> | undefined,
|
|
215
|
+
) => Record<string, unknown> | undefined;
|
|
200
216
|
/**
|
|
201
217
|
* Per-code copy. Everything not listed falls through to the default arm.
|
|
202
218
|
*
|
|
@@ -230,6 +246,7 @@ export function createErrorDescriber<
|
|
|
230
246
|
formatWait,
|
|
231
247
|
messageKeyPrefix,
|
|
232
248
|
knownMessageKeys,
|
|
249
|
+
localizeParams = (params) => params,
|
|
233
250
|
codes = {},
|
|
234
251
|
durableLimitCodes = [],
|
|
235
252
|
maxRetryWaitSecs,
|
|
@@ -245,7 +262,7 @@ export function createErrorDescriber<
|
|
|
245
262
|
const lookup = (named: string | undefined): string | null => {
|
|
246
263
|
if (named === undefined || !named.startsWith(messageKeyPrefix)) return null;
|
|
247
264
|
const name = named.slice(messageKeyPrefix.length);
|
|
248
|
-
return has(name) ? t(`${messageKeyPrefix}${name}`, error.params) : null;
|
|
265
|
+
return has(name) ? t(`${messageKeyPrefix}${name}`, localizeParams(error.params)) : null;
|
|
249
266
|
};
|
|
250
267
|
// `messageKey` first: an API that sends both means the key, and the code is what the UI
|
|
251
268
|
// switches on. The code is only consulted when there is no key to prefer, and it is spelled
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createRequireAnonymous,
|
|
5
|
+
createRequireAuth,
|
|
6
|
+
createRequireProfile,
|
|
7
|
+
type MeQuery,
|
|
8
|
+
} from "./guards.tsx";
|
|
9
|
+
|
|
10
|
+
// `react-router-dom` is mocked rather than wrapped in a real router. Every question here is only
|
|
11
|
+
// WHICH branch a guard picked, and a redirect is the one branch with no children to read — so
|
|
12
|
+
// `Navigate` never has to run, it only has to be identifiable as an element type. That is what
|
|
13
|
+
// lets the redirect cases assert a destination without rendering anything.
|
|
14
|
+
const router = vi.hoisted(() => ({
|
|
15
|
+
Navigate: vi.fn(),
|
|
16
|
+
useLocation: vi.fn(() => ({ pathname: "/posts", search: "?page=2" })),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock("react-router-dom", () => router);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One render, as plain data.
|
|
22
|
+
*
|
|
23
|
+
* A function component IS a function, so calling it returns the element it would have rendered —
|
|
24
|
+
* no DOM, no renderer, nothing mounted, and no test-only dependency to install. The cast is this
|
|
25
|
+
* file admitting it reads two fields off that element and nothing else.
|
|
26
|
+
*/
|
|
27
|
+
type Rendered = { type: unknown; props: Record<string, unknown> };
|
|
28
|
+
const render = (Guard: (p: { children: ReactNode }) => ReactNode): Rendered =>
|
|
29
|
+
Guard({ children: "CONTENT" }) as unknown as Rendered;
|
|
30
|
+
|
|
31
|
+
/** Which branch came back: a redirect names its destination, every other branch names its child. */
|
|
32
|
+
const branch = (r: Rendered): unknown =>
|
|
33
|
+
r.type === router.Navigate ? { to: r.props.to, state: r.props.state } : r.props.children;
|
|
34
|
+
|
|
35
|
+
const session =
|
|
36
|
+
(isAuthenticated: boolean, isLoading = false) =>
|
|
37
|
+
() => ({
|
|
38
|
+
isAuthenticated,
|
|
39
|
+
isLoading,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const profile = (me: MeQuery<{ suspended: boolean }>) =>
|
|
43
|
+
createRequireProfile(() => me, { loading: "LOADING" })(
|
|
44
|
+
(m) => !m.suspended,
|
|
45
|
+
"DENIED",
|
|
46
|
+
(error) => `FAILED:${String(error)}`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
describe("createRequireProfile", () => {
|
|
50
|
+
it("waits while the profile is still being asked for", () => {
|
|
51
|
+
expect(branch(render(profile({ status: "pending" })))).toBe("LOADING");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Invariant 4, and the reason this module exists rather than being written a ninth time.
|
|
55
|
+
// `!me?.isStaff` reads an undefined profile as a real "no", so an outage on /auth/me becomes a
|
|
56
|
+
// refusal — the wrong cause, no retry, and no request id to quote. Three repos shipped that, and
|
|
57
|
+
// in two of them the sibling guard failed the other way for the same reason: `me?.suspended` read
|
|
58
|
+
// a failure as "not suspended" and let a suspended account straight through. One mistake, closed
|
|
59
|
+
// on one screen and OPEN on the next, which is why a failure must reach neither answer.
|
|
60
|
+
it("does not read a failed query as a refusal", () => {
|
|
61
|
+
const failed = branch(render(profile({ status: "error", error: "boom" })));
|
|
62
|
+
expect(failed).toBe("FAILED:boom");
|
|
63
|
+
expect(failed).not.toBe("DENIED");
|
|
64
|
+
expect(failed).not.toBe("CONTENT");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("refuses only on a real no", () => {
|
|
68
|
+
expect(branch(render(profile({ status: "success", data: { suspended: true } })))).toBe(
|
|
69
|
+
"DENIED",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("lets an allowed profile through", () => {
|
|
74
|
+
expect(branch(render(profile({ status: "success", data: { suspended: false } })))).toBe(
|
|
75
|
+
"CONTENT",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("takes a refusal as a function, for one built only when it is used", () => {
|
|
80
|
+
const Guard = createRequireProfile(() => ({ status: "success" as const, data: 1 }), {
|
|
81
|
+
loading: "LOADING",
|
|
82
|
+
})(
|
|
83
|
+
() => false,
|
|
84
|
+
() => "DENIED-LAZY",
|
|
85
|
+
() => "FAILED",
|
|
86
|
+
);
|
|
87
|
+
expect(branch(render(Guard))).toBe("DENIED-LAZY");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("createRequireAuth", () => {
|
|
92
|
+
const guard = (isAuthenticated: boolean, isLoading = false) =>
|
|
93
|
+
createRequireAuth(session(isAuthenticated, isLoading), "/login", { loading: "LOADING" });
|
|
94
|
+
|
|
95
|
+
it("waits while the session is still resolving", () => {
|
|
96
|
+
expect(branch(render(guard(false, true)))).toBe("LOADING");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The redirect carries where they were headed, query string included: an adopter's sign-in screen
|
|
100
|
+
// reads `location.state.from` to land them on the page they actually wanted rather than the home
|
|
101
|
+
// screen. One donor hardcoded this in four places, which is what made it a factory.
|
|
102
|
+
it("remembers the address it turned away", () => {
|
|
103
|
+
expect(branch(render(guard(false)))).toEqual({
|
|
104
|
+
to: "/login",
|
|
105
|
+
state: { from: "/posts?page=2" },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("lets a signed-in visitor through", () => {
|
|
110
|
+
expect(branch(render(guard(true)))).toBe("CONTENT");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("createRequireAnonymous", () => {
|
|
115
|
+
const guard = (isAuthenticated: boolean, isLoading = false) =>
|
|
116
|
+
createRequireAnonymous(session(isAuthenticated, isLoading), "/", { loading: "LOADING" });
|
|
117
|
+
|
|
118
|
+
it("waits while the session is still resolving", () => {
|
|
119
|
+
expect(branch(render(guard(true, true)))).toBe("LOADING");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("sends a signed-in visitor home", () => {
|
|
123
|
+
expect(branch(render(guard(true)))).toEqual({ to: "/", state: undefined });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("lets an anonymous visitor through", () => {
|
|
127
|
+
expect(branch(render(guard(false)))).toBe("CONTENT");
|
|
128
|
+
});
|
|
129
|
+
});
|