@schmock/query 2.2.2 → 2.3.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,UAAU,iBAAiB;IACzB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,iCAAiC;IACjC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,gBAAgB;IACxB,mCAAmC;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,kBAAkB;IAC1B,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAoCvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,UAAU,iBAAiB;IACzB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,iCAAiC;IACjC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,gBAAgB;IACxB,mCAAmC;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,kBAAkB;IAC1B,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AA2CD,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAoCvE"}
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- var B="2.2.2";function N(c){return{name:"query",version:B,process(h,d){if(!Array.isArray(d))return{context:h,response:d};let j=[...d],k=h.query||{};if(c.filtering)j=E(j,k,c.filtering);if(c.sorting)j=G(j,k,c.sorting);if(c.pagination){let v=H(j,k,c.pagination);return{context:h,response:v}}return{context:h,response:j}}}}function E(c,h,d){let j=d.filterPrefix??"filter",k=c;for(let v of d.allowed){let g=`${j}[${v}]`,f=`${j}.${v}`,b=h[g]??h[f]??h[v];if(b!==void 0)k=k.filter((w)=>{if(typeof w!=="object"||w===null)return!1;let D=w[v];if(D===void 0)return!1;return String(D)===b})}return k}function G(c,h,d){let j=d.sortParam??"sort",k=d.orderParam??"order",v=h[j]??d.default,f=(h[k]??d.defaultOrder??"asc")==="desc"?"desc":"asc";if(!v)return c;if(!d.allowed.includes(v))return c;return c.sort((b,w)=>{if(typeof b!=="object"||b===null)return 0;if(typeof w!=="object"||w===null)return 0;let z=b,D=w,M=z[v],T=D[v];if(M===T)return 0;if(M===void 0)return 1;if(T===void 0)return-1;let A;if(typeof M==="number"&&typeof T==="number")A=M-T;else A=String(M).localeCompare(String(T));return f==="desc"?-A:A})}function H(c,h,d){let j=d.pageParam??"page",k=d.limitParam??"limit",v=d.defaultLimit??10,g=d.maxLimit??100,f=Math.max(1,Number.parseInt(h[j]||"1",10)||1),b=Math.min(g,Math.max(1,Number.parseInt(h[k]||String(v),10)||v)),w=c.length,z=Math.ceil(w/b),D=(f-1)*b;return{data:c.slice(D,D+b),pagination:{page:f,limit:b,total:w,totalPages:z}}}export{N as queryPlugin};
1
+ import{isStatusTuple as N}from"@schmock/core";var I="2.3.0";function H(c){return typeof c==="object"&&c!==null}function W(c){return H(c)&&Object.values(c).every((h)=>typeof h==="string")}function Q(c){return H(c)&&!Array.isArray(c)&&typeof c.status==="number"&&"body"in c&&(c.headers===void 0||W(c.headers))}function X(c){if(N(c))return c[1];if(Q(c))return c.body;return c}function J(c,h){if(N(c))return c.length===3?[c[0],h,c[2]]:[c[0],h];if(Q(c))return{...c,body:h};return h}function T(c){return{name:"query",version:I,process(h,j){let M=X(j);if(!Array.isArray(M))return{context:h,response:j};let w=[...M],k=h.query||{};if(c.filtering)w=Y(w,k,c.filtering);if(c.sorting)w=Z(w,k,c.sorting);if(c.pagination){let C=_(w,k,c.pagination);return{context:h,response:J(j,C)}}return{context:h,response:J(j,w)}}}}function Y(c,h,j){let M=j.filterPrefix??"filter",w=c;for(let k of j.allowed){let C=`${M}[${k}]`,E=`${M}.${k}`,f=h[C]??h[E]??h[k];if(f!==void 0)w=w.filter((z)=>{if(!H(z))return!1;let D=z[k];if(D===void 0)return!1;return String(D)===f})}return w}function Z(c,h,j){let M=j.sortParam??"sort",w=j.orderParam??"order",k=h[M]??j.default,E=(h[w]??j.defaultOrder??"asc")==="desc"?"desc":"asc";if(!k)return c;if(!j.allowed.includes(k))return c;return c.sort((f,z)=>{if(!H(f)||!H(z))return 0;let D=f[k],A=z[k];if(D===A)return 0;if(D===void 0)return 1;if(A===void 0)return-1;let G;if(typeof D==="number"&&typeof A==="number")G=D-A;else G=String(D).localeCompare(String(A));return E==="desc"?-G:G})}function _(c,h,j){let M=j.pageParam??"page",w=j.limitParam??"limit",k=j.defaultLimit??10,C=j.maxLimit??100,E=Math.max(1,Number.parseInt(h[M]||"1",10)||1),f=Math.min(C,Math.max(1,Number.parseInt(h[w]||String(k),10)||k)),z=c.length,D=Math.ceil(z/f),A=(E-1)*f;return{data:c.slice(A,A+f),pagination:{page:E,limit:f,total:z,totalPages:D}}}export{T as queryPlugin};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/query",
3
3
  "description": "Pagination, filtering, and sorting plugin for Schmock",
