Last reviewed: November 2, 2026 (Asia/Shanghai).

Direct answer

Run CometAPI MockAgent tests by passing an Undici MockAgent into your client’s HTTP request, disabling unmatched network connections, and registering one expected request and synthetic response per test. Check the outgoing method, path, authentication header, and parsed JSON body. Then test how your application handles HTTP errors, malformed responses, and completion outcomes it cannot accept.

The CometAPI chat reference supplies the real endpoint and response fields. The Undici MockAgent reference supplies the interception mechanism, per-request dispatcher option, and disableNetConnect(). Combining them lets a test exercise serialization and response handling through Undici without a live API key.

This tutorial implements a text-only, non-streaming adapter and a complete offline suite. Its 12 tests passed locally on Node.js 24.22.1 with Undici 8.10.0. The requests used an invalid fixture key and a synthetic model name. No authenticated CometAPI request was performed.

Who this is for

Use this pattern when maintaining a Node.js backend, CLI, or tutorial client whose HTTP behavior should be checked in CI. A misspelled route, missing stream: false, or incorrect content extraction should fail before someone runs an account-backed check.

Use Node.js 24.22.1 to reproduce the checked environment. Keep the installed Undici version explicit so the HTTP client and mock come from the same package. The Node.js 24 test-runner reference documents node:test, asynchronous tests, cleanup hooks, and the --test command.

These tests check your client against deliberately chosen fixtures. Keep a separate, controlled live check for current account access and model compatibility. The site’s request-fixture review guide helps decide which request fields belong in that baseline.

Key takeaways

  • Require the caller to supply the dispatcher, making the tested transport an explicit dependency.
  • Disable network connections on every mock agent before invoking the client.
  • Match parsed JSON so object-key ordering does not determine the result.
  • Check that every expected interceptor was consumed, then close the agent.
  • Test a narrow application’s acceptance rule, including refusals and truncated output.
  • Keep synthetic fixtures and live observations separately identified.

Create the small HTTP adapter

In an empty project directory, install the checked dependency:

npm install --save-exact [email protected]

Commit the resulting package manifest and lockfile with your application; use npm ci for subsequent CI installations. Save the following as chat-client.mjs.

import { request } from 'undici';

export class ChatFault extends Error {
  constructor(code) {
    super(code);
    this.code = code;
  }
}

export async function chatText({ key, model, messages, dispatcher, signal }) {
  if (!key || !model || !Array.isArray(messages) || !messages.length ||
      typeof dispatcher?.dispatch !== 'function') {
    throw new ChatFault('invalid_config');
  }

  let response;
  let raw;
  try {
    response = await request(
      'https://api.cometapi.com/v1/chat/completions',
      {
        dispatcher,
        signal,
        method: 'POST',
        headers: {
          authorization: `Bearer ${key}`,
          'content-type': 'application/json'
        },
        body: JSON.stringify({ model, messages, stream: false })
      }
    );
    raw = await response.body.text();
  } catch {
    throw new ChatFault('transport_error');
  }

  if (response.statusCode < 200 || response.statusCode >= 300) {
    throw new ChatFault(`http_${response.statusCode}`);
  }
  const mediaType = String(response.headers['content-type'] ?? '')
    .split(';')[0].trim().toLowerCase();
  if (mediaType !== 'application/json') {
    throw new ChatFault('unexpected_content_type');
  }
  let data;
  try {
    data = JSON.parse(raw);
  } catch {
    throw new ChatFault('invalid_json');
  }
  if (data?.object !== 'chat.completion' ||
      !Array.isArray(data.choices) || data.choices.length !== 1) {
    throw new ChatFault('invalid_completion');
  }
  const choice = data.choices[0];
  const message = choice?.message;
  if (choice?.finish_reason !== 'stop' ||
      message?.role !== 'assistant' ||
      typeof message.content !== 'string' || !message.content.trim() ||
      message.refusal != null || message.tool_calls != null) {
    throw new ChatFault('unaccepted_completion');
  }
  return message.content;
}

The URL, Bearer authentication, model, messages, and streaming switch follow the CometAPI reference. The acceptance rule is our application’s choice: exactly one completed assistant text answer, with a stop finish reason, nonblank content, and no refusal or tool-call field. Other valid API outcomes need their own application handling.

The adapter deliberately omits automatic retries. That keeps one invocation tied to one expected HTTP attempt. It consumes the small fixture body before inspecting it, so an HTML 500 response becomes http_500 rather than a misleading JSON parsing error. Fixed error codes keep response content out of ordinary application error messages.

This teaching adapter uses body.text(), which buffers the complete body. Before using it with untrusted live responses, add a response-byte ceiling and a deadline that covers body consumption. Adapt the site’s bounded JSON reader pattern to your chosen response interface. The tests below establish neither a memory bound nor an upstream cancellation guarantee.

