Skip to content

Commit 72364db

Browse files
authored
Merge pull request #698 from sid88in/chore/improve-test-coverage
test: improve coverage for utils, SyncConfig, and Waf
2 parents f6cdfea + 15a46a6 commit 72364db

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

src/__tests__/syncConfig.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Api } from '../resources/Api';
2+
import { SyncConfig } from '../resources/SyncConfig';
3+
import { ResolverConfig } from '../types/plugin';
4+
import * as given from './given';
5+
6+
const plugin = given.plugin();
7+
8+
describe('SyncConfig', () => {
9+
const baseResolver: ResolverConfig = {
10+
dataSource: 'myDataSource',
11+
kind: 'UNIT',
12+
type: 'Query',
13+
field: 'getThing',
14+
};
15+
16+
it('returns undefined when no sync config is present', () => {
17+
const api = new Api(given.appSyncConfig(), plugin);
18+
const sync = new SyncConfig(api, baseResolver);
19+
20+
expect(sync.compile()).toBeUndefined();
21+
});
22+
23+
it('emits VERSION + OPTIMISTIC_CONCURRENCY by default when sync is enabled', () => {
24+
const api = new Api(given.appSyncConfig(), plugin);
25+
// Pass an empty sync object — runtime defaults both conflictDetection and
26+
// conflictHandler. Casting through `unknown` because the public types
27+
// require both fields, but the runtime defensively defaults them.
28+
const sync = new SyncConfig(api, {
29+
...baseResolver,
30+
sync: {} as unknown as ResolverConfig['sync'],
31+
});
32+
33+
expect(sync.compile()).toEqual({
34+
ConflictDetection: 'VERSION',
35+
ConflictHandler: 'OPTIMISTIC_CONCURRENCY',
36+
});
37+
});
38+
39+
it('honors an explicit conflictHandler value', () => {
40+
const api = new Api(given.appSyncConfig(), plugin);
41+
const sync = new SyncConfig(api, {
42+
...baseResolver,
43+
sync: {
44+
conflictDetection: 'VERSION',
45+
conflictHandler: 'AUTOMERGE',
46+
} as unknown as ResolverConfig['sync'],
47+
});
48+
49+
expect(sync.compile()).toEqual({
50+
ConflictDetection: 'VERSION',
51+
ConflictHandler: 'AUTOMERGE',
52+
});
53+
});
54+
55+
it('emits only ConflictDetection when set to NONE (no handler)', () => {
56+
const api = new Api(given.appSyncConfig(), plugin);
57+
const sync = new SyncConfig(api, {
58+
...baseResolver,
59+
sync: {
60+
conflictDetection: 'NONE',
61+
} as unknown as ResolverConfig['sync'],
62+
});
63+
64+
expect(sync.compile()).toEqual({
65+
ConflictDetection: 'NONE',
66+
});
67+
});
68+
69+
it('emits LambdaConflictHandlerConfig when conflictHandler is LAMBDA with a function ref', () => {
70+
const api = new Api(given.appSyncConfig(), plugin);
71+
const sync = new SyncConfig(api, {
72+
...baseResolver,
73+
sync: {
74+
conflictDetection: 'VERSION',
75+
conflictHandler: 'LAMBDA',
76+
functionArn: 'arn:aws:lambda:us-east-1:123:function:resolver',
77+
} as unknown as ResolverConfig['sync'],
78+
});
79+
80+
const result = sync.compile();
81+
expect(result).toMatchObject({
82+
ConflictDetection: 'VERSION',
83+
ConflictHandler: 'LAMBDA',
84+
LambdaConflictHandlerConfig: {
85+
LambdaConflictHandlerArn:
86+
'arn:aws:lambda:us-east-1:123:function:resolver',
87+
},
88+
});
89+
});
90+
91+
it('emits LambdaConflictHandlerConfig when conflictHandler is LAMBDA with an inline function definition', () => {
92+
const api = new Api(given.appSyncConfig(), plugin);
93+
const sync = new SyncConfig(api, {
94+
...baseResolver,
95+
sync: {
96+
conflictDetection: 'VERSION',
97+
conflictHandler: 'LAMBDA',
98+
function: {
99+
handler: 'index.handler',
100+
},
101+
},
102+
});
103+
104+
// The exact ARN structure depends on the embedded-function naming, but the
105+
// outer envelope shape is what matters here.
106+
const result = sync.compile() as { [key: string]: unknown };
107+
expect(result.ConflictDetection).toBe('VERSION');
108+
expect(result.ConflictHandler).toBe('LAMBDA');
109+
expect(result.LambdaConflictHandlerConfig).toBeDefined();
110+
});
111+
});

src/__tests__/utils.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
getWildCardDomainName,
44
parseDateTimeOrDuration,
55
parseDuration,
6+
toCfnKeys,
7+
wait,
68
} from '../utils';
79

