From 6927d1a8e85aff35e1fe4ab8ab54c4e744dd0cc9 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 05:21:37 +0000 Subject: [PATCH 1/2] [#1238] Add lib/airdrop/twitterapi.ts (lookupXUser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit twitterapi.io wrapper for X user lookups: returns display name, avatar, follower count, user ID, and bio snippet. Includes 5-min in-memory LRU cache (200 entries max), 404→null, 5xx→throw. 8 unit tests cover success, 404, 5xx, cache hits, case normalization, missing API key, and bio truncation. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/twitterapi.test.ts | 116 +++++++++++++++++++++++++++++++++ lib/airdrop/twitterapi.ts | 88 +++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 lib/airdrop/twitterapi.test.ts create mode 100644 lib/airdrop/twitterapi.ts diff --git a/lib/airdrop/twitterapi.test.ts b/lib/airdrop/twitterapi.test.ts new file mode 100644 index 0000000..dc4ba7b --- /dev/null +++ b/lib/airdrop/twitterapi.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +beforeEach(() => { + vi.stubEnv("TWITTERAPI_IO_KEY", "test-key"); + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(async () => { + const { _clearCache } = await import("./twitterapi"); + _clearCache(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +function mockFetchResponse(status: number, body?: unknown) { + return vi.mocked(fetch).mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + statusText: status === 404 ? "Not Found" : status >= 500 ? "Internal Server Error" : "OK", + json: () => Promise.resolve(body), + } as Response); +} + +const MOCK_USER_DATA = { + data: { + id: "12345", + name: "Test User", + profile_image_url: "https://pbs.twimg.com/photo.jpg", + public_metrics: { followers_count: 1500 }, + description: "Hello world bio", + }, +}; + +describe("lookupXUser", () => { + it("returns user data for a successful lookup", async () => { + const { lookupXUser } = await import("./twitterapi"); + mockFetchResponse(200, MOCK_USER_DATA); + + const result = await lookupXUser("testuser"); + expect(result).toEqual({ + display_name: "Test User", + avatar_url: "https://pbs.twimg.com/photo.jpg", + follower_count: 1500, + x_user_id: "12345", + bio_snippet: "Hello world bio", + }); + expect(fetch).toHaveBeenCalledWith( + "https://api.twitterapi.io/v2/users/by/username/testuser", + { headers: { "X-API-Key": "test-key" } }, + ); + }); + + it("returns null for 404", async () => { + const { lookupXUser } = await import("./twitterapi"); + mockFetchResponse(404); + + const result = await lookupXUser("nonexistent"); + expect(result).toBeNull(); + }); + + it("throws on 5xx", async () => { + const { lookupXUser } = await import("./twitterapi"); + mockFetchResponse(500); + + await expect(lookupXUser("testuser")).rejects.toThrow("twitterapi.io error: 500"); + }); + + it("uses cache on second call within TTL", async () => { + const { lookupXUser } = await import("./twitterapi"); + mockFetchResponse(200, MOCK_USER_DATA); + + const first = await lookupXUser("cached_user"); + const second = await lookupXUser("cached_user"); + + expect(first).toEqual(second); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("caches null results (404)", async () => { + const { lookupXUser } = await import("./twitterapi"); + mockFetchResponse(404); + + await lookupXUser("missing"); + await lookupXUser("missing"); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("normalizes username to lowercase for cache key", async () => { + const { lookupXUser } = await import("./twitterapi"); + mockFetchResponse(200, MOCK_USER_DATA); + + await lookupXUser("TestUser"); + await lookupXUser("testuser"); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("throws when TWITTERAPI_IO_KEY is not set", async () => { + vi.stubEnv("TWITTERAPI_IO_KEY", ""); + const { lookupXUser } = await import("./twitterapi"); + + await expect(lookupXUser("test")).rejects.toThrow("TWITTERAPI_IO_KEY not configured"); + }); + + it("truncates bio to 200 chars", async () => { + const { lookupXUser } = await import("./twitterapi"); + const longBio = "a".repeat(300); + mockFetchResponse(200, { + data: { ...MOCK_USER_DATA.data, description: longBio }, + }); + + const result = await lookupXUser("longbio"); + expect(result!.bio_snippet).toHaveLength(200); + }); +}); diff --git a/lib/airdrop/twitterapi.ts b/lib/airdrop/twitterapi.ts new file mode 100644 index 0000000..670e185 --- /dev/null +++ b/lib/airdrop/twitterapi.ts @@ -0,0 +1,88 @@ +const CACHE_TTL_MS = 5 * 60_000; +const CACHE_MAX = 200; + +interface XUser { + display_name: string; + avatar_url: string; + follower_count: number; + x_user_id: string; + bio_snippet: string; +} + +interface CacheEntry { + value: XUser | null; + expiry: number; +} + +const cache = new Map(); + +function evictExpired() { + const now = Date.now(); + for (const [key, entry] of cache) { + if (entry.expiry <= now) cache.delete(key); + } +} + +function cacheGet(key: string): { hit: true; value: XUser | null } | { hit: false } { + const entry = cache.get(key); + if (entry && entry.expiry > Date.now()) { + return { hit: true, value: entry.value }; + } + if (entry) cache.delete(key); + return { hit: false }; +} + +function cacheSet(key: string, value: XUser | null) { + if (cache.size >= CACHE_MAX) evictExpired(); + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next().value!; + cache.delete(oldest); + } + cache.set(key, { value, expiry: Date.now() + CACHE_TTL_MS }); +} + +export async function lookupXUser(username: string): Promise { + const key = username.toLowerCase(); + + const cached = cacheGet(key); + if (cached.hit) return cached.value; + + const apiKey = process.env.TWITTERAPI_IO_KEY; + if (!apiKey) throw new Error("TWITTERAPI_IO_KEY not configured"); + + const res = await fetch( + `https://api.twitterapi.io/v2/users/by/username/${encodeURIComponent(key)}`, + { headers: { "X-API-Key": apiKey } }, + ); + + if (res.status === 404) { + cacheSet(key, null); + return null; + } + + if (!res.ok) { + throw new Error(`twitterapi.io error: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + const user = data?.data; + if (!user) { + cacheSet(key, null); + return null; + } + + const result: XUser = { + display_name: user.name ?? "", + avatar_url: user.profile_image_url ?? "", + follower_count: user.public_metrics?.followers_count ?? 0, + x_user_id: String(user.id), + bio_snippet: (user.description ?? "").slice(0, 200), + }; + + cacheSet(key, result); + return result; +} + +export function _clearCache() { + cache.clear(); +} diff --git a/package.json b/package.json index 782cf70..2969106 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.30.2", + "version": "1.30.3", "private": true, "workspaces": [ "packages/*" From 33705f9f7da67dfe41d3c76258ca909b3d4566f5 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 05:25:32 +0000 Subject: [PATCH 2/2] [#1238] Fix cache to true LRU: refresh recency on hit Move hit entries to most-recent position via delete+re-set on Map. Add test verifying a recently-hit old key survives eviction while the least-recently-used key (filler_0) gets evicted. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/airdrop/twitterapi.test.ts | 37 ++++++++++++++++++++++++++++++++++ lib/airdrop/twitterapi.ts | 2 ++ 2 files changed, 39 insertions(+) diff --git a/lib/airdrop/twitterapi.test.ts b/lib/airdrop/twitterapi.test.ts index dc4ba7b..a7a644a 100644 --- a/lib/airdrop/twitterapi.test.ts +++ b/lib/airdrop/twitterapi.test.ts @@ -103,6 +103,43 @@ describe("lookupXUser", () => { await expect(lookupXUser("test")).rejects.toThrow("TWITTERAPI_IO_KEY not configured"); }); + it("LRU: recently-hit key survives eviction past max entries", async () => { + const { lookupXUser } = await import("./twitterapi"); + + // Insert "old" + 199 fillers to fill cache to max (200) + mockFetchResponse(200, MOCK_USER_DATA); + await lookupXUser("old"); + for (let i = 0; i < 199; i++) { + mockFetchResponse(200, { + data: { ...MOCK_USER_DATA.data, id: String(1000 + i) }, + }); + await lookupXUser(`filler_${i}`); + } + expect(fetch).toHaveBeenCalledTimes(200); + + // Hit "old" to move it to most-recent position + await lookupXUser("old"); // cache hit, no fetch + expect(fetch).toHaveBeenCalledTimes(200); + + // Insert one more — evicts filler_0 (oldest), NOT "old" + mockFetchResponse(200, { + data: { ...MOCK_USER_DATA.data, id: "9999" }, + }); + await lookupXUser("new_entry"); + + // "old" should still be cached + const fetchCountBefore = vi.mocked(fetch).mock.calls.length; + await lookupXUser("old"); + expect(vi.mocked(fetch).mock.calls.length).toBe(fetchCountBefore); + + // filler_0 should have been evicted — triggers a new fetch + mockFetchResponse(200, { + data: { ...MOCK_USER_DATA.data, id: "1000" }, + }); + await lookupXUser("filler_0"); + expect(vi.mocked(fetch).mock.calls.length).toBe(fetchCountBefore + 1); + }); + it("truncates bio to 200 chars", async () => { const { lookupXUser } = await import("./twitterapi"); const longBio = "a".repeat(300); diff --git a/lib/airdrop/twitterapi.ts b/lib/airdrop/twitterapi.ts index 670e185..5550d1e 100644 --- a/lib/airdrop/twitterapi.ts +++ b/lib/airdrop/twitterapi.ts @@ -26,6 +26,8 @@ function evictExpired() { function cacheGet(key: string): { hit: true; value: XUser | null } | { hit: false } { const entry = cache.get(key); if (entry && entry.expiry > Date.now()) { + cache.delete(key); + cache.set(key, entry); return { hit: true, value: entry.value }; } if (entry) cache.delete(key);