/**
 * Tests for src/managers/tailscale-coord.js
 *
 * Strategy: inject a fake `fetchImpl` into the client so we can simulate
 * every Tailscale API response shape without making real HTTP calls. Each
 * test sets up a mock that responds to the URL path with a fixture body
 * and the expected status code, then asserts the client's behavior.
 *
 * The mock is intentionally simple: a function (method, path, opts) → Promise<{
 * status, body, headers }>. We don't try to be exhaustive about request
 * shape matching — just enough to verify the client's status handling,
 * caching, error mapping, and JSON parsing.
 */

'use strict';

/* eslint-disable require-await, no-unused-vars */
// require-await: many helper functions in this file are `async () => ...` to
// match the shape of the real function signatures — they don't need to await.
// no-unused-vars: some tests destructure fields they don't exercise.

const { TailscaleCoordClient, TailscaleCoordError } = require('../src/managers/tailscale-coord');

const VALID_TOKEN = 'tskey-api-kLD2XbydZ511CNTRL-CKorHnjoVpc11chfHcV8qcSz9hhjpUr3'; // realistic shape

/**
 * Build a fake fetchImpl from a route map.
 *
 *   {
 *     'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [...] } },
 *     'POST /api/v2/tailnet/-/keys':   { status: 200, body: { id: 'k1', key: 'tskey-auth-abc' } },
 *     'DELETE /api/v2/device/d1':      { status: 200, body: '' },
 *   }
 *
 * Unmatched routes return 404 by default (the client will then throw
 * TailscaleCoordError with code='not_found').
 */
function makeFetch(routes, { defaultStatus = 404, defaultBody = { message: 'no route' } } = {}) {
  const calls = [];
  const fn = jest.fn(async (method, path, opts) => {
    calls.push({ method, path, opts });
    const key = method + ' ' + path;
    const match = routes[key];
    if (match) {
      return {
        status: match.status,
        body: typeof match.body === 'string' ? match.body : JSON.stringify(match.body),
        headers: match.headers || { 'content-type': 'application/json' },
      };
    }
    return {
      status: defaultStatus,
      body: JSON.stringify(defaultBody),
      headers: { 'content-type': 'application/json' },
    };
  });
  fn.calls = calls;
  return fn;
}

describe('tailscale-coord: configuration', () => {
  test('isConfigured() returns false when no token set', () => {
    const c = new TailscaleCoordClient();
    expect(c.isConfigured()).toBe(false);
  });
  test('isConfigured() returns true after setApiToken()', () => {
    const c = new TailscaleCoordClient();
    c.setApiToken('tskey-api-foo');
    expect(c.isConfigured()).toBe(true);
  });
  test('setApiToken(null) clears the token', () => {
    const c = new TailscaleCoordClient({ apiToken: 'foo' });
    c.setApiToken(null);
    expect(c.isConfigured()).toBe(false);
  });
  test('constructor accepts apiToken in opts', () => {
    const c = new TailscaleCoordClient({ apiToken: 'x' });
    expect(c.isConfigured()).toBe(true);
  });
});

describe('tailscale-coord: not configured errors', () => {
  test('listDevices throws not_configured when no token', async () => {
    const c = new TailscaleCoordClient();
    await expect(c.listDevices()).rejects.toMatchObject({ code: 'not_configured' });
  });
  test('ping throws not_configured when no token', async () => {
    const c = new TailscaleCoordClient();
    await expect(c.ping()).rejects.toMatchObject({ code: 'not_configured' });
  });
  test('createAuthKey throws not_configured when no token', async () => {
    const c = new TailscaleCoordClient();
    await expect(c.createAuthKey({})).rejects.toMatchObject({ code: 'not_configured' });
  });
});

describe('tailscale-coord: ping()', () => {
  // ping() now hits /devices and derives tailnet name from magicDNSSuffix
  // on the first device. (Tailscale retired /preferences in 2026.)
  test('returns { domain, deviceCount } derived from /devices response', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 200,
        body: {
          devices: [
            { id: '1', hostname: 'dns2', name: 'dns2-sami.tail3e209.ts.net', addresses: ['100.121.150.22'] },
            { id: '2', hostname: 'laptop', name: 'laptop.tail3e209.ts.net', addresses: ['100.91.55.51'] },
          ],
        },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });

    const result = await c.ping();
    expect(result.domain).toBe('tail3e209.ts.net');
    expect(result.deviceCount).toBe(2);
    expect(fetchImpl).toHaveBeenCalledTimes(1);

    // Second call hits cache, no new HTTP request
    const result2 = await c.ping();
    expect(result2.domain).toBe('tail3e209.ts.net');
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  test('returns null domain when no .ts.net suffix is in name', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 200,
        body: {
          devices: [
            { id: '1', name: 'some-other-host.example.com', addresses: ['100.121.150.22'] },
          ],
        },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const result = await c.ping();
    expect(result.domain).toBeNull();
  });

  test('returns null domain when no useful name data is available', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 200,
        body: { devices: [] },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const result = await c.ping();
    expect(result.domain).toBeNull();
    expect(result.deviceCount).toBe(0);
  });

  test('skipCache forces a fresh request', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 200,
        body: { devices: [{ id: '1', name: 'foo.ts.net' }] },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.ping();
    await c.ping({ skipCache: true });
    expect(fetchImpl).toHaveBeenCalledTimes(2);
  });

  test('401 surfaces as TailscaleCoordError code=unauthorized', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 401, body: { message: 'unauthorized' } },
    });
    const c = new TailscaleCoordClient({ apiToken: 'bad-token', fetchImpl });
    await expect(c.ping()).rejects.toBeInstanceOf(TailscaleCoordError);
    await expect(c.ping()).rejects.toMatchObject({ status: 401, code: 'unauthorized' });
  });
});

describe('tailscale-coord: listDevices()', () => {
  const fixtureDevices = [
    { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'], os: 'linux', online: true },
    { id: 'nodekey:2', hostname: 'laptop', addresses: ['100.91.55.51'], os: 'windows', online: true },
    { id: 'nodekey:3', hostname: 'phone', addresses: ['100.106.44.35'], os: 'android', online: false },
  ];

  test('returns devices array on 200', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const devices = await c.listDevices();
    expect(devices).toHaveLength(3);
    expect(devices[0].hostname).toBe('dns2');
    expect(devices[2].online).toBe(false);
  });

  test('empty devices array on 200 with no devices', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [] } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const devices = await c.listDevices();
    expect(devices).toEqual([]);
  });

  test('missing devices field returns []', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: {} },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const devices = await c.listDevices();
    expect(devices).toEqual([]);
  });

  test('caches list for TTL_DEVICES_MS (60s)', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: fixtureDevices } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.listDevices();
    await c.listDevices();
    await c.listDevices();
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  test('5xx surfaces as server_error', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 503, body: { message: 'unavailable' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await expect(c.listDevices()).rejects.toMatchObject({ status: 503, code: 'server_error' });
  });

  test('429 surfaces as rate_limited with retryAfter', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 429,
        body: { message: 'too many requests' },
        headers: { 'content-type': 'application/json', 'retry-after': '30' },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await expect(c.listDevices()).rejects.toMatchObject({
      status: 429,
      code: 'rate_limited',
      retryAfter: '30',
    });
  });

  test('404 surfaces as not_found', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 404, body: { message: 'tailnet not found' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await expect(c.listDevices()).rejects.toMatchObject({ status: 404, code: 'not_found' });
  });
});

describe('tailscale-coord: getDevice()', () => {
  test('returns single device on 200', async () => {
    const dev = { id: 'nodekey:1', hostname: 'dns2', addresses: ['100.121.150.22'] };
    const fetchImpl = makeFetch({
      // client URL-encodes the deviceId, so route key uses %3A
      'GET /api/v2/device/nodekey%3A1': { status: 200, body: dev },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const got = await c.getDevice('nodekey:1');
    expect(got.id).toBe('nodekey:1');
  });

  test('encodes deviceId in URL', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/device/nodekey%3A1': { status: 200, body: { id: 'nodekey:1' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.getDevice('nodekey:1');
    expect(fetchImpl.calls[0].path).toBe('/api/v2/device/nodekey%3A1');
  });

  test('throws bad_input when deviceId missing', async () => {
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
    await expect(c.getDevice('')).rejects.toMatchObject({ code: 'bad_input' });
    await expect(c.getDevice(null)).rejects.toMatchObject({ code: 'bad_input' });
  });
});

describe('tailscale-coord: deleteDevice()', () => {
  test('returns success on 200 and invalidates device caches', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: 'd1' }] } },
      'DELETE /api/v2/device/d1': { status: 200, body: {} },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.listDevices(); // populates cache
    expect(fetchImpl).toHaveBeenCalledTimes(1);
    await c.deleteDevice('d1');
    expect(fetchImpl).toHaveBeenCalledTimes(2);
    // Next listDevices should re-fetch because cache was invalidated
    await c.listDevices();
    expect(fetchImpl).toHaveBeenCalledTimes(3);
  });

  test('throws bad_input when deviceId missing', async () => {
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
    await expect(c.deleteDevice('')).rejects.toMatchObject({ code: 'bad_input' });
  });
});

