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
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 10m59s
This commit is contained in:
parent
b2688c0cce
commit
5b59a7a010
6 changed files with 386 additions and 1 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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`));
|
||||
}
|
||||
|
||||
|
|
|
|||
168
src/routes/__tests__/usage.test.ts
Normal file
168
src/routes/__tests__/usage.test.ts
Normal 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
64
src/routes/usage.ts
Normal 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 });
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue