Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions lib/airdrop/twitterapi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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("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);
mockFetchResponse(200, {
data: { ...MOCK_USER_DATA.data, description: longBio },
});

const result = await lookupXUser("longbio");
expect(result!.bio_snippet).toHaveLength(200);
});
});
90 changes: 90 additions & 0 deletions lib/airdrop/twitterapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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<string, CacheEntry>();

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()) {
cache.delete(key);
cache.set(key, entry);
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<XUser | null> {
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();
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.30.2",
"version": "1.30.3",
"private": true,
"workspaces": [
"packages/*"
Expand Down
Loading