nuthatch/web/test/transport.test.mjs

240 lines
7.5 KiB
JavaScript
Raw Permalink Normal View History

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('delete batch decoding enforces the typed item outcome', async () => {
const failureClient = new BrowserProductClient(async () =>
jsonResponse({
results: [
{
path: 'folder/file.txt',
operation_id: 'operation-1',
result: null,
error: 'database_unavailable'
}
]
})
);
const failure = requireValue(
await failureClient.deleteBatch({ body: fixtures.delete_batch_request })
);
assert.equal(failure.results[0].error, 'database_unavailable');
const invalidClient = new BrowserProductClient(async () =>
jsonResponse({
results: [
{
path: 'folder/file.txt',
operation_id: 'operation-1',
result: 'succeeded',
error: 'object_store_unavailable'
}
]
})
);
const invalid = await invalidClient.deleteBatch({
body: fixtures.delete_batch_request
});
assert.equal(invalid.ok, false);
assert.equal(invalid.failure.kind, 'unexpected-http');
});
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('logout keeps session mutation policy in the transport', async () => {
const calls = [];
const client = new BrowserProductClient(async (input, init) => {
calls.push({ input, init });
return new Response(null, { status: 204 });
});
await client.logout('csrf-token');
assert.equal(calls.length, 1);
assert.equal(calls[0].input, '/logout');
assert.equal(calls[0].init.method, 'POST');
assert.equal(calls[0].init.credentials, 'same-origin');
assert.equal(calls[0].init.headers['X-CSRF-Token'], 'csrf-token');
});
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' }
});
}