feat: add usage dashboard (GET /v1/usage endpoint + usage.html page)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m59s

This commit is contained in:
Hoid 2026-02-25 14:06:07 +00:00
parent b2688c0cce
commit 5b59a7a010
6 changed files with 386 additions and 1 deletions

View file

@ -34,6 +34,7 @@ const options: swaggerJsdoc.Options = {
{ name: "Playground", description: "Free demo (no auth, watermarked)" },
{ name: "Signup", description: "Account creation" },
{ name: "Billing", description: "Subscription and payment management" },
{ name: "Usage", description: "API usage tracking" },
{ name: "System", description: "Health and status endpoints" },
],
components: {

View file

@ -17,6 +17,7 @@ import { initDatabase, pool } from "./services/db.js";
import { billingRouter } from "./routes/billing.js";
import { statusRouter } from "./routes/status.js";
import { signupRouter } from "./routes/signup.js";
import { usageRouter } from "./routes/usage.js";
import { openapiSpec } from "./docs/openapi.js";
const app = express();
@ -98,6 +99,7 @@ app.use("/v1/playground", playgroundRouter);
app.use("/v1/signup", signupRouter);
// Authenticated routes
app.use("/v1/usage", usageRouter);
app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter);
// API info
@ -108,6 +110,7 @@ app.get("/api", (_req, res) => {
endpoints: [
"POST /v1/playground — Try the API (no auth, watermarked, 5 req/hr)",
"POST /v1/screenshot — Take a screenshot (requires API key)",
"GET /v1/usage — Usage statistics (requires API key)",
"GET /health — Health check",
],
});
@ -123,7 +126,7 @@ app.get("/docs", (_req, res) => {
res.sendFile(path.join(__dirname, "../public/docs.html"));
});
// Clean URLs for legal pages (redirect /privacy → /privacy.html, etc.)
for (const page of ["privacy", "terms", "impressum", "status"]) {
for (const page of ["privacy", "terms", "impressum", "status", "usage"]) {
app.get(`/${page}`, (_req, res) => res.redirect(301, `/${page}.html`));
}

View file

@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock dependencies before imports
vi.mock('../../services/keys.js', () => ({
isValidKey: vi.fn(),
getKeyInfo: vi.fn(),
getTierLimit: vi.fn(),
}))
vi.mock('../../middleware/usage.js', () => ({
getUsageForKey: vi.fn(),
}))
const { isValidKey, getKeyInfo, getTierLimit } = await import('../../services/keys.js')
const { getUsageForKey } = await import('../../middleware/usage.js')
const mockIsValidKey = vi.mocked(isValidKey)
const mockGetKeyInfo = vi.mocked(getKeyInfo)
const mockGetTierLimit = vi.mocked(getTierLimit)
const mockGetUsageForKey = vi.mocked(getUsageForKey)
// Import after mocks
const { usageRouter } = await import('../usage.js')
function createMockRequest(overrides: any = {}): any {
return { method: 'GET', headers: {}, query: {}, ...overrides }
}
function createMockResponse(): any {
const res: any = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
}
return res
}
function getHandler() {
return usageRouter.stack.find((layer: any) =>
layer.route?.methods.get && layer.route.path === '/'
)?.route.stack
}
describe('GET /v1/usage', () => {
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { vi.restoreAllMocks() })
it('returns 401 without API key', async () => {
const req = createMockRequest()
const res = createMockResponse()
const stack = getHandler()!
// First handler is auth middleware
const authHandler = stack[0].handle
await authHandler(req, res, vi.fn())
expect(res.status).toHaveBeenCalledWith(401)
})
it('returns usage data with valid key', async () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
mockIsValidKey.mockResolvedValue(true)
mockGetKeyInfo.mockResolvedValue({
key: 'snap_test123',
tier: 'starter',
email: 'test@example.com',
createdAt: '2026-01-01T00:00:00Z',
})
mockGetTierLimit.mockReturnValue(1000)
mockGetUsageForKey.mockReturnValue({ count: 42, monthKey })
const req = createMockRequest({ headers: { authorization: 'Bearer snap_test123' } })
const res = createMockResponse()
// Run through all handlers in the stack
const stack = getHandler()!
let idx = 0
const runNext = async () => {
if (idx < stack.length) {
const handler = stack[idx++].handle
await handler(req, res, runNext)
}
}
await runNext()
expect(res.json).toHaveBeenCalledWith({
used: 42,
limit: 1000,
plan: 'starter',
month: monthKey,
remaining: 958,
percentUsed: 4.2,
})
})
it('returns 0 usage for key with no records', async () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
mockIsValidKey.mockResolvedValue(true)
mockGetKeyInfo.mockResolvedValue({
key: 'snap_newkey',
tier: 'free',
email: 'new@example.com',
createdAt: '2026-01-01T00:00:00Z',
})
mockGetTierLimit.mockReturnValue(100)
mockGetUsageForKey.mockReturnValue(undefined)
const req = createMockRequest({ headers: { authorization: 'Bearer snap_newkey' } })
const res = createMockResponse()
const stack = getHandler()!
let idx = 0
const runNext = async () => {
if (idx < stack.length) {
const handler = stack[idx++].handle
await handler(req, res, runNext)
}
}
await runNext()
expect(res.json).toHaveBeenCalledWith({
used: 0,
limit: 100,
plan: 'free',
month: monthKey,
remaining: 100,
percentUsed: 0,
})
})
it('includes all required fields', async () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
mockIsValidKey.mockResolvedValue(true)
mockGetKeyInfo.mockResolvedValue({
key: 'snap_abc',
tier: 'pro',
email: 'pro@example.com',
createdAt: '2026-01-01T00:00:00Z',
})
mockGetTierLimit.mockReturnValue(5000)
mockGetUsageForKey.mockReturnValue({ count: 2500, monthKey })
const req = createMockRequest({ headers: { authorization: 'Bearer snap_abc' } })
const res = createMockResponse()
const stack = getHandler()!
let idx = 0
const runNext = async () => {
if (idx < stack.length) {
const handler = stack[idx++].handle
await handler(req, res, runNext)
}
}
await runNext()
const data = res.json.mock.calls[0][0]
expect(data).toHaveProperty('used')
expect(data).toHaveProperty('limit')
expect(data).toHaveProperty('plan')
expect(data).toHaveProperty('month')
expect(data).toHaveProperty('remaining')
expect(data).toHaveProperty('percentUsed')
expect(typeof data.used).toBe('number')
expect(typeof data.percentUsed).toBe('number')
})
})

