A multi-tenant fake Discord API server for integration testing. It impersonates Discord's REST API so that a Discord plugin worker can be tested end-to-end without touching real Discord.
Built with 💥Fling!
Live at: https://fake-discord.flingit.run
npm install
npm start # Start the dev server on http://localhost:3210
npm test # Run all tests (server must be running)- Multi-tenant -- each test suite creates its own isolated tenant with unique credentials, guilds, and channels. Tenants never see each other's data.
- Two route families -- Discord API routes (
/api/v10/...,/oauth2/...) that mimic real Discord, and test control routes (/_test/...) for setup, teardown, and assertions. - D1 (SQLite) storage -- all state is persisted in the database. State survives server restarts.
- Ed25519 signing -- the
send-interactionendpoint signs payloads with the tenant's private key using@noble/ed25519(pure ESM, works in Cloudflare Workers).
Different endpoints resolve the tenant differently, matching how real Discord clients authenticate:
| Pattern | Resolution Method |
|---|---|
Authorization: Bot <token> |
Look up tenant by bot_token |
Authorization: Bearer <token> |
Look up tenant via access_tokens table |
client_id query/form param |
Look up tenant by client_id |
/webhooks/:clientId/... path param |
Look up tenant by client_id |
/applications/:clientId/... path param |
Look up tenant by client_id (cross-checked with bot token) |
/_test/:tenantId/... path param |
Direct lookup by tenant ID |
Each tenant has two categories of state:
Config (preserved across reset):
- Tenant credentials (bot token, client ID/secret, Ed25519 keys)
- Guilds and channels
Mutable state (cleared by reset):
- Authorization codes and access tokens
- Messages and message edit history
- Reactions
- Interaction responses
- Followup messages
- Registered slash commands
IDs are generated using a monotonic per-tenant counter stored in the tenants row (next_id column). Each ID has a prefix indicating its type:
| Prefix | Used For |
|---|---|
msg-N |
Messages |
resp-N |
Interaction responses |
followup-N |
Followup messages |
cmd-N |
Registered commands |
The counter resets to 1 when a tenant is reset via POST /_test/:tenantId/reset.
These routes live under /api/v10/ and /oauth2/ to match Discord's URL structure. All responses use Content-Type: application/json unless otherwise noted.
Simulates the Discord consent screen. Instead of showing UI, immediately redirects back with an authorization code.
GET /oauth2/authorize?client_id=X&redirect_uri=X&response_type=code&scope=X&state=X
Tenant resolution: client_id query parameter.
Behavior:
- Resolve tenant from
client_id. Return400if not found. - Generate a random authorization code, store it with
{ guildId: <first guild by ID>, redirectUri }. - Redirect
302to<redirect_uri>?code=<code>&state=<state>&guild_id=<first_guild_id>.
The "first guild" is determined by ORDER BY id ASC LIMIT 1 for determinism.
Errors:
400 { "error": "Unknown client_id" }
Exchange an authorization code for an access token.
POST /api/v10/oauth2/token
Content-Type: application/x-www-form-urlencoded
client_id=X&client_secret=X&grant_type=authorization_code&code=X&redirect_uri=X
Tenant resolution: client_id form field.
Behavior:
- Validate
client_idandclient_secret. - Look up the authorization code. Codes are one-time use -- they are deleted after consumption.
- Validate
redirect_urimatches what was stored with the code. - Generate an access token and store it for future Bearer auth.
Response (200):
{
"access_token": "fake-at-<tenantId>-<uuid>",
"token_type": "Bearer",
"expires_in": 604800,
"refresh_token": "fake-rt-<uuid>",
"scope": "identify guilds bot applications.commands",
"guild": {
"id": "<guildId from auth code>",
"name": "<guild name>"
}
}Errors:
401 { "error": "invalid_client" }-- unknownclient_idor wrongclient_secret401 { "error": "invalid_grant" }-- unknown or already-used code400 { "error": "invalid_request", "error_description": "redirect_uri mismatch" }
GET /api/v10/users/@me
Authorization: Bearer <access_token>
Tenant resolution: Bearer access token.
Response (200):
{
"id": "fake-user-<tenantId>",
"username": "fakeuser",
"global_name": "Fake User (<tenantId>)",
"discriminator": "0"
}Errors:
401 { "message": "401: Unauthorized" }-- missing or invalid token
GET /api/v10/channels/:channelId
Authorization: Bot <bot_token>
Tenant resolution: Bot token.
Response (200):
{
"id": "<channelId>",
"guild_id": "<guildId>",
"name": "<channel name>",
"type": 0
}Errors:
401 { "message": "401: Unauthorized" }-- invalid bot token404 { "message": "Unknown Channel" }
POST /api/v10/channels/:channelId/messages
Authorization: Bot <bot_token>
Content-Type: application/json
{ "content": "Hello!", "embeds": [...], ... }
Tenant resolution: Bot token.
The full request body is stored as the message payload. It can be retrieved later via the test control endpoint GET /_test/:tenantId/messages/:channelId.
Response (200):
{
"id": "msg-1",
"channel_id": "<channelId>",
"content": "<content from body, or empty string>"
}Errors:
401 { "message": "401: Unauthorized" }404 { "message": "Unknown Channel" }400 { "message": "Invalid request body" }-- missing/invalid Content-Type or unparseable JSON
PATCH /api/v10/channels/:channelId/messages/:messageId
Authorization: Bot <bot_token>
Content-Type: application/json
{ "content": "Updated!", ... }
Tenant resolution: Bot token.
The old payload is saved to the message's edit history before replacing it with the new body. Edit history is accessible via GET /_test/:tenantId/messages/:channelId.
Response (200):
{
"id": "<messageId>",
"channel_id": "<channelId>",
"content": "<new content>"
}Errors:
401 { "message": "401: Unauthorized" }404 { "message": "Unknown Message" }
PUT /api/v10/channels/:channelId/messages/:messageId/reactions/:emoji/@me
Authorization: Bot <bot_token>
The :emoji path segment is URL-encoded (e.g., %E2%9C%85 for a checkmark). The server URL-decodes it before storing.
Tenant resolution: Bot token.
Response: 204 No Content (empty body)
Errors:
401 { "message": "401: Unauthorized" }404 { "message": "Unknown Channel" }or{ "message": "Unknown Message" }
PATCH /api/v10/webhooks/:clientId/:interactionToken/messages/@original
Content-Type: application/json
{ "content": "Pong!", "embeds": [...], "flags": 64, ... }
No Authorization header. This matches real Discord behavior for webhook-based interaction responses.
Tenant resolution: clientId path parameter.
Stores the full request body as the interaction response, keyed by interactionToken. If called multiple times with the same token, the response is replaced (upsert).
Response (200):
{
"id": "resp-1",
"content": "<content from body>"
}Errors:
404 { "message": "Unknown Application" }
POST /api/v10/webhooks/:clientId/:interactionToken
Content-Type: application/json
{ "content": "Additional info", ... }
No Authorization header.
Tenant resolution: clientId path parameter.
Multiple followups can be sent for the same interaction token. Each gets a unique ID.
Response (200):
{
"id": "followup-1",
"channel_id": "chan-followup",
"content": "<content from body>"
}Errors:
404 { "message": "Unknown Application" }
PUT /api/v10/applications/:clientId/guilds/:guildId/commands
Authorization: Bot <bot_token>
Content-Type: application/json
[
{ "name": "ping", "description": "Ping the bot", "type": 1, "options": [...] }
]
Tenant resolution: Bot token. The clientId path parameter is cross-checked against the tenant's configured client_id.
This replaces (not merges) all commands for the guild. Previous commands are deleted before inserting the new set.
Response (200): Array of commands with generated IDs:
[
{
"id": "cmd-1",
"name": "ping",
"description": "Ping the bot",
"type": 1,
"application_id": "<clientId>",
"guild_id": "<guildId>",
"options": [...]
}
]Errors:
401 { "message": "401: Unauthorized" }400 { "message": "client_id mismatch" }--clientIdparam doesn't match tenant404 { "message": "Unknown Guild" }
These routes are used by test runners for setup, teardown, and assertions. They are not part of the Discord API surface. All routes live under /_test/.
Note: The prefix is
/_test/, not/__test/. Fling reserves all/__*paths for internal platform use.
POST /_test/tenants
Content-Type: application/json
{
"botToken": "fake-bot-token-abc123",
"clientId": "fake-client-id-abc123",
"clientSecret": "fake-client-secret-abc123",
"publicKey": "<Ed25519 public key, hex-encoded>",
"privateKey": "<Ed25519 private key, hex-encoded>",
"guilds": {
"guild-abc123": {
"name": "Test Guild",
"channels": {
"chan-abc123": { "name": "general" },
"chan-abc456": { "name": "bot-commands" }
}
}
}
}
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
botToken |
string | yes | Unique bot token. Used for Authorization: Bot header resolution. |
clientId |
string | yes | Unique client ID. Used for OAuth params and webhook path resolution. |
clientSecret |
string | yes | Client secret for OAuth token exchange. |
publicKey |
string | yes | Ed25519 public key (hex). Set as DISCORD_PUBLIC_KEY in the plugin worker. |
privateKey |
string | yes | Ed25519 private key (hex, 32-byte seed or 64-byte secret key). Used by send-interaction to sign payloads. |
guilds |
object | yes | Map of guildId to { name, channels }. Channels is a map of channelId to { name }. |
Validation:
- All fields are required.
botTokenmust be unique across all tenants.clientIdmust be unique across all tenants.- At least one guild with at least one channel.
Response (201):
{
"tenantId": "<generated UUID>",
"botToken": "fake-bot-token-abc123",
"clientId": "fake-client-id-abc123",
"guilds": ["guild-abc123"]
}Errors:
400 { "error": "Missing required field: ..." }409 { "error": "botToken already in use" }409 { "error": "clientId already in use" }
DELETE /_test/tenants/:tenantId
Removes the tenant and all its state (messages, reactions, commands, auth codes, access tokens, guilds, channels).
Response (200):
{ "deleted": true }Errors:
404 { "error": "Tenant not found" }
GET /_test/:tenantId/messages/:channelId
Returns all messages sent to a channel, in chronological order. Each message includes the full stored payload and its edit history.
Response (200):
{
"messages": [
{
"id": "msg-1",
"channelId": "chan-abc123",
"payload": { "content": "Hello!", "embeds": [] },
"editHistory": [
{
"payload": { "content": "Helo!" },
"editedAt": "2026-02-15T10:00:00.000Z"
}
],
"createdAt": "2026-02-15T09:59:00.000Z"
}
]
}payload-- the full request body from the most recent send/editeditHistory-- array of previous payloads (empty if never edited), oldest first
Returns { "messages": [] } if the channel has no messages.
Errors:
404 { "error": "Tenant not found" }
GET /_test/:tenantId/reactions
Returns all reactions added by this tenant, in chronological order.
Response (200):
{
"reactions": [
{
"channelId": "chan-abc123",
"messageId": "msg-1",
"emoji": "\u2705",
"createdAt": "2026-02-15T10:01:00.000Z"
}
]
}Returns { "reactions": [] } if no reactions have been added.
Errors:
404 { "error": "Tenant not found" }
GET /_test/:tenantId/interaction-responses/:token
Returns the response that was sent for a specific interaction token (via PATCH /api/v10/webhooks/:clientId/:token/messages/@original).
Response (200):
{
"payload": { "content": "Pong!", "embeds": [], "flags": 64 },
"respondedAt": "2026-02-15T10:02:00.000Z"
}Errors:
404 { "error": "Tenant not found" }404 { "error": "No response for this interaction token" }
GET /_test/:tenantId/followups/:token
Returns all followup messages for a specific interaction token, in chronological order.
Response (200):
{
"followups": [
{
"id": "followup-1",
"payload": { "content": "Additional info" },
"createdAt": "2026-02-15T10:03:00.000Z"
}
]
}Returns { "followups": [] } if no followups exist for this token.
Errors:
404 { "error": "Tenant not found" }
GET /_test/:tenantId/commands/:guildId
Returns the commands currently registered for a guild (from the most recent bulk overwrite).
Response (200):
{
"commands": [
{
"id": "cmd-1",
"name": "ping",
"description": "Ping the bot",
"type": 1,
"options": [],
"registeredAt": "2026-02-15T10:04:00.000Z"
}
]
}Returns { "commands": [] } if no commands have been registered for this guild.
Errors:
404 { "error": "Tenant not found" }
POST /_test/:tenantId/reset
Clears all mutable state for this tenant but preserves the tenant config (bot token, client ID, guilds, channels). Also resets the ID counter back to 1.
What gets cleared: messages, message edits, reactions, interaction responses, followups, registered commands, auth codes, access tokens, audit logs.
What is preserved: tenant credentials, guilds, channels.
Response (200):
{ "reset": true }Errors:
404 { "error": "Tenant not found" }
Pre-generates an authorization code for programmatic OAuth testing. This bypasses the /oauth2/authorize redirect flow.
POST /_test/:tenantId/auth-code
Content-Type: application/json
{
"guildId": "guild-abc123",
"redirectUri": "https://example.com/callback"
}
| Field | Type | Required | Description |
|---|---|---|---|
guildId |
string | yes | Must be a guild that exists in the tenant's config. |
redirectUri |
string | no | Stored with the code; validated on token exchange. |
Response (200):
{
"code": "fake-code-<uuid>",
"guildId": "guild-abc123"
}Errors:
404 { "error": "Tenant not found" }400 { "error": "Unknown guild: <guildId>" }
Convenience endpoint that Ed25519-signs a Discord interaction payload and POSTs it to a webhook URL, simulating Discord sending an interaction to your worker.
POST /_test/:tenantId/send-interaction
Content-Type: application/json
{
"webhookUrl": "https://your-worker.example.com/webhook",
"interaction": {
"type": 2,
"id": "interaction-001",
"application_id": "fake-client-id-abc123",
"token": "test-interaction-token-001",
"guild_id": "guild-abc123",
"channel_id": "chan-abc123",
"member": {
"user": {
"id": "discord-user-001",
"username": "testuser",
"discriminator": "0"
},
"roles": [],
"permissions": "0"
},
"data": {
"id": "cmd-1",
"name": "ping",
"type": 1,
"options": []
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
webhookUrl |
string | yes | Full URL to POST the signed interaction to. |
interaction |
object | yes | The Discord interaction payload. |
Behavior:
- Import the tenant's Ed25519 private key.
- Generate a Unix timestamp.
- Serialize
interactionto JSON. - Sign
timestamp + bodyusing Ed25519 (@noble/ed25519). - POST to
webhookUrlwith headersX-Signature-Ed25519andX-Signature-Timestamp. - Return the webhook's response.
Response (200):
{
"statusCode": 200,
"body": { "type": 5 }
}The statusCode and body reflect the response from the webhook URL. If the webhook returns non-JSON, body is the raw text string.
Errors:
404 { "error": "Tenant not found" }400 { "error": "Missing required field: webhookUrl" }400 { "error": "Missing required field: interaction" }502 { "error": "Webhook request failed: <details>" }-- network error calling webhookUrl
GET /_test/:tenantId/audit-logs?limit=100&offset=0
Returns all audit log entries for this tenant, most recent first. Supports pagination via limit (default 100, max 1000) and offset (default 0).
Response (200):
{
"logs": [
{
"id": 42,
"tenantId": "<tenantId>",
"method": "POST",
"url": "http://localhost:3210/api/v10/channels/chan-1/messages",
"requestBody": { "content": "Hello!" },
"responseStatus": 200,
"responseBody": { "id": "msg-1", "channel_id": "chan-1", "content": "Hello!" },
"createdAt": "2026-02-19T12:34:56.789Z"
}
],
"total": 42,
"limit": 100,
"offset": 0
}Errors:
404 { "error": "Tenant not found" }
Tenants are automatically cleaned up after 24 hours via a cron job that runs hourly (0 * * * *). Each tenant has a created_at timestamp set at creation time. The cron job finds all tenants with created_at older than 24 hours and deletes them along with all their data.
To manually trigger cleanup: npx fling cron trigger cleanup-old-tenants
Every HTTP request/response is automatically logged to the audit_logs table. The middleware captures:
- HTTP method and full URL
- Request body (for non-GET/HEAD requests)
- Response status code and body
- Associated tenant ID (null if auth failed or no tenant context)
- Timestamp
Audit logs are:
- Cleared when a tenant is reset (
POST /_test/:tenantId/reset) - Deleted when a tenant is deleted (
DELETE /_test/tenants/:tenantId) - Available per-tenant via
GET /_test/:tenantId/audit-logs - Available globally via
GET /_test/browse/audit-logs
These read-only endpoints power the state browser frontend and allow inspecting all tenant data. They live under /_test/browse/.
GET /_test/browse/tenants
Returns all tenants with guild and channel counts.
Response (200):
{
"tenants": [
{
"id": "<tenantId>",
"botToken": "...",
"clientId": "...",
"clientSecret": "...",
"publicKey": "...",
"nextId": 1,
"guildCount": 1,
"channelCount": 2,
"createdAt": "2026-02-19T12:34:56.789Z",
"logCount": 42
}
]
}GET /_test/browse/tenants/:tenantId
Returns tenant config with guilds and nested channels.
Response (200):
{
"tenant": { "id": "...", "botToken": "...", "clientId": "...", "clientSecret": "...", "publicKey": "...", "nextId": 1, "createdAt": "2026-02-19T12:34:56.789Z", "logCount": 42 },
"guilds": [
{
"id": "guild-1",
"name": "Test Guild",
"channels": [
{ "id": "chan-1", "name": "general" }
]
}
]
}Errors:
404 { "error": "Tenant not found" }
GET /_test/browse/tenants/:tenantId/state
Returns all mutable state for a tenant in one call: messages (with edit history), reactions, interaction responses, followups, commands, auth codes, and access tokens.
Response (200):
{
"messages": [...],
"reactions": [...],
"interactionResponses": [...],
"followups": [...],
"commands": [...],
"authCodes": [...],
"accessTokens": [...],
"auditLogs": [...]
}Errors:
404 { "error": "Tenant not found" }
GET /_test/browse/audit-logs?limit=100&offset=0
Returns audit logs across all tenants, most recent first. Supports pagination.
Response (200):
{
"logs": [
{
"id": 42,
"tenantId": "<tenantId or null>",
"method": "POST",
"url": "...",
"requestBody": {...},
"responseStatus": 200,
"responseBody": {...},
"createdAt": "2026-02-19T12:34:56.789Z"
}
],
"total": 100,
"limit": 100,
"offset": 0
}The React frontend at / provides a state browser for inspecting all tenants and their data. It uses the browse API endpoints above.
- Tenant list: shows all tenants with guild/channel counts; click a row to drill in
- Tenant detail: shows config, guilds/channels tree, and collapsible sections for each state type (messages, reactions, commands, etc.)
- Refresh button: re-fetches data from the API
- No auth required: this is a testing tool
In development, visit http://localhost:5173. In production, the frontend is served from the same URL as the API.
Auth is validated on every request to catch bugs where a client sends wrong credentials:
- Bot token:
Authorization: Bot <token>must resolve to a valid tenant. Returns401otherwise. - Bearer token:
Authorization: Bearer <token>must match a previously issued access token. Returns401otherwise. - Client ID cross-check: Endpoints with both a bot token header and a
clientIdpath param (e.g., bulk overwrite commands) verify they belong to the same tenant. Returns400on mismatch.
POST/PATCH/PUTendpoints that expect JSON requireContent-Type: application/json(with optional charset suffix like; charset=utf-8).- The OAuth token exchange requires
Content-Type: application/x-www-form-urlencoded. - Missing or wrong Content-Type returns
400 { "message": "Invalid request body" }. - Unparseable JSON also returns
400 { "message": "Invalid request body" }.
Any request that doesn't match a known route returns:
404 { "message": "404: Not Found" }All tables are created via a single idempotent migration (001_fake_discord_schema).
tenants (
id TEXT PRIMARY KEY,
bot_token TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL UNIQUE,
client_secret TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
next_id INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
guilds (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
)
channels (
tenant_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
id TEXT NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
)auth_codes (
code TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL DEFAULT ''
)
access_tokens (
token TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL
)
messages (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
channel_id TEXT NOT NULL,
payload TEXT NOT NULL, -- JSON string of the full request body
created_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
)
message_edits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT NOT NULL,
message_id TEXT NOT NULL,
payload TEXT NOT NULL, -- JSON string of the old payload before edit
edited_at TEXT NOT NULL
)
reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL
)
interaction_responses (
tenant_id TEXT NOT NULL,
interaction_token TEXT NOT NULL,
response_id TEXT NOT NULL,
payload TEXT NOT NULL,
responded_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, interaction_token)
)
followups (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
interaction_token TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
)
registered_commands (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
guild_id TEXT NOT NULL,
payload TEXT NOT NULL,
registered_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, id)
)audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT, -- nullable: failed auth = NULL
method TEXT NOT NULL,
url TEXT NOT NULL,
request_body TEXT,
response_status INTEGER NOT NULL,
response_body TEXT,
created_at TEXT NOT NULL
)idx_messages_channel ON messages (tenant_id, channel_id, created_at)
idx_reactions_tenant ON reactions (tenant_id, created_at)
idx_followups_token ON followups (tenant_id, interaction_token, created_at)
idx_commands_guild ON registered_commands (tenant_id, guild_id, registered_at)
idx_auth_codes_tenant ON auth_codes (tenant_id)
idx_access_tokens_tenant ON access_tokens (tenant_id)
idx_message_edits_tenant ON message_edits (tenant_id)
idx_audit_logs_tenant ON audit_logs (tenant_id, created_at)
idx_tenants_created_at ON tenants (created_at)src/worker/
index.ts # Migration, route registration, catch-all 404
helpers.ts # Tenant resolution, ID gen, body parsing, Ed25519 crypto
discord-api.ts # 10 Discord API endpoints (registerDiscordRoutes)
test-control.ts # 10 test control endpoints (registerTestRoutes)
tests/
setup.ts # Shared test fixtures (createTestTenant, deleteTenant, etc.)
helpers.test.ts # Unit tests for pure helper functions
test-control.test.ts # Integration tests for test control endpoints
discord-api.test.ts # Integration tests for Discord API endpoints
audit-logs.test.ts # Integration tests for audit logging and log APIs
cron-cleanup.test.ts # Tests for tenant expiry and created_at
vite.config.ts # Vite config with proxy entries for /api, /_test, /oauth2
vitest.config.ts # Vitest configuration
package.json # Dependencies and scripts
- Node.js 22+
- npm
npm install
npm startThis starts the Fling dev server:
- API server at
http://localhost:3210(all backend routes) - Frontend dev server at
http://localhost:5173(Vite with HMR, proxies/api,/_test,/oauth2to the API server)
Tests are self-contained — they automatically start and stop the dev server:
npm testIf the server is already running on port 3210 (e.g. from npm start), tests will use it and skip lifecycle management.
The test suite has 80 tests across 5 files:
helpers.test.ts-- 7 unit tests for hex encoding, key handling, and Ed25519 signingtest-control.test.ts-- 28 integration tests for all test control endpointsdiscord-api.test.ts-- 32 integration tests for all Discord API endpointsaudit-logs.test.ts-- 10 integration tests for audit logging and log retrievalcron-cleanup.test.ts-- 3 tests for tenant expiry andcreated_at
The Vite frontend dev server proxies these paths to the API server at http://localhost:3210:
/api/*/_test/*/oauth2/*
| Package | Purpose |
|---|---|
flingit |
Fling platform SDK (Hono HTTP, D1 database, migrations) |
@noble/ed25519 |
Pure ESM Ed25519 signing (no Node.js crypto dependency, works in Workers) |
@noble/curves |
Peer dependency for @noble/ed25519 |
vitest (dev) |
Test runner |
Why
@noble/ed25519instead oftweetnacl? Fling bundles workers similarly to Cloudflare Workers. Packages that userequire("crypto")(liketweetnacl) crash at startup.@noble/ed25519is pure ESM and usescrypto.subtlefor hashing.
A typical integration test flow:
// 1. Create a tenant
const resp = await fetch("http://localhost:3210/_test/tenants", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
botToken: "my-bot-token",
clientId: "my-client-id",
clientSecret: "my-client-secret",
publicKey: "<hex-encoded Ed25519 public key>",
privateKey: "<hex-encoded Ed25519 private key>",
guilds: {
"guild-1": {
name: "Test Guild",
channels: {
"chan-1": { name: "general" }
}
}
}
})
});
const { tenantId } = await resp.json();
// 2. Point your Discord plugin at this fake server
// Set DISCORD_API_BASE_URL=http://localhost:3210
// Set DISCORD_PUBLIC_KEY=<publicKey from above>
// 3. Use the Discord API as normal
await fetch("http://localhost:3210/api/v10/channels/chan-1/messages", {
method: "POST",
headers: {
"Authorization": "Bot my-bot-token",
"Content-Type": "application/json"
},
body: JSON.stringify({ content: "Hello from test!" })
});
// 4. Verify via test control
const msgs = await fetch(`http://localhost:3210/_test/${tenantId}/messages/chan-1`);
const { messages } = await msgs.json();
console.log(messages[0].payload.content); // "Hello from test!"
// 5. Run the full OAuth flow
const codeResp = await fetch(`http://localhost:3210/_test/${tenantId}/auth-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ guildId: "guild-1", redirectUri: "https://example.com/cb" })
});
const { code } = await codeResp.json();
const tokenResp = await fetch("http://localhost:3210/api/v10/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "client_id=my-client-id&client_secret=my-client-secret&grant_type=authorization_code&code=" + code + "&redirect_uri=https://example.com/cb"
});
const { access_token } = await tokenResp.json();
// 6. Use the access token
const me = await fetch("http://localhost:3210/api/v10/users/@me", {
headers: { "Authorization": `Bearer ${access_token}` }
});
const user = await me.json();
console.log(user.username); // "fakeuser"
// 7. Send a signed interaction to test your webhook handler
await fetch(`http://localhost:3210/_test/${tenantId}/send-interaction`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
webhookUrl: "http://localhost:3210/webhook", // your handler
interaction: {
type: 2,
id: "int-1",
application_id: "my-client-id",
token: "int-token-1",
data: { id: "cmd-1", name: "ping", type: 1, options: [] }
}
})
});
// 8. Clean up between tests
await fetch(`http://localhost:3210/_test/${tenantId}/reset`, { method: "POST" });
// 9. Delete when done
await fetch(`http://localhost:3210/_test/tenants/${tenantId}`, { method: "DELETE" });| Method | Path | Auth | Section |
|---|---|---|---|
GET |
/oauth2/authorize |
client_id query param | 1.1 |
POST |
/api/v10/oauth2/token |
client_id form field | 1.2 |
GET |
/api/v10/users/@me |
Bearer token | 1.3 |
GET |
/api/v10/channels/:channelId |
Bot token | 1.4 |
POST |
/api/v10/channels/:channelId/messages |
Bot token | 1.5 |
PATCH |
/api/v10/channels/:channelId/messages/:messageId |
Bot token | 1.6 |
PUT |
/api/v10/channels/:channelId/messages/:messageId/reactions/:emoji/@me |
Bot token | 1.7 |
PATCH |
/api/v10/webhooks/:clientId/:interactionToken/messages/@original |
None | 1.8 |
POST |
/api/v10/webhooks/:clientId/:interactionToken |
None | 1.9 |
PUT |
/api/v10/applications/:clientId/guilds/:guildId/commands |
Bot token | 1.10 |
| Method | Path | Section |
|---|---|---|
POST |
/_test/tenants |
2.1 |
DELETE |
/_test/tenants/:tenantId |
2.2 |
GET |
/_test/:tenantId/messages/:channelId |
2.3 |
GET |
/_test/:tenantId/reactions |
2.4 |
GET |
/_test/:tenantId/interaction-responses/:token |
2.5 |
GET |
/_test/:tenantId/followups/:token |
2.6 |
GET |
/_test/:tenantId/commands/:guildId |
2.7 |
POST |
/_test/:tenantId/reset |
2.8 |
POST |
/_test/:tenantId/auth-code |
2.9 |
POST |
/_test/:tenantId/send-interaction |
2.10 |
GET |
/_test/:tenantId/audit-logs |
2.11 |
| Method | Path | Section |
|---|---|---|
GET |
/_test/browse/tenants |
3.1 |
GET |
/_test/browse/tenants/:tenantId |
3.2 |
GET |
/_test/browse/tenants/:tenantId/state |
3.3 |
GET |
/_test/browse/audit-logs |
3.4 |