From 7b51201e1ce454b01ba9d3f67c4d34b183ee5b9e Mon Sep 17 00:00:00 2001 From: Turbojannik Date: Mon, 20 Jan 2025 11:01:13 +0100 Subject: [PATCH] feat: adding redis APL (#374) * feat: first commit with redis * feat: changeset added * fix: redis version more broad and return package manager * fix: using external redis client and adding some docs * fix: testfile * fix: removing not needed mock client functions * fix: set the redis state on mocked client connect and disconnect * fix:: making the docs more clear. Connect not needed manually. * fix: no longer use the process.env for the cache key * fix: ading redis to dev dependencies * feat: real client test * fix: using only redis sub methods actually needed * fix: integrate redis in tsup build pipeline * feat: adding integration test for redis * docs: adding docs on integration test * fix: type checking is failing so we need to use the src --- .changeset/ninety-otters-clap.md | 5 + README.md | 29 ++++ package.json | 15 +- pnpm-lock.yaml | 82 +++++++++ src/APL/redis/index.ts | 3 + src/APL/redis/redis-apl.test.ts | 197 ++++++++++++++++++++++ src/APL/redis/redis-apl.ts | 262 +++++++++++++++++++++++++++++ test/integration/redis-apl.test.ts | 91 ++++++++++ tsup.config.ts | 1 + 9 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 .changeset/ninety-otters-clap.md create mode 100644 src/APL/redis/index.ts create mode 100644 src/APL/redis/redis-apl.test.ts create mode 100644 src/APL/redis/redis-apl.ts create mode 100644 test/integration/redis-apl.test.ts diff --git a/.changeset/ninety-otters-clap.md b/.changeset/ninety-otters-clap.md new file mode 100644 index 00000000..da8cf0da --- /dev/null +++ b/.changeset/ninety-otters-clap.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Adding REDIS as APL provider diff --git a/README.md b/README.md index cf43fa63..fdee3e75 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,32 @@ the command: ```bash pnpm lint ``` + +### Running Integration Tests + +To run the integration tests (e.g., Redis APL tests), follow these steps: + +1. Start a Redis container: + +```bash +docker run --name saleor-app-sdk-redis -p 6379:6379 -d redis:7-alpine +``` + +2. Run the integration tests: + +```bash +pnpm test:integration +``` + +3. (Optional) Clean up the Redis container: + +```bash +docker stop saleor-app-sdk-redis +docker rm saleor-app-sdk-redis +``` + +Note: If your Redis instance is running on a different host or port, you can set the `REDIS_URL` environment variable: + +```bash +REDIS_URL=redis://custom-host:6379 pnpm test:integration +``` diff --git a/package.json b/package.json index b78de7a0..137ad680 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "copy-readme": "cp README.md dist/README.md", "publish:ci-prod": "pnpm publish && pnpm exec changeset tag && git push --follow-tags", "publish:ci-dev": "pnpm exec changeset version --snapshot pr && pnpm publish --tag dev --no-git-checks", - "lint-staged": "lint-staged" + "lint-staged": "lint-staged", + "test:integration": "INTEGRATION=1 vitest run test/integration" }, "keywords": [], "author": "", @@ -25,7 +26,8 @@ "graphql": ">=16.6.0", "next": ">=12", "react": ">=17", - "react-dom": ">=17" + "react-dom": ">=17", + "redis": ">=4" }, "dependencies": { "@opentelemetry/api": "^1.7.0", @@ -70,6 +72,7 @@ "prettier": "2.7.1", "react": "^18.2.0", "react-dom": "18.2.0", + "redis": "^4.7.0", "tsm": "^2.2.2", "tsup": "^6.2.3", "typescript": "4.9.5", @@ -80,6 +83,9 @@ "peerDependenciesMeta": { "@vercel/kv": { "optional": true + }, + "redis": { + "optional": true } }, "lint-staged": { @@ -98,6 +104,11 @@ "import": "./APL/index.mjs", "require": "./APL/index.js" }, + "./APL/redis": { + "types": "./APL/redis/index.d.ts", + "import": "./APL/redis/index.mjs", + "require": "./APL/redis/index.js" + }, "./APL/vercel-kv": { "types": "./APL/vercel-kv/index.d.ts", "import": "./APL/vercel-kv/index.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c53ef27..2114553f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + redis: + specifier: ^4.7.0 + version: 4.7.0 tsm: specifier: ^2.2.2 version: 2.2.2 @@ -632,6 +635,35 @@ packages: resolution: {integrity: sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.0': + resolution: {integrity: sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@swc/helpers@0.4.11': resolution: {integrity: sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw==} @@ -1049,6 +1081,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -1796,6 +1832,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2690,6 +2730,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis@4.7.0: + resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} + regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -3951,6 +3994,32 @@ snapshots: tiny-glob: 0.2.9 tslib: 2.4.0 + '@redis/bloom@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/client@1.6.0': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/json@1.0.7(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/search@1.2.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + + '@redis/time-series@1.1.0(@redis/client@1.6.0)': + dependencies: + '@redis/client': 1.6.0 + '@swc/helpers@0.4.11': dependencies: tslib: 2.4.0 @@ -4438,6 +4507,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -5194,6 +5265,8 @@ snapshots: functions-have-names@1.2.3: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -6094,6 +6167,15 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis@4.7.0: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.0) + '@redis/client': 1.6.0 + '@redis/graph': 1.1.1(@redis/client@1.6.0) + '@redis/json': 1.0.7(@redis/client@1.6.0) + '@redis/search': 1.2.0(@redis/client@1.6.0) + '@redis/time-series': 1.1.0(@redis/client@1.6.0) + regenerator-runtime@0.13.11: {} regenerator-runtime@0.13.9: {} diff --git a/src/APL/redis/index.ts b/src/APL/redis/index.ts new file mode 100644 index 00000000..a358c3bf --- /dev/null +++ b/src/APL/redis/index.ts @@ -0,0 +1,3 @@ +import { RedisAPL } from "./redis-apl"; + +export { RedisAPL }; diff --git a/src/APL/redis/redis-apl.test.ts b/src/APL/redis/redis-apl.test.ts new file mode 100644 index 00000000..85759825 --- /dev/null +++ b/src/APL/redis/redis-apl.test.ts @@ -0,0 +1,197 @@ +import { createClient } from "redis"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AuthData } from "../apl"; +import { RedisAPL } from "./redis-apl"; + +// Create a variable to control the connection state +let isRedisOpen = false; + +// Create properly typed mock functions +const mockHGet = vi.fn().mockResolvedValue(undefined); +const mockHSet = vi.fn().mockResolvedValue(1); +const mockHDel = vi.fn().mockResolvedValue(1); +const mockHGetAll = vi.fn().mockResolvedValue({}); +const mockPing = vi.fn().mockResolvedValue("PONG"); +const mockConnect = vi.fn().mockImplementation(async () => { + isRedisOpen = true; +}); +const mockDisconnect = vi.fn().mockImplementation(async () => { + isRedisOpen = false; +}); + +const mockRedisClient = { + connect: mockConnect, + ping: mockPing, + hGet: mockHGet, + hSet: mockHSet, + hDel: mockHDel, + hGetAll: mockHGetAll, + get isOpen() { + return isRedisOpen; + }, + disconnect: mockDisconnect, + isReady: false, +}; + +vi.mock("redis", () => ({ + createClient: vi.fn(() => mockRedisClient), +})); + +describe("RedisAPL", () => { + const mockHashKey = "test_hash_key"; + const mockAuthData: AuthData = { + token: "test-token", + saleorApiUrl: "https://test-store.saleor.cloud/graphql/", + appId: "test-app-id", + }; + + let apl: RedisAPL; + + beforeEach(() => { + apl = new RedisAPL({ + client: mockRedisClient, + hashCollectionKey: mockHashKey, + }); + vi.clearAllMocks(); + isRedisOpen = false; + }); + + afterEach(async () => { + if (mockRedisClient.isOpen) { + await mockRedisClient.disconnect(); + } + vi.clearAllMocks(); + }); + + describe("constructor", () => { + it("uses provided hash key", async () => { + const customHashKey = "custom_hash"; + const customApl = new RedisAPL({ + client: mockRedisClient, + hashCollectionKey: customHashKey, + }); + + await customApl.get(mockAuthData.saleorApiUrl); + expect(mockRedisClient.hGet).toHaveBeenCalledWith(customHashKey, mockAuthData.saleorApiUrl); + }); + + it("uses default hash key when not provided", async () => { + const defaultApl = new RedisAPL({ + client: mockRedisClient, + }); + + await defaultApl.get(mockAuthData.saleorApiUrl); + expect(mockRedisClient.hGet).toHaveBeenCalledWith( + "saleor_app_auth", + mockAuthData.saleorApiUrl + ); + }); + }); + + describe("get", () => { + it("returns undefined when no data found", async () => { + mockHGet.mockResolvedValueOnce(null); + const result = await apl.get(mockAuthData.saleorApiUrl); + expect(result).toBeUndefined(); + expect(mockConnect).toHaveBeenCalled(); + }); + + it("returns parsed auth data when found", async () => { + mockHGet.mockResolvedValueOnce(JSON.stringify(mockAuthData)); + const result = await apl.get(mockAuthData.saleorApiUrl); + expect(result).toEqual(mockAuthData); + expect(mockHGet).toHaveBeenCalledWith(mockHashKey, mockAuthData.saleorApiUrl); + }); + + it("throws error when Redis operation fails", async () => { + mockHGet.mockRejectedValueOnce(new Error("Redis error")); + await expect(apl.get(mockAuthData.saleorApiUrl)).rejects.toThrow("Redis error"); + }); + }); + + describe("set", () => { + it("successfully sets auth data", async () => { + mockHSet.mockResolvedValueOnce(1); + await apl.set(mockAuthData); + expect(mockHSet).toHaveBeenCalledWith( + mockHashKey, + mockAuthData.saleorApiUrl, + JSON.stringify(mockAuthData) + ); + }); + + it("throws error when Redis operation fails", async () => { + mockHSet.mockRejectedValueOnce(new Error("Redis error")); + await expect(apl.set(mockAuthData)).rejects.toThrow("Redis error"); + }); + }); + + describe("delete", () => { + it("successfully deletes auth data", async () => { + mockHDel.mockResolvedValueOnce(1); + await apl.delete(mockAuthData.saleorApiUrl); + expect(mockHDel).toHaveBeenCalledWith(mockHashKey, mockAuthData.saleorApiUrl); + }); + + it("throws error when Redis operation fails", async () => { + mockHDel.mockRejectedValueOnce(new Error("Redis error")); + await expect(apl.delete(mockAuthData.saleorApiUrl)).rejects.toThrow("Redis error"); + }); + }); + + describe("getAll", () => { + it("returns all auth data", async () => { + const mockAllData = { + [mockAuthData.saleorApiUrl]: JSON.stringify(mockAuthData), + }; + mockHGetAll.mockResolvedValueOnce(mockAllData); + const result = await apl.getAll(); + expect(result).toEqual([mockAuthData]); + }); + + it("throws error when Redis operation fails", async () => { + mockHGetAll.mockRejectedValueOnce(new Error("Redis error")); + await expect(apl.getAll()).rejects.toThrow("Redis error"); + }); + }); + + describe("isReady", () => { + it("returns ready true when Redis is connected", async () => { + mockPing.mockResolvedValueOnce("PONG"); + const result = await apl.isReady(); + expect(result).toEqual({ ready: true }); + }); + + it("returns ready false with error when Redis is not connected", async () => { + mockPing.mockRejectedValueOnce(new Error("Connection failed")); + const result = await apl.isReady(); + expect(result).toEqual({ ready: false, error: new Error("Connection failed") }); + }); + }); + + describe("isConfigured", () => { + it("returns configured true when Redis is connected", async () => { + mockPing.mockResolvedValueOnce("PONG"); + const result = await apl.isConfigured(); + expect(result).toEqual({ configured: true }); + }); + + it("returns configured false with error when Redis is not connected", async () => { + mockPing.mockRejectedValueOnce(new Error("Connection failed")); + const result = await apl.isConfigured(); + expect(result).toEqual({ configured: false, error: new Error("Connection failed") }); + }); + }); + + /** + * Type compatibility test with real Redis client + */ + describe("RedisAPL type compatibility", () => { + it("should accept Redis client type", () => { + const client = createClient(); + // This test is just for TypeScript to verify the types + expect(() => new RedisAPL({ client, hashCollectionKey: "test" })).not.toThrow(); + }); + }); +}); diff --git a/src/APL/redis/redis-apl.ts b/src/APL/redis/redis-apl.ts new file mode 100644 index 00000000..da670466 --- /dev/null +++ b/src/APL/redis/redis-apl.ts @@ -0,0 +1,262 @@ +import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; +import { SemanticAttributes } from "@opentelemetry/semantic-conventions"; +import type { createClient } from "redis"; + +import { getOtelTracer, OTEL_APL_SERVICE_NAME } from "../../open-telemetry"; +import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "../apl"; +import { createAPLDebug } from "../apl-debug"; + +type RedisClient = Pick< + ReturnType, + "connect" | "isOpen" | "hGet" | "hSet" | "hDel" | "hGetAll" | "ping" +>; + +/** + * Configuration options for RedisAPL + */ +type RedisAPLConfig = { + /** Redis client instance to use for storage */ + client: RedisClient; + /** Optional key to use for the hash collection. Defaults to "saleor_app_auth" */ + hashCollectionKey?: string; +}; + +/** + * Redis implementation of the Auth Persistence Layer (APL). + * This class provides Redis-based storage for Saleor App authentication data. + * + * @example + * ```typescript + * // Create and configure Redis client + * const client = createClient({ + * url: "redis://localhost:6379", + * // Add any additional Redis configuration options + * }); + * + * // Initialize RedisAPL with the client + * const apl = new RedisAPL({ + * client, + * // Optional: customize the hash collection key + * hashCollectionKey: "my_custom_auth_key" + * }); + * + * // Use the APL in your app + * await apl.set("saleorApiUrl", { token: "auth-token", saleorApiUrl: "https://saleor-api.com/graphql/", appId: "app-id" }); + * const authData = await apl.get("saleorApiUrl"); + * ``` + */ +export class RedisAPL implements APL { + private debug = createAPLDebug("RedisAPL"); + + private tracer = getOtelTracer(); + + private client: RedisClient; + + private hashCollectionKey: string; + + constructor(config: RedisAPLConfig) { + this.client = config.client; + this.hashCollectionKey = config.hashCollectionKey || "saleor_app_auth"; + + this.debug("Redis APL initialized"); + } + + private async ensureConnection(): Promise { + if (!this.client.isOpen) { + this.debug("Connecting to Redis..."); + await this.client.connect(); + this.debug("Connected to Redis"); + } + } + + async get(saleorApiUrl: string): Promise { + await this.ensureConnection(); + + this.debug("Will get auth data from Redis for %s", saleorApiUrl); + + return this.tracer.startActiveSpan( + "RedisAPL.get", + { + attributes: { + saleorApiUrl, + [SemanticAttributes.PEER_SERVICE]: OTEL_APL_SERVICE_NAME, + }, + kind: SpanKind.CLIENT, + }, + async (span) => { + try { + const authData = await this.client.hGet(this.hashCollectionKey, saleorApiUrl); + + this.debug("Received response from Redis"); + + if (!authData) { + this.debug("AuthData is empty for %s", saleorApiUrl); + span.setStatus({ code: SpanStatusCode.OK }).end(); + return undefined; + } + + const parsedAuthData = JSON.parse(authData) as AuthData; + span.setStatus({ code: SpanStatusCode.OK }).end(); + return parsedAuthData; + } catch (e) { + this.debug("Failed to get auth data from Redis"); + this.debug(e); + + span.recordException(e as Error); + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Failed to get auth data from Redis", + }) + .end(); + + throw e; + } + } + ); + } + + async set(authData: AuthData): Promise { + await this.ensureConnection(); + + this.debug("Will set auth data in Redis for %s", authData.saleorApiUrl); + + return this.tracer.startActiveSpan( + "RedisAPL.set", + { + attributes: { + saleorApiUrl: authData.saleorApiUrl, + appId: authData.appId, + [SemanticAttributes.PEER_SERVICE]: OTEL_APL_SERVICE_NAME, + }, + kind: SpanKind.CLIENT, + }, + async (span) => { + try { + await this.client.hSet( + this.hashCollectionKey, + authData.saleorApiUrl, + JSON.stringify(authData) + ); + + this.debug("Successfully set auth data in Redis"); + span.setStatus({ code: SpanStatusCode.OK }).end(); + } catch (e) { + this.debug("Failed to set auth data in Redis"); + this.debug(e); + + span.recordException(e as Error); + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Failed to set auth data in Redis", + }) + .end(); + + throw e; + } + } + ); + } + + async delete(saleorApiUrl: string): Promise { + await this.ensureConnection(); + + this.debug("Will delete auth data from Redis for %s", saleorApiUrl); + + return this.tracer.startActiveSpan( + "RedisAPL.delete", + { + attributes: { + saleorApiUrl, + [SemanticAttributes.PEER_SERVICE]: OTEL_APL_SERVICE_NAME, + }, + kind: SpanKind.CLIENT, + }, + async (span) => { + try { + await this.client.hDel(this.hashCollectionKey, saleorApiUrl); + + this.debug("Successfully deleted auth data from Redis"); + span.setStatus({ code: SpanStatusCode.OK }).end(); + } catch (e) { + this.debug("Failed to delete auth data from Redis"); + this.debug(e); + + span.recordException(e as Error); + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Failed to delete auth data from Redis", + }) + .end(); + + throw e; + } + } + ); + } + + async getAll(): Promise { + await this.ensureConnection(); + + this.debug("Will get all auth data from Redis"); + + return this.tracer.startActiveSpan( + "RedisAPL.getAll", + { + attributes: { + [SemanticAttributes.PEER_SERVICE]: OTEL_APL_SERVICE_NAME, + }, + kind: SpanKind.CLIENT, + }, + async (span) => { + try { + const allData = await this.client.hGetAll(this.hashCollectionKey); + + this.debug("Successfully retrieved all auth data from Redis"); + span.setStatus({ code: SpanStatusCode.OK }).end(); + + return Object.values(allData || {}).map((data) => JSON.parse(data) as AuthData); + } catch (e) { + this.debug("Failed to get all auth data from Redis"); + this.debug(e); + + span.recordException(e as Error); + span + .setStatus({ + code: SpanStatusCode.ERROR, + message: "Failed to get all auth data from Redis", + }) + .end(); + + throw e; + } + } + ); + } + + async isReady(): Promise { + try { + await this.ensureConnection(); + const ping = await this.client.ping(); + return ping === "PONG" + ? { ready: true } + : { ready: false, error: new Error("Redis server did not respond with PONG") }; + } catch (error) { + return { ready: false, error: error as Error }; + } + } + + async isConfigured(): Promise { + try { + await this.ensureConnection(); + const ping = await this.client.ping(); + return ping === "PONG" + ? { configured: true } + : { configured: false, error: new Error("Redis connection not configured properly") }; + } catch (error) { + return { configured: false, error: error as Error }; + } + } +} diff --git a/test/integration/redis-apl.test.ts b/test/integration/redis-apl.test.ts new file mode 100644 index 00000000..24b4d83e --- /dev/null +++ b/test/integration/redis-apl.test.ts @@ -0,0 +1,91 @@ +import { createClient } from "redis"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { RedisAPL } from "../../src/APL/redis"; + +// These tests require a running Redis instance +// Run with: INTEGRATION=1 pnpm test +const runIntegrationTests = process.env.INTEGRATION === "1"; +const testFn = runIntegrationTests ? describe : describe.skip; + +testFn("Redis APL Integration", () => { + const client = createClient({ + url: process.env.REDIS_URL || "redis://localhost:6379", + }); + + let apl: RedisAPL; + + beforeAll(async () => { + await client.connect(); + apl = new RedisAPL({ client }); + // Clear any existing test data + const allKeys = await client.hGetAll("saleor_app_auth"); + for (const key of Object.keys(allKeys)) { + await client.hDel("saleor_app_auth", key); + } + }); + + afterAll(async () => { + await client.quit(); + }); + + it("should successfully connect to Redis", async () => { + const result = await client.ping(); + expect(result).toBe("PONG"); + }); + + it("should store and retrieve auth data", async () => { + const testAuthData = { + token: "test-token", + saleorApiUrl: "https://test-store.saleor.cloud/graphql/", + appId: "test-app-id", + }; + + await apl.set(testAuthData); + const retrieved = await apl.get(testAuthData.saleorApiUrl); + + expect(retrieved).toEqual(testAuthData); + }); + + it("should delete auth data", async () => { + const testAuthData = { + token: "test-token-2", + saleorApiUrl: "https://test-store-2.saleor.cloud/graphql/", + appId: "test-app-id-2", + }; + + await apl.set(testAuthData); + await apl.delete(testAuthData.saleorApiUrl); + + const retrieved = await apl.get(testAuthData.saleorApiUrl); + expect(retrieved).toBeUndefined(); + }); + + it("should list all stored auth data", async () => { + // Clear any existing data first + const existingData = await apl.getAll(); + for (const data of existingData) { + await apl.delete(data.saleorApiUrl); + } + + const testData1 = { + token: "test-token-1", + saleorApiUrl: "https://test1.saleor.cloud/graphql/", + appId: "test-app-id-1", + }; + + const testData2 = { + token: "test-token-2", + saleorApiUrl: "https://test2.saleor.cloud/graphql/", + appId: "test-app-id-2", + }; + + await apl.set(testData1); + await apl.set(testData2); + + const allData = await apl.getAll(); + + expect(allData).toHaveLength(2); + expect(allData).toEqual(expect.arrayContaining([testData1, testData2])); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 720a028e..f94bb74d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "src/verify-jwt.ts", "src/verify-signature.ts", "src/APL/index.ts", + "src/APL/redis/index.ts", "src/APL/vercel-kv/index.ts", "src/app-bridge/index.ts", "src/app-bridge/next/index.ts",