Files
gbrain/test/resolvers.test.ts
Garry Tan 96178d726e fix(subagent): v0.16.3 — bind Anthropic SDK correctly + enable tsc in CI (#318)
* fix(subagent): bind Anthropic SDK messages.create() correctly

The makeSubagentHandler was casting `new Anthropic()` directly to
MessagesClient, but MessagesClient.create() maps to sdk.messages.create(),
not sdk.create(). Every subagent job immediately died with:

  client.create is not a function

Fix: wrap the SDK instance so .create() delegates to .messages.create()
with proper `this` binding via .bind(sdk.messages).

Discovered on first production run of gbrain agent against Supabase.

Co-Authored-By: Wintermute <wintermute@openclaw.ai>

* chore(ci): add typescript typecheck to test pipeline + clean up baseline errors

Root cause infra gap that let the v0.16.0 subagent bug ship: CI ran
only `bun test`, which transpiles types without checking them. Type
errors only surfaced at runtime, in production.

Changes:
- Add `typescript` devDep and a `typecheck` npm script (`tsc --noEmit`).
- Chain `bun run typecheck` into `bun run test` so developers get the
  same pipeline locally that CI runs.
- Flip `.github/workflows/test.yml` to invoke `bun run test` (the npm
  script, including typecheck) instead of `bun test` (runner only).
- Clean up 100+ pre-existing type errors across 30+ files so the first
  run of `tsc --noEmit` is green. Root causes were:
  - `databaseUrl` → `database_url` rename drift in test fixtures (9 files)
  - `PageType` union missing `'meeting'` / `'note'` entries that are
    already used in both src and tests (link-extraction.ts comments
    acknowledged the gap)
  - `GBrainConfig.storage` field never declared despite being read in
    files.ts and operations.ts
  - `ErrorCode` union missing `'permission_denied'`
  - `OrchestratorOpts` shape changed; test callers not updated
  - Dead-code comparisons in migration orchestrators against narrowed
    status types
  - postgres.js `Row`-callback type drift on several `.map()` calls
  - Buffer-as-BodyInit assignment in supabase.ts (real but non-fatal
    runtime bug; Uint8Array slice works and is type-correct)
  - Various `as X` single-step casts that now need `as unknown as X`
    per TS's stricter structural-conversion rules
- Bump `beforeAll` hook timeout to 30s on four PGLite-heavy tests that
  were flaky under parallel test execution: wait-for-completion,
  extract-fs, e2e/search-quality, e2e/graph-quality. All pass in
  isolation; timeouts only happened when dozens of PGLite instances
  init'd simultaneously.

The new CI pipeline now fails on any type error across src/ or test/,
giving us the compile-time regression guard the subagent fix depends on.

* fix(subagent): bind Anthropic SDK messages.create() correctly

Shipped bug: v0.16.0 cast `new Anthropic()` to `MessagesClient`, but
`.create()` lives at `sdk.messages.create`, not on the top-level client.
Every subagent job in production died on first LLM call with
`client.create is not a function`. Discovered on the first `gbrain agent
run` against Supabase.

Fix: assign `sdk.messages` directly to the `MessagesClient` slot.
`sdk.messages` IS the object with a callable `.create()`; the original
bug was picking the wrong entry point on the SDK. No helper, no
wrapper, no `.bind()` — JS method-call semantics preserve `this` at
the call site because `subagent.ts:336` invokes `client.create(...)`
with `client === sdk.messages`.

The one-line assignment also typechecks cleanly against the existing
`MessagesClient` interface (SDK's first `create` overload:
`(MessageCreateParamsNonStreaming, Core.RequestOptions?) =>
APIPromise<Message>` is assignable structurally). This gives us
compile-time regression protection: anyone reverting to
`new Anthropic()` would fail tsc because `Anthropic` has no top-level
`.create`. (The companion chore commit puts `tsc --noEmit` in CI so
this guard is enforced.)

Also adds a `makeAnthropic?: () => Anthropic` dep-injection seam so
the factory default construction branch is testable without real API
calls. Regression test drives one handler turn through a fake SDK,
asserting `sdk.messages.create` is actually called. If someone later
reverts to `new Anthropic()`, both guards fire: tsc fails AND the test
fails.

Co-Authored-By: Wintermute <wintermute@garrytan.com>

* chore(tests): add bunfig.toml + 60s hook timeouts to stabilize PGLite-heavy suites

After turning on tsc in CI (previous commit), running the full `bun run test`
suite in one shot triggered flaky `beforeEach/afterEach hook timed out`
failures on 8+ test files. Every failure traced to PGLite WASM init
contention when many test files spin up fresh PGLite instances in parallel;
each one alone passes in isolation.

- `bunfig.toml` sets the global test hook timeout to 60s (default is 5s),
  covering every test file without per-file edits.
- Individual `beforeAll(fn, 60_000)` / `beforeEach(fn, 15_000)` calls on
  the 8 tests that flaked most stay in place as explicit safety nets so
  a future bunfig config change doesn't silently re-introduce the flake.

Result: 1997 pass, 0 fail on `bun run test` (117 tests added since the
prior baseline by picking up typecheck-gated passes). No infrastructure
flake tolerated in CI.

* chore: bump version and changelog (v0.16.3)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Wintermute <wintermute@openclaw.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:34:22 -07:00

623 lines
21 KiB
TypeScript

/**
* Resolver SDK tests — interface contract + registry + 2 reference builtins.
*
* No network. url_reachable is tested via global fetch mock; x_handle_to_tweet
* via mocked fetch + env. Real-network E2E (if any) lives in test/e2e/.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import {
ResolverRegistry,
ResolverError,
getDefaultRegistry,
_resetDefaultRegistry,
} from '../src/core/resolvers/index.ts';
import type {
Resolver,
ResolverContext,
ResolverRequest,
ResolverResult,
} from '../src/core/resolvers/index.ts';
import { urlReachableResolver, checkDnsRebinding } from '../src/core/resolvers/builtin/url-reachable.ts';
import { xHandleToTweetResolver, computeBackoffMs } from '../src/core/resolvers/builtin/x-api/handle-to-tweet.ts';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeCtx(overrides: Partial<ResolverContext> = {}): ResolverContext {
return {
config: {},
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
requestId: 'test',
remote: false,
...overrides,
};
}
// Tiny fake resolver for contract tests
const echoResolver: Resolver<{ v: string }, { v: string }> = {
id: 'echo',
cost: 'free',
backend: 'local',
description: 'Echo',
async available() { return true; },
async resolve(req: ResolverRequest<{ v: string }>): Promise<ResolverResult<{ v: string }>> {
return {
value: { v: req.input.v },
confidence: 1,
source: 'local',
fetchedAt: new Date(),
};
},
};
// ---------------------------------------------------------------------------
// Registry tests
// ---------------------------------------------------------------------------
describe('ResolverRegistry', () => {
let reg: ResolverRegistry;
beforeEach(() => {
reg = new ResolverRegistry();
});
test('starts empty', () => {
expect(reg.size()).toBe(0);
expect(reg.list()).toEqual([]);
});
test('register + get + has', () => {
reg.register(echoResolver);
expect(reg.size()).toBe(1);
expect(reg.has('echo')).toBe(true);
expect(reg.get('echo').id).toBe('echo');
});
test('register rejects duplicate id', () => {
reg.register(echoResolver);
expect(() => reg.register(echoResolver)).toThrow(ResolverError);
try {
reg.register(echoResolver);
} catch (e) {
expect((e as ResolverError).code).toBe('already_registered');
}
});
test('register rejects empty id', () => {
expect(() => reg.register({ ...echoResolver, id: '' })).toThrow(ResolverError);
});
test('get throws not_found for unknown id', () => {
try {
reg.get('nope');
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ResolverError);
expect((e as ResolverError).code).toBe('not_found');
}
});
test('list returns summaries sorted by id', () => {
reg.register(echoResolver);
reg.register({ ...echoResolver, id: 'alpha' });
const list = reg.list();
expect(list.map(r => r.id)).toEqual(['alpha', 'echo']);
expect(list[0].cost).toBe('free');
expect(list[0].backend).toBe('local');
});
test('list filters by cost', () => {
reg.register(echoResolver); // free
reg.register({ ...echoResolver, id: 'paid-one', cost: 'paid' });
expect(reg.list({ cost: 'paid' }).map(r => r.id)).toEqual(['paid-one']);
expect(reg.list({ cost: 'free' }).map(r => r.id)).toEqual(['echo']);
});
test('list filters by backend', () => {
reg.register(echoResolver);
reg.register({ ...echoResolver, id: 'x-one', backend: 'x-api-v2' });
expect(reg.list({ backend: 'x-api-v2' }).map(r => r.id)).toEqual(['x-one']);
});
test('resolve returns result', async () => {
reg.register(echoResolver);
const r = await reg.resolve('echo', { v: 'hi' }, makeCtx());
expect(r.value).toEqual({ v: 'hi' });
expect(r.confidence).toBe(1);
});
test('resolve throws not_found for unknown id', async () => {
try {
await reg.resolve('nope', {}, makeCtx());
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('not_found');
}
});
test('resolve throws unavailable when available() returns false', async () => {
reg.register({
...echoResolver,
id: 'blocked',
async available() { return false; },
});
try {
await reg.resolve('blocked', {}, makeCtx());
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('unavailable');
}
});
test('clear empties registry', () => {
reg.register(echoResolver);
reg.clear();
expect(reg.size()).toBe(0);
});
});
describe('getDefaultRegistry', () => {
beforeEach(() => _resetDefaultRegistry());
afterEach(() => _resetDefaultRegistry());
test('returns a singleton', () => {
const a = getDefaultRegistry();
const b = getDefaultRegistry();
expect(a).toBe(b);
});
test('_resetDefaultRegistry gives a fresh instance', () => {
const a = getDefaultRegistry();
a.register(echoResolver);
_resetDefaultRegistry();
const b = getDefaultRegistry();
expect(b).not.toBe(a);
expect(b.size()).toBe(0);
});
});
// ---------------------------------------------------------------------------
// url_reachable builtin
// ---------------------------------------------------------------------------
describe('url_reachable resolver', () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
test('available() is true', async () => {
expect(await urlReachableResolver.available(makeCtx())).toBe(true);
});
test('schema: id + cost + backend match contract', () => {
expect(urlReachableResolver.id).toBe('url_reachable');
expect(urlReachableResolver.cost).toBe('free');
expect(urlReachableResolver.backend).toBe('head-check');
});
test('blocks localhost via SSRF guard', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'http://127.0.0.1:1' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.reason).toMatch(/internal|private/i);
});
test('blocks RFC1918 addresses', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'http://10.0.0.1/' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
});
test('blocks AWS metadata endpoint', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'http://169.254.169.254/latest/meta-data/' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
});
test('blocks non-http(s) schemes', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'file:///etc/passwd' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
});
test('throws schema error for empty url', async () => {
try {
await urlReachableResolver.resolve({ input: { url: '' }, context: makeCtx() });
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('schema');
}
});
test('200 response → reachable=true', async () => {
globalThis.fetch = (async () => new Response('', { status: 200 })) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/ok' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(true);
expect(r.value.status).toBe(200);
});
test('404 response → reachable=false with status + reason', async () => {
globalThis.fetch = (async () => new Response('', { status: 404 })) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/dead' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.status).toBe(404);
expect(r.value.reason).toMatch(/HTTP 404/);
});
test('HEAD 405 falls back to GET', async () => {
let callCount = 0;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
callCount++;
if (init?.method === 'HEAD') return new Response('', { status: 405 });
return new Response('ok', { status: 200 });
}) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/post-only' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(true);
expect(callCount).toBe(2);
});
test('follows redirect to external URL', async () => {
const responses = [
new Response('', { status: 301, headers: { location: 'https://example.org/final' } }),
new Response('', { status: 200 }),
];
let i = 0;
globalThis.fetch = (async () => responses[i++]) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/redirect' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(true);
expect(r.value.finalUrl).toBe('https://example.org/final');
});
test('blocks redirect to internal URL (per-hop SSRF revalidation)', async () => {
globalThis.fetch = (async () => new Response('', {
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
})) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/redirects-to-local' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.reason).toMatch(/redirect to blocked/i);
});
test('fetch network failure → reachable=false, confidence=1', async () => {
globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://nonexistent.example/' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.reason).toMatch(/fetch error/);
expect(r.confidence).toBe(1);
});
test('checkDnsRebinding: skips IP literals', async () => {
expect(await checkDnsRebinding('http://8.8.8.8/')).toBeNull();
expect(await checkDnsRebinding('http://127.0.0.1/')).toBeNull();
expect(await checkDnsRebinding('http://[::1]/')).toBeNull();
});
test('checkDnsRebinding: returns null for unparseable URL', async () => {
expect(await checkDnsRebinding('not a url')).toBeNull();
});
test('checkDnsRebinding: returns null on DNS failure (surface via fetch)', async () => {
// Nonexistent TLD; DNS lookup fails, we let the fetch surface the error.
const r = await checkDnsRebinding('http://definitely-not-a-real-tld.invalidtld123/');
expect(r).toBeNull();
});
test('AbortSignal fires mid-flight → ResolverError(aborted)', async () => {
const ac = new AbortController();
globalThis.fetch = (async () => {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
}) as unknown as typeof fetch;
ac.abort();
try {
await urlReachableResolver.resolve({
input: { url: 'https://example.com/' },
context: makeCtx({ signal: ac.signal }),
});
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ResolverError);
expect((e as ResolverError).code).toBe('aborted');
}
});
});
// ---------------------------------------------------------------------------
// x_handle_to_tweet builtin
// ---------------------------------------------------------------------------
describe('x_handle_to_tweet resolver', () => {
const originalFetch = globalThis.fetch;
const originalToken = process.env.X_API_BEARER_TOKEN;
beforeEach(() => {
delete process.env.X_API_BEARER_TOKEN;
});
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalToken) process.env.X_API_BEARER_TOKEN = originalToken;
else delete process.env.X_API_BEARER_TOKEN;
});
// ---- computeBackoffMs ----
test('computeBackoffMs: honors Retry-After seconds', () => {
const r = new Response('', { status: 429, headers: { 'retry-after': '10' } });
expect(computeBackoffMs(r)).toBe(10_000);
});
test('computeBackoffMs: honors Retry-After HTTP-date', () => {
const now = 1_700_000_000_000; // 2023-11-14T22:13:20Z
const future = new Date(now + 7_000).toUTCString();
const r = new Response('', { status: 429, headers: { 'retry-after': future } });
const ms = computeBackoffMs(r, now);
expect(ms).toBeGreaterThanOrEqual(6_000);
expect(ms).toBeLessThanOrEqual(8_000);
});
test('computeBackoffMs: honors x-rate-limit-reset epoch seconds', () => {
const now = 1_700_000_000_000;
const resetSec = Math.floor(now / 1000) + 15;
const r = new Response('', { status: 429, headers: { 'x-rate-limit-reset': String(resetSec) } });
expect(computeBackoffMs(r, now)).toBeGreaterThanOrEqual(14_000);
expect(computeBackoffMs(r, now)).toBeLessThanOrEqual(16_000);
});
test('computeBackoffMs: takes MAX when both headers present', () => {
const now = 1_700_000_000_000;
const resetSec = Math.floor(now / 1000) + 30;
const r = new Response('', {
status: 429,
headers: { 'retry-after': '5', 'x-rate-limit-reset': String(resetSec) },
});
const ms = computeBackoffMs(r, now);
expect(ms).toBeGreaterThanOrEqual(29_000);
});
test('computeBackoffMs: clamps to floor 2s when no headers', () => {
const r = new Response('', { status: 429 });
expect(computeBackoffMs(r)).toBeGreaterThanOrEqual(2_000);
});
test('computeBackoffMs: clamps to ceiling 60s', () => {
const now = 1_700_000_000_000;
const resetSec = Math.floor(now / 1000) + 600; // 10 min
const r = new Response('', { status: 429, headers: { 'x-rate-limit-reset': String(resetSec) } });
expect(computeBackoffMs(r, now)).toBeLessThanOrEqual(60_000);
});
test('available() false when token missing', async () => {
expect(await xHandleToTweetResolver.available(makeCtx())).toBe(false);
});
test('available() true when token in env', async () => {
process.env.X_API_BEARER_TOKEN = 'fake-token';
expect(await xHandleToTweetResolver.available(makeCtx())).toBe(true);
});
test('available() true when token in ctx.config', async () => {
const ctx = makeCtx({ config: { x_api_bearer_token: 'fake-token' } });
expect(await xHandleToTweetResolver.available(ctx)).toBe(true);
});
test('rejects invalid handle (schema)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'bad handle with spaces' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('schema');
}
});
test('rejects handle longer than 15 chars', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'a'.repeat(16) },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('schema');
}
});
test('throws unavailable when no token at resolve time', async () => {
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('unavailable');
}
});
test('zero candidates → confidence 0', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response(JSON.stringify({ data: [], meta: { result_count: 0 } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'nothing matches' },
context: makeCtx(),
});
expect(r.confidence).toBe(0);
expect(r.value.candidates).toEqual([]);
expect(r.value.url).toBeUndefined();
});
test('single strong match → confidence >= 0.8 (auto-repair bucket)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response(JSON.stringify({
data: [
{ id: '123', text: 'talking about building gbrain today', created_at: '2026-04-18T00:00:00Z' },
],
}), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'building gbrain' },
context: makeCtx(),
});
expect(r.confidence).toBeGreaterThanOrEqual(0.8);
expect(r.value.url).toBe('https://x.com/garrytan/status/123');
expect(r.value.tweet_id).toBe('123');
});
test('single weak-match → confidence in 0.5-0.8 review range', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response(JSON.stringify({
data: [{ id: '1', text: 'something unrelated entirely', created_at: '2026-04-18T00:00:00Z' }],
}), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'gbrain knowledge runtime specific terms' },
context: makeCtx(),
});
expect(r.confidence).toBeGreaterThanOrEqual(0.5);
expect(r.confidence).toBeLessThan(0.8);
});
test('many ambiguous candidates → confidence < 0.5 (skip bucket)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
const data = Array.from({ length: 10 }, (_, i) => ({
id: String(i + 1),
text: 'short noise text ' + i,
created_at: '2026-04-18T00:00:00Z',
}));
globalThis.fetch = (async () => new Response(JSON.stringify({ data }), {
status: 200, headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'completely different signal words unlikely to match' },
context: makeCtx(),
});
expect(r.confidence).toBeLessThan(0.5);
expect(r.value.candidates.length).toBe(10);
expect(r.value.url).toBeUndefined(); // gated by >= 0.5
});
test('401 → ResolverError(auth)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response('unauthorized', { status: 401 })) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('auth');
}
});
test('403 → ResolverError(auth)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response('forbidden', { status: 403 })) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('auth');
}
});
test('500 → ResolverError(upstream) with body snippet', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response('internal err', { status: 500 })) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('upstream');
expect((e as ResolverError).message).toMatch(/HTTP 500/);
}
});
test('429 retries then surfaces rate_limited', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return new Response('rate', { status: 429, headers: { 'retry-after': '0' } });
}) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('rate_limited');
expect(calls).toBeGreaterThanOrEqual(3); // initial + 2 retries
}
});
test('strips X operators from keyword input (injection defense)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
let capturedUrl = '';
globalThis.fetch = (async (url: string) => {
capturedUrl = url;
return new Response(JSON.stringify({ data: [] }), {
status: 200, headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'from:evil_user lang:ja to:someone normal words' },
context: makeCtx(),
});
// Decoded query should still have handle but not extra operators.
// URLSearchParams encodes spaces as '+', so use token-level assertions.
const params = new URL(capturedUrl).searchParams;
const query = params.get('query') ?? '';
expect(query).toContain('from:garrytan');
expect(query).not.toContain('from:evil_user');
expect(query).not.toContain('lang:ja');
expect(query).not.toContain('to:someone');
expect(query).toContain('normal');
expect(query).toContain('words');
});
});