- 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)
208 lines
7.3 KiB
TypeScript
208 lines
7.3 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|