test(24-05): add failing test for route53-change-submit
- ChangeBatch construction (CREATE/UPSERT/DELETE, SetIdentifier omission, NS/SOA guard) - isRetryableAwsError classification - submitRecordChange retry-with-backoff behavior - pollChangeStatus bounded polling (INSYNC / timeout / per-attempt error swallow)
This commit is contained in:
parent
2b009573c4
commit
9a9e691cd4
1 changed files with 208 additions and 0 deletions
208
lib/services/route53-change-submit.test.ts
Normal file
208
lib/services/route53-change-submit.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* lib/services/route53-change-submit.ts unit tests — ChangeBatch construction,
|
||||
* retryable-error classification, and bounded GetChange polling. No AWS SDK
|
||||
* mocking library, no network — pollChangeStatus is exercised with a
|
||||
* hand-rolled fake client exposing `send()`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
buildChangeBatch,
|
||||
isRetryableAwsError,
|
||||
submitRecordChange,
|
||||
pollChangeStatus,
|
||||
} from './route53-change-submit';
|
||||
|
||||
describe('buildChangeBatch', () => {
|
||||
const baseRecordSet = {
|
||||
name: 'www.example.com.',
|
||||
type: 'A',
|
||||
ttl: 300,
|
||||
resourceRecords: [{ value: '1.2.3.4' }],
|
||||
};
|
||||
|
||||
it('produces the expected ChangeBatch shape for UPSERT', () => {
|
||||
const batch = buildChangeBatch('UPSERT', baseRecordSet);
|
||||
expect(batch).toEqual({
|
||||
Changes: [
|
||||
{
|
||||
Action: 'UPSERT',
|
||||
ResourceRecordSet: {
|
||||
Name: 'www.example.com.',
|
||||
Type: 'A',
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: '1.2.3.4' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('sets Action to CREATE for a CREATE action', () => {
|
||||
const batch = buildChangeBatch('CREATE', baseRecordSet);
|
||||
expect(batch.Changes?.[0].Action).toBe('CREATE');
|
||||
});
|
||||
|
||||
it('sets Action to DELETE for a DELETE action', () => {
|
||||
const batch = buildChangeBatch('DELETE', baseRecordSet);
|
||||
expect(batch.Changes?.[0].Action).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('emits SetIdentifier when present on the input', () => {
|
||||
const batch = buildChangeBatch('UPSERT', { ...baseRecordSet, setIdentifier: 'primary' });
|
||||
expect(batch.Changes?.[0].ResourceRecordSet?.SetIdentifier).toBe('primary');
|
||||
});
|
||||
|
||||
it('omits SetIdentifier entirely when null/undefined rather than emitting undefined', () => {
|
||||
const batchNull = buildChangeBatch('UPSERT', { ...baseRecordSet, setIdentifier: null });
|
||||
expect('SetIdentifier' in (batchNull.Changes?.[0].ResourceRecordSet ?? {})).toBe(false);
|
||||
|
||||
const batchUndefined = buildChangeBatch('UPSERT', baseRecordSet);
|
||||
expect('SetIdentifier' in (batchUndefined.Changes?.[0].ResourceRecordSet ?? {})).toBe(false);
|
||||
});
|
||||
|
||||
it('emits the full TTL and complete ResourceRecords array for a DELETE from supplied current state', () => {
|
||||
const batch = buildChangeBatch('DELETE', {
|
||||
...baseRecordSet,
|
||||
ttl: 600,
|
||||
resourceRecords: [{ value: '1.2.3.4' }, { value: '5.6.7.8' }],
|
||||
});
|
||||
const rrs = batch.Changes?.[0].ResourceRecordSet;
|
||||
expect(rrs?.TTL).toBe(600);
|
||||
expect(rrs?.ResourceRecords).toEqual([{ Value: '1.2.3.4' }, { Value: '5.6.7.8' }]);
|
||||
});
|
||||
|
||||
it('throws when given record type NS', () => {
|
||||
expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'NS' })).toThrow(/NS/);
|
||||
});
|
||||
|
||||
it('throws when given record type SOA (case-insensitive)', () => {
|
||||
expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'soa' })).toThrow(/SOA/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRetryableAwsError', () => {
|
||||
it.each(['ThrottlingException', 'PriorRequestNotComplete', 'Throttling', 'ServiceUnavailable'])(
|
||||
'returns true for %s',
|
||||
(name) => {
|
||||
const err = new Error('boom');
|
||||
err.name = name;
|
||||
expect(isRetryableAwsError(err)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns false for InvalidChangeBatch', () => {
|
||||
const err = new Error('boom');
|
||||
err.name = 'InvalidChangeBatch';
|
||||
expect(isRetryableAwsError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a non-Error input', () => {
|
||||
expect(isRetryableAwsError('not an error')).toBe(false);
|
||||
expect(isRetryableAwsError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitRecordChange', () => {
|
||||
const recordSet = {
|
||||
name: 'www.example.com.',
|
||||
type: 'A',
|
||||
ttl: 300,
|
||||
resourceRecords: [{ value: '1.2.3.4' }],
|
||||
};
|
||||
|
||||
it('sends a ChangeResourceRecordSetsCommand and returns the change id', async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
ChangeInfo: { Id: '/change/C123', Status: 'PENDING' },
|
||||
});
|
||||
const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];
|
||||
|
||||
const result = await submitRecordChange({
|
||||
zoneId: 'Z123',
|
||||
action: 'UPSERT',
|
||||
recordSet,
|
||||
client: fakeClient,
|
||||
});
|
||||
|
||||
expect(result.changeId).toBe('/change/C123');
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries up to 2 additional times on a retryable error, then succeeds', async () => {
|
||||
const throttling = new Error('throttled');
|
||||
throttling.name = 'ThrottlingException';
|
||||
const send = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(throttling)
|
||||
.mockRejectedValueOnce(throttling)
|
||||
.mockResolvedValue({ ChangeInfo: { Id: '/change/C456', Status: 'PENDING' } });
|
||||
const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];
|
||||
|
||||
const result = await submitRecordChange({
|
||||
zoneId: 'Z123',
|
||||
action: 'UPSERT',
|
||||
recordSet,
|
||||
client: fakeClient,
|
||||
});
|
||||
|
||||
expect(result.changeId).toBe('/change/C456');
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
}, 10000);
|
||||
|
||||
it('rethrows immediately on a non-retryable error without retrying', async () => {
|
||||
const invalid = new Error('invalid batch');
|
||||
invalid.name = 'InvalidChangeBatch';
|
||||
const send = vi.fn().mockRejectedValue(invalid);
|
||||
const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];
|
||||
|
||||
await expect(
|
||||
submitRecordChange({ zoneId: 'Z123', action: 'UPSERT', recordSet, client: fakeClient })
|
||||
).rejects.toThrow('invalid batch');
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pollChangeStatus', () => {
|
||||
it('returns INSYNC as soon as the client reports ChangeInfo.Status === INSYNC', async () => {
|
||||
const send = vi.fn().mockResolvedValue({ ChangeInfo: { Status: 'INSYNC' } });
|
||||
const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
|
||||
| { client?: infer C }
|
||||
| undefined
|
||||
? C
|
||||
: never;
|
||||
|
||||
const status = await pollChangeStatus('/change/C1', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
|
||||
expect(status).toBe('INSYNC');
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns PENDING once the timeout budget elapses without an INSYNC answer, and stops calling send() after returning', async () => {
|
||||
const send = vi.fn().mockResolvedValue({ ChangeInfo: { Status: 'PENDING' } });
|
||||
const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
|
||||
| { client?: infer C }
|
||||
| undefined
|
||||
? C
|
||||
: never;
|
||||
|
||||
const status = await pollChangeStatus('/change/C2', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
|
||||
expect(status).toBe('PENDING');
|
||||
|
||||
const countAfterReturn = send.mock.calls.length;
|
||||
// Wait longer than the timeout budget to confirm no further calls happen.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(send.mock.calls.length).toBe(countAfterReturn);
|
||||
});
|
||||
|
||||
it('swallows a per-attempt GetChange error and keeps polling until the budget elapses', async () => {
|
||||
const send = vi.fn().mockRejectedValue(new Error('transient network blip'));
|
||||
const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
|
||||
| { client?: infer C }
|
||||
| undefined
|
||||
? C
|
||||
: never;
|
||||
|
||||
const status = await pollChangeStatus('/change/C3', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
|
||||
expect(status).toBe('PENDING');
|
||||
expect(send.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue