Generate the browser contract from ProductApi
This commit is contained in:
parent
67cc0cb802
commit
c9642f4067
1 changed files with 2572 additions and 130 deletions
189
web/test/transport.test.mjs
Normal file
189
web/test/transport.test.mjs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
BrowserProductClient,
|
||||
requireValue
|
||||
} from '../src/transport.ts';
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
await readFile(new URL('./fixtures/contract.json', import.meta.url), 'utf8')
|
||||
);
|
||||
|
||||
test('transport derives routes, credentials, and encoded queries from the contract', async () => {
|
||||
const calls = [];
|
||||
const client = new BrowserProductClient(
|
||||
async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return jsonResponse(fixtures.browse_response);
|
||||
},
|
||||
'https://nuthatch.test/'
|
||||
);
|
||||
|
||||
const response = requireValue(
|
||||
await client.browse({
|
||||
root: 'shared',
|
||||
prefix: '문서/a b',
|
||||
q: 'needle',
|
||||
continuation: 'next/token'
|
||||
})
|
||||
);
|
||||
assert.deepEqual(response, fixtures.browse_response);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.credentials, 'same-origin');
|
||||
assert.equal(calls[0].init.method, 'GET');
|
||||
const url = new URL(calls[0].input);
|
||||
assert.equal(url.pathname, '/api/browse');
|
||||
assert.equal(url.searchParams.get('root'), 'shared');
|
||||
assert.equal(url.searchParams.get('prefix'), '문서/a b');
|
||||
assert.equal(url.searchParams.get('q'), 'needle');
|
||||
assert.equal(url.searchParams.get('continuation'), 'next/token');
|
||||
});
|
||||
|
||||
test('binary upload preserves the Blob and required generated headers', async () => {
|
||||
const calls = [];
|
||||
const client = new BrowserProductClient(async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return jsonResponse(fixtures.upload_response);
|
||||
});
|
||||
const body = new Blob(['payload']);
|
||||
|
||||
const response = requireValue(
|
||||
await client.upload({
|
||||
root: 'personal',
|
||||
path: 'folder/문서.txt',
|
||||
operationId: 'operation-1',
|
||||
declaredSize: body.size,
|
||||
contentSha256: 'a'.repeat(64),
|
||||
csrfToken: 'b'.repeat(43),
|
||||
body
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(response, fixtures.upload_response);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.body, body);
|
||||
assert.equal(calls[0].init.headers.get('Content-Type'), 'application/octet-stream');
|
||||
assert.equal(calls[0].init.headers.get('X-Declared-Size'), '7');
|
||||
assert.equal(calls[0].init.headers.get('X-Content-SHA256'), 'a'.repeat(64));
|
||||
assert.equal(calls[0].init.headers.get('X-CSRF-Token'), 'b'.repeat(43));
|
||||
const url = new URL(calls[0].input, 'https://nuthatch.test');
|
||||
assert.equal(url.pathname, '/api/upload');
|
||||
assert.equal(url.searchParams.get('operation_id'), 'operation-1');
|
||||
assert.equal(url.searchParams.get('path'), 'folder/문서.txt');
|
||||
});
|
||||
|
||||
test('download exposes its stream and declared response headers', async () => {
|
||||
const client = new BrowserProductClient(async () =>
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: {
|
||||
'Content-Disposition': 'attachment; filename="file.bin"',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy': "default-src 'none'",
|
||||
'Content-Length': '3'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const download = requireValue(
|
||||
await client.download({ root: 'shared', path: 'file.bin' })
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Uint8Array(await new Response(download.body).arrayBuffer()),
|
||||
new Uint8Array([1, 2, 3])
|
||||
);
|
||||
assert.equal(download.contentDisposition, 'attachment; filename="file.bin"');
|
||||
assert.equal(download.xContentTypeOptions, 'nosniff');
|
||||
assert.equal(download.contentSecurityPolicy, "default-src 'none'");
|
||||
assert.equal(download.contentLength, 3);
|
||||
});
|
||||
|
||||
test('shared fixtures decode through every JSON transport method', async () => {
|
||||
const responses = [
|
||||
fixtures.session_response,
|
||||
fixtures.delete_response,
|
||||
fixtures.delete_plan_response,
|
||||
fixtures.delete_batch_response
|
||||
];
|
||||
const client = new BrowserProductClient(async () =>
|
||||
jsonResponse(responses.shift())
|
||||
);
|
||||
|
||||
assert.deepEqual(requireValue(await client.session({})), fixtures.session_response);
|
||||
assert.deepEqual(
|
||||
requireValue(await client.delete({ body: fixtures.delete_request })),
|
||||
fixtures.delete_response
|
||||
);
|
||||
assert.deepEqual(
|
||||
requireValue(
|
||||
await client.deletePlan({
|
||||
root: 'shared',
|
||||
path: 'folder/문서.txt',
|
||||
continuation: 'next-page'
|
||||
})
|
||||
),
|
||||
fixtures.delete_plan_response
|
||||
);
|
||||
assert.deepEqual(
|
||||
requireValue(
|
||||
await client.deleteBatch({ body: fixtures.delete_batch_request })
|
||||
),
|
||||
fixtures.delete_batch_response
|
||||
);
|
||||
});
|
||||
|
||||
test('declared, undeclared, and network failures remain distinct without retries', async () => {
|
||||
const declaredClient = new BrowserProductClient(async () =>
|
||||
jsonResponse(
|
||||
{
|
||||
error: 'csrf_validation_failed',
|
||||
message: 'CSRF validation failed',
|
||||
details: null
|
||||
},
|
||||
403
|
||||
)
|
||||
);
|
||||
const declared = await declaredClient.delete({
|
||||
body: fixtures.delete_request
|
||||
});
|
||||
assert.equal(declared.ok, false);
|
||||
assert.equal(declared.failure.kind, 'http');
|
||||
assert.equal(declared.failure.error.error, 'csrf_validation_failed');
|
||||
|
||||
const undeclaredClient = new BrowserProductClient(async () =>
|
||||
jsonResponse(
|
||||
{ error: 'unknown_error', message: 'unknown', details: null },
|
||||
500
|
||||
)
|
||||
);
|
||||
const undeclared = await undeclaredClient.session({});
|
||||
assert.equal(undeclared.ok, false);
|
||||
assert.equal(undeclared.failure.kind, 'unexpected-http');
|
||||
|
||||
let attempts = 0;
|
||||
const networkClient = new BrowserProductClient(async () => {
|
||||
attempts += 1;
|
||||
throw new Error('offline');
|
||||
});
|
||||
const network = await networkClient.browse({});
|
||||
assert.equal(network.ok, false);
|
||||
assert.equal(network.failure.kind, 'network');
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('checked generated artifact contains no transport policy', async () => {
|
||||
const generated = await readFile(
|
||||
new URL('../src/generated/contract.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
assert.doesNotMatch(generated, /\bfetch\s*\(/);
|
||||
assert.doesNotMatch(generated, /\bcredentials\b/);
|
||||
assert.doesNotMatch(generated, /\bretry\b/i);
|
||||
});
|
||||
|
||||
function jsonResponse(value, status = 200) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue