diff --git a/packages/stellar-sdk-helpers/src/keeper-heartbeat.test.ts b/packages/stellar-sdk-helpers/src/keeper-heartbeat.test.ts index 5b54d30a..d19bf84e 100644 --- a/packages/stellar-sdk-helpers/src/keeper-heartbeat.test.ts +++ b/packages/stellar-sdk-helpers/src/keeper-heartbeat.test.ts @@ -150,36 +150,150 @@ describe("createUpstashKeeperHeartbeatStore", () => { }); describe("loadKeeperHeartbeatStore", () => { - it("falls back to an in-memory store when Upstash is not configured", async () => { - const log = logger(); - const store = loadKeeperHeartbeatStore({}, { logger: log }); - await store.set("k", 1); - expect(await store.get("k")).toBe(1); - expect(log.info).toHaveBeenCalled(); - }); + const credentialPairs = [ + { + name: "unprefixed", + urlKey: "UPSTASH_REDIS_REST_URL", + tokenKey: "UPSTASH_REDIS_REST_TOKEN", + }, + { + name: "store-prefixed", + urlKey: "UPSTASH_REDIS_REST_KV_REST_API_URL", + tokenKey: "UPSTASH_REDIS_REST_KV_REST_API_TOKEN", + }, + ]; - it("warns (not just informs) when deployed without Upstash configured", () => { + async function expectUpstash( + env: Record, + url: string, + token: string + ) { const log = logger(); - loadKeeperHeartbeatStore({ VERCEL_ENV: "production" }, { logger: log }); - expect(log.warn).toHaveBeenCalled(); - }); - - it("picks the Upstash store when both env vars are present", async () => { - const fetchImpl = vi.fn(async () => ({ - ok: true, - status: 200, - json: async () => ({ result: null }), - })) as unknown as typeof fetch; + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ result: null })) + ); const store = loadKeeperHeartbeatStore( + { ...env, VERCEL_ENV: "production" }, + { logger: log, fetchImpl } + ); + await store.get("k"); + expect(fetchImpl).toHaveBeenCalledExactlyOnceWith( + url, + expect.objectContaining({ + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(["GET", "k"]), + }) + ); + expect(log.info).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + } + + async function expectInMemory(env: Record) { + for (const vercelEnv of [undefined, "production"]) { + const log = logger(); + const fetchImpl = vi.fn(); + const store = loadKeeperHeartbeatStore( + { ...env, VERCEL_ENV: vercelEnv }, + { logger: log, fetchImpl } + ); + await store.set("k", 123); + expect(await store.get("k")).toBe(123); + expect(fetchImpl).not.toHaveBeenCalled(); + if (vercelEnv) { + expect(log.warn).toHaveBeenCalledExactlyOnceWith(expect.any(String), { + store: "in-memory", + env: vercelEnv, + }); + expect(log.info).not.toHaveBeenCalled(); + } else { + expect(log.info).toHaveBeenCalledExactlyOnceWith(expect.any(String), { + store: "in-memory", + }); + expect(log.warn).not.toHaveBeenCalled(); + } + } + } + + describe.each(credentialPairs)( + "$name credentials", + ({ urlKey, tokenKey }) => { + it("uses Upstash and trims both credentials", async () => { + await expectUpstash( + { [urlKey]: " https://redis.example/ ", [tokenKey]: " tok " }, + "https://redis.example", + "tok" + ); + }); + + it.each([ + ["missing URL", undefined, "tok"], + ["missing token", "https://redis.example", undefined], + ["empty URL", "", "tok"], + ["empty token", "https://redis.example", ""], + ["whitespace URL", " \t ", "tok"], + ["whitespace token", "https://redis.example", " \t "], + ])("falls back to in-memory with %s", async (_name, url, token) => { + await expectInMemory({ [urlKey]: url, [tokenKey]: token }); + }); + } + ); + + it("prefers unprefixed credentials when both pairs are present", async () => { + await expectUpstash( { - UPSTASH_REDIS_REST_URL: "https://redis.example", - UPSTASH_REDIS_REST_TOKEN: "tok", + UPSTASH_REDIS_REST_URL: " https://primary.example/ ", + UPSTASH_REDIS_REST_TOKEN: " primary-token ", + UPSTASH_REDIS_REST_KV_REST_API_URL: "https://fallback.example", + UPSTASH_REDIS_REST_KV_REST_API_TOKEN: "fallback-token", }, - { logger: logger(), fetchImpl } + "https://primary.example", + "primary-token" ); - await store.get("k"); - // The in-memory store never calls fetch at all, so a call here proves - // the Upstash-backed store was picked instead. - expect(fetchImpl).toHaveBeenCalled(); + }); + + it.each([ + ["UPSTASH_REDIS_REST_URL", "https://fallback.example", "primary-token"], + ["UPSTASH_REDIS_REST_TOKEN", "https://primary.example", "fallback-token"], + ])( + "resolves the fallback independently when %s is missing", + async (key, url, token) => { + await expectUpstash( + { + UPSTASH_REDIS_REST_URL: "https://primary.example", + UPSTASH_REDIS_REST_TOKEN: "primary-token", + UPSTASH_REDIS_REST_KV_REST_API_URL: " https://fallback.example/ ", + UPSTASH_REDIS_REST_KV_REST_API_TOKEN: " fallback-token ", + [key]: undefined, + }, + url, + token + ); + } + ); + + it.each(["", " \t "])( + "does not replace blank unprefixed credentials (%j) with the fallback pair", + async (blank) => { + for (const key of [ + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", + ]) { + await expectInMemory({ + UPSTASH_REDIS_REST_URL: "https://primary.example", + UPSTASH_REDIS_REST_TOKEN: "primary-token", + UPSTASH_REDIS_REST_KV_REST_API_URL: "https://fallback.example", + UPSTASH_REDIS_REST_KV_REST_API_TOKEN: "fallback-token", + [key]: blank, + }); + } + } + ); + + it("falls back to in-memory when neither pair is configured", async () => { + await expectInMemory({}); }); }); diff --git a/packages/stellar-sdk-helpers/src/keeper-heartbeat.ts b/packages/stellar-sdk-helpers/src/keeper-heartbeat.ts index f32a100f..6d07fa76 100644 --- a/packages/stellar-sdk-helpers/src/keeper-heartbeat.ts +++ b/packages/stellar-sdk-helpers/src/keeper-heartbeat.ts @@ -197,8 +197,13 @@ export function loadKeeperHeartbeatStore( timeoutMs?: number; } ): KeeperHeartbeatStore { - const url = env.UPSTASH_REDIS_REST_URL?.trim(); - const token = env.UPSTASH_REDIS_REST_TOKEN?.trim(); + // Match keeper-state's support for Vercel's store-prefixed Upstash names. + const url = ( + env.UPSTASH_REDIS_REST_URL ?? env.UPSTASH_REDIS_REST_KV_REST_API_URL + )?.trim(); + const token = ( + env.UPSTASH_REDIS_REST_TOKEN ?? env.UPSTASH_REDIS_REST_KV_REST_API_TOKEN + )?.trim(); if (url && token) { return createUpstashKeeperHeartbeatStore({ url, diff --git a/packages/stellar-sdk-helpers/src/position-snapshots.test.ts b/packages/stellar-sdk-helpers/src/position-snapshots.test.ts index 4864e159..34b6bcf2 100644 --- a/packages/stellar-sdk-helpers/src/position-snapshots.test.ts +++ b/packages/stellar-sdk-helpers/src/position-snapshots.test.ts @@ -442,32 +442,151 @@ describe("createUpstashPositionSnapshotStore", () => { }); describe("loadPositionSnapshotStore", () => { - it("uses Upstash when both credentials are set", async () => { - const fetchImpl = vi.fn( + const credentialPairs = [ + { + name: "unprefixed", + urlKey: "UPSTASH_REDIS_REST_URL", + tokenKey: "UPSTASH_REDIS_REST_TOKEN", + }, + { + name: "store-prefixed", + urlKey: "UPSTASH_REDIS_REST_KV_REST_API_URL", + tokenKey: "UPSTASH_REDIS_REST_KV_REST_API_TOKEN", + }, + ]; + + async function expectUpstash( + env: Record, + url: string, + token: string + ) { + const log = logger(); + const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ result: [] })) - ) as unknown as typeof fetch; + ); const store = loadPositionSnapshotStore( + { ...env, VERCEL_ENV: "production" }, + { logger: log, fetchImpl } + ); + await store.latest("k"); + expect(fetchImpl).toHaveBeenCalledExactlyOnceWith( + url, + expect.objectContaining({ + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(["ZREVRANGE", "k", 0, 0]), + }) + ); + expect(log.info).not.toHaveBeenCalled(); + expect(log.warn).not.toHaveBeenCalled(); + } + + async function expectInMemory(env: Record) { + for (const vercelEnv of [undefined, "production"]) { + const log = logger(); + const fetchImpl = vi.fn(); + const store = loadPositionSnapshotStore( + { ...env, VERCEL_ENV: vercelEnv }, + { logger: log, fetchImpl } + ); + const snapshot = buildPositionSnapshot([], 123); + await store.append("k", snapshot, 10); + expect(await store.latest("k")).toEqual(snapshot); + expect(fetchImpl).not.toHaveBeenCalled(); + if (vercelEnv) { + expect(log.warn).toHaveBeenCalledExactlyOnceWith(expect.any(String), { + store: "in-memory", + env: vercelEnv, + }); + expect(log.info).not.toHaveBeenCalled(); + } else { + expect(log.info).toHaveBeenCalledExactlyOnceWith(expect.any(String), { + store: "in-memory", + }); + expect(log.warn).not.toHaveBeenCalled(); + } + } + } + + describe.each(credentialPairs)( + "$name credentials", + ({ urlKey, tokenKey }) => { + it("uses Upstash and trims both credentials", async () => { + await expectUpstash( + { [urlKey]: " https://redis.example/ ", [tokenKey]: " tok " }, + "https://redis.example", + "tok" + ); + }); + + it.each([ + ["missing URL", undefined, "tok"], + ["missing token", "https://redis.example", undefined], + ["empty URL", "", "tok"], + ["empty token", "https://redis.example", ""], + ["whitespace URL", " \t ", "tok"], + ["whitespace token", "https://redis.example", " \t "], + ])("falls back to in-memory with %s", async (_name, url, token) => { + await expectInMemory({ [urlKey]: url, [tokenKey]: token }); + }); + } + ); + + it("prefers unprefixed credentials when both pairs are present", async () => { + await expectUpstash( { - UPSTASH_REDIS_REST_URL: "https://r.example", - UPSTASH_REDIS_REST_TOKEN: "t", + UPSTASH_REDIS_REST_URL: " https://primary.example/ ", + UPSTASH_REDIS_REST_TOKEN: " primary-token ", + UPSTASH_REDIS_REST_KV_REST_API_URL: "https://fallback.example", + UPSTASH_REDIS_REST_KV_REST_API_TOKEN: "fallback-token", }, - { logger: logger(), fetchImpl } + "https://primary.example", + "primary-token" ); - await store.latest("k"); - expect(fetchImpl).toHaveBeenCalledOnce(); }); - it("falls back to in-memory, warning only on a deployed environment", () => { - const local = logger(); - loadPositionSnapshotStore({}, { logger: local }); - expect(local.info).toHaveBeenCalledOnce(); - expect(local.warn).not.toHaveBeenCalled(); + it.each([ + ["UPSTASH_REDIS_REST_URL", "https://fallback.example", "primary-token"], + ["UPSTASH_REDIS_REST_TOKEN", "https://primary.example", "fallback-token"], + ])( + "resolves the fallback independently when %s is missing", + async (key, url, token) => { + await expectUpstash( + { + UPSTASH_REDIS_REST_URL: "https://primary.example", + UPSTASH_REDIS_REST_TOKEN: "primary-token", + UPSTASH_REDIS_REST_KV_REST_API_URL: " https://fallback.example/ ", + UPSTASH_REDIS_REST_KV_REST_API_TOKEN: " fallback-token ", + [key]: undefined, + }, + url, + token + ); + } + ); - const deployed = logger(); - loadPositionSnapshotStore( - { VERCEL_ENV: "production" }, - { logger: deployed } - ); - expect(deployed.warn).toHaveBeenCalledOnce(); + it.each(["", " \t "])( + "does not replace blank unprefixed credentials (%j) with the fallback pair", + async (blank) => { + for (const key of [ + "UPSTASH_REDIS_REST_URL", + "UPSTASH_REDIS_REST_TOKEN", + ]) { + await expectInMemory({ + UPSTASH_REDIS_REST_URL: "https://primary.example", + UPSTASH_REDIS_REST_TOKEN: "primary-token", + UPSTASH_REDIS_REST_KV_REST_API_URL: "https://fallback.example", + UPSTASH_REDIS_REST_KV_REST_API_TOKEN: "fallback-token", + [key]: blank, + }); + } + } + ); + + it("falls back to in-memory when neither pair is configured", async () => { + await expectInMemory({}); }); }); diff --git a/packages/stellar-sdk-helpers/src/position-snapshots.ts b/packages/stellar-sdk-helpers/src/position-snapshots.ts index 6c0bc327..894a7e2b 100644 --- a/packages/stellar-sdk-helpers/src/position-snapshots.ts +++ b/packages/stellar-sdk-helpers/src/position-snapshots.ts @@ -414,8 +414,13 @@ export function loadPositionSnapshotStore( timeoutMs?: number; } ): PositionSnapshotStore { - const url = env.UPSTASH_REDIS_REST_URL?.trim(); - const token = env.UPSTASH_REDIS_REST_TOKEN?.trim(); + // Match keeper-state's support for Vercel's store-prefixed Upstash names. + const url = ( + env.UPSTASH_REDIS_REST_URL ?? env.UPSTASH_REDIS_REST_KV_REST_API_URL + )?.trim(); + const token = ( + env.UPSTASH_REDIS_REST_TOKEN ?? env.UPSTASH_REDIS_REST_KV_REST_API_TOKEN + )?.trim(); if (url && token) { return createUpstashPositionSnapshotStore({ url,