diff --git a/index.ts b/index.ts index 250ef92..fcb1ff7 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import type { PluginFn } from '@japa/runner/types' import { ApiClient } from './src/client.js' import { TestContext } from '@japa/runner/core' +import type { ApiClientPluginOptions } from './src/types.js' export { ApiClient } export { ApiRequest } from './src/request.js' @@ -19,12 +20,22 @@ export { ApiResponse } from './src/response.js' * API client plugin registers an HTTP request client that * can be used for testing API endpoints. */ -export function apiClient(options?: string | { baseURL?: string }): PluginFn { +export function apiClient(options?: string | ApiClientPluginOptions): PluginFn { return function () { + const normalizedOptions = typeof options === 'string' ? { baseURL: options } : options + + if (normalizedOptions?.registry) { + ApiClient.setRoutes(normalizedOptions.registry) + } + + if (normalizedOptions?.patternSerializer) { + ApiClient.setPatternSerializer(normalizedOptions.patternSerializer) + } + TestContext.getter( 'client', function (this: TestContext) { - return new ApiClient(typeof options === 'string' ? options : options?.baseURL, this.assert) + return new ApiClient(normalizedOptions?.baseURL, this.assert) }, true ) diff --git a/src/client.ts b/src/client.ts index 1a1eb3f..0456e07 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,7 +11,22 @@ import Macroable from '@poppinss/macroable' import type { Assert } from '@japa/assert' import { ApiRequest } from './request.js' -import { type SetupHandler, type TeardownHandler, type CookiesSerializer } from './types.js' +import { + type SetupHandler, + type TeardownHandler, + type CookiesSerializer, + type InferBody, + type InferResponse, + type InferQuery, + type RoutesRegistry, + type PatternSerializer, + type UserRoutesRegistry, + type InferRouteBody, + type InferRouteQuery, + type InferRouteResponse, + type InferRouteParams, + type IsEmptyObject, +} from './types.js' /** * ApiClient exposes the API to make HTTP requests in context of @@ -36,6 +51,18 @@ export class ApiClient extends Macroable { static #customCookiesSerializer?: CookiesSerializer + /** + * Routes registry for type-safe named routes + */ + static #routesRegistry?: RoutesRegistry + + /** + * Pattern serializer for converting patterns to URLs + */ + static #patternSerializer: PatternSerializer = (pattern, params) => { + return pattern.replace(/:(\w+)/g, (_, key) => String(params[key] ?? '')) + } + #baseUrl?: string #assert?: Assert @@ -104,6 +131,30 @@ export class ApiClient extends Macroable { return this } + /** + * Register a routes registry for type-safe named routes + */ + static setRoutes(registry: RoutesRegistry) { + this.#routesRegistry = registry + return this + } + + /** + * Register a custom pattern serializer + */ + static setPatternSerializer(serializer: PatternSerializer) { + this.#patternSerializer = serializer + return this + } + + /** + * Clear the routes registry + */ + static clearRoutes() { + this.#routesRegistry = undefined + return this + } + /** * Create an instance of the request */ @@ -142,49 +193,101 @@ export class ApiClient extends Macroable { /** * Create an instance of the request for GET method */ - get(endpoint: string) { - return this.request(endpoint, 'GET') + get

(endpoint: P): ApiRequest, InferQuery

> { + return this.request(endpoint, 'GET') as ApiRequest, InferQuery

> } /** * Create an instance of the request for POST method */ - post(endpoint: string) { - return this.request(endpoint, 'POST') + post

(endpoint: P): ApiRequest, InferResponse

, InferQuery

> { + return this.request(endpoint, 'POST') as ApiRequest< + InferBody

, + InferResponse

, + InferQuery

+ > } /** * Create an instance of the request for PUT method */ - put(endpoint: string) { - return this.request(endpoint, 'PUT') + put

(endpoint: P): ApiRequest, InferResponse

, InferQuery

> { + return this.request(endpoint, 'PUT') as ApiRequest< + InferBody

, + InferResponse

, + InferQuery

+ > } /** * Create an instance of the request for PATCH method */ - patch(endpoint: string) { - return this.request(endpoint, 'PATCH') + patch

(endpoint: P): ApiRequest, InferResponse

, InferQuery

> { + return this.request(endpoint, 'PATCH') as ApiRequest< + InferBody

, + InferResponse

, + InferQuery

+ > } /** * Create an instance of the request for DELETE method */ - delete(endpoint: string) { - return this.request(endpoint, 'DELETE') + delete

(endpoint: P): ApiRequest, InferResponse

, InferQuery

> { + return this.request(endpoint, 'DELETE') as ApiRequest< + InferBody

, + InferResponse

, + InferQuery

+ > } /** * Create an instance of the request for HEAD method */ - head(endpoint: string) { - return this.request(endpoint, 'HEAD') + head

(endpoint: P): ApiRequest, InferQuery

> { + return this.request(endpoint, 'HEAD') as ApiRequest, InferQuery

> } /** * Create an instance of the request for OPTIONS method */ - options(endpoint: string) { - return this.request(endpoint, 'OPTIONS') + options

(endpoint: P): ApiRequest, InferQuery

> { + return this.request(endpoint, 'OPTIONS') as ApiRequest, InferQuery

> + } + + /** + * Create a type-safe request using a named route from the registry. + * The route name must be registered in both the runtime registry + * (via ApiClient.setRoutes()) and the type registry (UserRoutesRegistry). + */ + visit( + ...args: IsEmptyObject> extends true + ? [name: Name] + : [name: Name, params: InferRouteParams] + ): ApiRequest, InferRouteResponse, InferRouteQuery> { + const name = args[0] + const params = (args[1] ?? {}) as Record + + const registry = (this.constructor as typeof ApiClient).#routesRegistry + if (!registry) { + throw new Error( + `Routes registry not configured. Use ApiClient.routes() to register your routes.` + ) + } + + const routeDef = registry[name as string] + if (!routeDef) { + throw new Error(`Route "${String(name)}" not found in routes registry.`) + } + + const serializer = (this.constructor as typeof ApiClient).#patternSerializer + const endpoint = serializer(routeDef.pattern, params) + const method = routeDef.methods[0] + + return this.request(endpoint, method) as ApiRequest< + InferRouteBody, + InferRouteResponse, + InferRouteQuery + > } } diff --git a/src/request.ts b/src/request.ts index a788ff1..fe26d80 100644 --- a/src/request.ts +++ b/src/request.ts @@ -33,7 +33,7 @@ const DUMP_CALLS = { headers: dumpRequestHeaders, } -export class ApiRequest extends Macroable { +export class ApiRequest extends Macroable { /** * The serializer to use for serializing request query params */ @@ -169,7 +169,7 @@ export class ApiRequest extends Macroable { * Send HTTP request to the server. Errors except the client errors * are tured into a response object. */ - async #sendRequest() { + async #sendRequest(): Promise> { let response: Response try { @@ -195,7 +195,7 @@ export class ApiRequest extends Macroable { } await this.#setupRunner.cleanup(null, this) - return new ApiResponse(this, response, this.config, this.#assert) + return new ApiResponse(this, response, this.config, this.#assert) } /** @@ -359,7 +359,17 @@ export class ApiRequest extends Macroable { * password: 'secret' * }) */ - form(values: string | object) { + form(values: TBody) { + this.type('form') + this.request.send(values as string | object) + return this + } + + /** + * Set form values without type checking. + * Useful for testing invalid form data. + */ + unsafeForm(values: string | object) { this.type('form') this.request.send(values) return this @@ -375,7 +385,17 @@ export class ApiRequest extends Macroable { * password: 'secret' * }) */ - json(values: string | object) { + json(values: TBody) { + this.type('json') + this.request.send(values as string | object) + return this + } + + /** + * Set JSON body for the request without type checking. + * Useful for testing invalid JSON payloads. + */ + unsafeJson(values: string | object) { this.type('json') this.request.send(values) return this @@ -389,8 +409,23 @@ export class ApiRequest extends Macroable { * request.qs({ order_by: 'id' }) */ qs(key: string, value: any): this - qs(values: string | object): this - qs(key: string | object, value?: any): this { + qs(values: TQuery): this + qs(key: string | TQuery, value?: any): this { + if (!value) { + this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key as object)) + } else { + this.request.query(ApiRequest.qsSerializer({ [key as string]: value })) + } + return this + } + + /** + * Set querystring for the request without type checking. + * Useful for testing invalid query parameters. + */ + unsafeQs(key: string, value: any): this + unsafeQs(values: string | object): this + unsafeQs(key: string | object, value?: any): this { if (!value) { this.request.query(typeof key === 'string' ? key : ApiRequest.qsSerializer(key)) } else { @@ -580,10 +615,16 @@ export class ApiRequest extends Macroable { * - 'ENETUNREACH' * - 'EAI_AGAIN' */ - retry(count: number, retryUntilCallback?: (error: any, response: ApiResponse) => boolean): this { + retry( + count: number, + retryUntilCallback?: (error: any, response: ApiResponse) => boolean + ): this { if (retryUntilCallback) { this.request.retry(count, (error, response) => { - return retryUntilCallback(error, new ApiResponse(this, response, this.config, this.#assert)) + return retryUntilCallback( + error, + new ApiResponse(this, response, this.config, this.#assert) + ) }) return this @@ -596,7 +637,7 @@ export class ApiRequest extends Macroable { /** * Make the API request */ - async send() { + async send(): Promise> { /** * Step 1: Instantiate hooks runners */ @@ -623,8 +664,11 @@ export class ApiRequest extends Macroable { /** * Implementation of `then` for the promise API */ - then( - resolve?: ((value: ApiResponse) => TResult1 | PromiseLike) | undefined | null, + then, TResult2 = never>( + resolve?: + | ((value: ApiResponse) => TResult1 | PromiseLike) + | undefined + | null, reject?: ((reason: any) => TResult2 | PromiseLike) | undefined | null ): Promise { return this.send().then(resolve, reject) @@ -634,15 +678,15 @@ export class ApiRequest extends Macroable { * Implementation of `catch` for the promise API */ catch( - reject?: ((reason: ApiResponse) => TResult | PromiseLike) | undefined | null - ): Promise { + reject?: ((reason: ApiResponse) => TResult | PromiseLike) | undefined | null + ): Promise | TResult> { return this.send().catch(reject) } /** * Implementation of `finally` for the promise API */ - finally(fullfilled?: (() => void) | undefined | null): Promise { + finally(fullfilled?: (() => void) | undefined | null): Promise> { return this.send().finally(fullfilled) } diff --git a/src/response.ts b/src/response.ts index b82ef9c..1169963 100644 --- a/src/response.ts +++ b/src/response.ts @@ -29,7 +29,7 @@ import { dumpResponseHeaders, } from './utils.js' -export class ApiResponse extends Macroable { +export class ApiResponse extends Macroable { #valuesDumped: Set = new Set() /** @@ -151,7 +151,7 @@ export class ApiResponse extends Macroable { /** * Response body */ - body(): any { + body(): TResponse { return this.response.body } @@ -349,7 +349,7 @@ export class ApiResponse extends Macroable { /** * Assert response body to match the expected body */ - assertBody(expectedBody: any) { + assertBody(expectedBody: TResponse) { this.#ensureHasAssert() this.assert!.deepEqual(this.body(), expectedBody) } diff --git a/src/types.ts b/src/types.ts index b79add7..a126d2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -138,3 +138,131 @@ export type ApiRequestHooks = { setup: [Parameters, Parameters] teardown: [Parameters, Parameters] } + +/** + * User-augmentable routes registry for type-safe API client. + * Augment this interface to enable type-safe endpoints. + * + * @example + * declare module '@japa/api-client' { + * interface UserRoutesRegistry { + * 'users.show': { + * methods: ['GET', 'HEAD'] + * pattern: '/users/:id' + * types: { + * params: { id: string } + * query: {} + * body: {} + * response: { user: { id: string; name: string } } + * } + * } + * } + * } + */ +export interface UserRoutesRegistry {} + +/** + * Shape of a route definition in the registry + */ +export interface RouteDefinition { + methods: readonly string[] + pattern: string + types: { + params: Record + query: Record + body: Record + response: any + } +} + +/** + * Runtime routes registry passed to the plugin + */ +export type RoutesRegistry = Record + +/** + * Pattern serializer function type + */ +export type PatternSerializer = (pattern: string, params: Record) => string + +/** + * Check if an object type is empty (has no keys) + */ +export type IsEmptyObject = keyof T extends never ? true : false + +/** + * Check if user has augmented the registry + */ +type HasUserRegistry = keyof UserRoutesRegistry extends never ? false : true + +/** + * Find a route definition by its pattern + */ +type FindRouteByPattern

= { + [K in keyof UserRoutesRegistry]: UserRoutesRegistry[K] extends { pattern: P } + ? UserRoutesRegistry[K] + : never +}[keyof UserRoutesRegistry] + +/** + * Extract all patterns from the registry + */ +type AllPatterns = UserRoutesRegistry[keyof UserRoutesRegistry] extends { pattern: infer P } + ? P extends string + ? P + : never + : never + +/** + * Helper to extract a type from a named route + */ +type InferFromRoute< + Name extends keyof UserRoutesRegistry, + Key extends 'params' | 'query' | 'body' | 'response', +> = UserRoutesRegistry[Name] extends { types: infer Types } + ? Key extends keyof Types + ? Types[Key] + : never + : never + +/** + * Helper to extract a type from a route pattern + */ +type InferFromPattern< + P extends string, + Key extends 'body' | 'query' | 'response', +> = HasUserRegistry extends true + ? [FindRouteByPattern

] extends [never] + ? any + : FindRouteByPattern

extends { types: infer Types } + ? Key extends keyof Types + ? Types[Key] + : any + : any + : any + +export type InferRouteParams = InferFromRoute +export type InferRouteQuery = InferFromRoute +export type InferRouteBody = InferFromRoute +export type InferRouteResponse = InferFromRoute< + Name, + 'response' +> + +export type InferBody

= InferFromPattern +export type InferResponse

= InferFromPattern +export type InferQuery

= InferFromPattern + +/** + * Valid patterns (restricted to known patterns if registry is configured) + */ +export type ValidPattern = HasUserRegistry extends true ? AllPatterns : string + +/** + * Options for the apiClient plugin + */ +export interface ApiClientPluginOptions { + baseURL?: string + registry?: RoutesRegistry + patternSerializer?: PatternSerializer +} diff --git a/tests/api_client/visit.spec.ts b/tests/api_client/visit.spec.ts new file mode 100644 index 0000000..52e4ea0 --- /dev/null +++ b/tests/api_client/visit.spec.ts @@ -0,0 +1,384 @@ +/* + * @japa/api-client + * + * (c) Japa.dev + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { ApiClient } from '../../src/client.js' +import { httpServer } from '../../tests_helpers/index.js' +import type { RoutesRegistry } from '../../src/types.js' + +/** + * Test routes registry (runtime) + */ +const testRoutes = { + 'users.index': { methods: ['GET'], pattern: '/users' }, + 'users.show': { methods: ['GET'], pattern: '/users/:id' }, + 'users.create': { methods: ['POST'], pattern: '/users' }, + 'users.update': { methods: ['PUT'], pattern: '/users/:id' }, + 'posts.index': { methods: ['GET', 'HEAD'], pattern: '/posts' }, +} as const satisfies RoutesRegistry + +/** + * Type augmentation for tests + */ +declare module '../../src/types.js' { + interface UserRoutesRegistry { + 'users.index': { + methods: ['GET'] + pattern: '/users' + types: { + params: {} + query: { page?: number; limit?: number } + body: {} + response: { users: Array<{ id: number; name: string }> } + } + } + 'users.show': { + methods: ['GET'] + pattern: '/users/:id' + types: { + params: { id: string } + query: {} + body: {} + response: { user: { id: number; name: string } } + } + } + 'users.create': { + methods: ['POST'] + pattern: '/users' + types: { + params: {} + query: {} + body: { name: string; email: string } + response: { user: { id: number; name: string } } + } + } + 'users.update': { + methods: ['PUT'] + pattern: '/users/:id' + types: { + params: { id: string } + query: {} + body: { name?: string; email?: string } + response: { user: { id: number; name: string } } + } + } + 'posts.index': { + methods: ['GET', 'HEAD'] + pattern: '/posts' + types: { + params: {} + query: {} + body: {} + response: { posts: Array<{ id: number; title: string }> } + } + } + } +} + +test.group('API client | visit', (group) => { + group.each.setup(async () => { + await httpServer.create() + return () => httpServer.close() + }) + + group.each.setup(() => { + ApiClient.setRoutes(testRoutes) + return () => { + ApiClient.clearRequestHandlers() + ApiClient.clearSetupHooks() + ApiClient.clearTeardownHooks() + } + }) + + test('make request using named route without params', async ({ assert }) => { + let requestMethod: string + let requestUrl: string + + httpServer.onRequest((req, res) => { + requestMethod = req.method! + requestUrl = req.url! + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ users: [{ id: 1, name: 'John' }] })) + }) + + const client = new ApiClient(httpServer.baseUrl) + const response = await client.visit('users.index') + + assert.equal(requestMethod!, 'GET') + assert.equal(requestUrl!, '/users') + assert.deepEqual(response.body(), { users: [{ id: 1, name: 'John' }] }) + }) + + test('make request using named route with params', async ({ assert }) => { + let requestMethod: string + let requestUrl: string + + httpServer.onRequest((req, res) => { + requestMethod = req.method! + requestUrl = req.url! + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ user: { id: 123, name: 'John' } })) + }) + + const client = new ApiClient(httpServer.baseUrl) + const response = await client.visit('users.show', { id: '123' }) + + assert.equal(requestMethod!, 'GET') + assert.equal(requestUrl!, '/users/123') + assert.deepEqual(response.body(), { user: { id: 123, name: 'John' } }) + }) + + test('use first method from methods array', async ({ assert }) => { + let requestMethod: string + + httpServer.onRequest((req, res) => { + requestMethod = req.method! + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ posts: [] })) + }) + + const client = new ApiClient(httpServer.baseUrl) + await client.visit('posts.index') + + assert.equal(requestMethod!, 'GET') + }) + + test('send typed JSON body', async ({ assert }) => { + let requestBody: any + + httpServer.onRequest((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + requestBody = JSON.parse(body) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ user: { id: 1, name: 'John' } })) + }) + }) + + const client = new ApiClient(httpServer.baseUrl) + await client.visit('users.create').json({ name: 'John', email: 'john@example.com' }) + + assert.deepEqual(requestBody, { name: 'John', email: 'john@example.com' }) + }) + + test('send typed query string', async ({ assert }) => { + let requestUrl: string + + httpServer.onRequest((req, res) => { + requestUrl = req.url! + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ users: [] })) + }) + + const client = new ApiClient(httpServer.baseUrl) + await client.visit('users.index').qs({ page: 2, limit: 10 }) + + assert.equal(requestUrl!, '/users?page=2&limit=10') + }) + + test('throw error when routes registry is not configured', async ({ assert }) => { + ApiClient.clearRoutes() + + const client = new ApiClient(httpServer.baseUrl) + + await assert.rejects(() => client.visit('users.index'), /Routes registry not configured/) + + // Re-configure for subsequent tests + ApiClient.setRoutes(testRoutes) + }) + + test('throw error when route is not found in registry', async ({ assert }) => { + httpServer.onRequest((_, res) => res.end()) + + const client = new ApiClient(httpServer.baseUrl) + + await assert.rejects( + // @ts-expect-error - intentionally using non-existent route + () => client.visit('non.existent'), + /Route "non.existent" not found/ + ) + }) + + test('use custom pattern serializer', async ({ assert }) => { + let requestUrl: string + + httpServer.onRequest((req, res) => { + requestUrl = req.url! + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ user: { id: 1, name: 'John' } })) + }) + + // Custom serializer that uses {param} instead of :param + ApiClient.setPatternSerializer((pattern, params) => { + return pattern.replace(/:(\w+)/g, (_, key) => `[${params[key]}]`) + }) + + const client = new ApiClient(httpServer.baseUrl) + await client.visit('users.show', { id: '456' }) + + assert.equal(requestUrl!, '/users/[456]') + + // Reset to default + ApiClient.setPatternSerializer((pattern, params) => { + return pattern.replace(/:(\w+)/g, (_, key) => String(params[key] ?? '')) + }) + }) +}) + +test.group('API client | unsafe methods', (group) => { + group.each.setup(async () => { + await httpServer.create() + return () => httpServer.close() + }) + + group.each.setup(() => { + ApiClient.setRoutes(testRoutes) + return () => { + ApiClient.clearRequestHandlers() + ApiClient.clearSetupHooks() + ApiClient.clearTeardownHooks() + } + }) + + test('unsafeJson allows sending invalid body', async ({ assert }) => { + let requestBody: any + + httpServer.onRequest((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + requestBody = JSON.parse(body) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ user: { id: 1, name: 'John' } })) + }) + }) + + const client = new ApiClient(httpServer.baseUrl) + // Using unsafeJson to send data that doesn't match the expected body type + await client.visit('users.create').unsafeJson({ invalid: 'data', extra: 123 }) + + assert.deepEqual(requestBody, { invalid: 'data', extra: 123 }) + }) + + test('unsafeForm allows sending invalid form data', async ({ assert }) => { + let requestBody: string + + httpServer.onRequest((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + requestBody = body + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ user: { id: 1, name: 'John' } })) + }) + }) + + const client = new ApiClient(httpServer.baseUrl) + await client.visit('users.create').unsafeForm({ wrong: 'field' }) + + assert.equal(requestBody!, 'wrong=field') + }) + + test('unsafeQs allows sending invalid query params', async ({ assert }) => { + let requestUrl: string + + httpServer.onRequest((req, res) => { + requestUrl = req.url! + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ users: [] })) + }) + + const client = new ApiClient(httpServer.baseUrl) + await client.visit('users.index').unsafeQs({ invalid: 'param' }) + + assert.equal(requestUrl!, '/users?invalid=param') + }) +}) + +test.group('API client | type safety', (group) => { + group.each.setup(() => { + ApiClient.setRoutes(testRoutes) + return () => { + ApiClient.clearRequestHandlers() + } + }) + + test('positive', async () => { + const client = new ApiClient() + + // visit without params when route has no params + client.visit('users.index') + + // visit with params when route requires params + client.visit('users.show', { id: '123' }) + + // json with correct body type + client.visit('users.create').json({ name: 'John', email: 'john@example.com' }) + + // qs with correct query type + client.visit('users.index').qs({ page: 1 }) + + // response body has correct type + const response = await client.visit('users.index') + const body = response.body() + const users: Array<{ id: number; name: string }> = body.users + console.log(users) + + // response body for single resource + const showResponse = await client.visit('users.show', { id: '1' }) + const showBody = showResponse.body() + const user: { id: number; name: string } = showBody.user + console.log(user) + }).skip(true, 'Type-only test') + + test('negative', async () => { + const client = new ApiClient() + + // visit non-existent route + // @ts-expect-error - 'invalid.route' doesn't exist in UserRoutesRegistry + client.visit('invalid.route') + + // visit without required params + // @ts-expect-error - users.show requires { id: string } param + client.visit('users.show') + + // visit with wrong param type + // @ts-expect-error - id should be string, not number + client.visit('users.show', { id: 123 }) + + // visit with missing param + // @ts-expect-error - missing required 'id' param + client.visit('users.show', {}) + + // json with wrong body type + // @ts-expect-error - body should have name and email, not just 'wrong' + client.visit('users.create').json({ wrong: 'field' }) + + // json with missing required field + // @ts-expect-error - missing required 'email' field + client.visit('users.create').json({ name: 'John' }) + + // qs with wrong query type + // @ts-expect-error - 'invalid' is not a valid query param for users.index + client.visit('users.index').qs({ invalid: 'param' }) + + // response body with wrong type + const response = await client.visit('users.index') + const body = response.body() + // @ts-expect-error - body.users is Array<{ id: number; name: string }>, not string + const wrongType: string = body.users + console.log(wrongType) + + // accessing non-existent property on response + // @ts-expect-error - 'nonExistent' doesn't exist on response body + console.log(body.nonExistent) + }).skip(true, 'Type-only test') +})