From 9a9e691cd4ec99dae51eb8b653bebf62a2e441c0 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:29:57 -0400 Subject: [PATCH] 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) --- lib/services/route53-change-submit.test.ts | 208 +++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 lib/services/route53-change-submit.test.ts diff --git a/lib/services/route53-change-submit.test.ts b/lib/services/route53-change-submit.test.ts new file mode 100644 index 0000000..e911289 --- /dev/null +++ b/lib/services/route53-change-submit.test.ts @@ -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[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[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[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[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[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[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); + }); +});