4
- "version": "2.2.2",
4
+ "version": "2.3.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -19,9 +19,9 @@
19
19
  },
20
20
  "scripts": {
21
21
  "build": "bun build:lib && bun build:types",
22
- "build:lib": "bun build --minify --target node --outdir=dist src/index.ts",
22
+ "build:lib": "bun build --minify --target node --format esm --outdir=dist src/index.ts --external @schmock/core",
23
23
  "build:types": "tsc -p tsconfig.json",
24
- "test": "vitest",
24
+ "test": "vitest run",
25
25
  "test:watch": "vitest --watch",
26
26
  "test:bdd": "vitest run --config vitest.config.bdd.ts",
27
27
  "lint": "biome check src/*.ts",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "license": "MIT",
32
32
  "peerDependencies": {
33
- "@schmock/core": "^2.2.2"
33
+ "@schmock/core": "^2.3.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@amiceli/vitest-cucumber": "^7.0.0",
package/src/index.test.ts CHANGED
@@ -52,6 +52,132 @@ describe("queryPlugin", () => {
52
52
  });
53
53
  });
54
54
 
55
+ it("paginates the array body of a two-element status tuple", async () => {
56
+ const plugin = queryPlugin({
57
+ pagination: { defaultLimit: 2, maxLimit: 10 },
58
+ });
59
+ const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
60
+ const context: Schmock.PluginContext = {
61
+ path: "/test",
62
+ route: {},
63
+ method: "GET",
64
+ params: {},
65
+ query: { page: "2", limit: "2" },
66
+ headers: {},
67
+ state: new Map(),
68
+ };
69
+
70
+ const result = await plugin.process(context, [206, items] satisfies [
71
+ number,
72
+ unknown,
73
+ ]);
74
+
75
+ expect(result.response).toEqual([
76
+ 206,
77
+ {
78
+ data: [{ id: 3 }, { id: 4 }],
79
+ pagination: { page: 2, limit: 2, total: 5, totalPages: 3 },
80
+ },
81
+ ]);
82
+ });
83
+
84
+ it("paginates a status tuple body without changing its headers", async () => {
85
+ const plugin = queryPlugin({
86
+ pagination: { defaultLimit: 2, maxLimit: 10 },
87
+ });
88
+ const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
89
+ const headers = {
90
+ "content-type": "application/json",
91
+ "x-response-source": "tuple",
92
+ };
93
+ const context: Schmock.PluginContext = {
94
+ path: "/test",
95
+ route: {},
96
+ method: "GET",
97
+ params: {},
98
+ query: { page: "2", limit: "2" },
99
+ headers: {},
100
+ state: new Map(),
101
+ };
102
+
103
+ const result = await plugin.process(context, [
104
+ 206,
105
+ items,
106
+ headers,
107
+ ] satisfies [number, unknown, Record<string, string>]);
108
+
109
+ expect(result.response).toEqual([
110
+ 206,
111
+ {
112
+ data: [{ id: 3 }, { id: 4 }],
113
+ pagination: { page: 2, limit: 2, total: 5, totalPages: 3 },
114
+ },
115
+ headers,
116
+ ]);
117
+ });
118
+
119
+ it("paginates a structured response body without changing metadata", async () => {
120
+ const plugin = queryPlugin({
121
+ pagination: { defaultLimit: 2, maxLimit: 10 },
122
+ });
123
+ const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }];
124
+ const response = {
125
+ status: 206,
126
+ body: items,
127
+ headers: { "x-response-source": "structured" },
128
+ };
129
+ const context: Schmock.PluginContext = {
130
+ path: "/test",
131
+ route: {},
132
+ method: "GET",
133
+ params: {},
134
+ query: { page: "2", limit: "2" },
135
+ headers: {},
136
+ state: new Map(),
137
+ };
138
+
139
+ const result = await plugin.process(context, response);
140
+
141
+ expect(result.response).toEqual({
142
+ status: 206,
143
+ body: {
144
+ data: [{ id: 3 }, { id: 4 }],
145
+ pagination: { page: 2, limit: 2, total: 5, totalPages: 3 },
146
+ },
147
+ headers: { "x-response-source": "structured" },
148
+ });
149
+ });
150
+
151
+ it("passes response containers through when their semantic body is not an array", async () => {
152
+ const plugin = queryPlugin({ pagination: { defaultLimit: 2 } });
153
+ const context: Schmock.PluginContext = {
154
+ path: "/test",
155
+ route: {},
156
+ method: "GET",
157
+ params: {},
158
+ query: { page: "2", limit: "2" },
159
+ headers: {},
160
+ state: new Map(),
161
+ };
162
+ const responses: unknown[] = [
163
+ [
164
+ 202,
165
+ { message: "accepted" },
166
+ { "x-response-source": "tuple" },
167
+ ] satisfies [number, unknown, Record<string, string>],
168
+ {
169
+ status: 202,
170
+ body: { message: "accepted" },
171
+ headers: { "x-response-source": "structured" },
172
+ },
173
+ ];
174
+
175
+ for (const response of responses) {
176
+ const result = await plugin.process(context, response);
177
+ expect(result.response).toBe(response);
178
+ }
179
+ });
180
+
55
181
  it("sorts array responses", async () => {
56
182
  const plugin = queryPlugin({
57
183
  sorting: { allowed: ["name"], default: "name" },
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /// <reference path="../../core/schmock.d.ts" />
2
2
 
3
+ import { isStatusTuple } from "@schmock/core";
3
4
  import { version as packageVersion } from "../package.json";
4
5
 
5
6
  interface PaginationOptions {
@@ -39,6 +40,47 @@ interface QueryPluginOptions {
39
40
  filtering?: FilteringOptions;
40
41
  }
41
42
 
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null;
45
+ }
46
+
47
+ function isStringRecord(value: unknown): value is Record<string, string> {
48
+ return (
49
+ isRecord(value) &&
50
+ Object.values(value).every((entry) => typeof entry === "string")
51
+ );
52
+ }
53
+
54
+ function isStructuredResponse(value: unknown): value is {
55
+ status: number;
56
+ body: unknown;
57
+ headers?: Record<string, string>;
58
+ } {
59
+ return (
60
+ isRecord(value) &&
61
+ !Array.isArray(value) &&
62
+ typeof value.status === "number" &&
63
+ "body" in value &&
64
+ (value.headers === undefined || isStringRecord(value.headers))
65
+ );
66
+ }
67
+
68
+ function getResponseBody(response: unknown): unknown {
69
+ if (isStatusTuple(response)) return response[1];
70
+ if (isStructuredResponse(response)) return response.body;
71
+ return response;
72
+ }
73
+
74
+ function replaceResponseBody(response: unknown, body: unknown): unknown {
75
+ if (isStatusTuple(response)) {
76
+ return response.length === 3
77
+ ? [response[0], body, response[2]]
78
+ : [response[0], body];
79
+ }
80
+ if (isStructuredResponse(response)) return { ...response, body };
81
+ return body;
82
+ }
83
+
42
84
  export function queryPlugin(options: QueryPluginOptions): Schmock.Plugin {
43
85
  return {
44
86
  name: "query",
@@ -48,12 +90,12 @@ export function queryPlugin(options: QueryPluginOptions): Schmock.Plugin {
48
90
  context: Schmock.PluginContext,
49
91
  response?: unknown,
50
92
  ): Schmock.PluginResult {
51
- // Only process array responses
52
- if (!Array.isArray(response)) {
93
+ const responseBody = getResponseBody(response);
94
+ if (!Array.isArray(responseBody)) {
53
95
  return { context, response };
54
96
  }
55
97
 
56
- let items: unknown[] = [...response];
98
+ let items: unknown[] = [...responseBody];
57
99
  const query = context.query || {};
58
100
 
59
101
  // Apply filtering
@@ -69,10 +111,10 @@ export function queryPlugin(options: QueryPluginOptions): Schmock.Plugin {
69
111
  // Apply pagination
70
112
  if (options.pagination) {
71
113
  const result = applyPagination(items, query, options.pagination);
72
- return { context, response: result };
114
+ return { context, response: replaceResponseBody(response, result) };
73
115
  }
74
116
 
75
- return { context, response: items };
117
+ return { context, response: replaceResponseBody(response, items) };
76
118
  },
77
119
  };
78
120
  }
@@ -95,9 +137,8 @@ function applyFiltering(
95
137
 
96
138
  if (value !== undefined) {
97
139
  result = result.filter((item) => {
98
- if (typeof item !== "object" || item === null) return false;
99
- const record = item as Record<string, unknown>;
100
- const itemValue = record[field];
140
+ if (!isRecord(item)) return false;
141
+ const itemValue = item[field];
101
142
  if (itemValue === undefined) return false;
102
143
  // Intentional string coercion: query params are inherently strings
103
144
  return String(itemValue) === value;
@@ -125,12 +166,9 @@ function applySorting(
125
166
  if (!options.allowed.includes(sortField)) return items;
126
167
 
127
168
  return items.sort((a, b) => {
128
- if (typeof a !== "object" || a === null) return 0;
129
- if (typeof b !== "object" || b === null) return 0;
130
- const aRecord = a as Record<string, unknown>;
131
- const bRecord = b as Record<string, unknown>;
132
- const aVal = aRecord[sortField];
133
- const bVal = bRecord[sortField];
169
+ if (!isRecord(a) || !isRecord(b)) return 0;
170
+ const aVal = a[sortField];
171
+ const bVal = b[sortField];
134
172
 
135
173
  if (aVal === bVal) return 0;
136
174
  if (aVal === undefined) return 1;
@@ -5,13 +5,52 @@ import { queryPlugin } from "../index";
5
5
 
6
6
  const feature = await loadFeature("../../features/query-plugin.feature");
7
7
 
8
- describeFeature(feature, ({ Scenario }) => {
9
- let mock: any;
10
- let response: any;
8
+ function isUnknownArray(value: unknown): value is unknown[] {
9
+ return Array.isArray(value);
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function requireRecord(
17
+ value: unknown,
18
+ description: string,
19
+ ): Record<string, unknown> {
20
+ if (!isRecord(value)) {
21
+ throw new Error(`Expected ${description} to be an object`);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ function requireRecordArray(
27
+ value: unknown,
28
+ description: string,
29
+ ): Record<string, unknown>[] {
30
+ if (!isUnknownArray(value)) {
31
+ throw new Error(`Expected ${description} to be an array`);
32
+ }
33
+ return value.map((item) => requireRecord(item, `${description} item`));
34
+ }
35
+
36
+ function requirePaginatedBody(response: Schmock.Response) {
37
+ const body = requireRecord(response.body, "paginated response body");
38
+ return {
39
+ data: requireRecordArray(body.data, "paginated response data"),
40
+ pagination: requireRecord(body.pagination, "pagination metadata"),
41
+ };
42
+ }
43
+
44
+ describeFeature(feature, ({ Scenario, ScenarioOutline }) => {
45
+ let mock: Schmock.CallableMockInstance;
46
+ let response: Schmock.Response;
11
47
 
12
48
  // Generate test data
13
49
  const generateItems = (count: number) =>
14
- Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}` }));
50
+ Array.from({ length: count }, (_, i) => ({
51
+ id: i + 1,
52
+ name: `Item ${i + 1}`,
53
+ }));
15
54
 
16
55
  const namedItems = [
17
56
  { id: 1, name: "Charlie" },
@@ -27,41 +66,51 @@ describeFeature(feature, ({ Scenario }) => {
27
66
  { id: 5, name: "Eve", role: "moderator" },
28
67
  ];
29
68
 
30
- Scenario("Basic pagination with default settings", ({ Given, When, Then, And }) => {
31
- Given("I create a mock with 25 items and pagination plugin", () => {
32
- mock = schmock();
33
- mock("GET /items", generateItems(25))
34
- .pipe(queryPlugin({
35
- pagination: { defaultLimit: 10, maxLimit: 100 },
36
- }));
37
- });
69
+ Scenario(
70
+ "Basic pagination with default settings",
71
+ ({ Given, When, Then, And }) => {
72
+ Given("I create a mock with 25 items and pagination plugin", () => {
73
+ mock = schmock();
74
+ mock("GET /items", generateItems(25)).pipe(
75
+ queryPlugin({
76
+ pagination: { defaultLimit: 10, maxLimit: 100 },
77
+ }),
78
+ );
79
+ });
38
80
 
39
- When("I request page 1", async () => {
40
- response = await mock.handle("GET", "/items", {
41
- query: { page: "1" },
81
+ When("I request page 1", async () => {
82
+ response = await mock.handle("GET", "/items", {
83
+ query: { page: "1" },
84
+ });
42
85
  });
43
- });
44
86
 
45
- Then("I should receive {int} items", (_, count: number) => {
46
- expect(response.body.data).toHaveLength(count);
47
- });
87
+ Then("I should receive {int} items", (_, count: number) => {
88
+ expect(requirePaginatedBody(response).data).toHaveLength(count);
89
+ });
48
90
 
49
- And("the pagination total should be {int}", (_, total: number) => {
50
- expect(response.body.pagination.total).toBe(total);
51
- });
91
+ And("the pagination total should be {int}", (_, total: number) => {
92
+ expect(requirePaginatedBody(response).pagination.total).toBe(total);
93
+ });
52
94
 
53
- And("the pagination totalPages should be {int}", (_, totalPages: number) => {
54
- expect(response.body.pagination.totalPages).toBe(totalPages);
55
- });
56
- });
95
+ And(
96
+ "the pagination totalPages should be {int}",
97
+ (_, totalPages: number) => {
98
+ expect(requirePaginatedBody(response).pagination.totalPages).toBe(
99
+ totalPages,
100
+ );
101
+ },
102
+ );
103
+ },
104
+ );
57
105
 
58
106
  Scenario("Pagination with custom limit", ({ Given, When, Then, And }) => {
59
107
  Given("I create a mock with 25 items and pagination plugin", () => {
60
108
  mock = schmock();
61
- mock("GET /items", generateItems(25))
62
- .pipe(queryPlugin({
109
+ mock("GET /items", generateItems(25)).pipe(
110
+ queryPlugin({
63
111
  pagination: { defaultLimit: 10, maxLimit: 100 },
64
- }));
112
+ }),
113
+ );
65
114
  });
66
115
 
67
116
  When("I request page 1 with limit 5", async () => {
@@ -71,69 +120,87 @@ describeFeature(feature, ({ Scenario }) => {
71
120
  });
72
121
 
73
122
  Then("I should receive {int} items", (_, count: number) => {
74
- expect(response.body.data).toHaveLength(count);
123
+ expect(requirePaginatedBody(response).data).toHaveLength(count);
75
124
  });
76
125
 
77
- And("the pagination totalPages should be {int}", (_, totalPages: number) => {
78
- expect(response.body.pagination.totalPages).toBe(totalPages);
79
- });
126
+ And(
127
+ "the pagination totalPages should be {int}",
128
+ (_, totalPages: number) => {
129
+ expect(requirePaginatedBody(response).pagination.totalPages).toBe(
130
+ totalPages,
131
+ );
132
+ },
133
+ );
80
134
  });
81
135
 
82
136
  Scenario("Sort items ascending by name", ({ Given, When, Then, And }) => {
83
137
  Given("I create a mock with named items and sorting plugin", () => {
84
138
  mock = schmock();
85
- mock("GET /items", namedItems)
86
- .pipe(queryPlugin({
139
+ mock("GET /items", namedItems).pipe(
140
+ queryPlugin({
87
141
  sorting: { allowed: ["name", "id"], default: "id" },
88
- }));
142
+ }),
143
+ );
89
144
  });
90
145
 
91
- When("I request with sort {string} order {string}", async (_, sort: string, order: string) => {
92
- response = await mock.handle("GET", "/items", {
93
- query: { sort, order },
94
- });
95
- });
146
+ When(
147
+ "I request with sort {string} order {string}",
148
+ async (_, sort: string, order: string) => {
149
+ response = await mock.handle("GET", "/items", {
150
+ query: { sort, order },
151
+ });
152
+ },
153
+ );
96
154
 
97
155
  Then("the first item name should be {string}", (_, name: string) => {
98
- expect(response.body[0].name).toBe(name);
156
+ const items = requireRecordArray(response.body, "sorted response body");
157
+ expect(items.at(0)?.name).toBe(name);
99
158
  });
100
159
 
101
160
  And("the last item name should be {string}", (_, name: string) => {
102
- expect(response.body[response.body.length - 1].name).toBe(name);
161
+ const items = requireRecordArray(response.body, "sorted response body");
162
+ expect(items.at(-1)?.name).toBe(name);
103
163
  });
104
164
  });
105
165
 
106
166
  Scenario("Sort items descending by name", ({ Given, When, Then, And }) => {
107
167
  Given("I create a mock with named items and sorting plugin", () => {
108
168
  mock = schmock();
109
- mock("GET /items", namedItems)
110
- .pipe(queryPlugin({
169
+ mock("GET /items", namedItems).pipe(
170
+ queryPlugin({
111
171
  sorting: { allowed: ["name", "id"], default: "id" },
112
- }));
172
+ }),
173
+ );
113
174
  });
114
175
 
115
- When("I request with sort {string} order {string}", async (_, sort: string, order: string) => {
116
- response = await mock.handle("GET", "/items", {
117
- query: { sort, order },
118
- });
119
- });
176
+ When(
177
+ "I request with sort {string} order {string}",
178
+ async (_, sort: string, order: string) => {
179
+ response = await mock.handle("GET", "/items", {
180
+ query: { sort, order },
181
+ });
182
+ },
183
+ );
120
184
 
121
185
  Then("the first item name should be {string}", (_, name: string) => {
122
- expect(response.body[0].name).toBe(name);
186
+ const items = requireRecordArray(response.body, "sorted response body");
187
+ expect(items.at(0)?.name).toBe(name);
123
188
  });
124
189
 
125
190
  And("the last item name should be {string}", (_, name: string) => {
126
- expect(response.body[response.body.length - 1].name).toBe(name);
191
+ const items = requireRecordArray(response.body, "sorted response body");
192
+ expect(items.at(-1)?.name).toBe(name);
127
193
  });
128
194
  });
129
195
 
130
196
  Scenario("Filter items by field value", ({ Given, When, Then, And }) => {
131
197
  Given("I create a mock with categorized items and filtering plugin", () => {
132
198
  mock = schmock();
133
- mock("GET /items", categorizedItems)
134
- .pipe(queryPlugin({
199
+ mock("GET /items", categorizedItems).pipe(
200
+ queryPlugin({
135
201
  filtering: { allowed: ["role"] },
136
- }));
202
+ }),
203
+ );
137
204
  });
138
205
 
139
206
  When("I request with filter role {string}", async (_, role: string) => {
@@ -143,75 +210,153 @@ describeFeature(feature, ({ Scenario }) => {
143
210
  });
144
211
 
145
212
  Then("I should receive {int} filtered items", (_, count: number) => {
146
- expect(response.body).toHaveLength(count);
213
+ expect(
214
+ requireRecordArray(response.body, "filtered response body"),
215
+ ).toHaveLength(count);
147
216
  });
148
217
 
149
218
  And("all items should have role {string}", (_, role: string) => {
150
- for (const item of response.body) {
219
+ const items = requireRecordArray(response.body, "filtered response body");
220
+ for (const item of items) {
151
221
  expect(item.role).toBe(role);
152
222
  }
153
223
  });
154
224
  });
155
225
 
156
- Scenario("Combined pagination, sorting, and filtering", ({ Given, When, Then, And }) => {
157
- const users = [
158
- { id: 1, name: "Dave", role: "admin" },
159
- { id: 2, name: "Alice", role: "user" },
160
- { id: 3, name: "Charlie", role: "admin" },
161
- { id: 4, name: "Bob", role: "admin" },
162
- { id: 5, name: "Eve", role: "user" },
163
- { id: 6, name: "Frank", role: "admin" },
164
- { id: 7, name: "Grace", role: "user" },
165
- { id: 8, name: "Hank", role: "admin" },
166
- { id: 9, name: "Ivy", role: "user" },
167
- { id: 10, name: "Jack", role: "admin" },
168
- { id: 11, name: "Karen", role: "user" },
169
- { id: 12, name: "Leo", role: "admin" },
170
- { id: 13, name: "Mona", role: "user" },
171
- { id: 14, name: "Nate", role: "admin" },
172
- { id: 15, name: "Olivia", role: "user" },
173
- { id: 16, name: "Paul", role: "admin" },
174
- { id: 17, name: "Quinn", role: "user" },
175
- { id: 18, name: "Rose", role: "admin" },
176
- { id: 19, name: "Sam", role: "user" },
177
- { id: 20, name: "Tina", role: "admin" },
178
- ];
179
-
180
- Given("I create a mock with 20 users and full query plugin", () => {
181
- mock = schmock();
182
- mock("GET /users", users)
183
- .pipe(queryPlugin({
184
- pagination: { defaultLimit: 10, maxLimit: 100 },
185
- sorting: { allowed: ["name", "id"] },
186
- filtering: { allowed: ["role"] },
187
- }));
188
- });
226
+ Scenario(
227
+ "Combined pagination, sorting, and filtering",
228
+ ({ Given, When, Then, And }) => {
229
+ const users = [
230
+ { id: 1, name: "Dave", role: "admin" },
231
+ { id: 2, name: "Alice", role: "user" },
232
+ { id: 3, name: "Charlie", role: "admin" },
233
+ { id: 4, name: "Bob", role: "admin" },
234
+ { id: 5, name: "Eve", role: "user" },
235
+ { id: 6, name: "Frank", role: "admin" },
236
+ { id: 7, name: "Grace", role: "user" },
237
+ { id: 8, name: "Hank", role: "admin" },
238
+ { id: 9, name: "Ivy", role: "user" },
239
+ { id: 10, name: "Jack", role: "admin" },
240
+ { id: 11, name: "Karen", role: "user" },
241
+ { id: 12, name: "Leo", role: "admin" },
242
+ { id: 13, name: "Mona", role: "user" },
243
+ { id: 14, name: "Nate", role: "admin" },
244
+ { id: 15, name: "Olivia", role: "user" },
245
+ { id: 16, name: "Paul", role: "admin" },
246
+ { id: 17, name: "Quinn", role: "user" },
247
+ { id: 18, name: "Rose", role: "admin" },
248
+ { id: 19, name: "Sam", role: "user" },
249
+ { id: 20, name: "Tina", role: "admin" },
250
+ ];
251
+
252
+ Given("I create a mock with 20 users and full query plugin", () => {
253
+ mock = schmock();
254
+ mock("GET /users", users).pipe(
255
+ queryPlugin({
256
+ pagination: { defaultLimit: 10, maxLimit: 100 },
257
+ sorting: { allowed: ["name", "id"] },
258
+ filtering: { allowed: ["role"] },
259
+ }),
260
+ );
261
+ });
189
262
 
190
- When("I request page 1 with limit 2 filter role {string} and sort {string}", async (_, role: string, sort: string) => {
191
- response = await mock.handle("GET", "/users", {
192
- query: { page: "1", limit: "2", "filter[role]": role, sort },
263
+ When(
264
+ "I request page 1 with limit 2 filter role {string} and sort {string}",
265
+ async (_, role: string, sort: string) => {
266
+ response = await mock.handle("GET", "/users", {
267
+ query: { page: "1", limit: "2", "filter[role]": role, sort },
268
+ });
269
+ },
270
+ );
271
+
272
+ Then("I should receive {int} items", (_, count: number) => {
273
+ expect(requirePaginatedBody(response).data).toHaveLength(count);
193
274
  });
194
- });
195
275
 
196
- Then("I should receive {int} items", (_, count: number) => {
197
- expect(response.body.data).toHaveLength(count);
198
- });
276
+ And("the items should be sorted by name ascending", () => {
277
+ const names = requirePaginatedBody(response).data.map((item) => {
278
+ if (typeof item.name !== "string") {
279
+ throw new Error(
280
+ "Expected each paginated item to have a string name",
281
+ );
282
+ }
283
+ return item.name;
284
+ });
285
+ const sorted = [...names].sort();
286
+ expect(names).toEqual(sorted);
287
+ });
199
288
 
200
- And("the items should be sorted by name ascending", () => {
201
- const names = response.body.data.map((item: any) => item.name);
202
- const sorted = [...names].sort();
203
- expect(names).toEqual(sorted);
204
- });
289
+ And("all items should have role {string}", (_, role: string) => {
290
+ for (const item of requirePaginatedBody(response).data) {
291
+ expect(item.role).toBe(role);
292
+ }
293
+ });
205
294
 
206
- And("all items should have role {string}", (_, role: string) => {
207
- for (const item of response.body.data) {
208
- expect(item.role).toBe(role);
209
- }
210
- });
295
+ And("the pagination total should reflect filtered count", () => {
296
+ // 11 admins in the dataset
297
+ expect(requirePaginatedBody(response).pagination.total).toBe(11);
298
+ });
299
+ },
300
+ );
301
+
302
+ ScenarioOutline(
303
+ "Paginate array response containers without losing metadata",
304
+ ({ Given, When, Then, And }, variables) => {
305
+ Given("I create a mock returning 5 items in a {string} response", () => {
306
+ mock = schmock();
307
+ const headers = { "x-query-source": variables.container };
308
+ if (variables.container === "tuple") {
309
+ mock(
310
+ "GET /items",
311
+ () =>
312
+ [206, generateItems(5), headers] satisfies [
313
+ number,
314
+ unknown,
315
+ Record<string, string>,
316
+ ],
317
+ );
318
+ } else if (variables.container === "structured") {
319
+ mock("GET /items", () => ({
320
+ status: 206,
321
+ body: generateItems(5),
322
+ headers,
323
+ }));
324
+ } else {
325
+ throw new Error(
326
+ `Unsupported query response container: ${variables.container}`,
327
+ );
328
+ }
329
+ mock.pipe(
330
+ queryPlugin({
331
+ pagination: { defaultLimit: 10, maxLimit: 100 },
332
+ }),
333
+ );
334
+ });
211
335
 
212
- And("the pagination total should reflect filtered count", () => {
213
- // 11 admins in the dataset
214
- expect(response.body.pagination.total).toBe(11);
215
- });
216
- });
336
+ When("I request page 2 with limit 2", async () => {
337
+ response = await mock.handle("GET", "/items", {
338
+ query: { page: "2", limit: "2" },
339
+ });
340
+ });
341
+
342
+ Then("the response status should be 206", () => {
343
+ expect(response.status).toBe(206);
344
+ });
345
+
346
+ And(
347
+ "response header {string} should be {string}",
348
+ (_, header: string) => {
349
+ expect(response.headers[header]).toBe(variables.container);
350
+ },
351
+ );
352
+
353
+ And("I should receive 2 items", () => {
354
+ expect(requirePaginatedBody(response).data).toHaveLength(2);
355
+ });
356
+
357
+ And("the pagination total should be 5", () => {
358
+ expect(requirePaginatedBody(response).pagination.total).toBe(5);
359
+ });
360
+ },
361
+ );
217
362
  });