import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)), })); import { AutodnsRateLimiter } from './autodns-client'; import { DomainsOrdersService } from './domains-orders.service'; /** * Das Netz ist hier NUR `fetchImpl`: die Tests pruefen die tatsaechlich * gesendete Anfrage (Methode, genaue Adresse, Kopfzeilen, Rumpf) mit Literalen * — nie mit Werten, die der Produktivcode selbst baut. */ interface Call { url: string; init: { method: string; headers: Record; body?: string; redirect: string }; } type Responder = (call: Call) => Promise | unknown; function reply(status: number, body: unknown) { return { status, headers: { get: () => null }, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), }; } function envelope( data: unknown[], extra: { type?: string; object?: unknown; messages?: unknown[] } = {}, ) { return { status: { code: 'S0000', text: 'ok', type: extra.type ?? 'SUCCESS' }, stid: 'stid-1', object: extra.object ?? { type: 'x', value: null, summary: data.length }, messages: extra.messages ?? [], data, }; } const CREDENTIALS_DEMO = { environment: 'DEMO', user: 'api-user', password: 'geheim', context: 4 }; const CREDENTIALS_LIVE = { environment: 'LIVE', user: 'api-user', password: 'geheim', context: 4 }; interface OrderRow { id: string; tenantId: string; environment: 'DEMO' | 'LIVE'; domainName: string; openKey: string | null; status: string; payload: any; jobId: string | null; jobStatus: string | null; errorText: string | null; createdByUserId: string; confirmedByUserId: string | null; confirmedByUsername: string | null; confirmedAt: Date | null; lastCheckedAt: Date | null; createdAt: Date; updatedAt: Date; } function matches(row: Record, where: Record = {}): boolean { return Object.entries(where).every(([key, expected]) => { if (expected && typeof expected === 'object' && !(expected instanceof Date)) { if ('in' in expected) return (expected.in as unknown[]).includes(row[key]); if ('not' in expected) return row[key] !== expected.not; } return row[key] === expected; }); } const PAYLOAD = { ownerContactId: 11, adminContactId: 11, techContactId: 22, zoneContactId: 22, nameServers: ['ns1.example.com', 'ns2.example.com'], periodYears: 1, price: { amount: 4.9, currency: 'EUR' }, availabilityCheckedAt: '2026-10-08T10:00:00.000Z', }; function makeHarness(opts: { environment?: 'DEMO' | 'LIVE'; timeoutMs?: number } = {}) { const state = { orders: [] as OrderRow[], environment: opts.environment ?? ('DEMO' as 'DEMO' | 'LIVE'), nextId: 1, createConflict: false, readBarrier: null as null | { remaining: number; release: () => void; gate: Promise }, }; const calls: Call[] = []; let responder: Responder = () => { throw new Error('kein Netz erwartet'); }; const fetchImpl = vi.fn(async (url: string, init: any) => { const call: Call = { url, init }; calls.push(call); return (await responder(call)) as never; }); const orderApi = { findFirst: vi.fn(async ({ where }: any) => { const found = state.orders.find((o) => matches(o, where)) ?? null; const snapshot = found ? { ...found } : null; if (state.readBarrier) { const barrier = state.readBarrier; barrier.remaining -= 1; if (barrier.remaining <= 0) barrier.release(); await barrier.gate; } return snapshot; }), findMany: vi.fn(async ({ where, orderBy, take }: any) => { let rows = state.orders.filter((o) => matches(o, where)).map((o) => ({ ...o })); if (orderBy?.createdAt === 'desc') { rows = rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } if (orderBy?.lastCheckedAt) { rows = rows.sort( (a, b) => (a.lastCheckedAt?.getTime() ?? -1) - (b.lastCheckedAt?.getTime() ?? -1), ); } return typeof take === 'number' ? rows.slice(0, take) : rows; }), create: vi.fn(async ({ data }: any) => { if (state.createConflict) throw Object.assign(new Error('unique'), { code: 'P2002' }); const row: OrderRow = { id: `o${state.nextId++}`, jobId: null, jobStatus: null, errorText: null, confirmedByUserId: null, confirmedByUsername: null, confirmedAt: null, lastCheckedAt: null, createdAt: new Date(Date.now() + state.nextId), updatedAt: new Date(), ...data, }; state.orders.push(row); return { ...row }; }), // Wie Postgres: prueft die Bedingung gegen den AKTUELLEN Zeilenstand, synchron. updateMany: vi.fn(async ({ where, data }: any) => { const rows = state.orders.filter((o) => matches(o, where)); for (const r of rows) Object.assign(r, data); return { count: rows.length }; }), }; const db = { __bound: () => ({ domainsOrder: orderApi }) }; const limiter = new AutodnsRateLimiter(0); const settings = { transport: { fetchImpl, limiter, timeoutMs: opts.timeoutMs }, getStatus: vi.fn(async () => ({ environment: state.environment })), getActiveCredentials: vi.fn(async () => ({ environment: state.environment, credentials: state.environment === 'DEMO' ? CREDENTIALS_DEMO : CREDENTIALS_LIVE, configVersion: 1, })), }; const contacts = [ { id: 11, displayName: 'Muster GmbH' }, { id: 22, displayName: 'Erika Muster' }, ]; const directory = { findContactsByIds: vi.fn(async (_t: string, ids: number[]) => contacts.filter((c) => ids.includes(c.id)), ), invalidate: vi.fn(), }; const service = new DomainsOrdersService(db as any, settings as any, directory as any); const addOrder = (patch: Partial = {}): OrderRow => { const row: OrderRow = { id: `o${state.nextId++}`, tenantId: 't1', environment: 'DEMO', domainName: 'beispiel.de', openKey: 'beispiel.de', status: 'DRAFT', payload: { ...PAYLOAD }, jobId: null, jobStatus: null, errorText: null, createdByUserId: 'u1', confirmedByUserId: null, confirmedByUsername: null, confirmedAt: null, lastCheckedAt: null, createdAt: new Date(), updatedAt: new Date(), ...patch, }; state.orders.push(row); return row; }; return { service, state, calls, fetchImpl, orderApi, settings, directory, addOrder, respond: (r: Responder) => { responder = r; }, }; } function studioReply(domain: string, status: string, prices?: unknown[]) { return reply( 200, envelope([ { domain, services: { whois: { data: { status } }, ...(prices ? { price: { data: { prices } } } : {}), }, }, ]), ); } const USER = { id: 'u1', username: 'anna' }; const EXPECTED_SUBMIT_BODY = { name: 'beispiel.de', period: { unit: 'YEAR', period: 1 }, ownerc: { id: 11 }, adminc: { id: 11 }, techc: { id: 22 }, zonec: { id: 22 }, nameServers: [{ name: 'ns1.example.com' }, { name: 'ns2.example.com' }], }; beforeEach(() => { vi.useRealTimers(); }); describe('checkAvailability', () => { it('lehnt einen ungueltigen Namen ab, ohne das Netz zu beruehren', async () => { const h = makeHarness(); await expect(h.service.checkAvailability('t1', 'bei spiel')).rejects.toMatchObject({ response: { code: 'invalidDomain' }, }); expect(h.fetchImpl).not.toHaveBeenCalled(); }); it('sendet genau einen POST /domainstudio mit dem erwarteten Rumpf', async () => { const h = makeHarness(); h.respond(() => studioReply('beispiel.de', 'FREE', [ { amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } }, ]), ); const result = await h.service.checkAvailability('t1', ' https://Beispiel.DE/x '); expect(h.calls).toHaveLength(1); expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/domainstudio'); expect(h.calls[0].init.method).toBe('POST'); expect(JSON.parse(h.calls[0].init.body as string)).toEqual({ searchToken: 'beispiel', currency: 'EUR', sources: { initial: { tlds: ['de'], services: ['WHOIS', 'PRICE'] } }, }); expect(result).toMatchObject({ domain: 'beispiel.de', available: true, whoisStatus: 'FREE', price: { amount: 4.9, currency: 'EUR' }, }); }); it('ASSIGNED ist nicht bestellbar', async () => { const h = makeHarness(); h.respond(() => studioReply('beispiel.de', 'ASSIGNED')); const result = await h.service.checkAvailability('t1', 'beispiel.de'); expect(result).toMatchObject({ available: false, whoisStatus: 'ASSIGNED', price: null }); }); it.each([ 'ERROR', 'TIMEOUT', 'PREMIUM', 'RESERVED', ])('%s ist nicht bestellbar', async (status) => { const h = makeHarness(); h.respond(() => studioReply('beispiel.de', status)); const result = await h.service.checkAvailability('t1', 'beispiel.de'); expect(result).toMatchObject({ available: false, whoisStatus: status }); }); it('der Umschlag einer anderen Domain zaehlt nicht', async () => { const h = makeHarness(); h.respond(() => studioReply('beispiel.com', 'FREE')); const result = await h.service.checkAvailability('t1', 'beispiel.de'); expect(result).toMatchObject({ available: false, whoisStatus: 'NO_RESULT' }); }); it('ein AutoDNS-Fehler wird zu 502', async () => { const h = makeHarness(); h.respond(() => reply(401, envelope([], { type: 'ERROR', messages: [{ text: 'x', status: 'ERROR' }] })), ); await expect(h.service.checkAvailability('t1', 'beispiel.de')).rejects.toMatchObject({ status: 502, }); }); }); describe('createOrder', () => { const dto = { domain: 'Beispiel.de', ownerContactId: 11, adminContactId: 11, techContactId: 22, zoneContactId: 22, nameServers: ['NS1.example.com ', 'ns2.example.com'], }; function free() { return studioReply('beispiel.de', 'FREE', [ { amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } }, ]); } it('legt einen Entwurf im aktiven System an und liefert die Zusammenfassung', async () => { const h = makeHarness(); h.respond(free); const result = await h.service.createOrder('t1', 'u1', dto as any); expect(h.state.orders).toHaveLength(1); expect(h.state.orders[0]).toMatchObject({ tenantId: 't1', environment: 'DEMO', domainName: 'beispiel.de', openKey: 'beispiel.de', status: 'DRAFT', createdByUserId: 'u1', }); expect(h.state.orders[0].payload).toMatchObject({ ownerContactId: 11, adminContactId: 11, techContactId: 22, zoneContactId: 22, nameServers: ['ns1.example.com', 'ns2.example.com'], periodYears: 1, price: { amount: 4.9, currency: 'EUR' }, }); expect(typeof h.state.orders[0].payload.availabilityCheckedAt).toBe('string'); expect(result.contacts.owner).toEqual({ id: 11, displayName: 'Muster GmbH' }); expect(result.contacts.zone).toEqual({ id: 22, displayName: 'Erika Muster' }); expect(result.nameServers).toEqual(['ns1.example.com', 'ns2.example.com']); expect(result.order).toMatchObject({ domainName: 'beispiel.de', status: 'DRAFT' }); // Der Entwurf selbst bestellt nichts. expect(h.calls.every((c) => !c.url.endsWith('/domain'))).toBe(true); }); it('nicht freie Domain -> 409 notAvailable, keine Zeile', async () => { const h = makeHarness(); h.respond(() => studioReply('beispiel.de', 'ASSIGNED')); await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ status: 409, response: { code: 'notAvailable' }, }); expect(h.state.orders).toHaveLength(0); }); it.each([ [['ns1.example.com'], 'tooFewNameServers'], [ [ 'a.example.com', 'b.example.com', 'c.example.com', 'd.example.com', 'e.example.com', 'f.example.com', 'g.example.com', ], 'tooManyNameServers', ], [['ns1.example.com', 'kein rechner'], 'invalidNameServer'], ])('Nameserver %j -> BadRequest %s', async (nameServers, code) => { const h = makeHarness(); h.respond(free); await expect( h.service.createOrder('t1', 'u1', { ...dto, nameServers } as any), ).rejects.toMatchObject({ response: { code } }); expect(h.state.orders).toHaveLength(0); }); it('fehlender Kontakt -> BadRequest', async () => { const h = makeHarness(); h.respond(free); await expect( h.service.createOrder('t1', 'u1', { ...dto, techContactId: undefined } as any), ).rejects.toBeInstanceOf(BadRequestException); expect(h.state.orders).toHaveLength(0); }); it('ein Kontakt, den AutoDNS nicht kennt -> BadRequest contactNotFound', async () => { const h = makeHarness(); h.respond(free); await expect( h.service.createOrder('t1', 'u1', { ...dto, zoneContactId: 999 } as any), ).rejects.toMatchObject({ response: { code: 'contactNotFound' } }); }); it('ein vorhandener Entwurf derselben Domain wird aktualisiert (gleiche Nummer)', async () => { const h = makeHarness(); const existing = h.addOrder({ payload: { ...PAYLOAD, ownerContactId: 22 } }); h.respond(free); const result = await h.service.createOrder('t1', 'u1', dto as any); expect(result.order.id).toBe(existing.id); expect(h.state.orders).toHaveLength(1); expect(h.state.orders[0].payload.ownerContactId).toBe(11); expect(h.orderApi.create).not.toHaveBeenCalled(); }); it.each([ 'SUBMITTING', 'SUBMITTED', 'UNKNOWN', 'SUCCESS', ])('bestehender Auftrag im Zustand %s -> 409 orderOpen, ohne Netz', async (status) => { const h = makeHarness(); h.addOrder({ status }); await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ status: 409, response: { code: 'orderOpen' }, }); expect(h.fetchImpl).not.toHaveBeenCalled(); }); it('Wettlauf (P2002) -> 409 orderOpen', async () => { const h = makeHarness(); h.respond(free); h.state.createConflict = true; await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ response: { code: 'orderOpen' }, }); }); it('ein FAILED/CANCELED-Auftrag (openKey null) blockiert nicht', async () => { const h = makeHarness(); h.addOrder({ status: 'FAILED', openKey: null }); h.respond(free); const result = await h.service.createOrder('t1', 'u1', dto as any); expect(result.order.status).toBe('DRAFT'); expect(h.state.orders).toHaveLength(2); }); }); describe('submitOrder — der Anspruch', () => { it('sendet nach dem Anspruch genau EINEN POST /domain ohne Query und mit dem erwarteten Rumpf', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply( 200, envelope([{ id: 987, status: 'RUNNING' }], { object: { type: 'job', value: '987', summary: 1 }, }), ), ); const view = await h.service.submitOrder('t1', USER, order.id); expect(h.orderApi.updateMany).toHaveBeenCalledTimes(2); const claim = h.orderApi.updateMany.mock.calls[0][0]; expect(claim.where).toMatchObject({ id: order.id, tenantId: 't1', status: 'DRAFT', environment: 'DEMO', }); expect(claim.data).toMatchObject({ status: 'SUBMITTING', confirmedByUserId: 'u1', confirmedByUsername: 'anna', }); expect(claim.data.confirmedAt).toBeInstanceOf(Date); expect(h.calls).toHaveLength(1); expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/domain'); expect(h.calls[0].init.method).toBe('POST'); expect(h.calls[0].init.redirect).toBe('error'); expect(h.calls[0].init.headers).toEqual({ Authorization: 'Basic YXBpLXVzZXI6Z2VoZWlt', 'X-Domainrobot-Context': '4', Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': expect.stringMatching(/^Tessera\//), }); expect(JSON.parse(h.calls[0].init.body as string)).toEqual(EXPECTED_SUBMIT_BODY); expect(view).toMatchObject({ status: 'SUBMITTED', jobStatus: 'RUNNING' }); expect(h.state.orders[0]).toMatchObject({ status: 'SUBMITTED', jobId: '987', jobStatus: 'RUNNING', confirmedByUsername: 'anna', openKey: 'beispiel.de', }); expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); }); it('zwei gleichzeitige Bestaetigungen: genau ein POST, eine 409 alreadySubmitted', async () => { const h = makeHarness(); const order = h.addOrder(); // Beide Aufrufe lesen noch den Entwurf, bevor einer den Anspruch stellt: // so beweist der Test den Zaehler (count === 1), nicht die Vorabpruefung. let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); h.state.readBarrier = { remaining: 2, release, gate }; h.respond(async () => { await new Promise((r) => setTimeout(r, 5)); return reply(200, envelope([{ id: 987, status: 'RUNNING' }])); }); const results = await Promise.allSettled([ h.service.submitOrder('t1', USER, order.id), h.service.submitOrder('t1', USER, order.id), ]); const ok = results.filter((r) => r.status === 'fulfilled'); const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; expect(ok).toHaveLength(1); expect(rejected).toHaveLength(1); expect(rejected[0].reason).toBeInstanceOf(ConflictException); expect(rejected[0].reason.getResponse()).toMatchObject({ code: 'alreadySubmitted' }); expect(h.calls.filter((c) => c.url.endsWith('/domain'))).toHaveLength(1); }); it('Systemwechsel nach dem Entwurf -> 409 environmentChanged, kein Netz, Entwurf bleibt', async () => { const h = makeHarness({ environment: 'DEMO' }); const order = h.addOrder({ environment: 'DEMO' }); h.state.environment = 'LIVE'; await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ status: 409, response: { code: 'environmentChanged' }, }); expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.state.orders[0].status).toBe('DRAFT'); }); it('Systemwechsel zwischen Lesen und Anspruch -> der Anspruch greift nicht, kein Netz', async () => { const h = makeHarness({ environment: 'DEMO' }); const order = h.addOrder({ environment: 'LIVE' }); // gehoert zu LIVE, aktiv ist DEMO // Vorabpruefung umgehen: die Zeile gibt sich beim Lesen als DEMO aus. const original = h.orderApi.findFirst.getMockImplementation() as (a: any) => Promise; let reads = 0; h.orderApi.findFirst.mockImplementation(async (a: any) => { const row = await original(a); reads += 1; return reads === 1 && row ? { ...row, environment: 'DEMO' } : row; }); await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ response: { code: 'environmentChanged' }, }); expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.state.orders[0].status).toBe('DRAFT'); }); it.each([ 'SUBMITTING', 'SUBMITTED', 'UNKNOWN', 'SUCCESS', 'FAILED', 'CANCELED', ])('Auftrag im Zustand %s -> 409 alreadySubmitted, kein Netz', async (status) => { const h = makeHarness(); const order = h.addOrder({ status }); await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ status: 409, response: { code: 'alreadySubmitted' }, }); expect(h.fetchImpl).not.toHaveBeenCalled(); }); it('fremde Nummer -> 404, kein Netz', async () => { const h = makeHarness(); h.addOrder({ tenantId: 'andere' }); await expect(h.service.submitOrder('t1', USER, 'o1')).rejects.toBeInstanceOf(NotFoundException); expect(h.fetchImpl).not.toHaveBeenCalled(); }); }); describe('submitOrder — Ausgang', () => { it('Zeitablauf -> UNKNOWN, genau ein Aufruf, lastCheckedAt bleibt leer', async () => { const h = makeHarness({ timeoutMs: 20 }); const order = h.addOrder(); h.respond(() => new Promise(() => undefined)); const view = await h.service.submitOrder('t1', USER, order.id); expect(view.status).toBe('UNKNOWN'); expect(h.calls).toHaveLength(1); expect(h.state.orders[0].status).toBe('UNKNOWN'); expect(h.state.orders[0].lastCheckedAt).toBeNull(); expect(h.state.orders[0].openKey).toBe('beispiel.de'); }); it('Verbindungsabbruch (ECONNRESET) -> UNKNOWN', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => { throw Object.assign(new Error('reset'), { code: 'ECONNRESET' }); }); const view = await h.service.submitOrder('t1', USER, order.id); expect(view.status).toBe('UNKNOWN'); expect(h.calls).toHaveLength(1); }); it('HTTP 200 mit HTML -> UNKNOWN', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply(200, 'Wartung')); expect((await h.service.submitOrder('t1', USER, order.id)).status).toBe('UNKNOWN'); }); it('HTTP 502 ohne Umschlag (Gateway) -> UNKNOWN, nicht FAILED', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply(502, 'Bad Gateway')); expect((await h.service.submitOrder('t1', USER, order.id)).status).toBe('UNKNOWN'); }); it('HTTP 200 mit status.type ERROR -> FAILED mit Text, openKey leer', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply( 200, envelope([], { type: 'ERROR', messages: [{ code: 'E1', text: 'Domain not available', status: 'ERROR' }], }), ), ); const view = await h.service.submitOrder('t1', USER, order.id); expect(view.status).toBe('FAILED'); expect(view.errorText).toContain('Domain not available'); expect(h.state.orders[0].openKey).toBeNull(); expect(h.directory.invalidate).not.toHaveBeenCalled(); }); it('HTTP 401 -> FAILED mit dem Anmeldetext, openKey leer', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply( 401, envelope([], { type: 'ERROR', messages: [{ code: 'EF00202', text: 'bad login', status: 'ERROR' }], }), ), ); const view = await h.service.submitOrder('t1', USER, order.id); expect(view.status).toBe('FAILED'); expect(view.errorText).toContain('Benutzername, Passwort und Kontext'); expect(h.state.orders[0].openKey).toBeNull(); }); it('Erfolg ohne Auftragsnummer -> SUBMITTED mit jobId null', async () => { const h = makeHarness(); const order = h.addOrder(); h.respond(() => reply(200, envelope([{}], { object: { type: 'domain', value: 'beispiel.de', summary: 1 } })), ); const view = await h.service.submitOrder('t1', USER, order.id); expect(view.status).toBe('SUBMITTED'); expect(h.state.orders[0].jobId).toBeNull(); }); it('nach UNKNOWN kann derselbe Auftrag nie erneut abgeschickt werden', async () => { const h = makeHarness({ timeoutMs: 20 }); const order = h.addOrder(); h.respond(() => new Promise(() => undefined)); await h.service.submitOrder('t1', USER, order.id); await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ response: { code: 'alreadySubmitted' }, }); expect(h.calls).toHaveLength(1); }); }); describe('refreshOpenOrders', () => { it('SUBMITTED mit Auftrag: SUCCESS -> SUCCESS, openKey bleibt, Zwischenspeicher geleert', async () => { const h = makeHarness(); h.addOrder({ status: 'SUBMITTED', jobId: '987', jobStatus: 'RUNNING' }); h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'SUCCESS' } }]))); await h.service.refreshOpenOrders('t1'); expect(h.calls).toHaveLength(1); expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/job/987'); expect(h.calls[0].init.method).toBe('GET'); expect(h.state.orders[0]).toMatchObject({ status: 'SUCCESS', openKey: 'beispiel.de' }); expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); }); it('FAILED -> FAILED mit Text, openKey leer', async () => { const h = makeHarness(); h.addOrder({ status: 'SUBMITTED', jobId: '987' }); h.respond(() => reply( 200, envelope([ { job: { id: 987, status: 'FAILED', events: [{ status: 'ERROR', message: 'Nameserver nicht erreichbar' }], }, }, ]), ), ); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0]).toMatchObject({ status: 'FAILED', openKey: null }); expect(h.state.orders[0].errorText).toContain('Nameserver nicht erreichbar'); }); it('CANCELED -> CANCELED, openKey leer', async () => { const h = makeHarness(); h.addOrder({ status: 'SUBMITTED', jobId: '987' }); h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'CANCELED' } }]))); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); }); it('RUNNING bleibt SUBMITTED, lastCheckedAt wird gesetzt', async () => { const h = makeHarness(); h.addOrder({ status: 'SUBMITTED', jobId: '987' }); h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'RUNNING' } }]))); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0]).toMatchObject({ status: 'SUBMITTED', jobStatus: 'RUNNING' }); expect(h.state.orders[0].lastCheckedAt).toBeInstanceOf(Date); }); it('SUPPORT bleibt SUBMITTED mit jobStatus SUPPORT', async () => { const h = makeHarness(); h.addOrder({ status: 'SUBMITTED', jobId: '987' }); h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'SUPPORT' } }]))); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0]).toMatchObject({ status: 'SUBMITTED', jobStatus: 'SUPPORT' }); }); it('SUBMITTING seit 3 Minuten -> UNKNOWN, seit 30 s -> unveraendert', async () => { const h = makeHarness(); h.addOrder({ id: 'alt', status: 'SUBMITTING', confirmedAt: new Date(Date.now() - 3 * 60_000) }); h.addOrder({ id: 'neu', domainName: 'neu.de', openKey: 'neu.de', status: 'SUBMITTING', confirmedAt: new Date(Date.now() - 30_000), }); await h.service.refreshOpenOrders('t1'); expect(h.state.orders.find((o) => o.id === 'alt')?.status).toBe('UNKNOWN'); expect(h.state.orders.find((o) => o.id === 'neu')?.status).toBe('SUBMITTING'); // Der gerade unklar gewordene Auftrag wird sofort abgeglichen; der junge nicht. expect(h.calls.map((c) => c.url)).toEqual([ 'https://api.demo.autodns.com/v1/domain/beispiel.de', ]); }); describe('UNKNOWN-Abgleich', () => { const confirmedAt = new Date(Date.now() - 10 * 60_000); it('GET /domain/{name} gelingt -> SUCCESS', async () => { const h = makeHarness(); h.addOrder({ status: 'UNKNOWN', confirmedAt }); h.respond(() => reply(200, envelope([{ name: 'beispiel.de' }]))); await h.service.refreshOpenOrders('t1'); expect(h.calls).toHaveLength(1); expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/domain/beispiel.de'); expect(h.state.orders[0].status).toBe('SUCCESS'); expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); }); it('nicht gefunden, aber Auftrag nach der Bestaetigung -> SUBMITTED mit dieser Nummer', async () => { const h = makeHarness(); h.addOrder({ status: 'UNKNOWN', confirmedAt }); h.respond((call) => { if (call.url.endsWith('/domain/beispiel.de')) { return reply( 404, envelope([], { type: 'ERROR', messages: [{ text: 'not found', status: 'ERROR' }] }), ); } return reply( 200, envelope([ { job: { id: 555, status: 'RUNNING', created: new Date(Date.now() - 9 * 60_000).toISOString(), }, object: 'beispiel.de', }, { job: { id: 1, status: 'FAILED', created: '2020-01-01T00:00:00Z' }, object: 'beispiel.de', }, ]), ); }); await h.service.refreshOpenOrders('t1'); expect(h.calls.map((c) => c.url)).toEqual([ 'https://api.demo.autodns.com/v1/domain/beispiel.de', 'https://api.demo.autodns.com/v1/job/_search', ]); expect(JSON.parse(h.calls[1].init.body as string)).toEqual({ filters: [{ key: 'object', operator: 'EQUAL', value: 'beispiel.de' }], view: { limit: 10, offset: 0 }, orders: [{ key: 'created', type: 'DESC' }], }); expect(h.state.orders[0]).toMatchObject({ status: 'SUBMITTED', jobId: '555', jobStatus: 'RUNNING', }); }); it('nichts gefunden -> bleibt UNKNOWN, lastCheckedAt gesetzt', async () => { const h = makeHarness(); h.addOrder({ status: 'UNKNOWN', confirmedAt }); h.respond((call) => call.url.endsWith('/domain/beispiel.de') ? reply( 404, envelope([], { type: 'ERROR', messages: [{ text: 'not found', status: 'ERROR' }] }), ) : reply(200, envelope([])), ); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0].status).toBe('UNKNOWN'); expect(h.state.orders[0].lastCheckedAt).toBeInstanceOf(Date); }); it('ein Fehler beim Nachsehen (Zeitablauf) setzt lastCheckedAt NICHT', async () => { const h = makeHarness({ timeoutMs: 20 }); h.addOrder({ status: 'UNKNOWN', confirmedAt }); h.respond(() => new Promise(() => undefined)); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0].status).toBe('UNKNOWN'); expect(h.state.orders[0].lastCheckedAt).toBeNull(); }); it('ein alter Auftrag ohne lesbare Anlagezeit zaehlt nicht', async () => { const h = makeHarness(); h.addOrder({ status: 'UNKNOWN', confirmedAt }); h.respond((call) => call.url.endsWith('/domain/beispiel.de') ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) : reply(200, envelope([{ job: { id: 7, status: 'FAILED' }, object: 'beispiel.de' }])), ); await h.service.refreshOpenOrders('t1'); expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', jobId: null }); }); }); it('prueft hoechstens 20 Auftraege, die am laengsten ungeprueften zuerst', async () => { const h = makeHarness(); for (let i = 0; i < 25; i += 1) { h.addOrder({ id: `x${i}`, domainName: `d${i}.de`, openKey: `d${i}.de`, status: 'SUBMITTED', jobId: String(100 + i), lastCheckedAt: new Date(Date.now() - (25 - i) * 1000), }); } h.respond(() => reply(200, envelope([{ job: { status: 'RUNNING' } }]))); await h.service.refreshOpenOrders('t1'); expect(h.calls).toHaveLength(20); // x0 ist am aeltesten geprueft, x24 am juengsten -> x20..x24 bleiben liegen. const urls = h.calls.map((c) => c.url); expect(urls[0]).toBe('https://api.demo.autodns.com/v1/job/100'); expect(urls).not.toContain('https://api.demo.autodns.com/v1/job/124'); }); it('ein AutoDNS-Fehler bei einem Auftrag haelt die anderen nicht auf', async () => { const h = makeHarness(); h.addOrder({ id: 'a', domainName: 'a.de', openKey: 'a.de', status: 'SUBMITTED', jobId: '1' }); h.addOrder({ id: 'b', domainName: 'b.de', openKey: 'b.de', status: 'SUBMITTED', jobId: '2' }); h.respond((call) => call.url.endsWith('/job/1') ? reply(500, 'kaputt') : reply(200, envelope([{ job: { id: 2, status: 'SUCCESS' } }])), ); await h.service.refreshOpenOrders('t1'); expect(h.state.orders.find((o) => o.id === 'b')?.status).toBe('SUCCESS'); expect(h.state.orders.find((o) => o.id === 'a')?.status).toBe('SUBMITTED'); }); it('Auftraege des anderen Systems werden nicht angefasst', async () => { const h = makeHarness({ environment: 'LIVE' }); h.addOrder({ environment: 'DEMO', status: 'SUBMITTED', jobId: '9' }); await h.service.refreshOpenOrders('t1'); expect(h.fetchImpl).not.toHaveBeenCalled(); }); }); describe('cancelOrder', () => { it('DRAFT -> CANCELED, openKey leer', async () => { const h = makeHarness(); const order = h.addOrder(); const view = await h.service.cancelOrder('t1', order.id); expect(view.status).toBe('CANCELED'); expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); }); it('UNKNOWN mit gesetztem lastCheckedAt -> CANCELED', async () => { const h = makeHarness(); const order = h.addOrder({ status: 'UNKNOWN', lastCheckedAt: new Date() }); await h.service.cancelOrder('t1', order.id); expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); }); it('UNKNOWN ohne Abgleich -> 409 checkFirst', async () => { const h = makeHarness(); const order = h.addOrder({ status: 'UNKNOWN' }); await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ response: { code: 'checkFirst' }, }); expect(h.state.orders[0].status).toBe('UNKNOWN'); }); it.each([ 'SUBMITTING', 'SUBMITTED', 'SUCCESS', 'FAILED', 'CANCELED', ])('%s -> 409 notCancelable', async (status) => { const h = makeHarness(); const order = h.addOrder({ status }); await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ response: { code: 'notCancelable' }, }); }); it('fremde Nummer -> 404', async () => { const h = makeHarness(); h.addOrder({ tenantId: 'andere' }); await expect(h.service.cancelOrder('t1', 'o1')).rejects.toBeInstanceOf(NotFoundException); }); }); describe('listOrders', () => { it('liefert die neuesten zuerst mit den Anzeigefeldern', async () => { const h = makeHarness(); h.addOrder({ id: 'alt', domainName: 'alt.de', openKey: null, status: 'FAILED', errorText: 'Fehler', createdAt: new Date('2026-10-01T10:00:00Z'), }); h.addOrder({ id: 'neu', domainName: 'xn--mller-kva.de', openKey: 'xn--mller-kva.de', status: 'SUCCESS', confirmedByUsername: 'anna', confirmedAt: new Date('2026-10-08T09:00:00Z'), createdAt: new Date('2026-10-08T08:00:00Z'), }); const list = await h.service.listOrders('t1'); expect(list.map((o) => o.id)).toEqual(['neu', 'alt']); expect(list[0]).toEqual({ id: 'neu', environment: 'DEMO', domainName: 'xn--mller-kva.de', domainNameUnicode: 'müller.de', status: 'SUCCESS', jobStatus: null, errorText: null, confirmedByUsername: 'anna', confirmedAt: '2026-10-08T09:00:00.000Z', createdAt: '2026-10-08T08:00:00.000Z', lastCheckedAt: null, price: { amount: 4.9, currency: 'EUR' }, }); expect(h.orderApi.findMany.mock.calls[0][0]).toMatchObject({ where: { tenantId: 't1' }, take: 200, }); }); });