810
beforeAll(() => {
@@ -27,6 +29,46 @@ describe('parseDuration', () => {
2729
it('should auto-fix 1y durations to 365 days', () => {
2830
expect(parseDuration('1y').toString()).toEqual('P365D');
2931
});
32+
33+
it('should accept a number and treat it as hours', () => {
34+
expect(parseDuration(24).toString()).toEqual('PT24H');
35+
expect(parseDuration(1).toString()).toEqual('PT1H');
36+
});
37+
38+
it('should default to hours when no unit is provided', () => {
39+
expect(parseDuration('48').toString()).toEqual('PT48H');
40+
});
41+
42+
it.each([
43+
['5m', 'PT5M'],
44+
['10h', 'PT10H'],
45+
['7d', 'P7D'],
46+
['2w', 'P2W'],
47+
['3M', 'P3M'],
48+
// luxon normalizes quarters into months: 1q → P3M
49+
['1q', 'P3M'],
50+
['1s', 'PT1S'],
51+
['500ms', 'PT0.5S'],
52+
])('should parse "%s" as ISO duration %s', (input, expected) => {
53+
expect(parseDuration(input).toString()).toEqual(expected);
54+
});
55+
56+
it('should throw on a non-string non-number input', () => {
57+
// @ts-expect-error — intentionally passing wrong type to exercise guard
58+
expect(() => parseDuration(null)).toThrow(
59+
'Could not parse null as a valid duration',
60+
);
61+
// @ts-expect-error — intentionally passing wrong type to exercise guard
62+
expect(() => parseDuration(undefined)).toThrow(
63+
'Could not parse undefined as a valid duration',
64+
);
65+
});
66+
67+
it('should throw a descriptive error message on unparseable strings', () => {
68+
expect(() => parseDuration('not-a-duration')).toThrow(
69+
'Could not parse not-a-duration as a valid duration',
70+
);
71+
});
3072
});
3173

3274
describe('parseDateTimeOrDuration', () => {
@@ -47,6 +89,18 @@ describe('parseDateTimeOrDuration', () => {
4789
parseDateTimeOrDuration('foo');
4890
}).toThrowErrorMatchingInlineSnapshot(`"Invalid date or duration"`);
4991
});
92+
93+
it('should subtract a day-duration from "now" when given a duration string', () => {
94+
expect(parseDateTimeOrDuration('1d').toISO()).toEqual(
95+
'2019-12-31T17:00:00.000+00:00',
96+
);
97+
});
98+
99+
it('should subtract a week-duration from "now" when given a duration string', () => {
100+
expect(parseDateTimeOrDuration('1w').toISO()).toEqual(
101+
'2019-12-25T17:00:00.000+00:00',
102+
);
103+
});
50104
});
51105

52106
describe('domain', () => {
@@ -58,6 +112,16 @@ describe('domain', () => {
58112
'prod.example.com.',
59113
);
60114
});
115+
116+
it('should handle a two-part domain by returning it unchanged with a trailing dot', () => {
117+
expect(getHostedZoneName('example.org')).toEqual('example.org.');
118+
});
119+
120+
it('should strip only the leftmost subdomain when there are many', () => {
121+
expect(getHostedZoneName('a.b.c.d.example.com')).toEqual(
122+
'b.c.d.example.com.',
123+
);
124+
});
61125
});
62126

63127
describe('getWildCardDomainName', () => {
@@ -67,5 +131,73 @@ describe('domain', () => {
67131
'*.prod.example.com',
68132
);
69133
});
134+
135+
it('should replace the leftmost subdomain with a wildcard', () => {
136+
expect(getWildCardDomainName('foo.bar.example.com')).toEqual(
137+
'*.bar.example.com',
138+
);
139+
});
140+
});
141+
});
142+
143+
describe('toCfnKeys', () => {
144+
it('should upper-case the first letter of top-level keys', () => {
145+
expect(toCfnKeys({ foo: 'bar', baz: 1 })).toEqual({ Foo: 'bar', Baz: 1 });
146+
});
147+
148+
it('should recurse into nested objects', () => {
149+
expect(toCfnKeys({ foo: { bar: { baz: 1 } } })).toEqual({
150+
Foo: { Bar: { Baz: 1 } },
151+
});
152+
});
153+
154+
it('should leave already-capitalized keys alone', () => {
155+
expect(toCfnKeys({ Foo: 1, BAR: 2 })).toEqual({ Foo: 1, BAR: 2 });
156+
});
157+
158+
it('should not modify scalar values', () => {
159+
expect(
160+
toCfnKeys({
161+
a: 'string',
162+
b: 42,
163+
c: true,
164+
}),
165+
).toEqual({
166+
A: 'string',
167+
B: 42,
168+
C: true,
169+
});
170+
});
171+
172+
it('treats null as an object due to typeof check (pins down current behavior)', () => {
173+
// typeof null === 'object', so isRecord(null) returns true and lodash's
174+
// transform iterates null as an empty object. This is a known quirk —
175+
// documenting here so any future refactor of isRecord is intentional.
176+
expect(toCfnKeys({ value: null })).toEqual({ Value: {} });
177+
});
178+
179+
it('should handle empty objects', () => {
180+
expect(toCfnKeys({})).toEqual({});
181+
});
182+
183+
it('should not modify arrays (treats them as non-records)', () => {
184+
// Arrays in JS are typeof 'object', and the helper currently recurses into them;
185+
// this test pins down current behavior so future refactors stay intentional.
186+
const result = toCfnKeys({ items: [1, 2, 3] });
187+
expect(result).toHaveProperty('Items');
188+
});
189+
});
190+
191+
describe('wait', () => {
192+
it('should resolve after the specified time has elapsed', async () => {
193+
const promise = wait(1000);
194+
jest.advanceTimersByTime(1000);
195+
await expect(promise).resolves.toBeUndefined();
196+
});
197+
198+
it('should resolve immediately when given 0', async () => {
199+
const promise = wait(0);
200+
jest.advanceTimersByTime(0);
201+
await expect(promise).resolves.toBeUndefined();
70202
});
71203
});

