Skip to content

Commit 7b1a108

Browse files
committed
test(requestBuilder): add property-based tests for edge cases
- Test parameter key validation (valid/invalid characters) - Test header management (accumulation, User-Agent, immutability) - Test value serialization (strings, integers, booleans, arrays) - Test deep object nesting within depth limits - Test body type handling for all supported types PBT improves coverage of boundary conditions and error paths.
1 parent cb062c4 commit 7b1a108

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed

src/requestBuilder.test.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { fc, test as fcTest } from '@fast-check/vitest';
12
import { http, HttpResponse } from 'msw';
23
import { server } from '../mocks/node';
34
import { type HttpExecuteConfig, type JsonObject, ParameterLocation } from './types';
@@ -522,3 +523,216 @@ describe('RequestBuilder', () => {
522523
});
523524
});
524525
});
526+
527+
describe('RequestBuilder - Property-Based Tests', () => {
528+
const baseConfig = {
529+
kind: 'http',
530+
method: 'GET',
531+
url: 'https://api.example.com/test',
532+
bodyType: 'json',
533+
params: [{ name: 'filter', location: ParameterLocation.QUERY, type: 'object' }],
534+
} satisfies HttpExecuteConfig;
535+
536+
// Arbitrary for valid parameter keys (alphanumeric, underscore, dot, hyphen)
537+
const validKeyArbitrary = fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9_.-]{0,19}$/);
538+
539+
// Arbitrary for invalid parameter keys (contains spaces or special chars)
540+
const invalidKeyArbitrary = fc
541+
.string({ minLength: 1, maxLength: 20 })
542+
.filter((s) => /[^a-zA-Z0-9_.-]/.test(s) && s.trim().length > 0);
543+
544+
describe('Parameter Key Validation', () => {
545+
fcTest.prop([validKeyArbitrary, fc.string()], { numRuns: 100 })(
546+
'accepts valid parameter keys',
547+
async (key, value) => {
548+
const builder = new RequestBuilder(baseConfig);
549+
const params = {
550+
filter: { [key]: value },
551+
};
552+
553+
// Should not throw for valid keys
554+
const result = await builder.execute(params, { dryRun: true });
555+
expect(result.url).toBeDefined();
556+
},
557+
);
558+
559+
fcTest.prop([invalidKeyArbitrary, fc.string()], { numRuns: 100 })(
560+
'rejects invalid parameter keys',
561+
async (key, value) => {
562+
const builder = new RequestBuilder(baseConfig);
563+
const params = {
564+
filter: { [key]: value },
565+
};
566+
567+
await expect(builder.execute(params, { dryRun: true })).rejects.toThrow(
568+
/Invalid parameter key/,
569+
);
570+
},
571+
);
572+
});
573+
574+
describe('Header Management', () => {
575+
// Arbitrary for header key-value pairs
576+
const headerArbitrary = fc.dictionary(
577+
fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9-]{0,29}$/),
578+
fc.string({ minLength: 1, maxLength: 100 }),
579+
{ minKeys: 1, maxKeys: 5 },
580+
);
581+
582+
fcTest.prop([headerArbitrary, headerArbitrary], { numRuns: 50 })(
583+
'setHeaders accumulates headers without losing existing ones',
584+
(headers1, headers2) => {
585+
const builder = new RequestBuilder(baseConfig, headers1);
586+
builder.setHeaders(headers2);
587+
588+
const result = builder.getHeaders();
589+
590+
// All headers from both sets should be present (with headers2 overriding duplicates)
591+
for (const [key, value] of Object.entries(headers2)) {
592+
expect(result[key]).toBe(value);
593+
}
594+
for (const [key, value] of Object.entries(headers1)) {
595+
if (!(key in headers2)) {
596+
expect(result[key]).toBe(value);
597+
}
598+
}
599+
},
600+
);
601+
602+
fcTest.prop([headerArbitrary], { numRuns: 50 })(
603+
'prepareHeaders always includes User-Agent',
604+
(headers) => {
605+
const builder = new RequestBuilder(baseConfig, headers);
606+
const prepared = builder.prepareHeaders();
607+
608+
expect(prepared['User-Agent']).toBe('stackone-ai-node');
609+
},
610+
);
611+
612+
fcTest.prop([headerArbitrary], { numRuns: 50 })(
613+
'getHeaders returns a copy, not the original',
614+
(headers) => {
615+
const builder = new RequestBuilder(baseConfig, headers);
616+
const retrieved = builder.getHeaders();
617+
618+
// Mutating the returned object should not affect internal state
619+
retrieved['Mutated-Header'] = 'mutated';
620+
621+
expect(builder.getHeaders()['Mutated-Header']).toBeUndefined();
622+
},
623+
);
624+
});
625+
626+
describe('Value Serialization', () => {
627+
fcTest.prop([fc.string()], { numRuns: 100 })(
628+
'string values serialize to themselves',
629+
async (str) => {
630+
const builder = new RequestBuilder(baseConfig);
631+
const params = { filter: { key: str } };
632+
633+
const result = await builder.execute(params, { dryRun: true });
634+
const url = new URL(result.url as string);
635+
636+
expect(url.searchParams.get('filter[key]')).toBe(str);
637+
},
638+
);
639+
640+
fcTest.prop([fc.integer()], { numRuns: 100 })(
641+
'integer values serialize to string',
642+
async (num) => {
643+
const builder = new RequestBuilder(baseConfig);
644+
const params = { filter: { key: num } };
645+
646+
const result = await builder.execute(params, { dryRun: true });
647+
const url = new URL(result.url as string);
648+
649+
expect(url.searchParams.get('filter[key]')).toBe(String(num));
650+
},
651+
);
652+
653+
fcTest.prop([fc.boolean()], { numRuns: 10 })(
654+
'boolean values serialize to string',
655+
async (bool) => {
656+
const builder = new RequestBuilder(baseConfig);
657+
const params = { filter: { key: bool } };
658+
659+
const result = await builder.execute(params, { dryRun: true });
660+
const url = new URL(result.url as string);
661+
662+
expect(url.searchParams.get('filter[key]')).toBe(String(bool));
663+
},
664+
);
665+
666+
fcTest.prop(
667+
[fc.array(fc.oneof(fc.string(), fc.integer(), fc.boolean()), { minLength: 1, maxLength: 5 })],
668+
{
669+
numRuns: 50,
670+
},
671+
)('arrays serialize to JSON string', async (arr) => {
672+
const builder = new RequestBuilder(baseConfig);
673+
const params = { filter: { key: arr } };
674+
675+
const result = await builder.execute(params, { dryRun: true });
676+
const url = new URL(result.url as string);
677+
678+
expect(url.searchParams.get('filter[key]')).toBe(JSON.stringify(arr));
679+
});
680+
});
681+
682+
describe('Deep Object Nesting', () => {
683+
fcTest.prop([fc.integer({ min: 1, max: 9 })], { numRuns: 20 })(
684+
'accepts objects within depth limit',
685+
async (depth) => {
686+
const builder = new RequestBuilder(baseConfig);
687+
const deepObject = {};
688+
let current: Record<string, unknown> = deepObject;
689+
for (let i = 0; i < depth; i++) {
690+
current.nested = {};
691+
current = current.nested as Record<string, unknown>;
692+
}
693+
current.value = 'test';
694+
695+
const params = { filter: deepObject } as JsonObject;
696+
const result = await builder.execute(params, { dryRun: true });
697+
698+
expect(result.url).toBeDefined();
699+
},
700+
);
701+
702+
test('rejects objects exceeding depth limit of 10', async () => {
703+
const builder = new RequestBuilder(baseConfig);
704+
let deepObject: Record<string, unknown> = { value: 'test' };
705+
for (let i = 0; i < 12; i++) {
706+
deepObject = { nested: deepObject };
707+
}
708+
709+
const params = { filter: deepObject } as JsonObject;
710+
711+
await expect(builder.execute(params, { dryRun: true })).rejects.toThrow(
712+
/Maximum nesting depth.*exceeded/,
713+
);
714+
});
715+
});
716+
717+
describe('Body Type Handling', () => {
718+
const bodyTypes = ['json', 'form', 'multipart-form'] as const;
719+
720+
fcTest.prop(
721+
[
722+
fc.constantFrom(...bodyTypes),
723+
fc.dictionary(fc.string(), fc.string(), { minKeys: 1, maxKeys: 3 }),
724+
],
725+
{
726+
numRuns: 30,
727+
},
728+
)('all valid body types produce valid fetch options', (bodyType, bodyParams) => {
729+
const config = { ...baseConfig, bodyType };
730+
const builder = new RequestBuilder(config);
731+
732+
const options = builder.buildFetchOptions(bodyParams);
733+
734+
expect(options.method).toBe('GET');
735+
expect(options.body).toBeDefined();
736+
});
737+
});
738+
});

0 commit comments

Comments
 (0)