@nifrajs/testing 1.1.0
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/README.md +33 -0
- package/dist/cookies.d.ts +23 -0
- package/dist/cookies.d.ts.map +1 -0
- package/dist/cookies.js +69 -0
- package/dist/cookies.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/session.d.ts +34 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +28 -0
- package/dist/session.js.map +1 -0
- package/package.json +52 -0
- package/src/cookies.ts +76 -0
- package/src/index.ts +12 -0
- package/src/session.ts +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @nifrajs/testing
|
|
2
|
+
|
|
3
|
+
Testing helpers for nifra apps. [`@nifrajs/client`](../client)'s **`testClient`** is already the typed,
|
|
4
|
+
no-network in-process request client (nifra's supertest / `inject`). This package adds what it doesn't: a
|
|
5
|
+
**cookie jar** and a **cookie-persisting `testSession`**, so a login → authenticated-request flow tests as
|
|
6
|
+
easily as a single request.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { testSession } from "@nifrajs/testing"
|
|
10
|
+
import { app } from "../src/app"
|
|
11
|
+
|
|
12
|
+
const { client, cookies } = testSession<typeof app>(app)
|
|
13
|
+
|
|
14
|
+
await client.auth.login.post({ email, password }) // Set-Cookie captured into the jar
|
|
15
|
+
const me = await client.me.get() // Cookie sent automatically
|
|
16
|
+
expect(me.ok && me.data.id).toBeDefined()
|
|
17
|
+
expect(cookies.get("sid")).toBeDefined() // inspect the jar
|
|
18
|
+
|
|
19
|
+
await client.auth.logout.post() // a Max-Age=0 Set-Cookie clears it
|
|
20
|
+
expect(cookies.get("sid")).toBeUndefined()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Same in-process client as `testClient` — the app's own `fetch`, no server/port/network, the full real
|
|
24
|
+
lifecycle (validation, middleware, contracts, auth) and end-to-end types from `App`. The only addition is
|
|
25
|
+
that every call carries and captures cookies via a shared jar.
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
- `testSession<App>(app, { origin?, cookies? })` → `{ client: Treaty<App>, cookies: CookieJar }`.
|
|
30
|
+
- `cookieJar()` → `CookieJar` — `header()` · `applyTo(headers)` · `store(response)` · `set` · `get` · `clear` · `size`.
|
|
31
|
+
Honours removal (`Max-Age=0` / past `Expires`); other cookie attributes are ignored (in-process, same-origin).
|
|
32
|
+
|
|
33
|
+
For a **stateless** request (no cookies), use `testClient` from `@nifrajs/client` directly.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny cookie jar for in-process tests — parses `Set-Cookie` off responses and emits a `Cookie`
|
|
3
|
+
* request header, so a login → authenticated-request flow works without threading headers by hand. It
|
|
4
|
+
* honours removal (`Max-Age=0` / a past `Expires`) so logout clears the cookie; other attributes
|
|
5
|
+
* (Domain/Path/Secure/SameSite) are ignored — everything is same-origin in-process.
|
|
6
|
+
*/
|
|
7
|
+
export interface CookieJar {
|
|
8
|
+
/** The current `Cookie` request-header value (`"a=1; b=2"`, or `""` when empty). */
|
|
9
|
+
header(): string;
|
|
10
|
+
/** Set the `Cookie` header on `headers` when the jar is non-empty. */
|
|
11
|
+
applyTo(headers: Headers): void;
|
|
12
|
+
/** Absorb every `Set-Cookie` on a response (setting or, on removal, deleting each). */
|
|
13
|
+
store(response: {
|
|
14
|
+
headers: Headers;
|
|
15
|
+
}): void;
|
|
16
|
+
set(name: string, value: string): void;
|
|
17
|
+
get(name: string): string | undefined;
|
|
18
|
+
clear(): void;
|
|
19
|
+
readonly size: number;
|
|
20
|
+
}
|
|
21
|
+
/** Create an empty cookie jar. */
|
|
22
|
+
export declare function cookieJar(): CookieJar;
|
|
23
|
+
//# sourceMappingURL=cookies.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.d.ts","sourceRoot":"","sources":["../src/cookies.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,SAAS;IACxB,oFAAoF;IACpF,MAAM,IAAI,MAAM,CAAA;IAChB,sEAAsE;IACtE,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IAC/B,uFAAuF;IACvF,KAAK,CAAC,QAAQ,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAA;IAC3C,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACtC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IACrC,KAAK,IAAI,IAAI,CAAA;IACb,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AA8BD,kCAAkC;AAClC,wBAAgB,SAAS,IAAI,SAAS,CA0BrC"}
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny cookie jar for in-process tests — parses `Set-Cookie` off responses and emits a `Cookie`
|
|
3
|
+
* request header, so a login → authenticated-request flow works without threading headers by hand. It
|
|
4
|
+
* honours removal (`Max-Age=0` / a past `Expires`) so logout clears the cookie; other attributes
|
|
5
|
+
* (Domain/Path/Secure/SameSite) are ignored — everything is same-origin in-process.
|
|
6
|
+
*/
|
|
7
|
+
/** Read `Set-Cookie` values portably: `getSetCookie()` (Bun/Node/edge) with a single-header fallback. */
|
|
8
|
+
function setCookies(headers) {
|
|
9
|
+
const withGetter = headers;
|
|
10
|
+
if (typeof withGetter.getSetCookie === "function")
|
|
11
|
+
return withGetter.getSetCookie();
|
|
12
|
+
const single = headers.get("set-cookie");
|
|
13
|
+
return single === null ? [] : [single];
|
|
14
|
+
}
|
|
15
|
+
function isRemoval(attributes) {
|
|
16
|
+
if (/(^|;)\s*max-age=0(\s|;|$)/.test(attributes))
|
|
17
|
+
return true;
|
|
18
|
+
const expires = /expires=([^;]+)/.exec(attributes);
|
|
19
|
+
if (expires === null)
|
|
20
|
+
return false;
|
|
21
|
+
const at = Date.parse(expires[1]);
|
|
22
|
+
return !Number.isNaN(at) && at < Date.now();
|
|
23
|
+
}
|
|
24
|
+
function parseSetCookie(line) {
|
|
25
|
+
const semicolon = line.indexOf(";");
|
|
26
|
+
const pair = semicolon === -1 ? line : line.slice(0, semicolon);
|
|
27
|
+
const equals = pair.indexOf("=");
|
|
28
|
+
if (equals === -1)
|
|
29
|
+
return null;
|
|
30
|
+
const name = pair.slice(0, equals).trim();
|
|
31
|
+
if (name === "")
|
|
32
|
+
return null;
|
|
33
|
+
const value = pair.slice(equals + 1).trim();
|
|
34
|
+
const attributes = semicolon === -1 ? "" : line.slice(semicolon + 1).toLowerCase();
|
|
35
|
+
return { name, value, remove: isRemoval(attributes) };
|
|
36
|
+
}
|
|
37
|
+
/** Create an empty cookie jar. */
|
|
38
|
+
export function cookieJar() {
|
|
39
|
+
const cookies = new Map();
|
|
40
|
+
const header = () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
41
|
+
return {
|
|
42
|
+
header,
|
|
43
|
+
get size() {
|
|
44
|
+
return cookies.size;
|
|
45
|
+
},
|
|
46
|
+
set: (name, value) => {
|
|
47
|
+
cookies.set(name, value);
|
|
48
|
+
},
|
|
49
|
+
get: (name) => cookies.get(name),
|
|
50
|
+
clear: () => cookies.clear(),
|
|
51
|
+
applyTo: (headers) => {
|
|
52
|
+
const current = header();
|
|
53
|
+
if (current !== "")
|
|
54
|
+
headers.set("cookie", current);
|
|
55
|
+
},
|
|
56
|
+
store: (response) => {
|
|
57
|
+
for (const line of setCookies(response.headers)) {
|
|
58
|
+
const parsed = parseSetCookie(line);
|
|
59
|
+
if (parsed === null)
|
|
60
|
+
continue;
|
|
61
|
+
if (parsed.remove)
|
|
62
|
+
cookies.delete(parsed.name);
|
|
63
|
+
else
|
|
64
|
+
cookies.set(parsed.name, parsed.value);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=cookies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.js","sourceRoot":"","sources":["../src/cookies.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/testing — helpers for testing nifra apps in-process. `@nifrajs/client`'s `testClient` is the
|
|
3
|
+
* typed, no-network request client (the supertest/inject equivalent); this adds what it doesn't: a
|
|
4
|
+
* {@link cookieJar} and a cookie-persisting {@link testSession}, so stateful auth/session flows are as
|
|
5
|
+
* easy to test as a single request.
|
|
6
|
+
*
|
|
7
|
+
* import { testSession } from "@nifrajs/testing"
|
|
8
|
+
* const { client, cookies } = testSession<typeof app>(app)
|
|
9
|
+
*/
|
|
10
|
+
export { type CookieJar, cookieJar } from "./cookies.js";
|
|
11
|
+
export { type AppLike, type TestSession, type TestSessionOptions, testSession } from "./session.js";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,KAAK,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxD,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/testing — helpers for testing nifra apps in-process. `@nifrajs/client`'s `testClient` is the
|
|
3
|
+
* typed, no-network request client (the supertest/inject equivalent); this adds what it doesn't: a
|
|
4
|
+
* {@link cookieJar} and a cookie-persisting {@link testSession}, so stateful auth/session flows are as
|
|
5
|
+
* easy to test as a single request.
|
|
6
|
+
*
|
|
7
|
+
* import { testSession } from "@nifrajs/testing"
|
|
8
|
+
* const { client, cookies } = testSession<typeof app>(app)
|
|
9
|
+
*/
|
|
10
|
+
export { cookieJar } from "./cookies.js";
|
|
11
|
+
export { testSession } from "./session.js";
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAkB,SAAS,EAAE,MAAM,cAAc,CAAA;AACxD,OAAO,EAA2D,WAAW,EAAE,MAAM,cAAc,CAAA"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stateful, cookie-aware in-process test client. `@nifrajs/client`'s `testClient` already drives an
|
|
3
|
+
* app's `fetch` with end-to-end types (no server/port/network) — this wraps it with a {@link CookieJar}
|
|
4
|
+
* that carries `Set-Cookie` across calls, so a login → authenticated-request flow just works.
|
|
5
|
+
*
|
|
6
|
+
* import { testSession } from "@nifrajs/testing"
|
|
7
|
+
* import { app } from "../src/app"
|
|
8
|
+
*
|
|
9
|
+
* const { client, cookies } = testSession<typeof app>(app)
|
|
10
|
+
* await client.auth.login.post({ email, password }) // Set-Cookie captured
|
|
11
|
+
* const me = await client.me.get() // Cookie sent automatically
|
|
12
|
+
* expect(me.ok && me.data.id).toBeDefined()
|
|
13
|
+
*/
|
|
14
|
+
import { type ClientOptions, type Treaty } from "@nifrajs/client";
|
|
15
|
+
import { type CookieJar } from "./cookies.js";
|
|
16
|
+
/** The minimal shape a nifra `server()` app satisfies — its own `fetch`. */
|
|
17
|
+
export interface AppLike {
|
|
18
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
19
|
+
}
|
|
20
|
+
export interface TestSessionOptions extends Omit<ClientOptions, "fetch"> {
|
|
21
|
+
/** Origin used to resolve relative paths. Default `"http://nifra.internal"`. */
|
|
22
|
+
readonly origin?: string;
|
|
23
|
+
/** Reuse an existing jar (e.g. seed a session cookie, or share one across sessions). */
|
|
24
|
+
readonly cookies?: CookieJar;
|
|
25
|
+
}
|
|
26
|
+
export interface TestSession<App> {
|
|
27
|
+
/** The end-to-end-typed in-process client — every call carries + captures cookies. */
|
|
28
|
+
readonly client: Treaty<App>;
|
|
29
|
+
/** The jar backing this session — inspect (`cookies.get("sid")`), seed, or `clear()` it. */
|
|
30
|
+
readonly cookies: CookieJar;
|
|
31
|
+
}
|
|
32
|
+
/** Create a cookie-persisting in-process test client for `app`. */
|
|
33
|
+
export declare function testSession<App extends AppLike>(app: App, options?: TestSessionOptions): TestSession<App>;
|
|
34
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,KAAK,aAAa,EAAwB,KAAK,MAAM,EAAE,MAAM,iBAAiB,CAAA;AACvF,OAAO,EAAE,KAAK,SAAS,EAAa,MAAM,cAAc,CAAA;AAExD,4EAA4E;AAC5E,MAAM,WAAW,OAAO;IACtB,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtD;AAED,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC;IACtE,gFAAgF;IAChF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,wFAAwF;IACxF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAC7B;AAED,MAAM,WAAW,WAAW,CAAC,GAAG;IAC9B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAA;IAC5B,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAA;CAC5B;AAED,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,GAAG,SAAS,OAAO,EAC7C,GAAG,EAAE,GAAG,EACR,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAAC,GAAG,CAAC,CAUlB"}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stateful, cookie-aware in-process test client. `@nifrajs/client`'s `testClient` already drives an
|
|
3
|
+
* app's `fetch` with end-to-end types (no server/port/network) — this wraps it with a {@link CookieJar}
|
|
4
|
+
* that carries `Set-Cookie` across calls, so a login → authenticated-request flow just works.
|
|
5
|
+
*
|
|
6
|
+
* import { testSession } from "@nifrajs/testing"
|
|
7
|
+
* import { app } from "../src/app"
|
|
8
|
+
*
|
|
9
|
+
* const { client, cookies } = testSession<typeof app>(app)
|
|
10
|
+
* await client.auth.login.post({ email, password }) // Set-Cookie captured
|
|
11
|
+
* const me = await client.me.get() // Cookie sent automatically
|
|
12
|
+
* expect(me.ok && me.data.id).toBeDefined()
|
|
13
|
+
*/
|
|
14
|
+
import { client } from "@nifrajs/client";
|
|
15
|
+
import { cookieJar } from "./cookies.js";
|
|
16
|
+
/** Create a cookie-persisting in-process test client for `app`. */
|
|
17
|
+
export function testSession(app, options = {}) {
|
|
18
|
+
const { origin = "http://nifra.internal", cookies = cookieJar(), ...clientOptions } = options;
|
|
19
|
+
const bridge = async (url, init) => {
|
|
20
|
+
const headers = new Headers(init?.headers);
|
|
21
|
+
cookies.applyTo(headers);
|
|
22
|
+
const response = await app.fetch(new Request(url, { ...init, headers }));
|
|
23
|
+
cookies.store(response);
|
|
24
|
+
return response;
|
|
25
|
+
};
|
|
26
|
+
return { client: client(origin, { ...clientOptions, fetch: bridge }), cookies };
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAsB,MAAM,EAA6B,MAAM,iBAAiB,CAAA;AACvF,OAAO,EAAkB,SAAS,EAAE,MAAM,cAAc,CAAA;AAqBxD,mEAAmE;AACnE,MAAM,UAAU,WAAW,CACzB,GAAQ,EACR,UAA8B,EAAE;IAEhC,MAAM,EAAE,MAAM,GAAG,uBAAuB,EAAE,OAAO,GAAG,SAAS,EAAE,EAAE,GAAG,aAAa,EAAE,GAAG,OAAO,CAAA;IAC7F,MAAM,MAAM,GAAY,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC1C,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;QAC1C,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QACxB,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAA;QACxE,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;QACvB,OAAO,QAAQ,CAAA;IACjB,CAAC,CAAA;IACD,OAAO,EAAE,MAAM,EAAE,MAAM,CAAM,MAAM,EAAE,EAAE,GAAG,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,CAAA;AACtF,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/testing",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Testing helpers for nifra apps — a cookie jar + a cookie-persisting in-process test session on top of @nifrajs/client's testClient, so stateful auth/session flows test as easily as a single request.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"testing",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nifrajs/client": "1.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@nifrajs/core": "workspace:*"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
46
|
+
"directory": "packages/testing"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
52
|
+
}
|
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny cookie jar for in-process tests — parses `Set-Cookie` off responses and emits a `Cookie`
|
|
3
|
+
* request header, so a login → authenticated-request flow works without threading headers by hand. It
|
|
4
|
+
* honours removal (`Max-Age=0` / a past `Expires`) so logout clears the cookie; other attributes
|
|
5
|
+
* (Domain/Path/Secure/SameSite) are ignored — everything is same-origin in-process.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface CookieJar {
|
|
9
|
+
/** The current `Cookie` request-header value (`"a=1; b=2"`, or `""` when empty). */
|
|
10
|
+
header(): string
|
|
11
|
+
/** Set the `Cookie` header on `headers` when the jar is non-empty. */
|
|
12
|
+
applyTo(headers: Headers): void
|
|
13
|
+
/** Absorb every `Set-Cookie` on a response (setting or, on removal, deleting each). */
|
|
14
|
+
store(response: { headers: Headers }): void
|
|
15
|
+
set(name: string, value: string): void
|
|
16
|
+
get(name: string): string | undefined
|
|
17
|
+
clear(): void
|
|
18
|
+
readonly size: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read `Set-Cookie` values portably: `getSetCookie()` (Bun/Node/edge) with a single-header fallback. */
|
|
22
|
+
function setCookies(headers: Headers): string[] {
|
|
23
|
+
const withGetter = headers as Headers & { getSetCookie?: () => string[] }
|
|
24
|
+
if (typeof withGetter.getSetCookie === "function") return withGetter.getSetCookie()
|
|
25
|
+
const single = headers.get("set-cookie")
|
|
26
|
+
return single === null ? [] : [single]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRemoval(attributes: string): boolean {
|
|
30
|
+
if (/(^|;)\s*max-age=0(\s|;|$)/.test(attributes)) return true
|
|
31
|
+
const expires = /expires=([^;]+)/.exec(attributes)
|
|
32
|
+
if (expires === null) return false
|
|
33
|
+
const at = Date.parse(expires[1] as string)
|
|
34
|
+
return !Number.isNaN(at) && at < Date.now()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseSetCookie(line: string): { name: string; value: string; remove: boolean } | null {
|
|
38
|
+
const semicolon = line.indexOf(";")
|
|
39
|
+
const pair = semicolon === -1 ? line : line.slice(0, semicolon)
|
|
40
|
+
const equals = pair.indexOf("=")
|
|
41
|
+
if (equals === -1) return null
|
|
42
|
+
const name = pair.slice(0, equals).trim()
|
|
43
|
+
if (name === "") return null
|
|
44
|
+
const value = pair.slice(equals + 1).trim()
|
|
45
|
+
const attributes = semicolon === -1 ? "" : line.slice(semicolon + 1).toLowerCase()
|
|
46
|
+
return { name, value, remove: isRemoval(attributes) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Create an empty cookie jar. */
|
|
50
|
+
export function cookieJar(): CookieJar {
|
|
51
|
+
const cookies = new Map<string, string>()
|
|
52
|
+
const header = (): string => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")
|
|
53
|
+
return {
|
|
54
|
+
header,
|
|
55
|
+
get size() {
|
|
56
|
+
return cookies.size
|
|
57
|
+
},
|
|
58
|
+
set: (name, value) => {
|
|
59
|
+
cookies.set(name, value)
|
|
60
|
+
},
|
|
61
|
+
get: (name) => cookies.get(name),
|
|
62
|
+
clear: () => cookies.clear(),
|
|
63
|
+
applyTo: (headers) => {
|
|
64
|
+
const current = header()
|
|
65
|
+
if (current !== "") headers.set("cookie", current)
|
|
66
|
+
},
|
|
67
|
+
store: (response) => {
|
|
68
|
+
for (const line of setCookies(response.headers)) {
|
|
69
|
+
const parsed = parseSetCookie(line)
|
|
70
|
+
if (parsed === null) continue
|
|
71
|
+
if (parsed.remove) cookies.delete(parsed.name)
|
|
72
|
+
else cookies.set(parsed.name, parsed.value)
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/testing — helpers for testing nifra apps in-process. `@nifrajs/client`'s `testClient` is the
|
|
3
|
+
* typed, no-network request client (the supertest/inject equivalent); this adds what it doesn't: a
|
|
4
|
+
* {@link cookieJar} and a cookie-persisting {@link testSession}, so stateful auth/session flows are as
|
|
5
|
+
* easy to test as a single request.
|
|
6
|
+
*
|
|
7
|
+
* import { testSession } from "@nifrajs/testing"
|
|
8
|
+
* const { client, cookies } = testSession<typeof app>(app)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { type CookieJar, cookieJar } from "./cookies.ts"
|
|
12
|
+
export { type AppLike, type TestSession, type TestSessionOptions, testSession } from "./session.ts"
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stateful, cookie-aware in-process test client. `@nifrajs/client`'s `testClient` already drives an
|
|
3
|
+
* app's `fetch` with end-to-end types (no server/port/network) — this wraps it with a {@link CookieJar}
|
|
4
|
+
* that carries `Set-Cookie` across calls, so a login → authenticated-request flow just works.
|
|
5
|
+
*
|
|
6
|
+
* import { testSession } from "@nifrajs/testing"
|
|
7
|
+
* import { app } from "../src/app"
|
|
8
|
+
*
|
|
9
|
+
* const { client, cookies } = testSession<typeof app>(app)
|
|
10
|
+
* await client.auth.login.post({ email, password }) // Set-Cookie captured
|
|
11
|
+
* const me = await client.me.get() // Cookie sent automatically
|
|
12
|
+
* expect(me.ok && me.data.id).toBeDefined()
|
|
13
|
+
*/
|
|
14
|
+
import { type ClientOptions, client, type FetchFn, type Treaty } from "@nifrajs/client"
|
|
15
|
+
import { type CookieJar, cookieJar } from "./cookies.ts"
|
|
16
|
+
|
|
17
|
+
/** The minimal shape a nifra `server()` app satisfies — its own `fetch`. */
|
|
18
|
+
export interface AppLike {
|
|
19
|
+
fetch(request: Request): Response | Promise<Response>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TestSessionOptions extends Omit<ClientOptions, "fetch"> {
|
|
23
|
+
/** Origin used to resolve relative paths. Default `"http://nifra.internal"`. */
|
|
24
|
+
readonly origin?: string
|
|
25
|
+
/** Reuse an existing jar (e.g. seed a session cookie, or share one across sessions). */
|
|
26
|
+
readonly cookies?: CookieJar
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TestSession<App> {
|
|
30
|
+
/** The end-to-end-typed in-process client — every call carries + captures cookies. */
|
|
31
|
+
readonly client: Treaty<App>
|
|
32
|
+
/** The jar backing this session — inspect (`cookies.get("sid")`), seed, or `clear()` it. */
|
|
33
|
+
readonly cookies: CookieJar
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Create a cookie-persisting in-process test client for `app`. */
|
|
37
|
+
export function testSession<App extends AppLike>(
|
|
38
|
+
app: App,
|
|
39
|
+
options: TestSessionOptions = {},
|
|
40
|
+
): TestSession<App> {
|
|
41
|
+
const { origin = "http://nifra.internal", cookies = cookieJar(), ...clientOptions } = options
|
|
42
|
+
const bridge: FetchFn = async (url, init) => {
|
|
43
|
+
const headers = new Headers(init?.headers)
|
|
44
|
+
cookies.applyTo(headers)
|
|
45
|
+
const response = await app.fetch(new Request(url, { ...init, headers }))
|
|
46
|
+
cookies.store(response)
|
|
47
|
+
return response
|
|
48
|
+
}
|
|
49
|
+
return { client: client<App>(origin, { ...clientOptions, fetch: bridge }), cookies }
|
|
50
|
+
}
|