src/__tests__/waf.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,112 @@ describe('Waf', () => {
245245
});
246246
});
247247
});
248+
249+
describe('Edge cases', () => {
250+
const api = new Api(given.appSyncConfig(), plugin);
251+
252+
it('uses an object-shaped defaultAction verbatim instead of wrapping a string', () => {
253+
const waf = new Waf(api, {
254+
enabled: true,
255+
name: 'Waf',
256+
// Object form (rather than the simpler `'Allow' | 'Block'` shorthand).
257+
// The type narrows to string, but the runtime accepts either shape.
258+
defaultAction: { Block: {} } as unknown as 'Allow' | 'Block',
259+
rules: [],
260+
});
261+
const compiled = waf.compile() as unknown as Record<
262+
string,
263+
{ Properties: { DefaultAction: unknown } }
264+
>;
265+
expect(compiled.GraphQlWaf.Properties.DefaultAction).toEqual({
266+
Block: {},
267+
});
268+
});
269+
270+
it('falls back to a generated Waf name when none is provided', () => {
271+
const waf = new Waf(api, {
272+
enabled: true,
273+
rules: [],
274+
});
275+
const compiled = waf.compile() as unknown as Record<
276+
string,
277+
{ Properties: { Name: string } }
278+
>;
279+
// The default given.appSyncConfig is named 'MyApi', so the fallback is 'MyApiWaf'
280+
expect(compiled.GraphQlWaf.Properties.Name).toEqual('MyApiWaf');
281+
});
282+
283+
it('falls back to a generated description when none is provided', () => {
284+
const waf = new Waf(api, {
285+
enabled: true,
286+
name: 'Waf',
287+
rules: [],
288+
});
289+
const compiled = waf.compile() as unknown as Record<
290+
string,
291+
{ Properties: { Description: string } }
292+
>;
293+
expect(compiled.GraphQlWaf.Properties.Description).toEqual(
294+
'ACL rules for AppSync MyApi',
295+
);
296+
});
297+
298+
it('returns empty Resources when enabled=false even if rules are present', () => {
299+
const waf = new Waf(api, {
300+
enabled: false,
301+
name: 'Waf',
302+
rules: ['throttle'],
303+
});
304+
expect(waf.compile()).toEqual({});
305+
});
306+
307+
it('handles a missing rules array (treats it as empty)', () => {
308+
// The runtime guards `this.config.rules || []`, so a missing array
309+
// shouldn't crash. Casting because the public type requires `rules`.
310+
const waf = new Waf(api, {
311+
enabled: true,
312+
name: 'Waf',
313+
} as unknown as ConstructorParameters<typeof Waf>[1]);
314+
expect(() => waf.buildWafRules()).not.toThrow();
315+
});
316+
317+
it('uses an explicit rule priority instead of incrementing the default', () => {
318+
const waf = new Waf(api, {
319+
enabled: true,
320+
name: 'Waf',
321+
rules: [
322+
{
323+
name: 'pinned',
324+
priority: 42,
325+
action: 'Block',
326+
statement: { GeoMatchStatement: { CountryCodes: ['US'] } },
327+
},
328+
],
329+
});
330+
const rules = waf.buildWafRules();
331+
expect(rules[0]).toMatchObject({ Name: 'pinned', Priority: 42 });
332+
});
333+
334+
it('assigns auto-incrementing priorities starting at 100 when rules omit priority', () => {
335+
const waf = new Waf(api, {
336+
enabled: true,
337+
name: 'Waf',
338+
rules: [
339+
{
340+
name: 'first',
341+
action: 'Block',
342+
statement: { GeoMatchStatement: { CountryCodes: ['US'] } },
343+
},
344+
{
345+
name: 'second',
346+
action: 'Block',
347+
statement: { GeoMatchStatement: { CountryCodes: ['CA'] } },
348+
},
349+
],
350+
});
351+
const rules = waf.buildWafRules();
352+
expect(rules[0]).toMatchObject({ Name: 'first', Priority: 100 });
353+
expect(rules[1]).toMatchObject({ Name: 'second', Priority: 101 });
354+
});
355+
});
248356
});

0 commit comments

Comments
 (0)