13 Commits

Author SHA1 Message Date
ramvignesh-b d28903c611 refactor: move healthcheck configuration from docker-compose to Dockerfile
CI / build (pull_request) Successful in 1m19s
2026-05-12 01:21:27 +05:30
ramvignesh-b a4f3ea7837 refactor: replace REDIS_URL with individual host and port configuration variables 2026-05-12 01:05:27 +05:30
ramvignesh-b 7c4ef8a51c chore: update healthcheck to use bun fetch instead of curl
CI / build (push) Successful in 22s
2026-05-12 00:57:23 +05:30
ramvignesh-b 2eab4b92cc test: implement redis unavailibility health check
CI / build (push) Successful in 1m27s
2026-05-11 23:59:01 +05:30
ramvignesh-b 51502055db feat: add healthcheck configuration
CI / build (push) Failing after 24s
2026-05-11 23:44:40 +05:30
ramvignesh-b 553d9647c2 chore: add production start script and update readme
CI / build (push) Successful in 21s
2026-05-11 17:44:19 +05:30
me b954ce5f72 ci: integrate tests workflow
CI / build (push) Successful in 22s
Reviewed-on: #2
2026-05-11 12:01:48 +00:00
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
22 changed files with 644 additions and 68 deletions
+3 -4
View File
@@ -1,6 +1,5 @@
# Core Server Configuration
PORT=3000
APP_PORT=3000
API_KEY=your_secret_api_key_here
# Redis Configuration (Use redis://redis:6379 for Docker)
REDIS_URL=redis://localhost:6379
REDIS_HOST=redis
REDIS_PORT=6379
+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
+4 -1
View File
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
USER bun
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(res => res.ok ? process.exit(0) : process.exit(1)).catch(e => process.exit(1))"
CMD ["bun", "run", "start"]
+41 -25
View File
@@ -20,47 +20,63 @@
- **Styling**: Tailwind CSS & DaisyUI
- **Schema & Validation**: Zod
## Quick Start
## Getting Started
toknd can be deployed either as a containerized service or self-hosted directly on your hardware.
### 1. Environment Setup
Clone the repository and create your environment file:
```bash
cp .env.example .env
```
Ensure you define a strong `API_KEY` in your `.env`.
Define a strong `API_KEY` and ensure `REDIS_URL` points to a valid Redis instance.
### 2. Local Development (with Auto-Watch)
We use a Docker Compose override system to enable hot-reloading locally:
```bash
podman compose up --build
```
*Note: This mounts your ./src directory into the container and uses bun --hot to restart on any code changes.*
### 2. Choose Deployment Method
### 3. Production Deployment
For production, only the core docker-compose.yml is used:
```bash
docker compose up -d --build
```
#### Option A: Containerized (Recommended)
This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
- **Development (with Hot-Reload)**:
```bash
podman compose up --build
```
- **Production**:
```bash
docker compose up -d --build
```
#### Option B: Self-Hosting (Bare Metal)
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
1. **Install Dependencies**:
```bash
bun install
```
2. **Start the Server**:
- **Development**: `bun run dev` (with hot-reload)
- **Production**: `bun run start`
*Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
---
## API Reference
All protected endpoints require an Authorization header:
toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
All protected endpoints require a Bearer token in the `Authorization` header:
`Authorization: Bearer <your_master_api_key>`
### Token Brokerage
- **Get Valid Token**: `GET /api/token/:provider`
- Returns a valid access token. Automatically triggers a refresh if the current one is expired.
- **Registry Status**: `GET /api/status`
- Returns the connectivity and refresh status of all configured providers.
### Authentication Flow
1. **Initiate**: `GET /auth/:provider/login`
2. **Callback**: `GET /auth/callback` (Handled internally by toknd)
### Core Concepts
- **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
- **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
## Dashboard
Access the toknd dashboard at:
Access the **toknd** dashboard at:
`http://localhost:3000/app`
Authenticate the registry using your Master API Key to manage your providers and view live token status.
The dashboard allows you to manage provider configurations, view live token statuses, and manually trigger refreshes. Authenticate using your **Master API Key**.
---
+13 -1
View File
@@ -61,5 +61,17 @@
"organizeImports": "on"
}
}
}
},
"overrides": [
{
"includes": ["tests/**/*"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
}
}
}
}
]
}
+3
View File
@@ -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=="],
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]
+5 -2
View File
@@ -3,9 +3,10 @@ services:
build: .
restart: always
ports:
- "${PORT:-3000}:3000"
- "${APP_PORT:-3000}:3000"
environment:
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- API_KEY=${API_KEY}
depends_on:
- redis
@@ -13,6 +14,8 @@ services:
redis:
image: redis:alpine
restart: always
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
+3
View File
@@ -4,8 +4,10 @@
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun 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 +22,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"
+3 -2
View File
@@ -1,8 +1,9 @@
import { z } from "zod";
const configSchema = z.object({
PORT: z.string().default("3000"),
REDIS_URL: z.string(),
APP_PORT: z.string().default("3000"),
REDIS_HOST: z.string().default("redis"),
REDIS_PORT: z.coerce.number().default(6379),
API_KEY: z.string(),
});
+4 -1
View File
@@ -1,4 +1,7 @@
import { Redis } from "ioredis";
import { config } from "../config";
export const redis = new Redis(config.REDIS_URL);
export const redis = new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
});
+10 -2
View File
@@ -3,6 +3,7 @@ import { Scalar } from "@scalar/hono-api-reference";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { config } from "./config";
import { redis } from "./core/RedisClient";
import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config";
@@ -63,9 +64,16 @@ app.onError((err, c) => {
return c.json({ error: "Internal Server Error", message: err.message }, 500);
});
app.get("/health", (c) => c.json({ status: "ok" }));
app.get("/health", async (c) => {
if (redis.status !== "ready") {
return c.json({ status: "error", message: "Redis down", redis: redis.status }, 503);
}
return c.json({ status: "ok" });
});
export { app };
export default {
port: Number.parseInt(config.PORT, 10),
port: Number.parseInt(config.APP_PORT, 10),
fetch: app.fetch,
};
+1 -1
View File
@@ -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 {
+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 { 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();
});
});
+146
View File
@@ -0,0 +1,146 @@
// @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 503 for health check if redis is down", async () => {
redis.status = "connecting";
const res = await app.request("/health");
const body = await res.json();
expect(res.status).toBe(503);
expect(body.status).toBe("error");
expect(body.redis).toBe("connecting");
});
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");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { mock } from "bun:test";
// Global test setup to stub environment variables
process.env.API_KEY = "test-api-key";
process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379";
process.env.APP_PORT = "3000";
// Global Redis mock
mock.module("../src/core/RedisClient", () => ({
redis: {
status: "ready",
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()),
keys: mock(() => Promise.resolve([])),
on: mock(() => {}),
},
}));
+2 -2
View File
@@ -6,7 +6,7 @@
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext"],
"types": ["node"]
"types": ["node", "bun-types"]
},
"include": ["src/**/*"]
"include": ["src/**/*", "tests/**/*"]
}