Add request matching and failure fixtures

Save this complete suite as chat-client.test.mjs beside the adapter. Every test creates its own agent and registers cleanup before making requests.

import test from 'node:test';
import assert from 'node:assert/strict';
import { isDeepStrictEqual } from 'node:util';
import { MockAgent, request, mockErrors } from 'undici';
import { chatText } from './chat-client.mjs';

const key = 'fixture-key-not-valid';
const model = 'fixture-model';
const messages = [{ role: 'user', content: 'Synthetic test prompt.' }];
const completion = {
  object: 'chat.completion',
  choices: [{
    index: 0,
    finish_reason: 'stop',
    message: { role: 'assistant', content: 'Fixture answer.', refusal: null }
  }]
};

function harness(t) {
  const agent = new MockAgent();
  agent.disableNetConnect();
  t.after(async () => {
    try {
      agent.assertNoPendingInterceptors();
    } finally {
      await agent.close();
    }
  });
  const pool = agent.get('https://api.cometapi.com');
  const match = () => pool.intercept({
    path: '/v1/chat/completions',
    method: 'POST',
    headers: {
      authorization: `Bearer ${key}`,
      'content-type': 'application/json'
    },
    body: raw => {
      try {
        return isDeepStrictEqual(JSON.parse(raw), {
          model, messages, stream: false
        });
      } catch {
        return false;
      }
    }
  });
  return {
    agent,
    match,
    call: () => chatText({ key, model, messages, dispatcher: agent }),
    reply: (raw, status = 200, type = 'application/json') =>
      match().reply(status, raw, { headers: { 'content-type': type } })
  };
}

test('matches the request and accepts a complete text answer', async t => {
  const h = harness(t);
  h.reply(JSON.stringify(completion));
  assert.equal(await h.call(), 'Fixture answer.');
});

const changedChoice = patch => JSON.stringify({
  ...completion,
  choices: [{ ...completion.choices[0], ...patch }]
});
const cases = [
  ['401 is an HTTP failure', '{}', 401, 'application/json', 'http_401'],
  ['429 is an HTTP failure', '{}', 429, 'application/json', 'http_429'],
  ['500 HTML stays an HTTP failure', '<h1>Failure</h1>', 500,
    'text/html', 'http_500'],
  ['200 HTML is rejected', '<h1>Not JSON</h1>', 200,
    'text/html', 'unexpected_content_type'],
  ['malformed JSON is rejected', '{', 200,
    'application/json', 'invalid_json'],
  ['empty choices are rejected', '{"object":"chat.completion","choices":[]}',
    200, 'application/json', 'invalid_completion'],
  ['truncated output is rejected', changedChoice({ finish_reason: 'length' }),
    200, 'application/json', 'unaccepted_completion'],
  ['refusal is rejected', changedChoice({ message: {
    role: 'assistant', content: 'Fixture answer.', refusal: 'Fixture refusal.'
  } }), 200, 'application/json', 'unaccepted_completion'],
  ['tool calls are rejected', changedChoice({ finish_reason: 'tool_calls',
    message: { role: 'assistant', content: null, tool_calls: [] }
  }), 200, 'application/json', 'unaccepted_completion']
];

for (const [name, raw, status, type, code] of cases) {
  test(name, async t => {
    const h = harness(t);
    h.reply(raw, status, type);
    await assert.rejects(h.call(), { code });
  });
}

test('transport failure becomes a fixed local code', async t => {
  const h = harness(t);
  h.match().replyWithError(new Error('synthetic_socket_failure'));
  await assert.rejects(h.call(), { code: 'transport_error' });
});

test('unmatched traffic through this agent is blocked', async t => {
  const h = harness(t);
  await assert.rejects(
    request('https://unregistered.invalid/blocked', { dispatcher: h.agent }),
    mockErrors.MockNotMatchedError
  );
});

The Undici MockPool reference documents the matching rules used here. Every supplied matcher must pass. Header maps match the specified headers while permitting additional headers, and the body predicate receives a string. Parsing that string and comparing objects makes the test sensitive to the JSON fields without depending on their serialization order.

The exact body comparison intentionally rejects extra options. If the application later adds a supported generation parameter, update the expected request after reviewing that change. Avoid changing the predicate to () => true merely to make the suite pass; that would remove the assertion about what the client sends.

Ordinary interceptors handle one matching request by default. assertNoPendingInterceptors() catches an expected request that never happened. Neither that assertion nor a consumed one-shot interceptor independently proves that the application made exactly one attempt: an extra failed attempt could be caught by retry code. If retries are added, count attempts explicitly and test the retry limit.

