mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-01 19:20:31 +00:00
347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { buildHeaders, encodeHeaderValue, decodeHeaderValue, HeaderSpecClient, getHeaderSpecClient } from '../headerspec/client';
|
|
import type { Options, ClientConfig, APIResponse } from '../common/types';
|
|
|
|
describe('buildHeaders (extended restheadspec options)', () => {
|
|
it('should set X-Preload-Where when all preloads share one where', () => {
|
|
const h = buildHeaders({
|
|
preload: [
|
|
{ relation: 'Items', columns: ['id'], where: 'active = true' },
|
|
{ relation: 'Tags', where: 'active = true' },
|
|
],
|
|
});
|
|
expect(h['X-Preload']).toBe('Items:id|Tags');
|
|
expect(h['X-Preload-Where']).toBe('active = true');
|
|
});
|
|
|
|
it('should use numbered headers for mixed where clauses', () => {
|
|
const h = buildHeaders({
|
|
preload: [
|
|
{ relation: 'Items', where: 'a = 1' },
|
|
{ relation: 'Category' },
|
|
{ relation: 'Tags', where: 'b = 2' },
|
|
],
|
|
});
|
|
expect(h['X-Preload']).toBe('Category');
|
|
expect(h['X-Preload-Where']).toBeUndefined();
|
|
expect(h['X-Preload-1']).toBe('Items');
|
|
expect(h['X-Preload-1-Where']).toBe('a = 1');
|
|
expect(h['X-Preload-2']).toBe('Tags');
|
|
expect(h['X-Preload-2-Where']).toBe('b = 2');
|
|
});
|
|
|
|
it('should set expand, joins, or-sql, search cols, advsql', () => {
|
|
const h = buildHeaders({
|
|
expand: [{ relation: 'Dept', columns: ['id', 'name'] }, { relation: 'Role' }],
|
|
custom_sql_joins: ['LEFT JOIN a ON a.id = b.id', 'INNER JOIN c ON c.id = b.cid'],
|
|
custom_sql_or: ['x = 1', 'y = 2'],
|
|
search_columns: ['name', 'email'],
|
|
advanced_sql: { total: 'a + b' },
|
|
});
|
|
expect(h['X-Expand']).toBe('Dept:id,name|Role');
|
|
expect(h['X-Custom-SQL-Join']).toBe('LEFT JOIN a ON a.id = b.id|INNER JOIN c ON c.id = b.cid');
|
|
expect(h['X-Custom-SQL-Or']).toBe('x = 1 OR y = 2');
|
|
expect(h['X-SearchCols']).toBe('name,email');
|
|
expect(h['X-AdvSQL-total']).toBe('a + b');
|
|
});
|
|
|
|
it('should set boolean flags, pk row and response format', () => {
|
|
const h = buildHeaders({
|
|
clean_json: true,
|
|
distinct: true,
|
|
skip_count: true,
|
|
skip_cache: false,
|
|
atomic_transaction: true,
|
|
single_record_as_object: false,
|
|
pk_row: '42',
|
|
response_format: 'detail',
|
|
});
|
|
expect(h['X-Clean-JSON']).toBe('true');
|
|
expect(h['X-Distinct']).toBe('true');
|
|
expect(h['X-SkipCount']).toBe('true');
|
|
expect(h['X-SkipCache']).toBe('false');
|
|
expect(h['X-Transaction-Atomic']).toBe('true');
|
|
expect(h['X-Single-Record-As-Object']).toBe('false');
|
|
expect(h['X-PKRow']).toBe('42');
|
|
expect(h['X-DetailApi']).toBe('true');
|
|
});
|
|
|
|
it('should set spatial and vector filters as JSON', () => {
|
|
const h = buildHeaders({
|
|
filters: [
|
|
{ column: 'geom', operator: 'st_dwithin', value: { geom: 'POINT(0 0)', distance: 5 }, logic_operator: 'OR' },
|
|
{ column: 'emb', operator: 'cosine_within', value: { vector: [1, 2], distance: 0.3 } },
|
|
],
|
|
});
|
|
expect(JSON.parse(h['X-SpatialFilter-geom'])).toEqual({
|
|
op: 'st_dwithin', value: { geom: 'POINT(0 0)', distance: 5 }, logic: 'or',
|
|
});
|
|
expect(JSON.parse(h['X-VectorFilter-emb']).op).toBe('cosine_within');
|
|
});
|
|
|
|
it('should set vector search headers', () => {
|
|
const h = buildHeaders({
|
|
vector_search: { column: 'emb', vector: [0.1, 0.2], metric: 'cosine', as: 'dist', direction: 'desc' },
|
|
});
|
|
expect(h['X-Vector-Search-emb']).toBe('cosine');
|
|
expect(h['X-Vector-Search-Vector']).toBe('[0.1,0.2]');
|
|
expect(h['X-Vector-Search-As']).toBe('dist');
|
|
expect(h['X-Vector-Search-Dir']).toBe('desc');
|
|
});
|
|
|
|
it('should encode X-Files as ZIP_ JSON', () => {
|
|
const xf = { tablename: 'users', prefix: 'USR', limit: 10 };
|
|
const h = buildHeaders({ xfiles: xf });
|
|
expect(h['X-Files'].startsWith('ZIP_')).toBe(true);
|
|
expect(JSON.parse(decodeHeaderValue(h['X-Files']))).toEqual(xf);
|
|
});
|
|
});
|
|
|
|
describe('buildHeaders', () => {
|
|
it('should set X-Select-Fields for columns', () => {
|
|
const h = buildHeaders({ columns: ['id', 'name', 'email'] });
|
|
expect(h['X-Select-Fields']).toBe('id,name,email');
|
|
});
|
|
|
|
it('should set X-Not-Select-Fields for omit_columns', () => {
|
|
const h = buildHeaders({ omit_columns: ['secret', 'internal'] });
|
|
expect(h['X-Not-Select-Fields']).toBe('secret,internal');
|
|
});
|
|
|
|
it('should set X-FieldFilter for eq AND filters', () => {
|
|
const h = buildHeaders({
|
|
filters: [{ column: 'status', operator: 'eq', value: 'active' }],
|
|
});
|
|
expect(h['X-FieldFilter-status']).toBe('active');
|
|
});
|
|
|
|
it('should set X-SearchOp for non-eq AND filters', () => {
|
|
const h = buildHeaders({
|
|
filters: [{ column: 'age', operator: 'gte', value: 18 }],
|
|
});
|
|
expect(h['X-SearchOp-greaterthanorequal-age']).toBe('18');
|
|
});
|
|
|
|
it('should set X-SearchOr for OR filters', () => {
|
|
const h = buildHeaders({
|
|
filters: [{ column: 'name', operator: 'contains', value: 'test', logic_operator: 'OR' }],
|
|
});
|
|
expect(h['X-SearchOr-contains-name']).toBe('test');
|
|
});
|
|
|
|
it('should set X-Sort with direction prefixes', () => {
|
|
const h = buildHeaders({
|
|
sort: [
|
|
{ column: 'name', direction: 'asc' },
|
|
{ column: 'created_at', direction: 'DESC' },
|
|
],
|
|
});
|
|
expect(h['X-Sort']).toBe('+name,-created_at');
|
|
});
|
|
|
|
it('should set X-Limit and X-Offset', () => {
|
|
const h = buildHeaders({ limit: 25, offset: 50 });
|
|
expect(h['X-Limit']).toBe('25');
|
|
expect(h['X-Offset']).toBe('50');
|
|
});
|
|
|
|
it('should set cursor pagination headers', () => {
|
|
const h = buildHeaders({ cursor_forward: 'abc', cursor_backward: 'xyz' });
|
|
expect(h['X-Cursor-Forward']).toBe('abc');
|
|
expect(h['X-Cursor-Backward']).toBe('xyz');
|
|
});
|
|
|
|
it('should set X-Preload with pipe-separated relations', () => {
|
|
const h = buildHeaders({
|
|
preload: [
|
|
{ relation: 'Items', columns: ['id', 'name'] },
|
|
{ relation: 'Category' },
|
|
],
|
|
});
|
|
expect(h['X-Preload']).toBe('Items:id,name|Category');
|
|
});
|
|
|
|
it('should set X-Fetch-RowNumber', () => {
|
|
const h = buildHeaders({ fetch_row_number: '42' });
|
|
expect(h['X-Fetch-RowNumber']).toBe('42');
|
|
});
|
|
|
|
it('should set X-CQL-SEL for computed columns', () => {
|
|
const h = buildHeaders({
|
|
computedColumns: [
|
|
{ name: 'total', expression: 'price * qty' },
|
|
],
|
|
});
|
|
expect(h['X-CQL-SEL-total']).toBe('price * qty');
|
|
});
|
|
|
|
it('should set X-Custom-SQL-W for custom operators', () => {
|
|
const h = buildHeaders({
|
|
customOperators: [
|
|
{ name: 'active', sql: "status = 'active'" },
|
|
{ name: 'verified', sql: "verified = true" },
|
|
],
|
|
});
|
|
expect(h['X-Custom-SQL-W']).toBe("status = 'active' AND verified = true");
|
|
});
|
|
|
|
it('should return empty object for empty options', () => {
|
|
const h = buildHeaders({});
|
|
expect(Object.keys(h)).toHaveLength(0);
|
|
});
|
|
|
|
it('should handle between filter with array value', () => {
|
|
const h = buildHeaders({
|
|
filters: [{ column: 'price', operator: 'between', value: [10, 100] }],
|
|
});
|
|
expect(h['X-SearchOp-between-price']).toBe('10,100');
|
|
});
|
|
|
|
it('should handle is_null filter with null value', () => {
|
|
const h = buildHeaders({
|
|
filters: [{ column: 'deleted_at', operator: 'is_null', value: null }],
|
|
});
|
|
expect(h['X-SearchOp-empty-deleted_at']).toBe('');
|
|
});
|
|
|
|
it('should handle in filter with array value', () => {
|
|
const h = buildHeaders({
|
|
filters: [{ column: 'id', operator: 'in', value: [1, 2, 3] }],
|
|
});
|
|
expect(h['X-SearchOp-in-id']).toBe('1,2,3');
|
|
});
|
|
});
|
|
|
|
describe('encodeHeaderValue / decodeHeaderValue', () => {
|
|
it('should round-trip encode/decode', () => {
|
|
const original = 'some complex value with spaces & symbols!';
|
|
const encoded = encodeHeaderValue(original);
|
|
expect(encoded.startsWith('ZIP_')).toBe(true);
|
|
const decoded = decodeHeaderValue(encoded);
|
|
expect(decoded).toBe(original);
|
|
});
|
|
|
|
it('should round-trip UTF-8 values', () => {
|
|
const original = 'café ☕ 你好';
|
|
expect(decodeHeaderValue(encodeHeaderValue(original))).toBe(original);
|
|
});
|
|
|
|
it('should decode __ prefixed values', () => {
|
|
const encoded = '__' + btoa('hello');
|
|
expect(decodeHeaderValue(encoded)).toBe('hello');
|
|
});
|
|
|
|
it('should decode UTF-8 values with the __ prefix', () => {
|
|
const bytes = new TextEncoder().encode('café ☕');
|
|
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
|
|
expect(decodeHeaderValue('__' + btoa(binary))).toBe('café ☕');
|
|
});
|
|
|
|
it('should return plain values as-is', () => {
|
|
expect(decodeHeaderValue('plain')).toBe('plain');
|
|
});
|
|
});
|
|
|
|
describe('HeaderSpecClient', () => {
|
|
const config: ClientConfig = { baseUrl: 'http://localhost:3000', token: 'tok' };
|
|
|
|
function mockFetch<T>(data: APIResponse<T>, ok = true) {
|
|
return vi.fn().mockResolvedValue({
|
|
ok,
|
|
headers: new Headers(),
|
|
json: () => Promise.resolve(data),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('read() sends GET with headers from options', async () => {
|
|
globalThis.fetch = mockFetch({ success: true, data: [{ id: 1 }] });
|
|
const client = new HeaderSpecClient(config);
|
|
|
|
await client.read('public', 'users', undefined, {
|
|
columns: ['id', 'name'],
|
|
limit: 10,
|
|
});
|
|
|
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
expect(url).toBe('http://localhost:3000/public/users');
|
|
expect(opts.method).toBe('GET');
|
|
expect(opts.headers['X-Select-Fields']).toBe('id,name');
|
|
expect(opts.headers['X-Limit']).toBe('10');
|
|
expect(opts.headers['Authorization']).toBe('Bearer tok');
|
|
});
|
|
|
|
it('read() with id appends to URL', async () => {
|
|
globalThis.fetch = mockFetch({ success: true, data: {} });
|
|
const client = new HeaderSpecClient(config);
|
|
|
|
await client.read('public', 'users', '42');
|
|
|
|
const [url] = (globalThis.fetch as any).mock.calls[0];
|
|
expect(url).toBe('http://localhost:3000/public/users/42');
|
|
});
|
|
|
|
it('create() sends POST with body and headers', async () => {
|
|
globalThis.fetch = mockFetch({ success: true, data: { id: 1 } });
|
|
const client = new HeaderSpecClient(config);
|
|
|
|
await client.create('public', 'users', { name: 'Test' });
|
|
|
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
expect(opts.method).toBe('POST');
|
|
expect(JSON.parse(opts.body)).toEqual({ name: 'Test' });
|
|
});
|
|
|
|
it('update() sends PUT with id in URL', async () => {
|
|
globalThis.fetch = mockFetch({ success: true, data: {} });
|
|
const client = new HeaderSpecClient(config);
|
|
|
|
await client.update('public', 'users', '1', { name: 'Updated' }, {
|
|
filters: [{ column: 'active', operator: 'eq', value: true }],
|
|
});
|
|
|
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
|
expect(opts.method).toBe('PUT');
|
|
expect(opts.headers['X-FieldFilter-active']).toBe('true');
|
|
});
|
|
|
|
it('delete() sends DELETE', async () => {
|
|
globalThis.fetch = mockFetch({ success: true, data: undefined as any });
|
|
const client = new HeaderSpecClient(config);
|
|
|
|
await client.delete('public', 'users', '1');
|
|
|
|
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
expect(url).toBe('http://localhost:3000/public/users/1');
|
|
expect(opts.method).toBe('DELETE');
|
|
});
|
|
|
|
it('throws on non-ok response', async () => {
|
|
globalThis.fetch = mockFetch(
|
|
{ success: false, data: null as any, error: { code: 'err', message: 'fail' } },
|
|
false
|
|
);
|
|
const client = new HeaderSpecClient(config);
|
|
|
|
await expect(client.read('public', 'users')).rejects.toThrow('fail');
|
|
});
|
|
});
|
|
|
|
describe('getHeaderSpecClient singleton', () => {
|
|
it('returns same instance for same baseUrl', () => {
|
|
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
|
|
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton:3000' });
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it('returns different instances for different baseUrls', () => {
|
|
const a = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-a:3000' });
|
|
const b = getHeaderSpecClient({ baseUrl: 'http://hs-singleton-b:3000' });
|
|
expect(a).not.toBe(b);
|
|
});
|
|
});
|