Generate the browser contract from ProductApi

This commit is contained in:
ms2k 2026-07-20 03:20:08 +09:00
commit c9642f4067
3 changed files with 2572 additions and 130 deletions

View file

@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import {
BrowserProductClient,
requireValue
} from '../src/transport.ts';
const baseUrl = process.argv[2];
if (baseUrl === undefined) {
throw new Error('missing integration base URL');
}
const client = new BrowserProductClient(globalThis.fetch.bind(globalThis), baseUrl);
const csrf = 'a'.repeat(43);
const path = 'folder/문서 a.txt';
const operationId = 'operation-1';
const session = requireValue(await client.session({}));
assert.deepEqual(session, {
user_id: 'user-1',
display: 'User',
csrf
});
const browse = requireValue(
await client.browse({
root: 'shared',
prefix: path,
q: 'needle',
continuation: 'next-page'
})
);
assert.equal(browse.entries[0].path, path);
assert.equal(browse.continuation, 'next-page');
assert.equal(browse.partial, true);
const download = requireValue(
await client.download({ root: 'shared', path })
);
assert.equal(await new Response(download.body).text(), 'abc');
assert.equal(download.contentDisposition, 'attachment; filename="file.txt"');
assert.equal(download.xContentTypeOptions, 'nosniff');
assert.equal(download.contentSecurityPolicy, "default-src 'none'");
assert.equal(download.contentLength, 3);
const upload = requireValue(
await client.upload({
root: 'personal',
path,
operationId,
declaredSize: 3,
contentSha256: 'b'.repeat(64),
csrfToken: csrf,
body: new Blob(['abc'])
})
);
assert.equal(upload.operation_id, operationId);
assert.equal(upload.result, 'succeeded');
const deletion = requireValue(
await client.delete({
body: {
root: 'shared',
path,
operation_id: operationId,
csrf
}
})
);
assert.equal(deletion.operation_id, operationId);
assert.equal(deletion.result, 'succeeded');
const plan = requireValue(
await client.deletePlan({
root: 'shared',
path,
continuation: 'next-page'
})
);
assert.deepEqual(plan.paths, [path]);
assert.equal(plan.continuation, 'next-page');
const batch = requireValue(
await client.deleteBatch({
body: {
root: 'shared',
csrf,
items: [{ path, operation_id: operationId }]
}
})
);
assert.deepEqual(batch.results, [
{
path,
operation_id: operationId,
result: 'succeeded',
error: null
}
]);

1
web/test/fixtures/contract.json vendored Normal file
View file

@ -0,0 +1 @@
{"browse_response":{"continuation":"next-page","entries":[{"is_prefix":false,"name":"문서.txt","path":"folder/문서.txt","size":7}],"partial":false},"delete_batch_item":{"operation_id":"operation-1","path":"folder/문서.txt"},"delete_batch_request":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","items":[{"operation_id":"operation-1","path":"folder/문서.txt"}],"root":"shared"},"delete_batch_response":{"results":[{"error":null,"operation_id":"operation-1","path":"folder/문서.txt","result":"succeeded"}]},"delete_item_response":{"error":null,"operation_id":"operation-1","path":"folder/문서.txt","result":"succeeded"},"delete_plan_response":{"continuation":"next-page","paths":["folder/문서.txt"]},"delete_request":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation_id":"operation-1","path":"folder/문서.txt","root":"shared"},"delete_response":{"idempotency_window_seconds":3600,"operation_id":"operation-1","result":"pending"},"entry_response":{"is_prefix":false,"name":"문서.txt","path":"folder/문서.txt","size":7},"errors":[{"details":null,"error":"authentication_required","message":"login required"},{"details":null,"error":"malformed_authentication","message":"authentication data is malformed"},{"details":null,"error":"object_store_error","message":"store"},{"details":null,"error":"object_store_error","message":"store"},{"details":null,"error":"not_found","message":"object not found"},{"details":null,"error":"range_not_supported","message":"Range downloads are not supported"},{"details":null,"error":"throttle_unavailable","message":"bandwidth throttle unavailable"},{"details":null,"error":"bandwidth_exceeded","message":"bandwidth limit exceeded"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"invalid_body_stream","message":"body"},{"details":null,"error":"size_mismatch","message":"request body does not match declared size"},{"details":null,"error":"object_too_large","message":"object too large"},{"details":null,"error":"content_identity_mismatch","message":"request body digest does not match"},{"details":null,"error":"throttle_unavailable","message":"bandwidth throttle unavailable"},{"details":null,"error":"bandwidth_exceeded","message":"bandwidth limit exceeded"},{"details":null,"error":"mutation_rejected","message":"mutation"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"mutation_rejected","message":"mutation"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"prefix_mutation_rejected","message":"prefix"}],"session_response":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","display":"User","user_id":"user-1"},"upload_response":{"idempotency_window_seconds":3600,"operation_id":"operation-1","result":"succeeded"}}

189
web/test/transport.test.mjs Normal file
View 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' }
});
}