Testing Guide
1. Quality Gates
Every change to Ririko must pass automated tests before it is merged. A TypeScript build that passes is the minimum. It does not prove that the code is correct.
Run these commands before you open a pull request:
| Command | What it checks |
|---|---|
pnpm lint | The code style (ESLint). |
pnpm format:check | The formatting (Prettier). |
pnpm build | That the TypeScript build passes. |
pnpm typecheck | The types in every workspace package. |
pnpm test:coverage | All tests, with the coverage thresholds. |
pnpm test:e2e | The end-to-end tests of the dashboard. |
CI runs the same gate on every push (see section 4). pnpm test:coverage includes the bot integration suites. pnpm test:integration runs only those.
2. Testing Principles
2.1. Deterministic Randomness in Tests
- No flaky tests. Tests for random number generators (card drops, gambling games, giveaway rolls) must use fixed random seeds.
- Never write tests with probabilistic assertions such as
expect(rareCount).toBeGreaterThan(10). Instead, seed the pseudo-random generator (PRNG) and assert exact deterministic outcomes.
2.2. Isolated Test Databases
- Repository suites declare their tests with
describeDialects(name, fn)frompackages/database/src/testing/dialects.ts(other packages import it as@ririko/database/testing). It runsfntwice:- SQLite: a fresh
:memory:database migrated bymigrateDatabasefor every test. - Postgres: only when
TEST_POSTGRES_URLis set, otherwise reported as skipped. Each suite gets a random schema built by the bot's own migration runner (migrateDatabase), the tables in use are emptied before every test, and the schema is dropped afterwards.
- SQLite: a fresh
- Use uuids for ids that Postgres stores as
uuid, and comparebigintcolumns (balances, XP) withNumber(...): Postgres returns them asbigint. - To run the Postgres variants on your computer, use a throwaway container:
-
Start the container:
docker run -d --rm --name ririko-pg-test -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:16-alpine -
Run the tests. In PowerShell, set the two variables with
$env:NAME = 'value'first.TEST_POSTGRES_URL=postgres://postgres:test@127.0.0.1:55432/postgres ADVENTURE_TEST_POSTGRES_URL=postgres://postgres:test@127.0.0.1:55432/postgres pnpm test:postgres -
Stop the container. It deletes itself:
docker stop ririko-pg-test
-
3. Test Suites & Domain Coverage
3.1. Unit Tests (packages/*/__tests__)
- Economy & Banking: Balance updates, double-entry ledger integrity, daily streaks, negative balance prevention.
- Anti-Spam & XP: Rolling window cooldowns, copy-paste similarity rejection, voice XP participant quorums.
- Waifu TCG Mechanics: Card drop weighting, element damage multipliers (1.5x), card stat calculations, level-up curves.
- Moderation Engine: Warning threshold escalation logic, regex/trie auto-mod filters, permission hierarchy validation.
- Stream Notification Deduplication: Idempotency key hash generation and duplicate message prevention.
- Command Router: $O(1)$ lookup speed, alias resolution, argument parsing.
- Dashboard Components: Vitest runs in
nodeby default. Markup tests render withreact-dom/serverthroughtests/support/markup.ts. Tests that click or type opt in to a DOM with a// @vitest-environment jsdomfirst line and use@testing-library/react(for exampleapps/web/src/components/account-interactions.test.tsx).
3.2. Bot Integration Tests (apps/bot/test/integration)
These drive the real bot: createBotServices over in-memory SQLite, the command router with its middlewares, every registered command and the component interaction handler, wired by the same functions main() uses (command-router.ts, command-set.ts, component-interactions.ts). apps/bot/test/support/bot-harness.ts builds it:
- discord.js REST points at the fake Discord API, so every reply is a real HTTP request the fake records.
- Gateway events (
READY,GUILD_CREATE,MESSAGE_CREATE,INTERACTION_CREATE) enter through discord.js's own packet handler, so commands receive realMessageandInteractionobjects. This uses one discord.js internal (client.ws.handlePacket), kept in the harness;harness.test.tsfails first if an upgrade changes it. harness.sendMessage,runSlashCommand,runMessageCommandanduseComponentbuild the packets from the fake's fixture;settle()waits for every router dispatch to finish;assertAllRoutesHandled()fails a test that called a route the fake does not implement.- Not wired: the gateway connection, voice, and the message listener (automod, XP, card drops, AI chat).
| Suite | Covers |
|---|---|
prefix-dispatch.test.ts | Replies, per-guild prefix, Manage Server check, a command disabled on the dashboard, cooldowns |
slash-dispatch.test.ts | Interaction callbacks, private permission errors, prefix-only commands, a deferred context menu reply edited through the webhook |
components.test.ts | Help center select menu; a giveaway from /giveaway through a member's entry to the draw |
command-sync.test.ts | Global and per-server registration on startup, per-server registration when the bot joins a server |
The repository suites and the adventure concurrency suite also run on Postgres (section 2.2 and the test-postgres CI job).
3.3. End-to-End Tests (apps/web/e2e)
Playwright (Chromium) against next start on port 3100, configured in apps/web/playwright.config.ts:
- The config starts the fake Discord API (port 3199) and the dashboard with
DISCORD_API_URLpointing at it, aftere2e/support/seed.tsrecreates a SQLite database in the OS temp folder with a 30-card collection for the fakeadminuser. signIn(login)ine2e/support/fixtures.tssets the fake'sfake_discord_usercookie and clicks Sign in with Discord. The real login route, the fake's authorize and token endpoints (with PKCE checks) and the real callback create the session.- Each test sends its own
X-Forwarded-For, because the auth routes allow 20 requests a minute per client IP. - Every test fails if the dashboard called a Discord route the fake does not implement.
- Specs are named
*.spec.tsso Vitest does not pick them up. - The seeded cards have fixed ids and no art asset, so the album renders them without network access and reuses its
public/cards/e2e-card-*.pngcache across runs.
| Spec | Covers |
|---|---|
auth.spec.ts | Sign-in, the new-device DM, sign-out; a forged OAuth2 callback is rejected |
guild-access.spec.ts | Only servers with Manage Server are listed; other servers answer 404; a member without it sees none |
settings.spec.ts | The command prefix persists to guild_settings; the AI channel is checked through the bot REST client and persists |
album.spec.ts | Card album paging (newest first, market label) and the rarity filter |
3.4. Fake Discord API
tests/support/fake-discord is a dependency-free node:http server on loopback that both suites use:
fixtures.ts: users, guilds, roles, channels and members with permission bits (defaultFixture(): the bot,adminwho owns Ririko Test Server,memberwithout permissions there, and Other Server whereadminlacks Manage Server). The same fixture feeds the fake's REST answers and the harness's gateway packets.server.ts:startFakeDiscord()serves the v10 routes the bot and the dashboard call, records every request and its response (requests,find,waitFor), and answers 404 for any other route, listing it inunhandled.cli.tsruns it as a process;/__fake/requests,/__fake/unhandledand/__fake/resetserve other processes.- To support a new route: add it to the route table in
server.ts, with a test inserver.test.ts. Do not make a suite pass by ignoringunhandled.
4. Continuous Integration
CircleCI runs .circleci/config.yml on every push to every branch. The ci workflow has eight parallel jobs. Each job that runs code on the host (all but docker and secrets) restores the pnpm store cache, runs pnpm install --frozen-lockfile and pnpm build first, because every @ririko/* package export points at dist/.
| Job | Runs | Fails when |
|---|---|---|
lint | pnpm lint, pnpm format:check, pnpm db:check | ESLint reports an error (warnings do not fail), a file is not Prettier-formatted, or a migration gate fails (below) |
typecheck | pnpm typecheck | Any workspace package has a type error |
test | pnpm test:ci | A test fails, or coverage drops below the thresholds in vitest.config.ts |
test-postgres | pnpm test:postgres with a cimg/postgres:16 service container, TEST_POSTGRES_URL and ADVENTURE_TEST_POSTGRES_URL set | A database or services test fails on Postgres, including the migration runner, adoption and baseline tests in packages/database/src/migrations |
build-web | pnpm --filter @ririko/web build | The Next.js production build fails |
e2e | Next.js build, then playwright test (Chromium cached, fonts installed) | A Playwright spec fails; report and traces are in the job's Artifacts tab |
docker | docker build of the bot-runner and web-runner targets on CircleCI's remote Docker engine, then node scripts/docker-smoke.ts bot web. Nothing is pushed | An image does not build (for example, a new workspace package is missing from the Dockerfile's manifests stage), or a smoke check fails: wrong user, wrong writable directories, FFmpeg missing, the bot not reaching command registration, or the dashboard not serving pages and opening its database (docs/deployment.md section 9.1) |
secrets | gitleaks git over the full history with .gitleaks.toml | gitleaks finds a secret that is not listed in .gitleaksignore |
pnpm db:check (scripts/check-migrations.ts, tested by scripts/check-migrations.test.ts) is the offline gate for the versioned migrations. It needs no database and no network, never writes the real migrations folder, and fails with the file and the statement when:
- schema:
drizzle-kit generatefor either dialect would write a new migration, which means a schema change has no migration (it runs on a temporary copy ofpackages/database/migrations/). Fix:pnpm db:generate. - embedded:
src/migrations/generated/{sqlite,pg}.tsdiffer from the SQL files (rebuilt in memory and compared). Fix:pnpm db:generate. - safety: a migration contains
DROP TABLE,DROP COLUMN,RENAME,ALTER COLUMN ... TYPEorSET NOT NULLand its first line is not-- ririko:contract. Comments, string literals and quoted identifiers are ignored. The baseline passes.
The SQLite runner treats a migration that contains PRAGMA foreign_keys=OFF (a drizzle-kit table rebuild) as SQLite's rebuild procedure; runner.test.ts shows that ON DELETE CASCADE children survive and that a dangling reference rolls the migration back (see database.md section 1.5).
Git hooks (Husky, installed by pnpm install) run the lint checks locally:
- pre-commit: blocks
.envfiles, scans staged changes with gitleaks, then runslint-staged:eslint --fixandprettier --writeon staged files (rules inpackage.jsonlint-staged), re-staging the fixes. - pre-push:
pnpm lint --quiet(errors only) andpnpm format:check, the same checks as thelintjob, so a push that would fail CI lint stops locally. Fix withpnpm lint:fixandpnpm format.
4.1. Coverage
- Merge gate (standing rule for every contributor and agent): a PR is not merged unless it passes every threshold in
vitest.config.tscoverage.thresholds. The thresholds are lines 80%, functions 81%, statements 79% and branches 68%. The CircleCItestjob fails below any of them. Runpnpm test:coveragebefore opening a PR, and cover the code you add or change with real tests. pnpm test:ciruns Vitest with v8 coverage and writescoverage/(HTML,lcov.info,coverage-summary.json) andtest-results/junit.xml. Runpnpm test:coveragelocally for the same numbers.- Coverage counts every source file under
packages/*/srcandapps/*/src, including files no test imports. - Ratchet rule:
coverage.thresholdsinvitest.config.tshold the measured baseline, rounded down. Raise them when coverage grows. Never lower them to get a build through; add tests instead. - A test that imports a workspace package (
@ririko/database,@ririko/music, ...) runs its builtdist/, which coverage does not count. To cover a package's code, test it from inside that package with a relative import.
4.2. Where to find results
- Test results: the CircleCI
testjob's Tests tab (fromjunit.xml) shows failures, per-test timing and flaky tests. - Coverage report: the
testjob's Artifacts tab has the HTML report undercoverage/index.html. - Codecov: the
testjob uploadslcov.infoandjunit.xmlto Codecov (needs theCODECOV_TOKENproject variable in CircleCI). Codecov comments on PRs with the coverage diff.codecov.ymlfails the project status only when coverage drops more than 1% from the base commit; patch coverage is informational.
4.3. CI Environment
- The
testjob runs on Linux in UTC with FFmpeg andfonts-dejavu-coreinstalled. Tests must not depend on the host time zone, path style or locally installed fonts. - Tests run offline.
vitest.setup.tsrefuses every connection and DNS lookup to a host other than loopback (localhost,127.*,::1), and fails the test that tried withThis test tried to reach the network: <hosts>. A live call would pass or time out depending on how fast YouTube, Spotify, SoundCloud or Deezer answer CircleCI.- Replace the client with a fake:
vi.mockthe SDK module (seepackages/music/src/extractors/extractors.test.ts),vi.stubGlobal('fetch', ...), or pass afetchFnwhere the class accepts one. vitest.config.tssetsUSE_PRIVATE_MUSIC_PACKAGE=false, socreateBotServicesnever loads the private music package during tests.packages/music-privateis excluded from the public test run and coverage.- Do not skip a test on CI to hide a live call. Fake the service instead.
- Replace the client with a fake:
4.4. Pre-Release Privacy Scan
Run this before a release, before a branch becomes public, and after any change to deployment docs, deploy/ or docs/kanban/. The repository is public, so no tracked file may hold a live IP address, a private hostname, or the name, region or size of a hosting resource. Placeholders are 192.0.2.x, 198.51.100.x, 203.0.113.x, 2001:db8::, <domain> and *.example.com. The WireGuard plan 10.77.0.0/24 is a private design example and may stay. The maintainer's private runbook is not in the repository, so it is never scanned.
Every command below must print nothing. Run them from the repository root in Git Bash or any POSIX shell. They read tracked files only (git grep) and skip this section's own file (':!docs/testing.md'), because it names the patterns it searches for.
-
Public IPv4 addresses. Documentation, private, loopback, link-local, multicast and reserved ranges are skipped, and so are four public addresses that tests use on purpose as "a public IP" (
1.1.1.1,8.8.8.8,93.184.216.34,192.0.1.1; the documentation ranges are blocked byisPrivateOrRestrictedIp, so they cannot stand in for them).git grep -n -I -P -o '(?<![\w./])(?:\d{1,3}\.){3}\d{1,3}(?![\w]|\.\d)' -- . ':!pnpm-lock.yaml' ':!docs/testing.md' |awk -F: '{ip = $NF; split(ip, o, ".")if (o[1] > 255 || o[2] > 255 || o[3] > 255 || o[4] > 255) nextif (ip ~ /(^|\.)0[0-9]/) nextif (ip ~ /^(0|10|127)\./ || ip ~ /^192\.168\./ || ip ~ /^169\.254\./) nextif (ip ~ /^172\.(1[6-9]|2[0-9]|3[01])\./ || ip ~ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./) nextif (ip ~ /^(192\.0\.[02]|198\.51\.100|203\.0\.113|198\.1[89])\./ || ip ~ /^(22[4-9]|2[3-5][0-9])\./) nextif (ip ~ /^(1\.1\.1\.1|8\.8\.8\.8|93\.184\.216\.34|192\.0\.1\.1)$/) nextprint}' -
Public IPv6 addresses. Documentation (
2001:db8::/32), link-local, unique-local, multicast and loopback addresses, clock times and one public DNS fixture (2606:4700:4700::1111) are skipped.git grep -n -I -P -o '(?<![\w:.\/])(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}(?![\w:.])|(?<![\w:.\/])(?:[0-9a-fA-F]{1,4}:){1,6}:(?:[0-9a-fA-F]{1,4})?(?![\w:])' -- . ':!pnpm-lock.yaml' ':!docs/testing.md' |awk -F: '{ ip = substr($0, length($1) + length($2) + 3)if (ip ~ /^[0-9]{1,2}:[0-9]{2}(:[0-9]{2})?$/) nextif (ip ~ /^(2001:db8|fe[89ab][0-9a-f]|f[cd][0-9a-f][0-9a-f]|ff[0-9a-f][0-9a-f]):/ || ip ~ /^::/) nextif (ip == "2606:4700:4700::1111") nextprint}' -
Project-domain hostnames other than
ririko.aianddashboard.ririko.ai. Use*.example.comnames in docs, tests and examples.git grep -n -I -i -o -P '(?<![\w.-])(?!dashboard\.)[a-z0-9-]+\.ririko\.ai\b' -- . ':!docs/testing.md' -
Regions, availability zones and instance sizes. Describe hosts by role ("the app host", "the Lavalink host"). This command holds only generic patterns: provider and plan names must never appear in a tracked file, this one included, so they live only in
.local/private-terms.txt(command 5). Keep sizes and plan names in the private runbook.git grep -n -I -i -P '\b(?:us|eu|ap|sa|ca|me|af)-(?:east|west|central|north|south|southeast|northeast)-\d\b|\bKVM\b|\bNVMe\b|\b\d+ ?GB (?:of )?RAM\b' -- . ':!pnpm-lock.yaml' ':!docs/testing.md' -
The maintainer's own list of private terms: the real provider and plan names, bucket names, host names and ping URLs from the private runbook. Keep one fixed string per line in
.local/private-terms.txt(git-ignored, never committed).test -f .local/private-terms.txt && git grep -n -I -i -F -f .local/private-terms.txt -- . ':!docs/testing.md' -
Secrets: the same command as CircleCI's
secretsjob, over the full history. Known findings are listed in.gitleaksignore. Staged changes are also scanned by the pre-commit hook.gitleaks git --redact --no-banner --config .gitleaks.toml
A hit is fixed in the tree: replace it with a placeholder or generic wording and run the scan again. A hit in history needs the maintainer's decision (a history rewrite is never an agent's call). To allow a new public test fixture, add it to the allowlist in command 1 or 2 and say why in the pull request.