6 Commits

Author SHA1 Message Date
ramvignesh-b b258ee0a07 test: centralize redis mock in test setup
CI / build (pull_request) Successful in 22s
2026-05-11 17:28:08 +05:30
ramvignesh-b e6354aae00 chore: centralize test environment configuration via bunfig and setup file
CI / build (pull_request) Successful in 21s
2026-05-11 17:07:23 +05:30
ramvignesh-b 78520b9069 feat: add CI workflow for linting and testing using Bun
CI / build (pull_request) Failing after 1m40s
2026-05-11 16:59:17 +05:30
ramvignesh-b dfff0e913d tests: improve coverage for config and provider manager 2026-05-11 16:59:12 +05:30
ramvignesh-b 21c030fee5 chore: add bun types 2026-05-11 16:58:20 +05:30
me 3716c42668 Merge pull request 'feat: integrate scalar api reference' (#1) from feature/api-reference-integration into main
Reviewed-on: #1
2026-05-11 11:18:39 +00:00
16 changed files with 561 additions and 31 deletions
+28
View File
@@ -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
+13 -1
View File
@@ -61,5 +61,17 @@
"organizeImports": "on" "organizeImports": "on"
} }
} }
} },
"overrides": [
{
"includes": ["tests/**/*"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
}
}
}
}
]
} }
+3
View File
@@ -15,6 +15,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
"@types/node": "^22.19.18", "@types/node": "^22.19.18",
"bun-types": "^1.3.13",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^17.0.4", "lint-staged": "^17.0.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -66,6 +67,8 @@
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "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-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=="], "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]
+2
View File
@@ -6,6 +6,7 @@
"dev": "bun run --hot src/index.ts", "dev": "bun run --hot src/index.ts",
"test": "bun test", "test": "bun test",
"lint": "bunx @biomejs/biome check src", "lint": "bunx @biomejs/biome check src",
"check-all": "bunx @biomejs/biome check .",
"format": "bunx @biomejs/biome format --write src", "format": "bunx @biomejs/biome format --write src",
"prepare": "husky" "prepare": "husky"
}, },
@@ -20,6 +21,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
"@types/node": "^22.19.18", "@types/node": "^22.19.18",
"bun-types": "^1.3.13",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^17.0.4", "lint-staged": "^17.0.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+2
View File
@@ -65,6 +65,8 @@ app.onError((err, c) => {
app.get("/health", (c) => c.json({ status: "ok" })); app.get("/health", (c) => c.json({ status: "ok" }));
export { app };
export default { export default {
port: Number.parseInt(config.PORT, 10), port: Number.parseInt(config.PORT, 10),
fetch: app.fetch, fetch: app.fetch,
+1 -1
View File
@@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface";
const TokenResponseSchema = z.object({ const TokenResponseSchema = z.object({
access_token: z.string(), access_token: z.string(),
refresh_token: z.string().optional(), refresh_token: z.string().optional(),
expires_in: z.number(), expires_in: z.coerce.number(),
}); });
export class GenericProvider implements OAuthProvider { export class GenericProvider implements OAuthProvider {
+62
View File
@@ -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();
});
});
+38 -27
View File
@@ -1,65 +1,76 @@
// @ts-nocheck
import { describe, expect, it, mock } from "bun:test"; import { describe, expect, it, mock } from "bun:test";
import { TokenManager } from "../../src/core/TokenManager"; import { TokenManager } from "../../src/core/TokenManager";
describe("TokenManager", () => { describe("TokenManager", () => {
it("should return token from redis if available", async () => { it("should return token from redis if available", async () => {
const redisMock = { get: mock(() => Promise.resolve("valid_token")) }; const redis = { get: mock(() => Promise.resolve("active-access-token")) };
const manager = new TokenManager(redisMock as any, {} as any); const manager = new TokenManager(redis, {});
const token = await manager.getAccessToken("trakt"); 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 () => { it("should refresh token if access token is missing but refresh token exists", async () => {
const redisMock = { const redis = {
get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)), get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)),
set: mock(() => Promise.resolve()), set: mock(() => Promise.resolve()),
}; };
const providerMock = { const provider = {
refreshToken: mock(() => refreshToken: mock(() =>
Promise.resolve({ Promise.resolve({
accessToken: "new_token", accessToken: "newly-refreshed-access-token",
refreshToken: "new_refresh", refreshToken: "newly-refreshed-refresh-token",
expiresIn: 3600, expiresIn: 3600,
}), }),
), ),
}; };
const manager = new TokenManager(redisMock as any, providerMock as any); const manager = new TokenManager(redis, provider);
const token = await manager.getAccessToken("trakt"); 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 () => { it("should return null if no tokens are found", async () => {
const redisMock = { get: mock(() => Promise.resolve(null)) }; const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redisMock as any, {} as any); const manager = new TokenManager(redis, {});
const token = await manager.getAccessToken("trakt"); const token = await manager.getAccessToken("trakt");
expect(token).toBeNull(); expect(token).toBeNull();
}); });
it("should refresh token via forceRefresh", async () => { it("should refresh token via refreshAccessToken", async () => {
const redisMock = { const redis = {
get: mock(() => Promise.resolve("refresh_token")), get: mock(() => Promise.resolve("existing-refresh-token")),
set: mock(() => Promise.resolve()), set: mock(() => Promise.resolve()),
}; };
const providerMock = { const provider = {
refreshToken: mock(() => refreshToken: mock(() =>
Promise.resolve({ Promise.resolve({
accessToken: "forced_token", accessToken: "manually-refreshed-access-token",
refreshToken: "new_refresh", refreshToken: "manually-refreshed-refresh-token",
expiresIn: 3600, expiresIn: 3600,
}), }),
), ),
}; };
const manager = new TokenManager(redisMock as any, providerMock as any); const manager = new TokenManager(redis, provider);
const token = await manager.forceRefresh("trakt");
expect(token).toBe("forced_token"); const token = await manager.refreshAccessToken("trakt");
expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token");
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 () => { it("should return null in refreshAccessToken if no refresh token is found", async () => {
const redisMock = { get: mock(() => Promise.resolve(null)) }; const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redisMock as any, {} as any); const manager = new TokenManager(redis, {});
const token = await manager.forceRefresh("trakt");
const token = await manager.refreshAccessToken("trakt");
expect(token).toBeNull(); expect(token).toBeNull();
}); });
}); });
+135
View File
@@ -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");
});
});
+71
View File
@@ -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);
});
});
+81
View File
@@ -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);
});
});
+37
View File
@@ -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("<!DOCTYPE html>");
});
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");
});
});
+69
View File
@@ -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");
});
});
+15
View File
@@ -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([])),
},
}));
+2 -2
View File
@@ -6,7 +6,7 @@
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"lib": ["ESNext"], "lib": ["ESNext"],
"types": ["node"] "types": ["node", "bun-types"]
}, },
"include": ["src/**/*"] "include": ["src/**/*", "tests/**/*"]
} }