DoDomain

Testing your integration

Drive the hosted connect flow from your own Playwright suite — the supported automation path, what needs real DNS, and how to assert the result without one.

Your DoDomain integration has two halves worth covering in your own end-to-end suite: the server half (you mint a session, you receive a webhook, you activate the domain) and the browser half (your user gets to the connect flow and comes back). Both are testable against the real API — this page describes the path that works.

Drive the hosted page, not the widget

The @dodomain/connect widget renders the hosted flow inside a cross-origin iframe on app.dodomain.io, wrapped in modal chrome the widget draws. Automating that means reaching through a frame boundary and pinning assertions to our sheet's internals — brittle, and none of it is your integration.

The supported automation path is the one the hosted link already gives you: open connectUrl as a top-level page. https://app.dodomain.io/connect/<token> is a plain server-rendered page with no embed requirement — the widget loads that same URL with a few query parameters appended (embed, the host page's origin, and optionally theme). Everything the user sees in the sheet, they see at the top level.

// playwright — the shape of an integration test against the real API
import { expect, test } from "@playwright/test";

test("a customer can take a domain through the DoDomain connect flow", async ({
  page,
  request,
}) => {
  // 1. Mint a session the way YOUR server does, with your secret key.
  const res = await request.post("https://app.dodomain.io/api/v1/sessions", {
    headers: { Authorization: `Bearer ${process.env.DODOMAIN_SECRET_KEY}` },
    data: {
      domain: "customer.example.com",
      records: [{ type: "CNAME", host: "app", value: "edge.yourproduct.com" }],
      returnUrl: "https://yourproduct.test/settings/domains",
    },
  });
  expect(res.ok(), `POST /api/v1/sessions answered HTTP ${res.status()}`).toBe(true);
  const session = await res.json();

  // 2. Open the hosted flow top-level. No iframe, no widget.
  await page.goto(session.connectUrl);

  // 3. The manual flow shows the exact records the user must create.
  await expect(page.getByRole("heading", { name: "customer.example.com", level: 1 })).toBeVisible();
  const row = page.getByRole("listitem").filter({ hasText: "edge.yourproduct.com" });
  await expect(row.getByText("CNAME", { exact: true })).toBeVisible();

  // 4. Drive verification.
  await page.getByRole("button", { name: "Verify records" }).click();
});

Useful selectors on the connect page

Each requested record is a list item carrying its type, the fully-qualified name, and the value. The primary button reads Verify records before the first check and Check again after it. A record that isn't live yet shows not found yet; a check that couldn't run shows couldn't check. Success replaces the button with a Domain connected panel. An unknown or deleted token renders Connection not found instead of a flow — a good negative test.

After the first manual click the flow re-checks on its own every 15 seconds (up to 20 automatic checks) and shows a Re-checking in Ns countdown. It never polls before that first click, so a test that opens the page and asserts without clicking generates no DNS traffic.

The part that needs real DNS

Verification is not simulated. POST /api/v1/sessions/:token/verify queries the domain's authoritative nameservers for each record and is fail-closed: a lookup that cannot be completed can never report the record as present. There is no test mode, no fixture resolver, and no way to make a record verify without the record existing.

So a test that asserts all the way to connection.verified has to put a real record in real DNS. Two patterns work:

Pattern A — a dedicated test zone

Register a cheap throwaway domain (or delegate a subzone you already own) purely for CI, and give your test suite API credentials for its DNS provider. Each test writes the record the session asked for, then drives the flow to green. This is the only pattern that exercises the whole chain — records live, session verified, connection.verified webhook delivered, your product activating the domain.

Keep the zone's records clean between runs: use a unique host per run (run-${Date.now()}) rather than reusing one name, so a leftover record from a previous test can never make a broken run look green.

Pattern B — stop at pending, and test the rest directly

Most integration bugs are not in DNS. You can cover almost everything without a test zone:

  • Mint a session against a domain you don't control and assert the honest miss: the flow reports not found yet, the counter reads 0/1 found, and the button stays available as Check again. That proves your session shape, your record values, and your return URL — and it proves DoDomain isn't faking success.
  • Assert GET /api/v1/sessions/:token echoes exactly the domain and records you meant to send. It is authorized by the session token alone, so a test can read it without your secret key.
  • Assert records[].fqdn in the create response. That is the name verification will actually look up, so a wrong host/domain combination fails your test at session creation instead of silently failing in production. (See host is relative to domain.)
  • Test your webhook handler against a body you construct yourself, signed with your own whsec_... secret. verifyWebhook(secret, rawBody, signatureHeader) from @dodomain/node is the same function that runs in production — you don't need DoDomain to send the request to test what you do with it.

Asserting the outcome

Once records are live, there are two independent signals and it is worth checking both:

  • The webhook. connection.verified fires the moment everything matches. In CI, point the app's webhook endpoint at a tunnel or a small collector and wait for the delivery — dedupe on id, because delivery is at-least-once.
  • The API. GET /api/v1/connections?domain=customer.example.com lists what DoDomain believes is connected. Each entry carries status, recordFqdns (the names DoDomain monitors, one per record), verifiedAt, lastCheckedAt, and disconnectedAt — a clean poll-and-assert that needs no inbound network in your test environment. (fqdn is also present but is the session's domain repeated, kept for wire compatibility — assert on recordFqdns.)

Clean up with DELETE /api/v1/connections/:connectionId. It is idempotent: a repeat call returns the original disconnectedAt with alreadyDisconnected: true and fires no second webhook, so a retrying teardown can't corrupt the next run.

Sessions expire; connections count

Session tokens expire 24 hours after creation, so mint them inside the test rather than checking one into a fixture — after expiry every session endpoint answers 410 expired. And a finalized connection counts one unit against your monthly quota the first time it verifies (re-verifying and reconnecting add nothing). A suite that verifies real domains on every commit will consume quota; Pattern B does not.

On this page