mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
refactor(schema): simplify validation and OpenAPI generation (#2709)
This commit is contained in:
1 parent
4bc5593f9f
commit
de1fd95a99
510 files changed
+29494
-36131
No files matched your search
@@ -0,0 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
export function chunkArray<T>(items: ReadonlyArray<T>, chunkSize: number): Array<Array<T>> {
|
||||
assert(Number.isSafeInteger(chunkSize) && chunkSize > 0, 'Chunk size must be a positive safe integer');
|
||||
const chunks: Array<Array<T>> = [];
|
||||
for (let index = 0; index < items.length; index += chunkSize) {
|
||||
chunks.push(items.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
export async function mapWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>,
|
||||
): Promise<Array<TResult>> {
|
||||
assert(Number.isSafeInteger(concurrency) && concurrency > 0, 'Concurrency must be a positive safe integer');
|
||||
const results = new Array<TResult>(items.length);
|
||||
let nextIndex = 0;
|
||||
async function worker(): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
|
||||
import type {PremiumCurrency} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
|
||||
export type Currency = PremiumCurrency;
|
||||
|
||||
export function getCurrency(countryCode: string | null | undefined): Currency {
|
||||
return getCurrencyPreferences(countryCode)[0];
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
|
||||
import type {HttpClient, RequestOptions, RequestUrlPolicy, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import type {
|
||||
HttpClient,
|
||||
RequestOptions,
|
||||
RequestUrlPolicy,
|
||||
ResponseStream,
|
||||
StreamResponse,
|
||||
} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
|
||||
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
|
||||
@@ -40,7 +46,7 @@ function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
|
||||
return scopedClient;
|
||||
}
|
||||
|
||||
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) {
|
||||
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions): Promise<StreamResponse> {
|
||||
const requestClient = getHttpClientForRequest(options);
|
||||
return requestClient.sendRequest(opts);
|
||||
}
|
||||
@@ -56,7 +62,7 @@ export class ResponseBodyTooLargeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamToStringWithLimitOptions {
|
||||
interface ResponseBodyReadOptions {
|
||||
maxBytes: number;
|
||||
headers?: Headers;
|
||||
url?: string;
|
||||
@@ -77,7 +83,7 @@ function parseContentLength(value: string | null | undefined): number | null {
|
||||
}
|
||||
|
||||
function createResponseBodyTooLargeError(
|
||||
options: StreamToStringWithLimitOptions,
|
||||
options: ResponseBodyReadOptions,
|
||||
actualBytes: number | null,
|
||||
): ResponseBodyTooLargeError {
|
||||
const description = options.description ?? 'Response body';
|
||||
@@ -91,8 +97,8 @@ function createResponseBodyTooLargeError(
|
||||
}
|
||||
|
||||
export async function streamToBufferWithLimit(
|
||||
stream: StreamResponse['stream'],
|
||||
options: StreamToStringWithLimitOptions,
|
||||
stream: ResponseStream,
|
||||
options: ResponseBodyReadOptions,
|
||||
): Promise<Uint8Array> {
|
||||
if (!stream) {
|
||||
return new Uint8Array(0);
|
||||
@@ -124,6 +130,9 @@ export async function streamToBufferWithLimit(
|
||||
throw abortError;
|
||||
}
|
||||
const {done, value} = await reader.read();
|
||||
if (options.signal?.aborted) {
|
||||
throw abortError;
|
||||
}
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
@@ -133,7 +142,7 @@ export async function streamToBufferWithLimit(
|
||||
totalSize += value.byteLength;
|
||||
if (totalSize > options.maxBytes) {
|
||||
const error = createResponseBodyTooLargeError(options, totalSize);
|
||||
await reader.cancel(error).catch(() => {});
|
||||
void reader.cancel(error).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
chunks.push(value);
|
||||
@@ -155,8 +164,8 @@ export async function streamToBufferWithLimit(
|
||||
}
|
||||
|
||||
export async function streamToStringWithLimit(
|
||||
stream: StreamResponse['stream'],
|
||||
options: StreamToStringWithLimitOptions,
|
||||
stream: ResponseStream,
|
||||
options: ResponseBodyReadOptions,
|
||||
): Promise<string> {
|
||||
const merged = await streamToBufferWithLimit(stream, options);
|
||||
return new TextDecoder().decode(merged);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {chunkArray} from '../ArrayUtils';
|
||||
|
||||
describe('chunkArray', () => {
|
||||
it.each([
|
||||
{size: 1, expected: [[1], [2], [3], [4], [5]]},
|
||||
{size: 2, expected: [[1, 2], [3, 4], [5]]},
|
||||
{size: 5, expected: [[1, 2, 3, 4, 5]]},
|
||||
{size: 10, expected: [[1, 2, 3, 4, 5]]},
|
||||
])('partitions input into chunks of at most $size', ({size, expected}) => {
|
||||
expect(chunkArray([1, 2, 3, 4, 5], size)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('preserves the input and returns independent chunk arrays', () => {
|
||||
const items = Object.freeze([1, 2, 3]);
|
||||
const chunks = chunkArray(items, 2);
|
||||
chunks[0]!.push(4);
|
||||
expect(items).toEqual([1, 2, 3]);
|
||||
expect(chunks).toEqual([[1, 2, 4], [3]]);
|
||||
});
|
||||
|
||||
it('returns no chunks for empty input', () => {
|
||||
expect(chunkArray([], 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])('rejects invalid chunk size %j even for empty input', (size) => {
|
||||
expect(() => chunkArray([1], size)).toThrow('Chunk size must be a positive safe integer');
|
||||
expect(() => chunkArray([], size)).toThrow('Chunk size must be a positive safe integer');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {mapWithConcurrency} from '../ConcurrencyUtils';
|
||||
|
||||
describe('mapWithConcurrency', () => {
|
||||
it('limits in-flight work and preserves input order when later items finish first', async () => {
|
||||
const items = ['a', 'b', 'c', 'd'];
|
||||
const gates = items.map(() => Promise.withResolvers<number>());
|
||||
const thirdStarted = Promise.withResolvers<void>();
|
||||
const fourthStarted = Promise.withResolvers<void>();
|
||||
const started: Array<[string, number]> = [];
|
||||
let active = 0;
|
||||
let peakActive = 0;
|
||||
const result = mapWithConcurrency(items, 2, async (item, index) => {
|
||||
started.push([item, index]);
|
||||
active++;
|
||||
peakActive = Math.max(peakActive, active);
|
||||
if (index === 2) thirdStarted.resolve();
|
||||
if (index === 3) fourthStarted.resolve();
|
||||
try {
|
||||
return await gates[index]!.promise;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
});
|
||||
|
||||
expect(started).toEqual([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
]);
|
||||
gates[1]!.resolve(20);
|
||||
await thirdStarted.promise;
|
||||
expect(started).toEqual([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
['c', 2],
|
||||
]);
|
||||
expect(active).toBe(2);
|
||||
gates[2]!.resolve(30);
|
||||
await fourthStarted.promise;
|
||||
expect(started).toEqual([
|
||||
['a', 0],
|
||||
['b', 1],
|
||||
['c', 2],
|
||||
['d', 3],
|
||||
]);
|
||||
gates[3]!.resolve(40);
|
||||
gates[0]!.resolve(10);
|
||||
|
||||
await expect(result).resolves.toEqual([10, 20, 30, 40]);
|
||||
expect(peakActive).toBe(2);
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
|
||||
it.each([1, 2, 10])('maps every item exactly once with concurrency %i', async (concurrency) => {
|
||||
const indexes: Array<number> = [];
|
||||
const result = await mapWithConcurrency([10, 20, 30], concurrency, async (item, index) => {
|
||||
indexes.push(index);
|
||||
return item + index;
|
||||
});
|
||||
expect(indexes).toEqual([0, 1, 2]);
|
||||
expect(result).toEqual([10, 21, 32]);
|
||||
});
|
||||
|
||||
it('returns an empty array without invoking the mapper', async () => {
|
||||
await expect(
|
||||
mapWithConcurrency([], 2, () => {
|
||||
throw new Error('An empty input must not invoke the mapper');
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])('rejects invalid concurrency %j before scheduling work', async (concurrency) => {
|
||||
let calls = 0;
|
||||
const mapper = async () => {
|
||||
calls++;
|
||||
return 1;
|
||||
};
|
||||
await expect(mapWithConcurrency([1], concurrency, mapper)).rejects.toThrow(
|
||||
'Concurrency must be a positive safe integer',
|
||||
);
|
||||
await expect(mapWithConcurrency([], concurrency, mapper)).rejects.toThrow(
|
||||
'Concurrency must be a positive safe integer',
|
||||
);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('propagates a mapper rejection without waiting for another in-flight item', async () => {
|
||||
const first = Promise.withResolvers<number>();
|
||||
const second = Promise.withResolvers<number>();
|
||||
const failure = new Error('Mapper failed');
|
||||
const result = mapWithConcurrency([first.promise, second.promise], 2, (promise) => promise);
|
||||
const rejected = expect(result).rejects.toBe(failure);
|
||||
first.reject(failure);
|
||||
await rejected;
|
||||
second.resolve(2);
|
||||
await second.promise;
|
||||
});
|
||||
|
||||
it('rejects synchronous mapper failures without starting queued serial work', async () => {
|
||||
const failure = new Error('Mapper failed synchronously');
|
||||
const started: Array<number> = [];
|
||||
await expect(
|
||||
mapWithConcurrency([1, 2], 1, (item) => {
|
||||
started.push(item);
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
expect(started).toEqual([1]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
import * as FetchUtils from '../FetchUtils';
|
||||
|
||||
function createStream(chunks: Array<string>): ReadableStream<Uint8Array> {
|
||||
@@ -18,31 +18,76 @@ function createStream(chunks: Array<string>): ReadableStream<Uint8Array> {
|
||||
});
|
||||
}
|
||||
|
||||
describe('FetchUtils', () => {
|
||||
describe('streamToBufferWithLimit', () => {
|
||||
it('returns the full response body when it is within the limit', async () => {
|
||||
const body = await FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
|
||||
maxBytes: 32,
|
||||
describe('streamToBufferWithLimit', () => {
|
||||
it('returns the full response body when it is within the limit', async () => {
|
||||
const body = await FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
|
||||
maxBytes: 32,
|
||||
description: 'Test response',
|
||||
});
|
||||
expect(new TextDecoder().decode(body)).toBe('hello world');
|
||||
});
|
||||
it('rejects when the declared content-length exceeds the configured limit', async () => {
|
||||
await expect(
|
||||
FetchUtils.streamToBufferWithLimit(createStream(['hello']), {
|
||||
maxBytes: 4,
|
||||
headers: new Headers({'Content-Length': '5'}),
|
||||
description: 'Test response',
|
||||
});
|
||||
expect(new TextDecoder().decode(body)).toBe('hello world');
|
||||
});
|
||||
it('rejects when the declared content-length exceeds the configured limit', async () => {
|
||||
await expect(
|
||||
FetchUtils.streamToBufferWithLimit(createStream(['hello']), {
|
||||
maxBytes: 4,
|
||||
headers: new Headers({'Content-Length': '5'}),
|
||||
description: 'Test response',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
|
||||
});
|
||||
it('rejects when the streamed body grows beyond the configured limit', async () => {
|
||||
await expect(
|
||||
FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
|
||||
maxBytes: 5,
|
||||
description: 'Test response',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
|
||||
});
|
||||
it('rejects when the streamed body grows beyond the configured limit', async () => {
|
||||
await expect(
|
||||
FetchUtils.streamToBufferWithLimit(createStream(['hello', ' world']), {
|
||||
maxBytes: 5,
|
||||
description: 'Test response',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchUtils.ResponseBodyTooLargeError);
|
||||
});
|
||||
it('rejects an abort during a pending read instead of returning a partial response', async () => {
|
||||
const pendingRead = Promise.withResolvers<void>();
|
||||
const abort = new AbortController();
|
||||
const stream = new ReadableStream<Uint8Array>(
|
||||
{
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('partial'));
|
||||
},
|
||||
pull() {
|
||||
pendingRead.resolve();
|
||||
},
|
||||
},
|
||||
{highWaterMark: 0},
|
||||
);
|
||||
const result = FetchUtils.streamToBufferWithLimit(stream, {maxBytes: 32, signal: abort.signal});
|
||||
await pendingRead.promise;
|
||||
abort.abort();
|
||||
await expect(result).rejects.toMatchObject({name: 'AbortError'});
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an oversized response without waiting for cancellation to finish', async () => {
|
||||
vi.useFakeTimers();
|
||||
const cancelled = Promise.withResolvers<void>();
|
||||
const cleanup = Promise.withResolvers<void>();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(5));
|
||||
},
|
||||
cancel() {
|
||||
cancelled.resolve();
|
||||
return cleanup.promise;
|
||||
},
|
||||
});
|
||||
const settled = vi.fn();
|
||||
const result = FetchUtils.streamToBufferWithLimit(stream, {maxBytes: 4}).then(settled, settled);
|
||||
try {
|
||||
await cancelled.promise;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(FetchUtils.ResponseBodyTooLargeError));
|
||||
expect(stream.locked).toBe(false);
|
||||
} finally {
|
||||
cleanup.resolve();
|
||||
await result;
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,81 +4,43 @@ import {describe, expect, it} from 'vitest';
|
||||
import {toIdString, toSortedIdArray} from '../IdUtils';
|
||||
|
||||
describe('toIdString', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(toIdString(null)).toBeNull();
|
||||
});
|
||||
it('returns null for undefined input', () => {
|
||||
expect(toIdString(undefined)).toBeNull();
|
||||
});
|
||||
it('converts bigint to string', () => {
|
||||
expect(toIdString(123456789012345678n)).toBe('123456789012345678');
|
||||
});
|
||||
it('converts string to string (passthrough)', () => {
|
||||
expect(toIdString('123456789012345678')).toBe('123456789012345678');
|
||||
});
|
||||
it('handles zero bigint', () => {
|
||||
expect(toIdString(0n)).toBe('0');
|
||||
});
|
||||
it('handles very large bigints', () => {
|
||||
const large = 999999999999999999999999999999n;
|
||||
expect(toIdString(large)).toBe(large.toString());
|
||||
});
|
||||
it('handles negative bigints', () => {
|
||||
expect(toIdString(-1n)).toBe('-1');
|
||||
});
|
||||
it('preserves numeric string exactly', () => {
|
||||
expect(toIdString('0')).toBe('0');
|
||||
expect(toIdString('00123')).toBe('00123');
|
||||
it.each([
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
[123456789012345678n, '123456789012345678'],
|
||||
['123456789012345678', '123456789012345678'],
|
||||
[0n, '0'],
|
||||
[999999999999999999999999999999n, '999999999999999999999999999999'],
|
||||
[-1n, '-1'],
|
||||
['0', '0'],
|
||||
['00123', '00123'],
|
||||
])('preserves the exact ID representation of %s', (input, expected) => {
|
||||
expect(toIdString(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSortedIdArray', () => {
|
||||
it('returns empty array for null input', () => {
|
||||
expect(toSortedIdArray(null)).toEqual([]);
|
||||
it.each([
|
||||
{input: null, expected: []},
|
||||
{input: undefined, expected: []},
|
||||
{input: [], expected: []},
|
||||
{input: new Set<bigint>(), expected: []},
|
||||
{input: [300n, 100n, 200n], expected: ['100', '200', '300']},
|
||||
{input: ['300', '100', '200'], expected: ['100', '200', '300']},
|
||||
{input: new Set([300n, 100n, 200n]), expected: ['100', '200', '300']},
|
||||
{input: [42n], expected: ['42']},
|
||||
{input: new Set([123n]), expected: ['123']},
|
||||
{input: [100n, 200n, 300n], expected: ['100', '200', '300']},
|
||||
{input: [300n, 200n, 100n], expected: ['100', '200', '300']},
|
||||
{input: [2n, 10n, 1n], expected: ['1', '10', '2']},
|
||||
{input: [100n, 100n, 200n], expected: ['100', '100', '200']},
|
||||
])('stringifies and lexicographically sorts case %#', ({input, expected}) => {
|
||||
expect(toSortedIdArray<bigint | string>(input)).toEqual(expected);
|
||||
});
|
||||
it('returns empty array for undefined input', () => {
|
||||
expect(toSortedIdArray(undefined)).toEqual([]);
|
||||
});
|
||||
it('returns empty array for empty array input', () => {
|
||||
expect(toSortedIdArray([])).toEqual([]);
|
||||
});
|
||||
it('converts and sorts array of bigints', () => {
|
||||
|
||||
it('does not reorder the caller-owned array', () => {
|
||||
const input = [300n, 100n, 200n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
it('converts and sorts array of strings', () => {
|
||||
const input = ['300', '100', '200'];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
it('converts Set to sorted array', () => {
|
||||
const input = new Set([300n, 100n, 200n]);
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
it('handles single element array', () => {
|
||||
expect(toSortedIdArray([42n])).toEqual(['42']);
|
||||
});
|
||||
it('handles already sorted input', () => {
|
||||
const input = [100n, 200n, 300n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
it('handles reverse sorted input', () => {
|
||||
const input = [300n, 200n, 100n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '200', '300']);
|
||||
});
|
||||
it('sorts lexicographically (string sort)', () => {
|
||||
const input = [2n, 10n, 1n];
|
||||
expect(toSortedIdArray(input)).toEqual(['1', '10', '2']);
|
||||
});
|
||||
it('handles duplicates in input array', () => {
|
||||
const input = [100n, 100n, 200n];
|
||||
expect(toSortedIdArray(input)).toEqual(['100', '100', '200']);
|
||||
});
|
||||
it('handles Set with single element', () => {
|
||||
const input = new Set([123n]);
|
||||
expect(toSortedIdArray(input)).toEqual(['123']);
|
||||
});
|
||||
it('handles empty Set', () => {
|
||||
const input = new Set<bigint>();
|
||||
expect(toSortedIdArray(input)).toEqual([]);
|
||||
toSortedIdArray(input);
|
||||
expect(input).toEqual([300n, 100n, 200n]);
|
||||
});
|
||||
});
|
||||
@@ -4,79 +4,40 @@ import {describe, expect, it} from 'vitest';
|
||||
import {hasVisibleContent, parseString} from '../StringUtils';
|
||||
|
||||
describe('parseString', () => {
|
||||
it('returns trimmed string within max length', () => {
|
||||
expect(parseString('Hello World', 50)).toBe('Hello World');
|
||||
});
|
||||
it('trims whitespace from the string', () => {
|
||||
expect(parseString(' Hello World ', 50)).toBe('Hello World');
|
||||
});
|
||||
it('truncates string exceeding max length', () => {
|
||||
const result = parseString('This is a very long string that needs truncation', 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain('...');
|
||||
});
|
||||
it('handles exact max length', () => {
|
||||
const result = parseString('12345', 5);
|
||||
expect(result).toBe('12345');
|
||||
});
|
||||
it('decodes HTML entities before processing', () => {
|
||||
expect(parseString('Hello & World', 50)).toBe('Hello & World');
|
||||
expect(parseString('<script>', 50)).toBe('<script>');
|
||||
});
|
||||
it('handles empty string', () => {
|
||||
expect(parseString('', 50)).toBe('');
|
||||
});
|
||||
it('handles string with only whitespace', () => {
|
||||
expect(parseString(' ', 50)).toBe('');
|
||||
});
|
||||
it('handles unicode characters', () => {
|
||||
expect(parseString('Hello \u{1F600} World', 50)).toBe('Hello \u{1F600} World');
|
||||
});
|
||||
it('truncates with ellipsis by default', () => {
|
||||
const result = parseString('This is a test string for truncation', 15);
|
||||
expect(result.endsWith('...')).toBe(true);
|
||||
});
|
||||
it('handles max length of 0 (lodash truncate returns ellipsis)', () => {
|
||||
const result = parseString('Hello', 0);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
it('handles max length of 1 (lodash truncate minimum is ellipsis length)', () => {
|
||||
const result = parseString('Hello', 1);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
it('handles max length of 3 (minimum for truncation)', () => {
|
||||
const result = parseString('Hello', 3);
|
||||
expect(result).toBe('...');
|
||||
});
|
||||
it('handles max length of 4', () => {
|
||||
const result = parseString('Hello', 4);
|
||||
expect(result.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
it('preserves string with special characters', () => {
|
||||
const input = 'Hello\nWorld\tTab';
|
||||
const result = parseString(input, 50);
|
||||
expect(result).toContain('\n');
|
||||
expect(result).toContain('\t');
|
||||
});
|
||||
it('handles combined HTML entities and long strings', () => {
|
||||
const input = '& '.repeat(20);
|
||||
const result = parseString(input, 30);
|
||||
expect(result.length).toBeLessThanOrEqual(30);
|
||||
it.each([
|
||||
{input: 'Hello World', max: 50, expected: 'Hello World'},
|
||||
{input: ' Hello World ', max: 50, expected: 'Hello World'},
|
||||
{input: 'This is a very long string that needs truncation', max: 20, expected: 'This is a very lo...'},
|
||||
{input: '12345', max: 5, expected: '12345'},
|
||||
{input: 'Hello & World', max: 50, expected: 'Hello & World'},
|
||||
{input: '<script>', max: 50, expected: '<script>'},
|
||||
{input: '', max: 50, expected: ''},
|
||||
{input: ' ', max: 50, expected: ''},
|
||||
{input: 'Hello 😀 World', max: 50, expected: 'Hello 😀 World'},
|
||||
{input: 'This is a test string for truncation', max: 15, expected: 'This is a te...'},
|
||||
{input: 'Hello', max: 0, expected: '...'},
|
||||
{input: 'Hello', max: 1, expected: '...'},
|
||||
{input: 'Hello', max: 3, expected: '...'},
|
||||
{input: 'Hello', max: 4, expected: 'H...'},
|
||||
{input: 'Hello\nWorld\tTab', max: 50, expected: 'Hello\nWorld\tTab'},
|
||||
{input: '&&&abc', max: 5, expected: '&&...'},
|
||||
{input: '😀abcd', max: 4, expected: '😀...'},
|
||||
])('decodes, trims, and truncates $input to $expected', ({input, max, expected}) => {
|
||||
expect(parseString(input, max)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasVisibleContent', () => {
|
||||
it('rejects whitespace and invisible-only strings', () => {
|
||||
expect(hasVisibleContent('')).toBe(false);
|
||||
expect(hasVisibleContent(' \t\n')).toBe(false);
|
||||
expect(hasVisibleContent('\u200e \u200b\ufeff')).toBe(false);
|
||||
expect(hasVisibleContent('\u2800\u3164\u{e0100}')).toBe(false);
|
||||
it.each([
|
||||
'',
|
||||
' \t\n',
|
||||
'\u200e \u200b\ufeff',
|
||||
'\u2800\u3164\u{e0100}',
|
||||
])('rejects whitespace or invisible-only input %j', (input) => {
|
||||
expect(hasVisibleContent(input)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows any visible character', () => {
|
||||
expect(hasVisibleContent('hello')).toBe(true);
|
||||
expect(hasVisibleContent('\u200e hello')).toBe(true);
|
||||
expect(hasVisibleContent('🙂')).toBe(true);
|
||||
expect(hasVisibleContent('` `')).toBe(true);
|
||||
it.each(['hello', '\u200e hello', '🙂', '` `'])('accepts visible input %j', (input) => {
|
||||
expect(hasVisibleContent(input)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user