@zerotal/testing 1.10.0 → 1.11.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/CHANGELOG.md CHANGED
@@ -8,6 +8,26 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Changed
14
+
15
+ - **`assertRedirect` compares the path exactly**, where it used to use `includes()`.
16
+ The loose form made the assertion mean less than it looks like it means:
17
+ `assertRedirect("/login")` was satisfied by `/login-as-someone-else` and by
18
+ `/admin?next=/login` — the two cases a test about a login redirect exists to rule
19
+ out. An absolute `Location` still matches a relative expectation, and naming a query
20
+ string compares that too. `assertRedirectContains()` is the old behaviour, for the
21
+ cases that want it (a signed URL with an unpredictable token).
22
+
23
+ ### Documented
24
+
25
+ - **How to authenticate a test when identity is not a row.** `withSession()` already
26
+ did it, and an app with no users table — an IMAP login _is_ the identity — reached
27
+ past it to the session driver instead, guessing `driver.write()`. Reaching for the
28
+ driver is the wrong layer and does not work; the doc now says so and shows the form
29
+ that does.
30
+
11
31
  ## [1.10.0] — 2026-08-30
12
32
 
13
33
  ### Added
package/api-surface.md CHANGED
@@ -197,6 +197,7 @@ class TestResponse = {
197
197
  assertNotFound: () => TestResponse
198
198
  assertOk: () => TestResponse
199
199
  assertRedirect: (url: string) => TestResponse
200
+ assertRedirectContains: (fragment: string) => TestResponse
200
201
  assertSee: (needle: string) => TestResponse
201
202
  assertSeeText: (needle: string) => TestResponse
202
203
  assertServerError: () => TestResponse
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -32,14 +32,14 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@zerotal/core": "1.10.0",
36
- "@zerotal/orm": "1.10.0",
37
- "@zerotal/queue": "1.10.0",
38
- "@zerotal/notifications": "1.10.0"
35
+ "@zerotal/core": "1.11.1",
36
+ "@zerotal/orm": "1.11.1",
37
+ "@zerotal/queue": "1.11.1",
38
+ "@zerotal/notifications": "1.11.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/session": "1.10.0"
42
+ "@zerotal/session": "1.11.1"
43
43
  },
44
44
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
45
45
  "keywords": [
@@ -179,22 +179,74 @@ export class TestResponse {
179
179
  return this;
180
180
  }
181
181
 
182
- /** Assert the response is a redirect to `url`. */
182
+ /**
183
+ * Assert the response is a redirect to `url`.
184
+ *
185
+ * The comparison is on the `Location` header's **path**, exactly. A substring
186
+ * match — which this used to do — makes the assertion mean less than it looks
187
+ * like it means: `assertRedirect("/login")` was satisfied by
188
+ * `/login-as-someone-else` and by `/admin?next=/login`, so a redirect to the
189
+ * wrong place passed a test written to catch exactly that.
190
+ *
191
+ * A `url` carrying a query string or a fragment is compared whole, so you can
192
+ * still pin one when it matters. For anything looser, use
193
+ * {@link assertRedirectContains}.
194
+ *
195
+ * @param url - Expected `Location`, or just its path.
196
+ */
183
197
  assertRedirect(url: string): this {
184
- if (this._res.status < 300 || this._res.status > 399) {
198
+ const location = this._assertIsRedirect();
199
+
200
+ // Compare paths unless the expectation names a query or a fragment — an
201
+ // absolute `Location` and a relative expectation are the same redirect.
202
+ const wantsMore = url.includes("?") || url.includes("#");
203
+ const actual = wantsMore ? _withoutOrigin(location) : _pathOf(location);
204
+ const expected = wantsMore ? _withoutOrigin(url) : _pathOf(url);
205
+
206
+ if (actual !== expected) {
185
207
  throw new Error(
186
- this._decorate(`Expected a redirect but got HTTP ${this._res.status}.`, { body: true }),
208
+ this._decorate(
209
+ `Expected redirect to "${url}" but Location was "${location}".` +
210
+ (location.includes(url)
211
+ ? `\n (It contains the expected value but is not equal to it. ` +
212
+ `Use assertRedirectContains() if that is what you meant.)`
213
+ : ""),
214
+ ),
187
215
  );
188
216
  }
189
- const location = this._res.headers.get("Location") ?? "";
190
- if (!location.includes(url)) {
217
+ return this;
218
+ }
219
+
220
+ /**
221
+ * Assert the response is a redirect whose `Location` *contains* `fragment`.
222
+ *
223
+ * The old behaviour of {@link assertRedirect}, kept for the cases where it is
224
+ * genuinely what you want — a signed URL with an unpredictable token, say.
225
+ *
226
+ * @param fragment - Substring the `Location` must contain.
227
+ */
228
+ assertRedirectContains(fragment: string): this {
229
+ const location = this._assertIsRedirect();
230
+ if (!location.includes(fragment)) {
191
231
  throw new Error(
192
- this._decorate(`Expected redirect to "${url}" but Location was "${location}".`),
232
+ this._decorate(
233
+ `Expected redirect containing "${fragment}" but Location was "${location}".`,
234
+ ),
193
235
  );
194
236
  }
195
237
  return this;
196
238
  }
197
239
 
240
+ /** The `Location` of a response that is a redirect, or a failure saying it is not. */
241
+ private _assertIsRedirect(): string {
242
+ if (this._res.status < 300 || this._res.status > 399) {
243
+ throw new Error(
244
+ this._decorate(`Expected a redirect but got HTTP ${this._res.status}.`, { body: true }),
245
+ );
246
+ }
247
+ return this._res.headers.get("Location") ?? "";
248
+ }
249
+
198
250
  /**
199
251
  * Assert the response is a redirect a browser running Inertia will actually follow.
200
252
  *
@@ -1006,3 +1058,27 @@ function _indent(text: string): string {
1006
1058
  .map((line) => ` ${line}`)
1007
1059
  .join("\n");
1008
1060
  }
1061
+
1062
+ /**
1063
+ * A URL's path, so an absolute `Location` and a relative expectation compare equal.
1064
+ *
1065
+ * A redirect to `https://app.test/dashboard` and an expectation of `/dashboard` are
1066
+ * the same redirect, and a test should not have to know which form the handler used.
1067
+ */
1068
+ function _pathOf(url: string): string {
1069
+ try {
1070
+ return new URL(url, "http://localhost").pathname;
1071
+ } catch {
1072
+ return url;
1073
+ }
1074
+ }
1075
+
1076
+ /** A URL without its origin — path, query and fragment — for an exact comparison. */
1077
+ function _withoutOrigin(url: string): string {
1078
+ try {
1079
+ const parsed = new URL(url, "http://localhost");
1080
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
1081
+ } catch {
1082
+ return url;
1083
+ }
1084
+ }