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 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/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/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/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/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..497370a --- /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 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("trakt", traktConfig); + const retrieved = await manager.getProviderConfig("trakt"); + + expect(retrieved).toEqual(traktConfig); + expect(redis.set).toHaveBeenCalled(); + }); + + it("should return all providers", async () => { + const redis = { + keys: mock(() => Promise.resolve(["config:trakt", "config:github"])), + get: mock((key) => + Promise.resolve( + JSON.stringify({ + clientId: `${key}-id`, + clientSecret: "secret", + authUrl: "https://auth.com", + tokenUrl: "https://token.com", + scope: "all", + }), + ), + ), + }; + const manager = new ConfigManager(redis); + + const providers = await manager.getAllProviders(); + + expect(Object.keys(providers)).toHaveLength(2); + 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-provider"); + + expect(config).toBeNull(); + }); +}); diff --git a/tests/core/TokenManager.test.ts b/tests/core/TokenManager.test.ts index 97c4051..f2456d1 100644 --- a/tests/core/TokenManager.test.ts +++ b/tests/core/TokenManager.test.ts @@ -1,65 +1,76 @@ +// @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("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 redisMock = { - get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)), + const redis = { + get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)), set: mock(() => Promise.resolve()), }; - const providerMock = { + const provider = { refreshToken: mock(() => Promise.resolve({ - accessToken: "new_token", - refreshToken: "new_refresh", + accessToken: "newly-refreshed-access-token", + refreshToken: "newly-refreshed-refresh-token", expiresIn: 3600, }), ), }; - 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(token).toBe("newly-refreshed-access-token"); + 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 = { - get: mock(() => Promise.resolve("refresh_token")), + it("should refresh token via refreshAccessToken", async () => { + const redis = { + get: mock(() => Promise.resolve("existing-refresh-token")), set: mock(() => Promise.resolve()), }; - const providerMock = { + const provider = { refreshToken: mock(() => Promise.resolve({ - accessToken: "forced_token", - refreshToken: "new_refresh", + accessToken: "manually-refreshed-access-token", + refreshToken: "manually-refreshed-refresh-token", expiresIn: 3600, }), ), }; - const manager = new TokenManager(redisMock as any, providerMock as any); - const token = await manager.forceRefresh("trakt"); - expect(token).toBe("forced_token"); - expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token"); + const manager = new TokenManager(redis, provider); + + const token = await manager.refreshAccessToken("trakt"); + + expect(token).toBe("manually-refreshed-access-token"); + expect(provider.refreshToken).toHaveBeenCalledWith("existing-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..f455100 --- /dev/null +++ b/tests/integration/api.test.ts @@ -0,0 +1,135 @@ +// @ts-nocheck +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"); + + 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(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", { + 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("current-access-token"); + }); + + it("should return 404 for unknown provider token", async () => { + const res = await app.request("/api/token/unconfigured-provider", { + headers: { + Authorization: "Bearer test-api-key", + }, + }); + + 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 new file mode 100644 index 0000000..e8e86b8 --- /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 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(() => { + mock.restore(); + }); + + it("should generate correct auth URL", () => { + const provider = new GenericProvider("trakt", traktConfig); + + const url = provider.getAuthUrl("random-state-123", "https://callback.com"); + + 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("trakt", traktConfig); + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: "7200", + }), + text: () => Promise.resolve(""), + }), + ); + + const tokens = await provider.refreshToken("old-refresh-token"); + + 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("trakt", traktConfig); + spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token-only", + expires_in: 3600, + }), + text: () => Promise.resolve(""), + }), + ); + + const tokens = await provider.refreshToken("existing-refresh-token"); + + 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 new file mode 100644 index 0000000..883bb21 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +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([])), + }, +})); 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/**/*"] }