describe('tailscale-coord: createAuthKey()', () => {
  test('sends correct body and returns key on 200', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/keys': {
        status: 200,
        body: { id: 'k1', key: 'tskey-auth-abc123', created: '2026-07-07T00:00:00Z' },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const result = await c.createAuthKey({
      reusable: false,
      ephemeral: true,
      preauthorized: true,
      tags: ['tag:guest-plex'],
      description: 'Plex invite for friend',
      expirySeconds: 86400,
    });
    expect(result.key).toBe('tskey-auth-abc123');
    expect(result.id).toBe('k1');

    const sent = JSON.parse(fetchImpl.calls[0].opts.body);
    expect(sent.reusable).toBe(false);
    expect(sent.ephemeral).toBe(true);
    expect(sent.preauthorized).toBe(true);
    expect(sent.tags).toEqual(['tag:guest-plex']);
    expect(sent.description).toBe('Plex invite for friend');
    expect(sent.expirySeconds).toBe(86400);
  });

  test('omits optional fields when not provided', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k2', key: 'k' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.createAuthKey({});
    const sent = JSON.parse(fetchImpl.calls[0].opts.body);
    expect(sent.tags).toBeUndefined();
    expect(sent.description).toBeUndefined();
    expect(sent.expirySeconds).toBeUndefined();
    expect(sent.reusable).toBe(false); // default
    expect(sent.ephemeral).toBe(false); // default
    expect(sent.preauthorized).toBe(true); // default
  });

  test('caps expirySeconds at 7776000 (90 days)', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k3' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.createAuthKey({ expirySeconds: 99999999 });
    const sent = JSON.parse(fetchImpl.calls[0].opts.body);
    expect(sent.expirySeconds).toBe(7776000);
  });

  test('ignores non-positive expirySeconds', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k4' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.createAuthKey({ expirySeconds: 0 });
    const sent = JSON.parse(fetchImpl.calls[0].opts.body);
    expect(sent.expirySeconds).toBeUndefined();
  });

  test('ignores non-array tags', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k5' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.createAuthKey({ tags: 'tag:foo' });
    const sent = JSON.parse(fetchImpl.calls[0].opts.body);
    expect(sent.tags).toBeUndefined();
  });
});

describe('tailscale-coord: listAuthKeys()', () => {
  test('returns keys array and caches for TTL_LIST_MS', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/keys': {
        status: 200,
        body: { keys: [{ id: 'k1' }, { id: 'k2' }] },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const k1 = await c.listAuthKeys();
    const k2 = await c.listAuthKeys();
    expect(k1).toHaveLength(2);
    expect(k2).toBe(k1); // cached
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  test('missing keys field returns []', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/keys': { status: 200, body: {} },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const keys = await c.listAuthKeys();
    expect(keys).toEqual([]);
  });
});

describe('tailscale-coord: deleteAuthKey()', () => {
  test('invalidates keys:list cache', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/keys': { status: 200, body: { keys: [{ id: 'k1' }] } },
      'DELETE /api/v2/keys/k1': { status: 200, body: {} },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.listAuthKeys();
    await c.deleteAuthKey('k1');
    await c.listAuthKeys();
    expect(fetchImpl).toHaveBeenCalledTimes(3);
  });

  test('throws bad_input when keyId missing', async () => {
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
    await expect(c.deleteAuthKey('')).rejects.toMatchObject({ code: 'bad_input' });
  });
});

describe('tailscale-coord: listUsers()', () => {
  test('returns users array and caches', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/users': {
        status: 200,
        body: {
          users: [
            { id: 'u1', displayName: 'Sami', loginName: 'sami@github', role: 'admin' },
            { id: 'u2', displayName: 'Friend', loginName: 'friend@gmail.com', role: 'member' },
          ],
        },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const u = await c.listUsers();
    expect(u).toHaveLength(2);
    expect(u[0].role).toBe('admin');
    await c.listUsers();
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });
});

describe('tailscale-coord: ACL', () => {
  const aclFixture = {
    acls: [{ action: 'accept', src: ['autogroup:member'], dst: ['*:*'] }],
    ssh: [{ action: 'accept', src: ['autogroup:member'], dst: ['autogroup:self'], users: ['root', 'autogroup:nonroot'] }],
  };

  test('getAcl returns parsed body (not cached)', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/acl': { status: 200, body: aclFixture },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const a1 = await c.getAcl();
    const a2 = await c.getAcl();
    expect(a1.acls[0].action).toBe('accept');
    expect(fetchImpl).toHaveBeenCalledTimes(2); // explicitly not cached
  });

  test('updateAcl sends the object as JSON body', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/acl': { status: 200, body: {} },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.updateAcl(aclFixture);
    const sent = JSON.parse(fetchImpl.calls[0].opts.body);
    expect(sent.acls[0].src).toContain('autogroup:member');
  });

  test('updateAcl throws bad_input on non-object', async () => {
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl: makeFetch({}) });
    await expect(c.updateAcl(null)).rejects.toMatchObject({ code: 'bad_input' });
    await expect(c.updateAcl('a string')).rejects.toMatchObject({ code: 'bad_input' });
    await expect(c.updateAcl([])).rejects.toMatchObject({ code: 'bad_input' });
  });
});

