Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 48 additions & 45 deletions clients/typescript/contract/document_contract_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,27 @@
* sparse fieldsets, null-stripping.
*/
import { assert, assertEquals, assertFalse } from '@std/assert';
import { hydrateResponse, JsonApiQueryBuilder } from '../src/index.ts';
import {
hydrateResponse,
type JsonApiArrayResponse,
JsonApiQueryBuilder,
type JsonApiSingleResponse,
} from '../src/index.ts';
import { type ContractArticle, getDoc } from './helpers.ts';
type ContractArticle,
getDoc,
type List,
type Single,
} from './helpers.ts';

Deno.test('document shape', async (t) => {
await t.step('primary resources use the controller type string', async () => {
const { status, doc } = await getDoc('articles/3');
const { status, doc } = await getDoc<Single>('articles/3');
assertEquals(status, 200);
assertEquals(doc.data.type, 'articles');
assertEquals(doc.data.id, '3'); // ids are strings on the wire
assertEquals(doc.data.links.self.endsWith('/articles/3'), true);
assertEquals(doc.data.links?.self?.endsWith('/articles/3'), true);
});

await t.step(
'attributes: camelCase, ISO dates, FK ids leak as attributes',
async () => {
const { doc } = await getDoc('articles/3');
const { doc } = await getDoc<Single>('articles/3');
assertEquals(doc.data.attributes.title, 'Article 03');
assertEquals(doc.data.attributes.publishedAt, '2025-01-03T12:00:00Z');
assertEquals(doc.data.attributes.viewCount, 30);
Expand All @@ -35,39 +35,39 @@ Deno.test('document shape', async (t) => {
await t.step(
'primitive collections are attributes (JSON column detection)',
async () => {
const { doc } = await getDoc('articles/3');
const { doc } = await getDoc<Single>('articles/3');
assertEquals(doc.data.attributes.tags, ['tech', 'news']);
// empty collections serialize as [], not stripped
const { doc: doc23 } = await getDoc('articles/23');
const { doc: doc23 } = await getDoc<Single>('articles/23');
assertEquals(doc23.data.attributes.tags, []);
},
);

await t.step('null attributes are stripped from responses', async () => {
// article 25: body is null; publishedAt is set (odd id)
const { doc } = await getDoc('articles/25');
const { doc } = await getDoc<Single>('articles/25');
assertFalse('body' in doc.data.attributes);
// article 2: publishedAt is null (even id)
const { doc: doc2 } = await getDoc('articles/2');
const { doc: doc2 } = await getDoc<Single>('articles/2');
assertFalse('publishedAt' in doc2.data.attributes);
assertEquals(doc2.data.attributes.published, false);
// authors/2 has null email
const { doc: author } = await getDoc('authors/2');
const { doc: author } = await getDoc<Single>('authors/2');
assertEquals(author.data.attributes, { name: 'Bjarne Moen' });
});

await t.step(
'included resources use the camelCased CLR class name, not the controller type',
async () => {
const { doc } = await getDoc('articles/3?include=author');
const { doc } = await getDoc<Single>('articles/3?include=author');
assertEquals(doc.data.type, 'articles');
assertEquals(doc.data.relationships.author.data, {
assertEquals(doc.data.relationships?.author.data, {
id: '3',
type: 'author',
});
assertEquals(doc.included.length, 1);
assertEquals(doc.included[0].type, 'author'); // singular CLR name
assertEquals(doc.included[0].attributes.name, 'Carmen Diaz');
assertEquals(doc.included?.length, 1);
assertEquals(doc.included?.[0].type, 'author'); // singular CLR name
assertEquals(doc.included?.[0].attributes.name, 'Carmen Diaz');
},
);

Expand All @@ -76,18 +76,21 @@ Deno.test('document shape', async (t) => {
async () => {
// include=comments.author: included is populated, but data has no
// relationships object at all, so the linkage is unrecoverable.
const { doc } = await getDoc('articles/3?include=comments.author');
const { doc } = await getDoc<Single>(
'articles/3?include=comments.author',
);
assertFalse('relationships' in doc.data);
const includedTypes = doc.included.map((r: { type: string }) => r.type)
const includedTypes = doc.included?.map((r) => r.type)
.sort();
assertEquals(includedTypes, ['author', 'author', 'comment', 'comment']);
// included comments DO carry their own relationships
const comment = doc.included.find((r: { type: string }) =>
r.type === 'comment'
const comment = doc.included?.find((r) => r.type === 'comment');
const authorRef = comment?.relationships?.author.data;
assert(
authorRef && !Array.isArray(authorRef) && authorRef.type === 'author',
);
assertEquals(comment.relationships.author.data.type, 'author');
// same on collections
const { doc: list } = await getDoc(
const { doc: list } = await getDoc<List>(
'articles?include=comments.author&page%5Bsize%5D=2',
);
assertFalse('relationships' in list.data[0]);
Expand All @@ -103,7 +106,7 @@ Deno.test('sparse fieldsets', async (t) => {
.fields<ContractArticle>('articles', ['title', 'publishedAt'])
.page(1, 2)
.build();
const { doc } = await getDoc(`articles?${qs}`);
const { doc } = await getDoc<List>(`articles?${qs}`);
assertEquals(Object.keys(doc.data[0].attributes), [
'title',
'publishedAt',
Expand All @@ -115,22 +118,22 @@ Deno.test('sparse fieldsets', async (t) => {
'included resources need the CLR-derived type name, not the wire type',
async () => {
// fields[author] (CLR name) trims the included author...
const { doc } = await getDoc(
const { doc } = await getDoc<Single>(
'articles/3?include=author&fields%5Bauthor%5D=name',
);
assertEquals(Object.keys(doc.included[0].attributes), ['name']);
assertEquals(Object.keys(doc.included?.[0].attributes ?? {}), ['name']);
// ...fields[authors] (what a JSON:API client would guess) does nothing
const { doc: doc2 } = await getDoc(
const { doc: doc2 } = await getDoc<Single>(
'articles/3?include=author&fields%5Bauthors%5D=name',
);
assert(Object.keys(doc2.included[0].attributes).length > 1);
assert(Object.keys(doc2.included?.[0].attributes ?? {}).length > 1);
},
);

await t.step(
'id and type are always present regardless of fieldset',
async () => {
const { doc } = await getDoc(
const { doc } = await getDoc<List>(
'articles?fields%5Barticles%5D=title&page%5Bsize%5D=1',
);
assertEquals(doc.data[0].id, '1');
Expand All @@ -143,39 +146,39 @@ Deno.test('hydration', async (t) => {
await t.step(
'single-level include hydrates to a flat nested object',
async () => {
const { doc } = await getDoc('articles/3?include=author');
const { data } = hydrateResponse<ContractArticle>(
doc as JsonApiSingleResponse,
);
const { doc } = await getDoc<Single>('articles/3?include=author');
const { data } = hydrateResponse<ContractArticle>(doc);
assertEquals(data.title, 'Article 03');
assertEquals(data.author.name, 'Carmen Diaz');
// un-included to-many relationship is simply absent, not [] or null
assertFalse('type' in data);
// WART: un-included relationships are absent from the wire entirely,
// so the hydrator cannot emit [] or null for them without a resource
// descriptor (arity is not on the wire)
assertEquals(data.comments, undefined);
},
);

await t.step(
'nested include hydrates to NOTHING because linkage is missing',
async () => {
const { doc } = await getDoc('articles/3?include=comments.author');
const { data } = hydrateResponse<ContractArticle>(
doc as JsonApiSingleResponse,
const { doc } = await getDoc<Single>(
'articles/3?include=comments.author',
);
const { data } = hydrateResponse<ContractArticle>(doc);
// included has 4 resources, but without data.relationships the
// hydrator cannot attach any of them
assertEquals(data.comments, undefined);
assertEquals(data.author, undefined);
},
);

await t.step('collection hydration preserves meta and links', async () => {
const { doc } = await getDoc('articles?include=author&page%5Bsize%5D=2');
const { data, meta, links } = hydrateResponse<ContractArticle>(
doc as JsonApiArrayResponse,
await t.step('collection hydration returns data and pagination', async () => {
const { doc } = await getDoc<List>(
'articles?include=author&page%5Bsize%5D=2',
);
const { data, pagination } = hydrateResponse<ContractArticle>(doc);
assertEquals(data.length, 2);
assertEquals(data[0].author.name, 'Astrid Berg');
assertEquals(meta?.pagination?.totalResources, 25);
assert(links?.self);
assertEquals(pagination?.totalResources, 25);
});
});
20 changes: 10 additions & 10 deletions clients/typescript/contract/errors_contract_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@
*/
import { assert, assertEquals, assertFalse } from '@std/assert';
import { isJsonApiErrorResponse } from '../src/index.ts';
import { getDoc, request } from './helpers.ts';
import { type Errors, getDoc, type List, request } from './helpers.ts';

Deno.test('not found', async (t) => {
await t.step('WART: plain GET 404 has no error code', async () => {
const { doc, status } = await getDoc('articles/999');
const { doc, status } = await getDoc<Errors>('articles/999');
assertEquals(status, 404);
assertEquals(doc.errors, [
{ status: '404', title: 'Not Found', detail: 'Resource not found' },
Expand All @@ -19,7 +19,7 @@ Deno.test('not found', async (t) => {
await t.step(
'explicit JsonApiErrors.NotFound carries RESOURCE_NOT_FOUND and meta',
async () => {
const { doc, status } = await request('DELETE', 'articles/999');
const { doc, status } = await request<Errors>('DELETE', 'articles/999');
assertEquals(status, 404);
assertEquals(doc.errors[0].code, 'RESOURCE_NOT_FOUND');
assertEquals(doc.errors[0].meta, { resourceType: 'articles', id: 999 });
Expand All @@ -31,7 +31,7 @@ Deno.test('include allowlisting', async (t) => {
await t.step(
'unlisted include is 403 INCLUDE_NOT_ALLOWED with meta',
async () => {
const { doc, status } = await getDoc('articles/3?include=bogus');
const { doc, status } = await getDoc<Errors>('articles/3?include=bogus');
assertEquals(status, 403);
assertEquals(doc.errors[0].code, 'INCLUDE_NOT_ALLOWED');
assertEquals(doc.errors[0].meta, {
Expand All @@ -43,30 +43,30 @@ Deno.test('include allowlisting', async (t) => {
);

await t.step('empty [AllowedIncludes] forbids every include', async () => {
const { doc, status } = await getDoc('authors?include=articles');
const { doc, status } = await getDoc<Errors>('authors?include=articles');
assertEquals(status, 403);
assertEquals(doc.errors[0].code, 'INCLUDE_NOT_ALLOWED');
assertEquals(doc.errors[0].meta.allowedIncludes, []);
assertEquals(doc.errors[0].meta?.allowedIncludes, []);
});

await t.step(
'WART: un-indexed group syntax on an allowlisted action is 403',
async () => {
// filter[or][field][op] parses as an include-filter on a relationship
// named "or", which a non-empty allowlist then rejects
const { doc, status } = await getDoc(
const { doc, status } = await getDoc<Errors>(
'articles/3?filter%5Bor%5D%5BviewCount%5D%5Bgt%5D=230',
);
assertEquals(status, 403);
assertEquals(doc.errors[0].code, 'FILTER_NOT_ALLOWED');
assertEquals(doc.errors[0].meta.forbiddenFilterPaths, ['or']);
assertEquals(doc.errors[0].meta?.forbiddenFilterPaths, ['or']);
},
);

await t.step(
'indexed (builder-emitted) groups pass the allowlist and apply',
async () => {
const { doc, status } = await getDoc(
const { doc, status } = await getDoc<List>(
'authors?filter%5Bor%5D%5B0%5D%5Bname%5D=x',
);
assertEquals(status, 200);
Expand Down Expand Up @@ -108,7 +108,7 @@ Deno.test('server errors', async (t) => {
await t.step(
'WART: unconvertible filter values are 500, not 400',
async () => {
const { doc, status } = await getDoc(
const { doc, status } = await getDoc<Errors>(
'articles?filter%5BpublishedAt%5D=isnull',
);
assertEquals(status, 500);
Expand Down
39 changes: 28 additions & 11 deletions clients/typescript/contract/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
// deno-lint-ignore-file no-explicit-any
/**
* Shared plumbing for the contract test suite.
*
* The suite runs against samples/ContractApi and pins the toolkit's ACTUAL
* wire behavior, warts included. It assumes a freshly seeded server (see
* samples/ContractApi/Data.cs); restart the sample between local runs.
*/
import type {
JsonApiArrayResponse,
JsonApiErrorResponse,
JsonApiSingleResponse,
} from '../src/index.ts';

export const BASE_URL = Deno.env.get('CONTRACT_API_URL') ??
'http://localhost:5198';
Expand All @@ -17,6 +21,11 @@ export const TOTAL_ARTICLES = 25;
export const PUBLISHED_ARTICLES = 13; // odd ids 1..25
export const UNPUBLISHED_ARTICLES = 12; // even ids, publishedAt is null

/** Wire document shapes, named short because every test step names one. */
export type Single = JsonApiSingleResponse;
export type List = JsonApiArrayResponse;
export type Errors = JsonApiErrorResponse;

/**
* Resource types as today's consumers write them: non-nullable everywhere,
* because the current AttributeKeys/RelationshipKeys helpers drop nullable
Expand Down Expand Up @@ -50,17 +59,22 @@ export type ContractArticle = {
comments: ContractComment[];
};

export interface WireResult {
export interface WireResult<T> {
status: number;
doc: any;
/** Parsed JSON body as `T`; `null` when the body is empty or not JSON. */
doc: T;
headers: Headers;
}

export async function request(
/**
* Raw request. `T` is the caller's claim about the body shape; the suite
* asserts on the wire, so a wrong claim fails loudly at the assertion.
*/
export async function request<T = unknown>(
method: string,
path: string,
opts: { body?: unknown; contentType?: string; base?: string } = {},
): Promise<WireResult> {
): Promise<WireResult<T>> {
const res = await fetch(`${opts.base ?? BASE_URL}/${path}`, {
method,
headers: opts.body !== undefined
Expand All @@ -69,22 +83,25 @@ export async function request(
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
const text = await res.text();
let doc: any = null;
let doc: unknown = null;
if (text) {
try {
doc = JSON.parse(text);
} catch {
doc = text;
}
}
return { status: res.status, doc, headers: res.headers };
return { status: res.status, doc: doc as T, headers: res.headers };
}

export function getDoc(path: string, base?: string): Promise<WireResult> {
return request('GET', path, { base });
export function getDoc<T = unknown>(
path: string,
base?: string,
): Promise<WireResult<T>> {
return request<T>('GET', path, { base });
}

/** Convenience: totalResources from a collection response. */
export function total(doc: any): number {
return doc?.meta?.pagination?.totalResources;
export function total(doc: List): number | undefined {
return doc.meta?.pagination?.totalResources;
}
Loading