/** * 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); }); });