describe('tailscale-coord: HTTP shape', () => {
  test('sends Authorization: Bearer <token> header', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.ping();
    expect(fetchImpl.calls[0].opts.headers.Authorization).toBe('Bearer ' + VALID_TOKEN);
  });

  test('sends Content-Type: application/json on POST with body', async () => {
    const fetchImpl = makeFetch({
      'POST /api/v2/tailnet/-/keys': { status: 200, body: { id: 'k1' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.createAuthKey({ tags: ['tag:x'] });
    expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBe('application/json');
  });

  test('does not send Content-Type when no body', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.ping();
    expect(fetchImpl.calls[0].opts.headers['Content-Type']).toBeUndefined();
  });

  test('parses string JSON body correctly', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 200,
        body: JSON.stringify({ devices: [{ id: '1', name: 'foo.ts.net' }] }),
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const result = await c.ping();
    expect(result.domain).toBe('foo.ts.net');
    expect(result.deviceCount).toBe(1);
  });

  test('non-JSON 200 body returned as string', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/acl': { status: 200, body: 'not-json', headers: { 'content-type': 'text/plain' } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    const result = await c.getAcl();
    expect(result).toBe('not-json');
  });

  test('extracts retryAfter from response headers', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': {
        status: 429,
        body: { message: 'slow down' },
        headers: { 'content-type': 'application/json', 'retry-after': '60' },
      },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    try {
      await c.listDevices();
      throw new Error('expected throw');
    } catch (e) {
      expect(e).toBeInstanceOf(TailscaleCoordError);
      expect(e.retryAfter).toBe('60');
    }
  });
});

describe('tailscale-coord: cache lifecycle', () => {
  test('setApiToken clears all caches', async () => {
    const fetchImpl = makeFetch({
      'GET /api/v2/tailnet/-/devices': { status: 200, body: { devices: [{ id: '1', name: 'foo.ts.net' }] } },
    });
    const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
    await c.ping();
    expect(fetchImpl).toHaveBeenCalledTimes(1);
    c.setApiToken('tskey-api-other');
    await c.ping();
    expect(fetchImpl).toHaveBeenCalledTimes(2);
  });

  test('expired cache entries re-fetch', async () => {
      const fetchImpl = makeFetch({
        'GET /api/v2/tailnet/-/devices': {
          status: 200,
          body: { devices: [{ id: '1', name: 'a.ts.net' }] },
        },
      });
      const c = new TailscaleCoordClient({ apiToken: VALID_TOKEN, fetchImpl });
      await c.ping();
      expect(fetchImpl).toHaveBeenCalledTimes(1);
      // Manually expire the cache entry
      c._cache.set('ping', { expiresAt: Date.now() - 1000, value: { stale: true } });
      const fresh = await c.ping();
      expect(fresh.domain).toBe('a.ts.net');
      expect(fetchImpl).toHaveBeenCalledTimes(2);
    });
});

describe('tailscale-coord: error class', () => {
  test('TailscaleCoordError carries status, code, body, retryAfter', () => {
    const e = new TailscaleCoordError('test', { status: 429, body: { x: 1 }, retryAfter: '60', code: 'rate_limited' });
    expect(e.message).toBe('test');
    expect(e.status).toBe(429);
    expect(e.code).toBe('rate_limited');
    expect(e.body).toEqual({ x: 1 });
    expect(e.retryAfter).toBe('60');
    expect(e).toBeInstanceOf(Error);
    expect(e).toBeInstanceOf(TailscaleCoordError);
  });

  test('default code derives from status', () => {
    expect(new TailscaleCoordError('x', { status: 401 }).code).toBe('unauthorized');
    expect(new TailscaleCoordError('x', { status: 403 }).code).toBe('unauthorized');
    expect(new TailscaleCoordError('x', { status: 404 }).code).toBe('not_found');
    expect(new TailscaleCoordError('x', { status: 429 }).code).toBe('rate_limited');
    expect(new TailscaleCoordError('x', { status: 500 }).code).toBe('server_error');
    expect(new TailscaleCoordError('x', { status: 502 }).code).toBe('server_error');
    expect(new TailscaleCoordError('x', {}).code).toBe('unknown');
  });
});