@hyperspan/framework 1.0.29 → 2.0.0-alpha.13

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/src/client/js.ts CHANGED
@@ -1,98 +1,322 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { isAbsolute, join, relative } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { html } from '@hyperspan/html';
2
5
  import { assetHash as assetHashFn } from '../utils';
3
- import { join } from 'node:path';
6
+ import { getImportMap, registerImport, resolveImport } from './manifest';
4
7
  import type { Hyperspan as HS } from '../types';
5
8
 
6
- const CWD = process.cwd();
7
- const IS_PROD = process.env.NODE_ENV === 'production';
9
+ export { registerImport, resolveImport, getImportMap } from './manifest';
8
10
 
9
11
  export const JS_PUBLIC_PATH = '/_hs/js';
10
12
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
11
- export const JS_IMPORT_MAP = new Map<string, string>();
12
- const CLIENT_JS_CACHE = new Map<string, { esmName: string, exports: string, fnArgs: string, publicPath: string }>();
13
- const CLIENT_JS_BUILD_PROMISES = new Map<string, Promise<void>>();
14
- const EXPORT_REGEX = /export\{(.*)\}/g;
13
+
14
+ export type ClientJSType = 'module' | 'iife';
15
+
16
+ export type BuildClientJSOptions = {
17
+ /** `iife` emits a classic <script> (runs during HTML streaming). Default `module`. */
18
+ type?: ClientJSType;
19
+ };
20
+
21
+ export type ClientJSEntry = {
22
+ /** Original path/specifier passed to `buildClientJS`. */
23
+ modulePath: string;
24
+ absPath: string;
25
+ assetHash: string;
26
+ esmName: string;
27
+ publicPath: string;
28
+ exports: string;
29
+ fnArgs: string;
30
+ type: ClientJSType;
31
+ };
32
+
33
+ const CLIENT_JS_REGISTRY = Symbol.for('@hyperspan/client-js-entries');
34
+ const PATH_ALIASES = Symbol.for('@hyperspan/path-aliases');
35
+
36
+ function getPathAliases(): Record<string, string> {
37
+ const globalAliases = globalThis as {
38
+ [PATH_ALIASES]?: Record<string, string>;
39
+ };
40
+ if (!globalAliases[PATH_ALIASES]) {
41
+ globalAliases[PATH_ALIASES] = {};
42
+ }
43
+ return globalAliases[PATH_ALIASES];
44
+ }
45
+
46
+ /** Register tsconfig path aliases (`~/` → project root). Called by the Vite plugin. */
47
+ export function registerPathAliases(aliases: Record<string, string>): void {
48
+ Object.assign(getPathAliases(), aliases);
49
+ }
50
+
51
+ function resolveWithAliases(specifier: string): string | undefined {
52
+ const aliases = getPathAliases();
53
+ const keys = Object.keys(aliases).sort((a, b) => b.length - a.length);
54
+ for (const key of keys) {
55
+ if (specifier === key || specifier.startsWith(key)) {
56
+ const rest = specifier.slice(key.length).replace(/^\//, '');
57
+ return join(aliases[key], rest);
58
+ }
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ function getClientJSRegistry(): Map<string, ClientJSEntry> {
64
+ const globalRegistry = globalThis as {
65
+ [CLIENT_JS_REGISTRY]?: Map<string, ClientJSEntry>;
66
+ };
67
+ if (!globalRegistry[CLIENT_JS_REGISTRY]) {
68
+ globalRegistry[CLIENT_JS_REGISTRY] = new Map();
69
+ }
70
+ return globalRegistry[CLIENT_JS_REGISTRY];
71
+ }
15
72
 
16
73
  /**
17
- * Build a client JS module and return a Hyperspan.ClientJSBuildResult object
74
+ * Backward-compatible import map (backed by asset manifest).
18
75
  */
19
- export async function buildClientJS(modulePathResolved: string): Promise<HS.ClientJSBuildResult> {
20
- const modulePath = modulePathResolved.replace('file://', '');
21
- const assetHash = assetHashFn(modulePath);
22
-
23
- // Cache: Avoid re-processing the same file
24
- if (!CLIENT_JS_CACHE.has(assetHash)) {
25
- const existingBuild = CLIENT_JS_BUILD_PROMISES.get(assetHash);
26
- // Await the existing build promise if it exists (this can get called in parallel from Bun traversing imports)
27
- if (existingBuild) {
28
- await existingBuild;
29
- } else {
30
- const buildPromise = (async () => {
31
- // Build the client JS module
32
- const result = await Bun.build({
33
- entrypoints: [modulePath],
34
- outdir: join(CWD, './public', JS_PUBLIC_PATH), // @TODO: Make this configurable... should be read from config file...
35
- naming: IS_PROD ? '[dir]/[name]-[hash].[ext]' : undefined,
36
- external: Array.from(JS_IMPORT_MAP.keys()),
37
- minify: true,
38
- format: 'esm',
39
- target: 'browser',
40
- env: 'APP_PUBLIC_*',
41
- });
42
-
43
- // Add output file to import map
44
- const esmName = String(result.outputs[0].path.split('/').reverse()[0]).replace('.js', '');
45
- const publicPath = `${JS_PUBLIC_PATH}/${esmName}.js`;
46
- JS_IMPORT_MAP.set(esmName, publicPath);
47
-
48
- // Get the contents of the file to extract the exports
49
- const contents = await result.outputs[0].text();
50
- const { exports, fnArgs } = extractExports(contents);
51
-
52
- CLIENT_JS_CACHE.set(assetHash, { esmName, exports, fnArgs, publicPath });
53
- })();
54
-
55
- CLIENT_JS_BUILD_PROMISES.set(assetHash, buildPromise);
56
- try {
57
- await buildPromise;
58
- } finally {
59
- CLIENT_JS_BUILD_PROMISES.delete(assetHash);
60
- }
76
+ export const JS_IMPORT_MAP = {
77
+ get size() {
78
+ return Object.keys(getImportMap()).length;
79
+ },
80
+ keys() {
81
+ return Object.keys(getImportMap()).values();
82
+ },
83
+ has(key: string) {
84
+ return resolveImport(key) !== undefined;
85
+ },
86
+ get(key: string) {
87
+ return resolveImport(key);
88
+ },
89
+ set(key: string, value: string) {
90
+ registerImport(key, value);
91
+ return this;
92
+ },
93
+ entries() {
94
+ return Object.entries(getImportMap()).values();
95
+ },
96
+ [Symbol.iterator]() {
97
+ return Object.entries(getImportMap())[Symbol.iterator]();
98
+ },
99
+ };
100
+
101
+ /**
102
+ * Prefer a cwd-relative hash so `import.meta.resolve(...)` and `app/client/foo.ts`
103
+ * produce the same public URL. Absolute paths outside the project stay as-is.
104
+ */
105
+ function stableHashKey(absPath: string): string {
106
+ const normalized = absPath.replace(/\\/g, '/');
107
+ if (typeof process !== 'undefined' && typeof process.cwd === 'function') {
108
+ const rel = relative(process.cwd(), absPath).replace(/\\/g, '/');
109
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
110
+ return rel;
61
111
  }
62
112
  }
113
+ const marker = '/node_modules/';
114
+ const idx = normalized.lastIndexOf(marker);
115
+ if (idx !== -1) {
116
+ return normalized.slice(idx + marker.length);
117
+ }
118
+ return normalized;
119
+ }
120
+
121
+ function toFilePath(modulePath: string): string {
122
+ return modulePath.startsWith('file://') ? fileURLToPath(modulePath) : modulePath;
123
+ }
124
+
125
+ /**
126
+ * `import.meta.resolve('./file.ts')` → file URL or absolute path.
127
+ * `~/app/client/foo.ts` → registered tsconfig alias.
128
+ * `app/client/foo.ts` → project root.
129
+ * Relative `./` / `../` paths error — resolve them at the call site.
130
+ */
131
+ function resolveClientModule(modulePath: string): { hashKey: string; absPath: string } {
132
+ if (modulePath.startsWith('file://') || isAbsolute(modulePath)) {
133
+ const absPath = toFilePath(modulePath);
134
+ return { hashKey: stableHashKey(absPath), absPath };
135
+ }
136
+
137
+ const specifier = modulePath.replace(/\\/g, '/');
138
+ if (specifier.startsWith('.')) {
139
+ throw new Error(
140
+ `[Hyperspan] buildClientJS(${JSON.stringify(modulePath)}) got a relative path. ` +
141
+ `Use import.meta.resolve(${JSON.stringify(modulePath)}) at the call site, ` +
142
+ `or a tsconfig alias like '~/app/client/file.ts'.`
143
+ );
144
+ }
145
+
146
+ const aliased = resolveWithAliases(specifier);
147
+ if (aliased) {
148
+ return { hashKey: specifier, absPath: aliased };
149
+ }
150
+
151
+ return { hashKey: specifier, absPath: join(process.cwd(), specifier) };
152
+ }
153
+
154
+ export function getClientJSEntries(): ClientJSEntry[] {
155
+ return [...getClientJSRegistry().values()];
156
+ }
157
+
158
+ export function getClientJSEntryByEsmName(esmName: string): ClientJSEntry | undefined {
159
+ return getClientJSRegistry().get(esmName);
160
+ }
161
+
162
+ export function resetClientJSEntriesForTests(): void {
163
+ getClientJSRegistry().clear();
164
+ const aliases = getPathAliases();
165
+ for (const key of Object.keys(aliases)) {
166
+ delete aliases[key];
167
+ }
168
+ }
169
+
170
+ function registerClientJS(
171
+ modulePathResolved: string,
172
+ options: BuildClientJSOptions = {}
173
+ ): HS.ClientJSBuildResult {
174
+ const type = options.type ?? 'module';
175
+ const { hashKey, absPath } = resolveClientModule(modulePathResolved);
176
+ const hash = assetHashFn(hashKey);
177
+ const esmName = `client-${hash}`;
178
+ const publicPath = resolveImport(esmName) ?? `${JS_PUBLIC_PATH}/${esmName}.js`;
179
+
180
+ let exports = '* as _module';
181
+ let fnArgs = '_module';
182
+ const sourcePath = existsSync(absPath) ? absPath : null;
183
+ if (sourcePath) {
184
+ const source = readFileSync(sourcePath, 'utf-8');
185
+ const discovered = discoverClientExports(source);
186
+ exports = discovered.exports;
187
+ fnArgs = discovered.fnArgs;
188
+ }
63
189
 
64
- const { esmName, exports, fnArgs, publicPath } = CLIENT_JS_CACHE.get(assetHash)!;
190
+ getClientJSRegistry().set(esmName, {
191
+ modulePath: modulePathResolved,
192
+ absPath,
193
+ assetHash: hash,
194
+ esmName,
195
+ publicPath,
196
+ exports,
197
+ fnArgs,
198
+ type,
199
+ });
200
+ registerImport(esmName, publicPath);
65
201
 
66
202
  return {
67
- assetHash,
203
+ assetHash: hash,
68
204
  esmName,
69
205
  publicPath,
70
206
  renderScriptTag: (loadScript) => {
207
+ if (type === 'iife') {
208
+ return html`<script src="${publicPath}"></script>`;
209
+ }
210
+
71
211
  const t = typeof loadScript;
72
212
 
73
213
  if (t === 'string') {
74
214
  return html`
75
- <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(loadScript as string)})(${fnArgs});</script>
215
+ <script type="module" data-source-id="${hash}">
216
+ import ${exports} from '${esmName}';
217
+ (${html.raw(loadScript as string)})(${fnArgs});
218
+ </script>
76
219
  `;
77
220
  }
78
221
  if (t === 'function') {
79
222
  return html`
80
- <script type="module" data-source-id="${assetHash}">import ${exports} from "${esmName}";\n(${html.raw(functionToString(loadScript))})(${fnArgs});</script>
223
+ <script type="module" data-source-id="${hash}">
224
+ import ${exports} from '${esmName}';
225
+ (${html.raw(functionToString(loadScript))})(${fnArgs});
226
+ </script>
81
227
  `;
82
228
  }
83
229
 
84
230
  return html`
85
- <script type="module" data-source-id="${assetHash}">import "${esmName}";</script>
231
+ <script type="module" data-source-id="${hash}">
232
+ import '${esmName}';
233
+ </script>
86
234
  `;
235
+ },
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Register a client JS module for Vite to bundle.
241
+ * Prefer a tsconfig alias or project-root path (`~/app/client/foo.ts`,
242
+ * `app/client/foo.ts`). Relative `./` / `../` paths must be resolved at the
243
+ * call site with `import.meta.resolve('./file.ts')`.
244
+ */
245
+ export async function buildClientJS(
246
+ modulePathResolved: string,
247
+ options: BuildClientJSOptions = {}
248
+ ): Promise<HS.ClientJSBuildResult> {
249
+ return registerClientJS(modulePathResolved, options);
250
+ }
251
+
252
+ export const streamingClient = registerClientJS(
253
+ import.meta.resolve('./_hs/hyperspan-streaming.client.ts'),
254
+ { type: 'iife' }
255
+ );
256
+ export const actionsClient = registerClientJS(
257
+ import.meta.resolve('./_hs/hyperspan-actions.client.ts')
258
+ );
259
+
260
+ /**
261
+ * Discover export names from source or bundled client JS.
262
+ * Prefers bundled `export{...}` form, then common source export patterns.
263
+ */
264
+ export function discoverClientExports(contents: string): { exports: string; fnArgs: string } {
265
+ const bundled = extractExports(contents);
266
+ if (bundled.exports !== '* as _module') {
267
+ return bundled;
268
+ }
269
+
270
+ const names: string[] = [];
271
+ const seen = new Set<string>();
272
+
273
+ for (const re of [
274
+ /export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g,
275
+ /export\s+const\s+([A-Za-z_$][\w$]*)/g,
276
+ /export\s+class\s+([A-Za-z_$][\w$]*)/g,
277
+ /export\s+let\s+([A-Za-z_$][\w$]*)/g,
278
+ ]) {
279
+ let match: RegExpExecArray | null;
280
+ while ((match = re.exec(contents)) !== null) {
281
+ if (!seen.has(match[1])) {
282
+ names.push(match[1]);
283
+ seen.add(match[1]);
284
+ }
87
285
  }
88
286
  }
287
+
288
+ const exportBlock = /export\s*\{([^}]+)\}/g;
289
+ let block: RegExpExecArray | null;
290
+ while ((block = exportBlock.exec(contents)) !== null) {
291
+ for (const part of block[1].split(',')) {
292
+ const trimmed = part.trim();
293
+ if (!trimmed || trimmed.startsWith('type ')) continue;
294
+ const asMatch = trimmed.match(/^(?:([\w$]+)\s+as\s+)?([\w$]+)$/);
295
+ const name = asMatch?.[2] ?? trimmed;
296
+ if (name && name !== 'default' && !seen.has(name)) {
297
+ names.push(name);
298
+ seen.add(name);
299
+ }
300
+ }
301
+ }
302
+
303
+ if (names.length === 0) {
304
+ if (/export\s+default\b/.test(contents)) {
305
+ return { exports: 'DefaultExport', fnArgs: 'DefaultExport' };
306
+ }
307
+ return { exports: '* as _module', fnArgs: '_module' };
308
+ }
309
+
310
+ const exports = `{${names.join(', ')}}`;
311
+ return { exports, fnArgs: exports };
89
312
  }
90
313
 
91
314
  /**
92
- * Extract the exports from a client JS module
315
+ * Extract the exports from a client JS module (bundled `export{a as b}` form).
93
316
  */
94
- export function extractExports(contents: string): { exports: string, fnArgs: string } {
95
- const exportLine = EXPORT_REGEX.exec(contents);
317
+ export function extractExports(contents: string): { exports: string; fnArgs: string } {
318
+ const exportRegex = /export\{(.*)\}/g;
319
+ const exportLine = exportRegex.exec(contents);
96
320
  let exports = '{}';
97
321
  let fnArgs = '{}';
98
322
 
@@ -111,8 +335,8 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
111
335
  fnArgs = exports.replace(/(\w+)\s*as\s*(\w+)/g, '$1: $2').trim();
112
336
 
113
337
  if (exports === '{}' && fnArgs === '{}') {
114
- exports = '* as _module'
115
- fnArgs = '_module'
338
+ exports = '* as _module';
339
+ fnArgs = '_module';
116
340
  }
117
341
 
118
342
  return { exports, fnArgs };
@@ -120,8 +344,7 @@ export function extractExports(contents: string): { exports: string, fnArgs: str
120
344
 
121
345
  /**
122
346
  * Convert a function to a string (results in loss of context!)
123
- * Handles named, async, and arrow functions
124
347
  */
125
- export function functionToString(fn: any) {
126
- return fn.toString().trim();
127
- }
348
+ export function functionToString(fn: unknown) {
349
+ return (fn as (...args: unknown[]) => unknown).toString().trim();
350
+ }
@@ -0,0 +1,83 @@
1
+ import type { Hyperspan as HS } from '../types';
2
+
3
+ export type AssetManifest = {
4
+ imports: Record<string, string>;
5
+ css: Record<string, string[]>;
6
+ clients: Record<string, string>;
7
+ };
8
+
9
+ let _manifest: AssetManifest = {
10
+ imports: {},
11
+ css: {},
12
+ clients: {},
13
+ };
14
+
15
+ /**
16
+ * Set the build-time asset manifest (called by Vite plugin or build step).
17
+ */
18
+ export function setAssetManifest(manifest: AssetManifest): void {
19
+ _manifest = manifest;
20
+ }
21
+
22
+ /**
23
+ * Get the current asset manifest.
24
+ */
25
+ export function getAssetManifest(): AssetManifest {
26
+ return _manifest;
27
+ }
28
+
29
+ /**
30
+ * Resolve a module import name to its public URL.
31
+ */
32
+ export function resolveImport(name: string): string | undefined {
33
+ return _manifest.imports[name];
34
+ }
35
+
36
+ /**
37
+ * Get CSS files for a route path.
38
+ */
39
+ export function getRouteCss(path: string): string[] {
40
+ return _manifest.css[path] ?? [];
41
+ }
42
+
43
+ /**
44
+ * Build import map object for layout script tags.
45
+ */
46
+ export function getImportMap(): Record<string, string> {
47
+ return { ..._manifest.imports };
48
+ }
49
+
50
+ /**
51
+ * Register a client module URL at runtime (dev fallback).
52
+ */
53
+ export function registerImport(name: string, publicPath: string): void {
54
+ _manifest.imports[name] = publicPath;
55
+ }
56
+
57
+ export type ClientJSBuildResult = HS.ClientJSBuildResult;
58
+
59
+ /**
60
+ * Create a ClientJSBuildResult from manifest data (replaces runtime Bun.build).
61
+ */
62
+ export function getClientJSFromManifest(
63
+ moduleId: string,
64
+ _exportNames = 'default'
65
+ ): ClientJSBuildResult {
66
+ const publicPath = _manifest.clients[moduleId];
67
+ if (!publicPath) {
68
+ throw new Error(
69
+ `[Hyperspan] Client module "${moduleId}" not found in asset manifest. Run hyperspan build or start dev server.`
70
+ );
71
+ }
72
+
73
+ const esmName = publicPath.split('/').pop()?.replace('.js', '') ?? moduleId;
74
+
75
+ return {
76
+ assetHash: moduleId,
77
+ esmName,
78
+ publicPath,
79
+ renderScriptTag: () => {
80
+ throw new Error('renderScriptTag requires html template - use layout helpers instead');
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,14 @@
1
+ /** Dev-only platform bindings (D1, KV, etc.) shared across Vite SSR modules. */
2
+ let devBindings: unknown;
3
+
4
+ export function setDevBindings(env: unknown): void {
5
+ devBindings = env;
6
+ }
7
+
8
+ export function getDevBindings(): unknown {
9
+ return devBindings;
10
+ }
11
+
12
+ export function clearDevBindingsForTests(): void {
13
+ devBindings = undefined;
14
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createRoute, createServer } from './server';
3
+ import { createFetchHandler, compileRoutePath } from './fetch-handler';
4
+ import type { Hyperspan as HS } from './types';
5
+
6
+ describe('compileRoutePath', () => {
7
+ test('matches static paths', () => {
8
+ const { pattern } = compileRoutePath('/about');
9
+ expect('/about'.match(pattern)).toBeTruthy();
10
+ expect('/about/'.match(pattern)).toBeTruthy();
11
+ });
12
+
13
+ test('matches dynamic params', () => {
14
+ const { pattern, paramNames } = compileRoutePath('/users/:id');
15
+ expect(paramNames).toEqual(['id']);
16
+ const match = '/users/42'.match(pattern);
17
+ expect(match?.[1]).toBe('42');
18
+ });
19
+
20
+ test('matches wildcard routes', () => {
21
+ const { pattern, paramNames } = compileRoutePath('/blog/*');
22
+ expect(paramNames).toEqual(['...slug']);
23
+ const match = '/blog/hello/world'.match(pattern);
24
+ expect(match?.[1]).toBe('hello/world');
25
+ });
26
+ });
27
+
28
+ describe('createFetchHandler', () => {
29
+ test('routes GET requests to matching handler', async () => {
30
+ const server = await createServer({
31
+ appDir: './app',
32
+ publicDir: './public',
33
+ plugins: [],
34
+ });
35
+
36
+ server.get('/hello', (c: HS.Context) => c.res.html('<h1>Hello</h1>'));
37
+
38
+ const fetch = createFetchHandler(server);
39
+ const response = await fetch(new Request('http://localhost/hello'));
40
+
41
+ expect(response.status).toBe(200);
42
+ expect(await response.text()).toBe('<h1>Hello</h1>');
43
+ });
44
+
45
+ test('returns 404 for unknown paths', async () => {
46
+ const server = await createServer({
47
+ appDir: './app',
48
+ publicDir: './public',
49
+ plugins: [],
50
+ });
51
+
52
+ const fetch = createFetchHandler(server);
53
+ const response = await fetch(new Request('http://localhost/missing'));
54
+
55
+ expect(response.status).toBe(404);
56
+ });
57
+
58
+ test('passes route params to context', async () => {
59
+ const server = await createServer({
60
+ appDir: './app',
61
+ publicDir: './public',
62
+ plugins: [],
63
+ });
64
+
65
+ const route = createRoute({ path: '/items/:id' }).get((c: HS.Context) =>
66
+ c.res.text(c.route.params.id ?? '')
67
+ );
68
+ server._routes.push(route);
69
+
70
+ const fetch = createFetchHandler(server);
71
+ const response = await fetch(new Request('http://localhost/items/abc'));
72
+
73
+ expect(await response.text()).toBe('abc');
74
+ });
75
+
76
+ test('preserves Cookie on the request passed to the route', async () => {
77
+ const server = await createServer({
78
+ appDir: './app',
79
+ publicDir: './public',
80
+ plugins: [],
81
+ });
82
+
83
+ const route = createRoute({ path: '/session' }).get((c: HS.Context) =>
84
+ c.res.text(c.req.raw.headers.get('cookie') ?? '')
85
+ );
86
+ server._routes.push(route);
87
+
88
+ const headers = new Headers({ cookie: 'session=abc' });
89
+ const request = new Request('http://localhost/session', { headers });
90
+ // Node's Request constructor strips Cookie; re-attach like Vite's nodeToWebRequest.
91
+ Object.defineProperty(request, 'headers', { value: headers });
92
+
93
+ const fetch = createFetchHandler(server);
94
+ const response = await fetch(request);
95
+
96
+ expect(await response.text()).toBe('session=abc');
97
+ });
98
+
99
+ test('redirects trailing slashes', async () => {
100
+ const server = await createServer({
101
+ appDir: './app',
102
+ publicDir: './public',
103
+ plugins: [],
104
+ });
105
+
106
+ server.get('/page', (c: HS.Context) => c.res.text('ok'));
107
+
108
+ const fetch = createFetchHandler(server);
109
+ const response = await fetch(new Request('http://localhost/page/', { redirect: 'manual' }));
110
+
111
+ expect(response.status).toBe(308);
112
+ });
113
+ });