* 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>
623 lines
21 KiB
TypeScript
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');
|
|
});
|
|
});
|