64
src/routes/usage.ts Normal file
View file

@ -0,0 +1,64 @@
import { Router } from "express";
import { authMiddleware } from "../middleware/auth.js";
import { getUsageForKey } from "../middleware/usage.js";
import { getTierLimit } from "../services/keys.js";
export const usageRouter = Router();
/**
* @openapi
* /v1/usage:
* get:
* tags: [Usage]
* summary: Get current usage for your API key
* description: Returns usage statistics for the authenticated API key in the current billing month.
* operationId: getUsage
* security:
* - BearerAuth: []
* - ApiKeyAuth: []
* - QueryKeyAuth: []
* responses:
* 200:
* description: Usage statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* used:
* type: integer
* description: Screenshots used this month
* limit:
* type: integer
* description: Monthly screenshot limit for your plan
* plan:
* type: string
* description: Current plan name
* month:
* type: string
* description: Current billing month (YYYY-MM)
* remaining:
* type: integer
* description: Screenshots remaining this month
* percentUsed:
* type: number
* description: Percentage of limit used
* 401:
* description: Missing API key
* 403:
* description: Invalid API key
*/
usageRouter.get("/", authMiddleware, (req, res) => {
const keyInfo = (req as any).apiKeyInfo;
const limit = getTierLimit(keyInfo.tier);
const now = new Date();
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const record = getUsageForKey(keyInfo.key);
const used = record && record.monthKey === monthKey ? record.count : 0;
const remaining = Math.max(0, limit - used);
const percentUsed = limit > 0 ? Math.round((used / limit) * 1000) / 10 : 0;
res.json({ used, limit, plan: keyInfo.tier, month: monthKey, remaining, percentUsed });
});