The direct unmatched-request check uses the documented Undici mock error class . Checking MockNotMatchedError avoids matching an explanatory error message. The adapter wraps transport errors, so this particular assertion calls Undici directly to confirm the harness blocks an unregistered origin.

Run the offline suite

Run the exact test file:

node --test chat-client.test.mjs

The local run completed with 12 tests, 12 passes, and zero failures, cancellations, or skipped tests. The suite covers:

FixtureRequired application result
Matching request and complete text answerReturn the fixture text.
401, 429, or 500 responseReject with the corresponding fixed HTTP code.
HTML returned with HTTP 200Reject the media type.
Invalid JSON returned as JSONReject as invalid_json.
Empty choices arrayReject the completion envelope.
Length-limited completion, refusal, or tool callReject the unsupported text outcome.
Synthetic transport failureReject as transport_error.
Unregistered origin through the mock agentRaise MockNotMatchedError locally.

The expected string is appropriate here because the test itself supplies that string. A live model check should verify the accepted response structure and outcome without demanding identical generated wording. Reusing a deterministic fixture assertion against a live model would test a different contract.

For CI, keep real credentials out of this job. Record the Node.js version, installed Undici version, exact command, and pass/fail summary. The test output needs no prompt dump, raw response archive, or live key to explain a failure.

Contract details to verify

BoundaryWhat this implementation checksSeparate verification needed
Outbound requestOrigin, path, method, selected headers, and exact JSON fieldsCurrent model support for any new options.
Accepted answerOne complete assistant text choiceApplication handling for tools, refusals, and other response families.
HTTP failureA non-2xx response becomes a fixed status codeAny account-specific troubleshooting or retry policy.
Mock isolationUnmatched requests through this agent are rejectedProcess or CI network controls for other code paths.
Resource cleanupExpected interceptors are consumed and the agent closesLive response-size, concurrency, and timeout limits.

disableNetConnect() applies to requests routed through that agent. It does not intercept arbitrary sockets, another independently created client, or unrelated process traffic. Passing the dispatcher through the adapter is therefore part of the test contract. Use CI network restrictions as an additional control if the whole job must be offline.

Failure modes

A test unexpectedly contacts the network. Check that the tested function passes the supplied dispatcher to Undici and that the harness calls disableNetConnect(). Creating a second client inside the adapter can bypass this agent. An invalid fixture key helps avoid accidentally using an account credential but is not a network isolation mechanism.

A wrong request is reported as a transport error. The adapter intentionally normalizes transport failures. In a failing local test, the pending-interceptor report identifies the expected route that was not consumed. Compare the header and body matchers before changing the fixture. Keep real secrets out of any diagnostic reproduction.

A test passes although its expected call never ran. Ensure cleanup still calls assertNoPendingInterceptors(). The finally block closes the agent even when that assertion fails; cleanup should not hide an unconsumed expectation.

Fixtures drift from public documentation. Review the request and response fixtures whenever the selected endpoint or application options change. The synthetic model string is intentionally unsuitable for live use. Replacing it with a current model name does not make a mocked test evidence of model access.

A 200 response is accepted too early. HTTP success alone does not satisfy this text adapter. Keep the envelope and completion checks, and add explicit cases when broadening the acceptance rule. Do not silently convert a tool call or truncated answer into completed text.

Sources checked

These official technical references were fetched on November 2, 2026 in Asia/Shanghai, corresponding to November 1 UTC. They support an evergreen implementation task; this article does not report a new API launch.

FAQ

Does the test need a CometAPI key or balance?

No. It uses a deliberately invalid fixture key, a synthetic model name, and programmed replies. The tested requests go through a mock agent with unmatched network connections disabled.

Why not replace the whole chat function with a stub?

A high-level stub can test the caller, but it bypasses this adapter’s request construction and response handling. MockAgent lets those code paths execute against controlled HTTP fixtures.

Can the tests prove that a model is available now?

No. Run a separate authorized live smoke check with the current model and account configuration. Preserve that observed result separately from the offline suite’s outcome.

Does this suite test streaming?

No. Its matcher requires stream: false and the adapter expects a complete JSON envelope. Streaming needs its own event-framing, partial-output, and cancellation cases.

Reader next step

Copy the two files into a small project and run the offline suite. Introduce a deliberate route or body-field mistake to confirm the matcher and pending-expectation check catch it, then restore the client. Integrate the adapter with your own response bounds and request deadline before proceeding to a controlled live check.

When you need account access for that separate check, Start with CometAPI . Select a currently supported model and verify the live response through the same application acceptance path. Keep the offline suite in ordinary CI so later HTTP handling changes remain repeatable.