From 21c030fee5252f1b56bdeb45644b4629c28dfcc7 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Mon, 11 May 2026 16:58:20 +0530 Subject: [PATCH 1/5] chore: add bun types --- bun.lock | 3 +++ package.json | 2 ++ tsconfig.json | 4 ++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 027ce16..eb7c2c8 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "devDependencies": { "@biomejs/biome": "2.4.15", "@types/node": "^22.19.18", + "bun-types": "^1.3.13", "husky": "^9.1.7", "lint-staged": "^17.0.4", "typescript": "^5.9.3", @@ -66,6 +67,8 @@ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], diff --git a/package.json b/package.json index 484e00b..7bc663d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "bun run --hot src/index.ts", "test": "bun test", "lint": "bunx @biomejs/biome check src", + "check-all": "bunx @biomejs/biome check .", "format": "bunx @biomejs/biome format --write src", "prepare": "husky" }, @@ -20,6 +21,7 @@ "devDependencies": { "@biomejs/biome": "2.4.15", "@types/node": "^22.19.18", + "bun-types": "^1.3.13", "husky": "^9.1.7", "lint-staged": "^17.0.4", "typescript": "^5.9.3" diff --git a/tsconfig.json b/tsconfig.json index 6e0daff..1fac6c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "strict": true, "skipLibCheck": true, "lib": ["ESNext"], - "types": ["node"] + "types": ["node", "bun-types"] }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*"] } From dfff0e913d49ef211e320230c12a59766dc22b82 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Mon, 11 May 2026 16:59:12 +0530 Subject: [PATCH 2/5] tests: improve coverage for config and provider manager --- biome.json | 14 ++++- src/index.ts | 2 + src/providers/GenericProvider.ts | 2 +- tests/core/ConfigManager.test.ts | 62 +++++++++++++++++++ tests/core/TokenManager.test.ts | 49 +++++++++------ tests/integration/api.test.ts | 82 +++++++++++++++++++++++++ tests/providers/GenericProvider.test.ts | 69 +++++++++++++++++++++ 7 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 tests/core/ConfigManager.test.ts create mode 100644 tests/integration/api.test.ts create mode 100644 tests/providers/GenericProvider.test.ts diff --git a/biome.json b/biome.json index 191d2e7..9f7df62 100644 --- a/biome.json +++ b/biome.json @@ -61,5 +61,17 @@ "organizeImports": "on" } } - } + }, + "overrides": [ + { + "includes": ["tests/**/*"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] } diff --git a/src/index.ts b/src/index.ts index ea4dbc1..806d1b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,8 @@ app.onError((err, c) => { app.get("/health", (c) => c.json({ status: "ok" })); +export { app }; + export default { port: Number.parseInt(config.PORT, 10), fetch: app.fetch, diff --git a/src/providers/GenericProvider.ts b/src/providers/GenericProvider.ts index e2086c3..a1dcc07 100644 --- a/src/providers/GenericProvider.ts +++ b/src/providers/GenericProvider.ts @@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface"; const TokenResponseSchema = z.object({ access_token: z.string(), refresh_token: z.string().optional(), - expires_in: z.number(), + expires_in: z.coerce.number(), }); export class GenericProvider implements OAuthProvider { diff --git a/tests/core/ConfigManager.test.ts b/tests/core/ConfigManager.test.ts new file mode 100644 index 0000000..5cf4718 --- /dev/null +++ b/tests/core/ConfigManager.test.ts @@ -0,0 +1,62 @@ +// @ts-nocheck +import { describe, expect, it, mock } from "bun:test"; +import { ConfigManager } from "../../src/core/ConfigManager"; + +describe("ConfigManager", () => { + it("should save and retrieve provider configuration", async () => { + const storage = {}; + const redis = { + set: mock((key, val) => { + storage[key] = val; + return Promise.resolve(); + }), + get: mock((key) => Promise.resolve(storage[key] || null)), + }; + const manager = new ConfigManager(redis); + const config = { + clientId: "id", + clientSecret: "secret", + authUrl: "https://auth.com", + tokenUrl: "https://token.com", + scope: "all", + }; + + await manager.setProviderConfig("test", config); + const retrieved = await manager.getProviderConfig("test"); + + expect(retrieved).toEqual(config); + expect(redis.set).toHaveBeenCalled(); + }); + + it("should return all providers", async () => { + const redis = { + keys: mock(() => Promise.resolve(["config:p1", "config:p2"])), + get: mock((key) => + Promise.resolve( + JSON.stringify({ + clientId: key, + clientSecret: "s", + authUrl: "https://a.com", + tokenUrl: "https://t.com", + scope: "x", + }), + ), + ), + }; + const manager = new ConfigManager(redis); + + const providers = await manager.getAllProviders(); + + expect(Object.keys(providers)).toHaveLength(2); + expect(providers.p1.clientId).toBe("config:p1"); + }); + + it("should return null for non-existent provider", async () => { + const redis = { get: mock(() => Promise.resolve(null)) }; + const manager = new ConfigManager(redis); + + const config = await manager.getProviderConfig("missing"); + + expect(config).toBeNull(); + }); +}); diff --git a/tests/core/TokenManager.test.ts b/tests/core/TokenManager.test.ts index 97c4051..1d4c91a 100644 --- a/tests/core/TokenManager.test.ts +++ b/tests/core/TokenManager.test.ts @@ -1,20 +1,23 @@ +// @ts-nocheck import { describe, expect, it, mock } from "bun:test"; import { TokenManager } from "../../src/core/TokenManager"; describe("TokenManager", () => { it("should return token from redis if available", async () => { - const redisMock = { get: mock(() => Promise.resolve("valid_token")) }; - const manager = new TokenManager(redisMock as any, {} as any); + const redis = { get: mock(() => Promise.resolve("valid_token")) }; + const manager = new TokenManager(redis, {}); + const token = await manager.getAccessToken("trakt"); + expect(token).toBe("valid_token"); }); it("should refresh token if access token is missing but refresh token exists", async () => { - const redisMock = { - get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)), + const redis = { + get: mock((key) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)), set: mock(() => Promise.resolve()), }; - const providerMock = { + const provider = { refreshToken: mock(() => Promise.resolve({ accessToken: "new_token", @@ -23,25 +26,29 @@ describe("TokenManager", () => { }), ), }; - const manager = new TokenManager(redisMock as any, providerMock as any); + const manager = new TokenManager(redis, provider); + const token = await manager.getAccessToken("trakt"); + expect(token).toBe("new_token"); - expect(redisMock.set).toHaveBeenCalled(); + expect(redis.set).toHaveBeenCalled(); }); it("should return null if no tokens are found", async () => { - const redisMock = { get: mock(() => Promise.resolve(null)) }; - const manager = new TokenManager(redisMock as any, {} as any); + const redis = { get: mock(() => Promise.resolve(null)) }; + const manager = new TokenManager(redis, {}); + const token = await manager.getAccessToken("trakt"); + expect(token).toBeNull(); }); - it("should refresh token via forceRefresh", async () => { - const redisMock = { + it("should refresh token via refreshAccessToken", async () => { + const redis = { get: mock(() => Promise.resolve("refresh_token")), set: mock(() => Promise.resolve()), }; - const providerMock = { + const provider = { refreshToken: mock(() => Promise.resolve({ accessToken: "forced_token", @@ -50,16 +57,20 @@ describe("TokenManager", () => { }), ), }; - const manager = new TokenManager(redisMock as any, providerMock as any); - const token = await manager.forceRefresh("trakt"); + const manager = new TokenManager(redis, provider); + + const token = await manager.refreshAccessToken("trakt"); + expect(token).toBe("forced_token"); - expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token"); + expect(provider.refreshToken).toHaveBeenCalledWith("refresh_token"); }); - it("should return null in forceRefresh if no refresh token is found", async () => { - const redisMock = { get: mock(() => Promise.resolve(null)) }; - const manager = new TokenManager(redisMock as any, {} as any); - const token = await manager.forceRefresh("trakt"); + it("should return null in refreshAccessToken if no refresh token is found", async () => { + const redis = { get: mock(() => Promise.resolve(null)) }; + const manager = new TokenManager(redis, {}); + + const token = await manager.refreshAccessToken("trakt"); + expect(token).toBeNull(); }); }); diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts new file mode 100644 index 0000000..07af84e --- /dev/null +++ b/tests/integration/api.test.ts @@ -0,0 +1,82 @@ +// @ts-nocheck +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { redis } from "../../src/core/RedisClient"; +import { app } from "../../src/index"; + +mock.module("../../src/core/RedisClient", () => ({ + redis: { + get: mock(() => Promise.resolve(null)), + set: mock(() => Promise.resolve()), + keys: mock(() => Promise.resolve([])), + }, +})); + +mock.module("../../src/config", () => ({ + config: { + API_KEY: "test-api-key", + PORT: "3000", + REDIS_URL: "redis://localhost:6379", + }, +})); + +describe("API Integration", () => { + afterEach(() => { + redis.get.mockImplementation(() => Promise.resolve(null)); + redis.set.mockImplementation(() => Promise.resolve()); + redis.keys.mockImplementation(() => Promise.resolve([])); + }); + + it("should return 401 if API Key is missing", async () => { + const res = await app.request("/api/status"); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Missing or invalid authorization header" }); + }); + + it("should return 200 for health check (no auth needed)", async () => { + const res = await app.request("/health"); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("ok"); + }); + + it("should return 200 for status with valid API Key", async () => { + redis.keys.mockReturnValue(Promise.resolve(["config:trakt"])); + redis.get.mockImplementation((key) => { + if (key.includes("config")) + return Promise.resolve( + JSON.stringify({ + clientId: "id", + clientSecret: "s", + authUrl: "https://a.com", + tokenUrl: "https://t.com", + scope: "x", + }), + ); + return Promise.resolve("mock_value"); + }); + + const res = await app.request("/api/status", { + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.trakt).toBeDefined(); + expect(body.trakt.accessToken).toBe("mock_value"); + }); + + it("should return 404 for unknown provider token", async () => { + const res = await app.request("/api/token/unknown", { + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + expect(res.status).toBe(404); + }); +}); diff --git a/tests/providers/GenericProvider.test.ts b/tests/providers/GenericProvider.test.ts new file mode 100644 index 0000000..5fca566 --- /dev/null +++ b/tests/providers/GenericProvider.test.ts @@ -0,0 +1,69 @@ +// @ts-nocheck +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { GenericProvider } from "../../src/providers/GenericProvider"; + +describe("GenericProvider", () => { + const config = { + clientId: "id", + clientSecret: "secret", + authUrl: "https://auth.com", + tokenUrl: "https://token.com", + scope: "all", + }; + + afterEach(() => { + mock.restore(); + }); + + it("should generate correct auth URL", () => { + const provider = new GenericProvider("test", config); + + const url = provider.getAuthUrl("test", "http://cb.com"); + + expect(url).toContain("client_id=id"); + expect(url).toContain("redirect_uri=http%3A%2F%2Fcb.com"); + expect(url).toContain("state=test"); + }); + + it("should handle token response with string expiry", async () => { + const provider = new GenericProvider("test", config); + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + access_token: "at", + refresh_token: "rt", + expires_in: "3600", + }), + text: () => Promise.resolve(""), + }), + ); + + const tokens = await provider.refreshToken("old_rt"); + + expect(tokens.accessToken).toBe("at"); + expect(tokens.expiresIn).toBe(3600); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("should handle token response without new refresh token", async () => { + const provider = new GenericProvider("test", config); + spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + access_token: "at", + expires_in: 3600, + }), + text: () => Promise.resolve(""), + }), + ); + + const tokens = await provider.refreshToken("old_rt"); + + expect(tokens.accessToken).toBe("at"); + expect(tokens.refreshToken).toBe("old_rt"); + }); +}); From 78520b9069dd7236cf26bb35536c776fc33a82c0 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Mon, 11 May 2026 16:59:17 +0530 Subject: [PATCH 3/5] feat: add CI workflow for linting and testing using Bun --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b4ea2f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run linting + run: bun run check-all + + - name: Run tests + run: bun run test From e6354aae0051ac72ec0d83030a372a3741f5abd4 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Mon, 11 May 2026 17:07:23 +0530 Subject: [PATCH 4/5] chore: centralize test environment configuration via bunfig and setup file --- bunfig.toml | 2 ++ tests/integration/api.test.ts | 11 ++--------- tests/setup.ts | 4 ++++ 3 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 bunfig.toml create mode 100644 tests/setup.ts diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8370a01 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/setup.ts"] diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index 07af84e..6313d4a 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -1,7 +1,5 @@ // @ts-nocheck import { afterEach, describe, expect, it, mock } from "bun:test"; -import { redis } from "../../src/core/RedisClient"; -import { app } from "../../src/index"; mock.module("../../src/core/RedisClient", () => ({ redis: { @@ -11,13 +9,8 @@ mock.module("../../src/core/RedisClient", () => ({ }, })); -mock.module("../../src/config", () => ({ - config: { - API_KEY: "test-api-key", - PORT: "3000", - REDIS_URL: "redis://localhost:6379", - }, -})); +import { redis } from "../../src/core/RedisClient"; +import { app } from "../../src/index"; describe("API Integration", () => { afterEach(() => { diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..1d0a6f2 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,4 @@ +// Global test setup to stub environment variables +process.env.API_KEY = "test-api-key"; +process.env.REDIS_URL = "redis://localhost:6379"; +process.env.PORT = "3000"; From b258ee0a071ff4d2eece2c4ae991013dd0241cc5 Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Mon, 11 May 2026 17:28:08 +0530 Subject: [PATCH 5/5] test: centralize redis mock in test setup --- tests/core/ConfigManager.test.ts | 34 ++++---- tests/core/TokenManager.test.ts | 22 ++--- tests/integration/api.test.ts | 106 +++++++++++++++++++----- tests/integration/auth.test.ts | 71 ++++++++++++++++ tests/integration/config.test.ts | 81 ++++++++++++++++++ tests/integration/dashboard.test.ts | 37 +++++++++ tests/providers/GenericProvider.test.ts | 46 +++++----- tests/setup.ts | 11 +++ 8 files changed, 334 insertions(+), 74 deletions(-) create mode 100644 tests/integration/auth.test.ts create mode 100644 tests/integration/config.test.ts create mode 100644 tests/integration/dashboard.test.ts diff --git a/tests/core/ConfigManager.test.ts b/tests/core/ConfigManager.test.ts index 5cf4718..497370a 100644 --- a/tests/core/ConfigManager.test.ts +++ b/tests/core/ConfigManager.test.ts @@ -13,32 +13,32 @@ describe("ConfigManager", () => { get: mock((key) => Promise.resolve(storage[key] || null)), }; const manager = new ConfigManager(redis); - const config = { - clientId: "id", - clientSecret: "secret", - authUrl: "https://auth.com", - tokenUrl: "https://token.com", - scope: "all", + const traktConfig = { + clientId: "trakt-client-id", + clientSecret: "trakt-client-secret", + authUrl: "https://trakt.tv/oauth/authorize", + tokenUrl: "https://api.trakt.tv/oauth/token", + scope: "public", }; - await manager.setProviderConfig("test", config); - const retrieved = await manager.getProviderConfig("test"); + await manager.setProviderConfig("trakt", traktConfig); + const retrieved = await manager.getProviderConfig("trakt"); - expect(retrieved).toEqual(config); + expect(retrieved).toEqual(traktConfig); expect(redis.set).toHaveBeenCalled(); }); it("should return all providers", async () => { const redis = { - keys: mock(() => Promise.resolve(["config:p1", "config:p2"])), + keys: mock(() => Promise.resolve(["config:trakt", "config:github"])), get: mock((key) => Promise.resolve( JSON.stringify({ - clientId: key, - clientSecret: "s", - authUrl: "https://a.com", - tokenUrl: "https://t.com", - scope: "x", + clientId: `${key}-id`, + clientSecret: "secret", + authUrl: "https://auth.com", + tokenUrl: "https://token.com", + scope: "all", }), ), ), @@ -48,14 +48,14 @@ describe("ConfigManager", () => { const providers = await manager.getAllProviders(); expect(Object.keys(providers)).toHaveLength(2); - expect(providers.p1.clientId).toBe("config:p1"); + expect(providers.trakt.clientId).toBe("config:trakt-id"); }); it("should return null for non-existent provider", async () => { const redis = { get: mock(() => Promise.resolve(null)) }; const manager = new ConfigManager(redis); - const config = await manager.getProviderConfig("missing"); + const config = await manager.getProviderConfig("missing-provider"); expect(config).toBeNull(); }); diff --git a/tests/core/TokenManager.test.ts b/tests/core/TokenManager.test.ts index 1d4c91a..f2456d1 100644 --- a/tests/core/TokenManager.test.ts +++ b/tests/core/TokenManager.test.ts @@ -4,24 +4,24 @@ import { TokenManager } from "../../src/core/TokenManager"; describe("TokenManager", () => { it("should return token from redis if available", async () => { - const redis = { get: mock(() => Promise.resolve("valid_token")) }; + const redis = { get: mock(() => Promise.resolve("active-access-token")) }; const manager = new TokenManager(redis, {}); const token = await manager.getAccessToken("trakt"); - expect(token).toBe("valid_token"); + expect(token).toBe("active-access-token"); }); it("should refresh token if access token is missing but refresh token exists", async () => { const redis = { - get: mock((key) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)), + get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)), set: mock(() => Promise.resolve()), }; const provider = { refreshToken: mock(() => Promise.resolve({ - accessToken: "new_token", - refreshToken: "new_refresh", + accessToken: "newly-refreshed-access-token", + refreshToken: "newly-refreshed-refresh-token", expiresIn: 3600, }), ), @@ -30,7 +30,7 @@ describe("TokenManager", () => { const token = await manager.getAccessToken("trakt"); - expect(token).toBe("new_token"); + expect(token).toBe("newly-refreshed-access-token"); expect(redis.set).toHaveBeenCalled(); }); @@ -45,14 +45,14 @@ describe("TokenManager", () => { it("should refresh token via refreshAccessToken", async () => { const redis = { - get: mock(() => Promise.resolve("refresh_token")), + get: mock(() => Promise.resolve("existing-refresh-token")), set: mock(() => Promise.resolve()), }; const provider = { refreshToken: mock(() => Promise.resolve({ - accessToken: "forced_token", - refreshToken: "new_refresh", + accessToken: "manually-refreshed-access-token", + refreshToken: "manually-refreshed-refresh-token", expiresIn: 3600, }), ), @@ -61,8 +61,8 @@ describe("TokenManager", () => { const token = await manager.refreshAccessToken("trakt"); - expect(token).toBe("forced_token"); - expect(provider.refreshToken).toHaveBeenCalledWith("refresh_token"); + expect(token).toBe("manually-refreshed-access-token"); + expect(provider.refreshToken).toHaveBeenCalledWith("existing-refresh-token"); }); it("should return null in refreshAccessToken if no refresh token is found", async () => { diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index 6313d4a..f455100 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -1,24 +1,24 @@ // @ts-nocheck -import { afterEach, describe, expect, it, mock } from "bun:test"; - -mock.module("../../src/core/RedisClient", () => ({ - redis: { - get: mock(() => Promise.resolve(null)), - set: mock(() => Promise.resolve()), - keys: mock(() => Promise.resolve([])), - }, -})); - +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { redis } from "../../src/core/RedisClient"; import { app } from "../../src/index"; describe("API Integration", () => { afterEach(() => { + mock.restore(); redis.get.mockImplementation(() => Promise.resolve(null)); redis.set.mockImplementation(() => Promise.resolve()); redis.keys.mockImplementation(() => Promise.resolve([])); }); + const mockTraktConfig = JSON.stringify({ + clientId: "trakt-client-id", + clientSecret: "trakt-client-secret", + authUrl: "https://trakt.tv/oauth/authorize", + tokenUrl: "https://api.trakt.tv/oauth/token", + scope: "public", + }); + it("should return 401 if API Key is missing", async () => { const res = await app.request("/api/status"); @@ -38,17 +38,10 @@ describe("API Integration", () => { it("should return 200 for status with valid API Key", async () => { redis.keys.mockReturnValue(Promise.resolve(["config:trakt"])); redis.get.mockImplementation((key) => { - if (key.includes("config")) - return Promise.resolve( - JSON.stringify({ - clientId: "id", - clientSecret: "s", - authUrl: "https://a.com", - tokenUrl: "https://t.com", - scope: "x", - }), - ); - return Promise.resolve("mock_value"); + if (key.includes("config")) return Promise.resolve(mockTraktConfig); + if (key.includes("access_token")) return Promise.resolve("current-access-token"); + if (key.includes("refresh_token")) return Promise.resolve("current-refresh-token"); + return Promise.resolve(null); }); const res = await app.request("/api/status", { @@ -60,11 +53,11 @@ describe("API Integration", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.trakt).toBeDefined(); - expect(body.trakt.accessToken).toBe("mock_value"); + expect(body.trakt.accessToken).toBe("current-access-token"); }); it("should return 404 for unknown provider token", async () => { - const res = await app.request("/api/token/unknown", { + const res = await app.request("/api/token/unconfigured-provider", { headers: { Authorization: "Bearer test-api-key", }, @@ -72,4 +65,71 @@ describe("API Integration", () => { expect(res.status).toBe(404); }); + + it("should return token for a configured provider", async () => { + redis.get.mockImplementation((key) => { + if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig); + if (key.includes("access_token")) return Promise.resolve("trakt-active-token"); + return Promise.resolve(null); + }); + + const res = await app.request("/api/token/trakt", { + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.access_token).toBe("trakt-active-token"); + }); + + it("should return 404 if no access token is in redis for a valid provider", async () => { + redis.get.mockImplementation((key) => { + if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig); + return Promise.resolve(null); + }); + + const res = await app.request("/api/token/trakt", { + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toContain("No tokens found"); + }); + + it("should successfully refresh a token", async () => { + redis.get.mockImplementation((key) => { + if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig); + if (key.includes("refresh_token")) return Promise.resolve("old-refresh-token"); + return Promise.resolve("new-access-token-from-refresh"); + }); + + spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token-from-fetch", + refresh_token: "new-refresh-token-from-fetch", + expires_in: 3600, + }), + }), + ); + + const res = await app.request("/api/refresh/trakt", { + method: "POST", + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.status.accessToken).toBe("new-access-token-from-refresh"); + }); }); diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 0000000..f342b58 --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,71 @@ +// @ts-nocheck +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { redis } from "../../src/core/RedisClient"; +import { app } from "../../src/index"; + +describe("Auth Integration", () => { + afterEach(() => { + mock.restore(); + redis.get.mockImplementation(() => Promise.resolve(null)); + }); + + const mockProviderConfig = JSON.stringify({ + clientId: "trakt-client-id", + clientSecret: "trakt-client-secret", + authUrl: "https://trakt.tv/oauth/authorize", + tokenUrl: "https://api.trakt.tv/oauth/token", + scope: "public", + }); + + it("should redirect to provider login", async () => { + redis.get.mockImplementation((key) => { + if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig); + return Promise.resolve(null); + }); + + const res = await app.request("/auth/trakt/login"); + + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize"); + expect(res.headers.get("Location")).toContain("client_id=trakt-client-id"); + }); + + it("should handle callback and exchange code", async () => { + redis.get.mockImplementation((key) => { + if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig); + return Promise.resolve(null); + }); + + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + access_token: "exchange-access-token", + refresh_token: "exchange-refresh-token", + expires_in: 3600, + }), + }), + ); + + const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code"); + + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("trakt"); + expect(redis.set).toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalled(); + }); + + it("should return 404 if provider not configured during login", async () => { + const res = await app.request("/auth/unknown-provider/login"); + + expect(res.status).toBe(404); + }); + + it("should return 400 if callback is missing state or code", async () => { + const res = await app.request("/auth/callback?code=some-code"); + + expect(res.status).toBe(400); + }); +}); diff --git a/tests/integration/config.test.ts b/tests/integration/config.test.ts new file mode 100644 index 0000000..68fcbcd --- /dev/null +++ b/tests/integration/config.test.ts @@ -0,0 +1,81 @@ +// @ts-nocheck +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { redis } from "../../src/core/RedisClient"; +import { app } from "../../src/index"; + +describe("Config Integration", () => { + afterEach(() => { + mock.restore(); + redis.get.mockImplementation(() => Promise.resolve(null)); + redis.set.mockImplementation(() => Promise.resolve()); + redis.keys.mockImplementation(() => Promise.resolve([])); + }); + + it("should list all configured providers", async () => { + redis.keys.mockReturnValue(Promise.resolve(["config:trakt"])); + redis.get.mockImplementation(() => + Promise.resolve( + JSON.stringify({ + clientId: "trakt-client-id", + clientSecret: "trakt-client-secret", + authUrl: "https://trakt.tv/oauth/authorize", + tokenUrl: "https://api.trakt.tv/oauth/token", + scope: "public", + }), + ), + ); + + const res = await app.request("/api/config", { + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.trakt).toBeDefined(); + expect(body.trakt.clientId).toBe("trakt-client-id"); + }); + + it("should set a new provider config", async () => { + const newProviderConfig = { + clientId: "github-client-id", + clientSecret: "github-client-secret", + authUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + scope: "user:email", + }; + + const res = await app.request("/api/config/github", { + method: "POST", + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + }, + body: JSON.stringify(newProviderConfig), + }); + + expect(res.status).toBe(200); + expect(redis.set).toHaveBeenCalledWith( + "config:github", + expect.stringContaining("github-client-id"), + ); + }); + + it("should return 400 for invalid config body", async () => { + const invalidConfig = { + clientId: "missing-other-required-fields", + }; + + const res = await app.request("/api/config/invalid", { + method: "POST", + headers: { + Authorization: "Bearer test-api-key", + "Content-Type": "application/json", + }, + body: JSON.stringify(invalidConfig), + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/tests/integration/dashboard.test.ts b/tests/integration/dashboard.test.ts new file mode 100644 index 0000000..507f952 --- /dev/null +++ b/tests/integration/dashboard.test.ts @@ -0,0 +1,37 @@ +// @ts-nocheck +import { describe, expect, it } from "bun:test"; +import { redis } from "../../src/core/RedisClient"; +import { app } from "../../src/index"; + +describe("Dashboard & Common Integration", () => { + it("should serve the dashboard HTML", async () => { + const res = await app.request("/app"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain(""); + }); + + it("should return 404 with custom handler for unknown routes", async () => { + const res = await app.request("/unknown-route"); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Not Found"); + }); + + it("should return 500 for internal errors", async () => { + redis.keys.mockImplementationOnce(() => { + throw new Error("Redis Crash"); + }); + + const res = await app.request("/api/status", { + headers: { Authorization: "Bearer test-api-key" }, + }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Internal Server Error"); + }); +}); diff --git a/tests/providers/GenericProvider.test.ts b/tests/providers/GenericProvider.test.ts index 5fca566..e8e86b8 100644 --- a/tests/providers/GenericProvider.test.ts +++ b/tests/providers/GenericProvider.test.ts @@ -3,12 +3,12 @@ import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import { GenericProvider } from "../../src/providers/GenericProvider"; describe("GenericProvider", () => { - const config = { - clientId: "id", - clientSecret: "secret", - authUrl: "https://auth.com", - tokenUrl: "https://token.com", - scope: "all", + const traktConfig = { + clientId: "trakt-client-id", + clientSecret: "trakt-client-secret", + authUrl: "https://trakt.tv/oauth/authorize", + tokenUrl: "https://api.trakt.tv/oauth/token", + scope: "public", }; afterEach(() => { @@ -16,54 +16,54 @@ describe("GenericProvider", () => { }); it("should generate correct auth URL", () => { - const provider = new GenericProvider("test", config); + const provider = new GenericProvider("trakt", traktConfig); - const url = provider.getAuthUrl("test", "http://cb.com"); + const url = provider.getAuthUrl("random-state-123", "https://callback.com"); - expect(url).toContain("client_id=id"); - expect(url).toContain("redirect_uri=http%3A%2F%2Fcb.com"); - expect(url).toContain("state=test"); + expect(url).toContain("client_id=trakt-client-id"); + expect(url).toContain("redirect_uri=https%3A%2F%2Fcallback.com"); + expect(url).toContain("state=random-state-123"); }); it("should handle token response with string expiry", async () => { - const provider = new GenericProvider("test", config); + const provider = new GenericProvider("trakt", traktConfig); const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ - access_token: "at", - refresh_token: "rt", - expires_in: "3600", + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: "7200", }), text: () => Promise.resolve(""), }), ); - const tokens = await provider.refreshToken("old_rt"); + const tokens = await provider.refreshToken("old-refresh-token"); - expect(tokens.accessToken).toBe("at"); - expect(tokens.expiresIn).toBe(3600); + expect(tokens.accessToken).toBe("new-access-token"); + expect(tokens.expiresIn).toBe(7200); expect(fetchSpy).toHaveBeenCalled(); }); it("should handle token response without new refresh token", async () => { - const provider = new GenericProvider("test", config); + const provider = new GenericProvider("trakt", traktConfig); spyOn(globalThis, "fetch").mockImplementation(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ - access_token: "at", + access_token: "new-access-token-only", expires_in: 3600, }), text: () => Promise.resolve(""), }), ); - const tokens = await provider.refreshToken("old_rt"); + const tokens = await provider.refreshToken("existing-refresh-token"); - expect(tokens.accessToken).toBe("at"); - expect(tokens.refreshToken).toBe("old_rt"); + expect(tokens.accessToken).toBe("new-access-token-only"); + expect(tokens.refreshToken).toBe("existing-refresh-token"); }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 1d0a6f2..883bb21 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,4 +1,15 @@ +import { mock } from "bun:test"; + // Global test setup to stub environment variables process.env.API_KEY = "test-api-key"; process.env.REDIS_URL = "redis://localhost:6379"; process.env.PORT = "3000"; + +// Global Redis mock +mock.module("../src/core/RedisClient", () => ({ + redis: { + get: mock(() => Promise.resolve(null)), + set: mock(() => Promise.resolve()), + keys: mock(() => Promise.resolve([])), + }, +}));