- Local PostgreSQL and Redis running (
docker compose up -d postgres redis) - Run
bun db:migrateto apply schema changes - Run
bun db:generateto regenerate Prisma client - All apps running (
bun dev)
- Open
/dashboard/discord— verify "Monitored Channels" card appears below Test Notification - Each monitored Twitch channel shows display name, avatar, and live/offline status
- Channels with overrides show "(overrides active)" indicator
- Click "Configure" on a channel — dialog opens with correct current values
- Notification Channel Override dropdown loads guild text/announcement channels
- Notification Role Override dropdown loads guild roles + @everyone option
- "Use guild default" option works (sets override to null)
- Toggle: "Update message while live" defaults to on
- Toggle: "Delete when offline" defaults to off
- Toggle: "Auto-publish in announcement channels" defaults to off
- Toggle: "Use custom embed message" defaults to off
- Save button saves settings and shows success toast
- Audit log records
discord.channel-settingsaction
- Enabling "Use custom embed message" reveals online/offline JSON textareas
- Embed preview renders below each textarea in real-time
- Preview shows title, description, fields, author, footer, color bar
- Template variables display with sample data:
{streamer},{title},{game},{viewers},{url},{thumbnail},{duration} - Invalid JSON shows "Invalid JSON — preview unavailable" message
- Empty textarea shows "Enter embed JSON to see preview"
- Per-channel override: Configure a channel with a different notification channel — verify notification goes to the override channel, not the guild default
- Per-channel role override: Configure a channel with a different role — verify the correct role is mentioned
- Update message while live = OFF: Verify the "Still Live" embed update is skipped (title/game/viewer count stay at initial values)
- Delete when offline: When stream goes offline, the notification message is deleted (not edited to offline embed)
- Auto-publish: Send notification to an announcement channel with auto-publish on — verify the message is crossposted
- Custom online embed: Set custom JSON for online notification — verify custom embed is used instead of default
- Custom offline embed: Set custom JSON for offline notification — verify custom embed is used when stream goes offline
- Invalid custom JSON fallback: Set invalid JSON with custom message enabled — verify default embed is used as fallback
- Guild default fallback: Leave channel overrides blank — verify guild-level channel/role settings are used
- Send test notification with per-channel overrides configured — verify it respects the channel-level settings
- Verify
cleanup-inactive-accountsjob appears in worker logs on startup - Job is scheduled with cron pattern
0 3 * * *(daily at 3 AM)
- Create a test user with role USER, no sessions, created > 1 year ago — verify deleted
- Verify ADMIN users are NOT deleted regardless of inactivity
- Verify MODERATOR users are NOT deleted regardless of inactivity
- Verify LEAD_MODERATOR users are NOT deleted regardless of inactivity
- Verify the broadcaster account is NOT deleted regardless of inactivity
- Verify users with recent sessions (within 365 days) are NOT deleted
- Verify users created within 365 days (even with no sessions) are NOT deleted
- Check logs for deletion count message
- Enable Server Members Intent in Discord Developer Portal → Bot → Privileged Gateway Intents
- Bot is running and connected to your Discord server
- Web dashboard is running and Discord guild is linked
- Enable welcome message in dashboard → Discord → Welcome & Leave Messages
- Select a channel and enter a plain text message (e.g.,
Welcome {displayName} to {server}! We now have {memberCount} members.) - Save settings
- Join the guild with an alt account — verify message appears in the selected channel with variables replaced
- Switch to embed mode, paste custom embed JSON, verify preview renders
- Save and rejoin — verify embed appears in channel
- Enable leave message in dashboard
- Select a channel and enter a plain text message (e.g.,
{displayName} has left {server}. ({memberCount} members)) - Save settings
- Leave the guild with the alt account — verify leave message appears
- Test embed mode similarly
- Enable auto-role in dashboard
- Select a role from the dropdown
- Save settings
- Join the guild with an alt account — verify the role is assigned automatically
- Verify error is logged (not crashed) if bot lacks Manage Roles permission or role is higher than bot's role
- Enable DM welcome in dashboard
- Enter a plain text DM message (e.g.,
Welcome to {server}, {displayName}! Check out the rules channel.) - Save settings
- Join the guild with an alt account (with DMs enabled) — verify DM is received
- Join with DMs disabled — verify bot logs error but doesn't crash
- Test embed mode for DM
- Click Test Welcome — verify welcome message appears in the configured channel (uses bot's own member as stand-in)
- Click Test Leave — verify leave message appears in the configured channel
- Click Test DM — verify DM is sent to the bot (check logs for success/failure)
- Verify buttons are disabled when corresponding features are not enabled or channels not set
- Verify all three actions (welcome, DM, auto-role) fire independently — failure in one does not block others
- Verify partial guild member (leave event for uncached member) doesn't crash — uses "Unknown" for missing fields
- Verify settings persist after page reload
- Verify audit log entries appear for
discord.welcome-settingsanddiscord.test-welcomeactions - Verify embed preview updates in real-time as JSON is typed
- Verify template variables (
{user},{username},{displayName},{server},{memberCount},{tag}) all resolve correctly
- Open a monitored channel's settings dialog (Configure button)
- Enable "Use custom embed message" toggle
- Online Embed Builder appears with form sections and preview
- Offline Embed Builder appears below it
- Edit title/description — preview updates live
- Pick a color from presets — left border in preview changes
- Enter a hex color manually — preview reflects it
- Open Author section — fill name/icon/url — preview shows author row
- Add a field — name + value appear in preview
- Toggle field "Inline" checkbox — field layout changes in preview
- Add multiple fields, reorder with up/down — preview reflects order
- Remove a field — disappears from preview
- Open Images section — enter thumbnail URL — preview shows placeholder
- Open Footer section — enter text — footer appears in preview
- Toggle timestamp checkbox
- Click a variable pill (e.g.
{streamer}) — toast confirms copied to clipboard - Paste variable into title/description — preview substitutes with sample value
- Open "JSON Import / Export" — JSON textarea shows current embed JSON
- Copy button copies JSON to clipboard
- Modify JSON in textarea → click "Apply JSON" — form updates to match
- Enter invalid JSON → click "Apply JSON" — inline error shown
- Clear all fields — JSON output becomes empty string
- Save settings → reload dialog — form repopulates from saved JSON
- Both Online and Offline builders work independently
- Navigate to Dashboard > Discord > Welcome & Leave Messages
- Enable Welcome Message → switch to "Embed" mode
- Embed Builder appears with full two-column layout (desktop)
- Variable pills show:
{user},{username},{displayName},{server},{memberCount},{tag} - All form sections work (Basic, Author, Fields, Images, Footer)
- Preview updates live with sample variable substitutions
- JSON Import/Export works
- Save Welcome Settings → reload page → form repopulates correctly
- Switch back to "Plain Text" mode — VariableHint shows, builder hides
- Enable Leave Message → switch to "Embed" mode
- Embed Builder appears with same functionality as welcome
- All form sections, preview, variables, JSON import/export work
- Save → reload → form repopulates
- Enable DM Welcome → switch to "Embed" mode
- Embed Builder appears (no channel selector for DM)
- All form sections, preview, variables, JSON import/export work
- Save → reload → form repopulates
- Desktop (≥768px): two-column layout — form left, sticky preview right
- Mobile (<768px): single column — form on top, preview below
- Channel settings dialog: always compact (single column) since inside dialog
- Collapsible sections toggle open/close correctly
- Fields section auto-opens when fields exist
- Empty initial value — form starts blank, preview shows "Enter embed JSON to see preview"
- Existing JSON from Phase 1/2 — form correctly parses and populates all fields
- Max 25 fields — "Add Field" button disables at 25, counter shows
(25/25) - Color input accepts only valid hex patterns
- Fields with empty name/value are omitted from JSON output
- Visit
/p— banner + sidebar + content renders correctly - Visit
/p/commands— sidebar shows with Commands link active - Visit
/p/queue— sidebar shows with Queue link active - Navigate between
/p,/p/commands,/p/queue— sidebar active state updates without full page reload - Sidebar nav only shows Commands link if commands exist
- Sidebar nav only shows Queue link if queue is not CLOSED
- Banner height and avatar size are consistent across all 3 pages
-
/p— sidebar card fades in, each content card staggers in sequence -
/p/commands— header and tabs fade in with stagger -
/p/queue— header and queue entries fade in with stagger
- Resize to mobile (<640px) — sidebar becomes horizontal row with avatar + name inline
-
/p/commandson mobile — commands display as cards (not a table) -
/p/commandson desktop (≥640px) — commands display as a table - Queue page looks correct on mobile
-
/p— page has<title>like "{username}'s Community" and a meta description -
/p/commands— page has<title>like "Commands — {username}" and a meta description -
/p/queue— page has<title>like "Viewer Queue — {username}" and a meta description
- "Visit Channel" button visible in hero section when broadcaster has a Twitch username
- "Visit Channel" button links to
https://twitch.tv/{username} - Button does not render when no broadcaster is configured
-
bun db:migrateruns cleanly (renames ADMIN→BROADCASTER, adds ban fields) - Existing ADMIN users in the database are now BROADCASTER after migration
-
bun check-types— no type errors
- Can log in and view dashboard
- Can view commands page (read-only — no create/edit/delete buttons)
- Can view regulars page (read-only — no add/remove buttons)
- Can view Discord settings (read-only — no mutation controls)
- Cannot see Users page in sidebar
- Cannot access
/dashboard/usersdirectly (tRPC rejects) - Bot controls card is visible but enable/disable/mute are hidden
- Can view own profile in Settings page
- Can export data
- All USER permissions plus:
- Can create, edit, delete, and toggle commands
- Can add and remove regulars
- Can import from StreamElements
- Cannot enable/disable/mute the bot
- Cannot modify Discord settings
- Cannot see Users page in sidebar
- All MODERATOR permissions plus:
- Can enable/disable the bot
- Can mute/unmute the bot
- Can update default command toggles and access levels
- Can modify all Discord settings (link guild, set channel/role, enable/disable, welcome, test notifications)
- Cannot see Users page in sidebar
- All LEAD_MODERATOR permissions plus:
- Can see "Management" section with "Users" link in sidebar
- Can access
/dashboard/userspage - Can search users by name/email
- Can filter users by role
- Can change a user's role (USER ↔ MODERATOR ↔ LEAD_MODERATOR)
- Cannot change own role (tRPC rejects)
- Cannot change another BROADCASTER's role (tRPC rejects)
- Can ban a user with an optional reason
- Can unban a user
- Cannot ban self (tRPC rejects)
- Audit log shows all entries (BROADCASTER sees everything)
- Banned user can still log in
- Banned user sees "Account Suspended" page with ban reason when accessing
/dashboard - Banned user's tRPC mutations are rejected with FORBIDDEN
- Unbanning restores dashboard access immediately
- Ban reason displays correctly (or gracefully hidden if none)
- First user completing setup is promoted to BROADCASTER (not ADMIN)
- Setup wizard text says "Sign in to become the broadcaster."
- Role change logged as
user.role-changewith previous/new role - Ban logged as
user.banwith target name and reason - Unban logged as
user.unbanwith target name - BROADCASTER sees all audit entries
- LEAD_MODERATOR sees entries from their level and below
- MODERATOR sees entries from their level and below
- USER sees only USER-level entries
- Discord OAuth now requests
connectionsscope (check consent screen) - After Discord login, if user has verified Twitch connection and no Twitch account linked, Account entry is auto-created
- No duplicate created if Twitch already linked
- No error if no Twitch connection on Discord
-
sync-twitch-linksjob scheduled daily at 4 AM - Job processes users with Discord OAuth tokens
- Skips users who already have Twitch linked
- Creates Twitch Account entries for users with verified Twitch connections
- Handles expired/invalid tokens gracefully (skips, no crash)
- Logs summary (linked, skipped, errors)
- Commands CRUD still works for MODERATOR+ roles
- Regulars add/remove still works for MODERATOR+ roles
- Bot enable/disable/mute still works for LEAD_MODERATOR+ roles
- Discord settings still work for LEAD_MODERATOR+ roles
- Cleanup inactive accounts job still only targets USER role
- Role display badges show correctly (Owner, Lead Mod, Moderator, User)
- Channel owner USER still shows "Owner" badge via
getRoleDisplay
- USER: Join/Leave/Mute/Unmute buttons hidden, "managed by lead moderators" message shown
- MODERATOR: Same as USER — buttons hidden, read-only message shown
- LEAD_MODERATOR: All bot control buttons visible and functional
- BROADCASTER: All bot control buttons visible and functional
- Bot status text (active/muted/not joined) visible to all roles
- USER: Command list visible, search works, Create/Edit/Delete/Toggle all hidden
- MODERATOR: All controls visible — Create, Edit, Delete, Toggle per row
- LEAD_MODERATOR: Same as MODERATOR
- BROADCASTER: Same as MODERATOR
- Empty state "Create your first command" button hidden for USER
- USER: Command list visible, toggle switches replaced with static "On"/"Off" text, access level shows as plain text
- MODERATOR: Same as USER — toggles and dropdowns hidden (these are botChannel mutations)
- LEAD_MODERATOR: Interactive toggle switches and access level dropdowns visible
- BROADCASTER: Same as LEAD_MODERATOR
- USER: Regulars list visible, search works, Refresh Names visible, Add/Remove hidden
- MODERATOR: Add Regular button visible, Remove buttons visible per row
- LEAD_MODERATOR: Same as MODERATOR
- BROADCASTER: Same as MODERATOR
- Empty state "Add your first regular" button hidden for USER
- USER: Guild info visible, all mutation controls hidden (enable/disable, save buttons, test notification, configure buttons)
- USER (unlinked): Shows "No Discord server linked yet. A lead moderator can link one."
- MODERATOR: Same as USER
- LEAD_MODERATOR: All controls visible — Link Server, Enable/Disable, Save Channel/Role, Send Test, Configure per channel, all Welcome settings
- BROADCASTER: Same as LEAD_MODERATOR
- Read-only notification channel shows "# channel-name" or "Not set"
- Read-only notification role shows "@role-name", "@everyone", or "Not set"
- USER/MODERATOR: Enable/Disable buttons hidden, form sections hidden when enabled, Test Messages section hidden
- LEAD_MODERATOR+: All toggles, editors, save buttons, and test buttons visible and functional
- USER: Export Data button visible, StreamElements Import section hidden
- MODERATOR: Export Data + StreamElements Import both visible
- LEAD_MODERATOR: Same as MODERATOR
- BROADCASTER: Same as MODERATOR
- Verify that no hidden mutation buttons means no FORBIDDEN toast errors appear during normal navigation
- Read-only views display all data correctly — no missing information
- Open
/dashboard/queue— page loads, shows current queue state - Status badge shows correct color: green (OPEN), amber (PAUSED), grey (CLOSED)
- Click Open → status changes to OPEN, success toast, audit log entry created
- Click Pause → status changes to PAUSED, success toast
- Click Close → status changes to CLOSED, success toast
- Active status button is disabled (can't set status to current status)
- With entries in queue: table shows position, username, joined time
- Pick Next removes the lowest position entry, returns username in toast
- Pick Random removes a random entry, returns username in toast
- Remove button on individual entry shows confirm/cancel before deleting
- After removing an entry, remaining entries reorder positions correctly
- Clear Queue shows confirm step, then removes all entries
- "Queue is empty" message shown when no entries exist
- "Enable the bot for your channel first" shown when bot is not enabled
- Pick Next / Pick Random / Clear Queue buttons hidden when queue is empty
- USER: Can see queue status and entry list (read-only), no control buttons
- MODERATOR: Can see and use all controls (Open/Close/Pause, Pick, Remove, Clear)
- LEAD_MODERATOR: Same as MODERATOR
- BROADCASTER: Same as MODERATOR
- Sidebar shows "Queue" link with ListOrdered icon under Twitch section
- Queue link active state highlights when on
/dashboard/queue - Quick Stats card shows queue status (OPEN/PAUSED/CLOSED) with correct color
- Quick Stats card shows queue entry count
-
queue.openlogged when opening queue -
queue.closelogged when closing queue -
queue.pauselogged when pausing queue -
queue.picklogged when picking entry (includes mode and username) -
queue.remove-entrylogged when removing entry (includes username) -
queue.clearlogged when clearing queue (includes count)
- Queue mutations from Twitch chat (
!queue join/leave/pick/remove/clear/open/close/pause) publishqueue:updatedevent - Verify no crash if EventBus is not initialized (graceful catch in bot startup)
-
/docs/web-dashboard/welcome-messages— page loads with correct content -
/docs/web-dashboard/user-management— page loads with correct content -
/docs/web-dashboard/queue-management— page loads with correct content
- All 3 new pages appear in the sidebar under "Web Dashboard" section
- Order is: Overview, Public Pages, Audit Log, Discord Settings, Welcome & Leave Messages, User Management, Queue Management
-
grep -r "ADMIN" apps/docs/content/docs/returns no results - Setup wizard page says "BROADCASTER" not "ADMIN"
- Audit log page role hierarchy ends with BROADCASTER
- Web dashboard overview says BROADCASTER not ADMIN
- Audit log page lists all 28 actions across 7 sections (Bot Controls, Commands, Regulars, Discord, User Management, Queue, Imports)
- Includes Phase 2 actions:
discord.welcome-settings,discord.test-welcome - Includes Phase 1 action:
discord.channel-settings - Includes Phase 5 actions:
user.role-change,user.ban,user.unban - Includes Phase 6 actions:
queue.open,queue.close,queue.pause,queue.pick,queue.remove-entry,queue.clear - Includes bot.mute and bot.unmute actions
- Queue system page describes position-based model (not WAITING/PICKED/REMOVED statuses)
- Queue state table shows OPEN/CLOSED/PAUSED
- Entries are described as being deleted on pick/leave (not status changes)
- Dashboard management section exists with cross-link to
/docs/web-dashboard/queue-management - EventBus sync note is present
- Per-channel notification overrides section exists
- Lists all override options: custom channel, role, embed, update-while-live, delete-when-offline, auto-publish
- Welcome & Leave Messages section exists with cross-link
- Audit logging table includes
discord.channel-settings,discord.welcome-settings,discord.test-welcome
- Discord bot overview lists welcome messages, auto-role, and DM welcome features
- Twitch notifications page has per-channel overrides section
-
discord:test-welcomeevent listed in Discord Settings table with{ guildId, type }payload - Queue section lists publisher as "Twitch, Web" (not "Any")
- Event Flow Summary ASCII table includes
queue:updatedanddiscord:test-welcomerows
- Welcome messages page links work (from discord-settings, web-dashboard index)
- Queue management page links work (from queue-system, web-dashboard index)
- User management page link works (from web-dashboard index)
-
bun run --filter docs buildsucceeds with no errors - All 40 pages generate successfully (34 original + 3 new + base paths)
- Run
bun test— all tests pass - Run
bun check-types— all packages pass (verified for Phase 6) - Run
bun turbo build --filter="!web"— all builds succeed
-
vitest.workspace.tsincludespackages/eventsandpackages/api -
packages/events/vitest.config.tsexists withname: "events" -
packages/api/vitest.config.tsexists withname: "api" -
packages/api/src/test-helpers.tsexportsmockSession,mockUser,createMockPrisma
- Publishes JSON-serialized messages to prefixed channel
- Uses custom prefix when provided
- Subscribes to prefixed Redis channel
- Only subscribes once for multiple handlers on same event
- Dispatches messages to registered handlers
- Dispatches to multiple handlers for same event
- Ignores messages for events without handlers
- Ignores malformed JSON messages
-
ping()returns true when Redis responds PONG -
ping()returns false when Redis throws -
disconnect()unsubscribes and disconnects both clients
- Looks up user role and creates audit log entry
- Defaults to USER role when user not found
- Stores optional metadata and ipAddress
- Stores userImage when provided
- Omits undefined optional fields
-
getStatusreturns linked account status -
getStatusreturns false when no accounts linked -
getStatusthrows UNAUTHORIZED without session -
enableupserts botChannel and publisheschannel:join -
enablethrows when no Twitch account linked -
enablerejects USER and MODERATOR roles -
disabledisables bot and publisheschannel:leave -
disablethrows when bot not enabled -
mutemutes bot and publishesbot:mute -
muteusesbot.unmuteaction for unmuting -
mutethrows when bot not enabled -
updateCommandTogglesupdates and publishes event -
updateCommandTogglesthrows for invalid command names -
updateCommandAccessLevelcreates override for non-default level -
updateCommandAccessLeveldeletes override when resetting to default -
updateCommandAccessLevelthrows for invalid command name
-
listreturns commands for user's bot channel -
listthrows PRECONDITION_FAILED when bot not enabled -
listthrows UNAUTHORIZED without session -
createcreates command and publishescommand:created -
createlowercases command name -
createrejects built-in command names (BAD_REQUEST) -
createrejects duplicate names (CONFLICT) -
createrejects invalid characters via Zod -
createrejects USER role -
updateupdates command and publishescommand:updated -
updatethrows NOT_FOUND for nonexistent command -
updatethrows NOT_FOUND for command in different channel -
deletedeletes command and publishescommand:deleted -
deletethrows NOT_FOUND for nonexistent command -
toggleEnabledtoggles state and publishes event -
toggleEnabledthrows NOT_FOUND for nonexistent command
-
listreturns paginated users -
listsupports search filtering -
listrejects non-BROADCASTER role -
listrejects unauthenticated calls -
listrejects banned users -
getUserreturns user details -
getUserthrows NOT_FOUND for missing user -
updateRoleupdates role and logs audit -
updateRoleprevents changing own role -
updateRoleprevents changing broadcaster's role -
updateRolethrows NOT_FOUND for missing user -
banbans user with reason and logs audit -
banprevents banning yourself -
banprevents banning the broadcaster -
unbanunbans user and logs audit -
unbanthrows NOT_FOUND for missing user
-
getStateupserts and returns singleton state -
listreturns entries ordered by position -
setStatusOPEN publishes event and logsqueue.open -
setStatusCLOSED maps toqueue.close -
setStatusPAUSED maps toqueue.pause -
setStatusrejects USER role -
removeEntryremoves entry, reorders positions, publishes event -
removeEntrythrows NOT_FOUND for missing entry -
pickEntrypicks next entry -
pickEntrythrows NOT_FOUND for empty queue (next) -
pickEntrythrows NOT_FOUND for empty queue (random) -
clearclears all entries and publishes event
-
listreturns all regulars -
addadds regular and publishesregular:created -
addthrows NOT_FOUND when Twitch user doesn't exist -
addthrows CONFLICT when already a regular -
addrejects USER role -
removeremoves regular and publishesregular:deleted -
removethrows NOT_FOUND for missing regular -
refreshUsernamesupdates display names from Twitch
-
getStatusreturns linked guild info -
getStatusreturns null when no guild linked -
listAvailableGuildsreturns unlinked guilds -
getGuildChannelsreturns filtered text/announcement channels -
getGuildChannelsthrows NOT_FOUND when no guild linked -
getGuildRolesfilters out managed roles and @everyone -
linkGuildlinks guild and publishesdiscord:settings-updated -
linkGuildthrows NOT_FOUND for unknown guild -
linkGuildthrows CONFLICT when linked to another user -
linkGuildrejects MODERATOR role -
setNotificationChannelsets channel and publishes event -
setNotificationChannelthrows NOT_FOUND when no guild linked -
setNotificationRolesets role and publishes event -
enableenables notifications -
disabledisables notifications -
listMonitoredChannelsreturns monitored channels -
updateChannelSettingsupdates settings and publishes event -
updateChannelSettingsthrows NOT_FOUND for unknown channel -
updateWelcomeSettingsupdates welcome settings -
testWelcomeMessagepublishes test welcome event -
testNotificationpublishes test notification event -
testNotificationthrows PRECONDITION_FAILED when no channel set
-
getProfilereturns profile with connected accounts -
getProfilethrows NOT_FOUND when user doesn't exist -
getProfilethrows UNAUTHORIZED without session -
exportDatareturns full user data export -
exportDatareturns null botChannel when none exists -
importStreamElementsimports commands and publishes events -
importStreamElementsskips existing commands -
importStreamElementsskips invalid names -
importStreamElementsmaps SE access levels correctly -
importStreamElementsthrows PRECONDITION_FAILED when bot not enabled
- BROADCASTER sees all logs without role filter
- MODERATOR only sees USER and MODERATOR logs
- USER only sees USER logs
- Supports action and resourceType filters
- Returns isChannelOwner flag for each item
- Paginates results
- Throws UNAUTHORIZED without session
-
statusreturns true when setup complete -
statusreturns false when not configured -
statusworks without authentication (public procedure) -
getStepreturns current setup step -
getStepreturns null when no step saved -
getSteprequires authentication -
saveStepupserts the setup step -
completecompletes setup with valid token -
completethrows FORBIDDEN with invalid token -
completethrows FORBIDDEN when no token exists -
startBotAuthinitiates device code flow -
startBotAuththrows on Twitch API failure -
pollBotAuthreturns pending when not yet complete -
pollBotAuthstores credentials on success -
pollBotAuththrows on non-pending errors -
pollBotAuththrows when validation fails
- Run
bun test— all 523 tests pass across 52 test files - No test file has import/mock errors
- All new tests use
vi.hoisted()pattern for mock factories
- Local PostgreSQL running (
docker compose up -d postgres) - Create test database:
createdb -U postgres community_bot_test - Or set
TEST_DATABASE_URLenv var to your test database
-
packages/db/src/test-client.ts— exportstestPrisma,cleanDatabase(), seed helpers -
packages/db/src/integration-setup.ts— runsprisma migrate deployagainst test DB -
packages/api/vitest.integration.config.ts— includes*.integration.test.ts, sequential, 30s timeout -
apps/twitch/vitest.integration.config.ts— same pattern for Twitch bot -
apps/discord/vitest.integration.config.ts— same pattern for Discord bot -
vitest.config.ts(root) — excludes*.integration.test.tsfrom unit runs -
bun testruns only unit tests (395 tests, 35 files — no integration tests) -
bun test:integrationruns all integration tests sequentially
-
getStatecreates and returns singleton CLOSED state -
setStatuspersists OPEN status to real DB -
setStatustransitions between all statuses -
listreturns entries ordered by position -
removeEntryremoves entry and reorders positions via raw SQL -
removeEntrythrows NOT_FOUND for non-existent entry -
pickEntrypicks next (lowest position) and reorders -
pickEntrypicks random entry -
pickEntrythrows NOT_FOUND for empty queue (next) -
pickEntrythrows NOT_FOUND for empty queue (random) -
clearremoves all entries from database - Position reordering verified with real
$executeRawUnsafe
-
createcreates command with correctbotChannelId -
createduplicate → CONFLICT error (compound unique constraint) -
createrejects built-in command names -
createlowercases name and aliases -
listreturns only commands for user's bot channel -
updateupdates command fields in DB -
updatethrows NOT_FOUND for command from different channel -
deleteremoves command from DB -
toggleEnabledflips enabled flag
-
getStatusreturns null when not enabled -
getStatusreturns bot channel when enabled -
enablecreates BotChannel record -
enablere-enables previously disabled channel -
disablesets enabled to false -
updateCommandTogglesupdates disabledCommands array -
updateCommandAccessLevelcreates DefaultCommandOverride -
updateCommandAccessLeveldeletes override when reverting to default
-
addcreates TwitchRegular in DB -
addduplicate → CONFLICT (unique constraint) -
addthrows NOT_FOUND for unknown Twitch user -
removedeletes regular from DB -
listreturns all regulars ordered by createdAt desc
-
updateRolechanges role in DB -
updateRolerejects self-change -
updateRolerejects changing broadcaster role -
bansets banned flag and reason in DB -
banrejects self-ban -
unbanclears all ban fields
- Broadcaster sees all audit entries
- Moderator only sees entries at their level and below
- Pagination works with real data
- Enriches entries with isChannelOwner flag
- Filters by action prefix
-
statusreturns false when no config exists -
statusreturns true when setupComplete configured -
completewith valid token finalizes setup (sets broadcaster, promotes user, deletes token) -
completewith invalid token throws FORBIDDEN
-
getQueueStatusdefaults to CLOSED -
setQueueStatuspersists status changes - Status transitions between OPEN/PAUSED/CLOSED
-
joincreates entry with correct position -
joinduplicate → rejected -
joinwhen CLOSED → rejected -
joinwhen PAUSED → rejected -
leaveremoves entry and reorders positions -
leavereturns false for non-existent user -
getPositionreturns correct position -
pick("next")picks lowest position -
pick("random")picks random entry -
pickby username (case-insensitive) -
removeby username with reordering -
clearremoves all entries -
listEntriesreturns ordered entries
-
loadloads enabled commands from DB -
loadskips disabled commands -
getByNameOrAliasfinds by name (case-insensitive) -
getByNameOrAliasfinds by alias -
getByNameOrAliasreturns undefined for non-existent -
getRegexCommandsreturns regex-type commands with compiled RegExp -
reloadrefreshes cache after DB changes - Multi-channel isolation: commands separated per channel
-
loadRegularsloads from real DB -
loadRegularsrefreshes when new regulars added -
loadRegularsremoves deleted regulars -
meetsAccessLevel— EVERYONE/EVERYONE → true -
meetsAccessLevel— REGULAR/MODERATOR → false -
meetsAccessLevel— BROADCASTER meets all levels
-
guildCreateEventcreates DiscordGuild record -
guildCreateEventhandles duplicate guild gracefully -
guildDeleteEventremoves DiscordGuild record -
guildDeleteEventhandles non-existent guild gracefully - Round-trip: create → delete → re-create works
-
resolveNotificationChannelIduses per-channel override -
resolveNotificationChannelIdfalls back to guild default -
resolveNotificationChannelIdreturns null when neither set -
resolveRoleMentionformats @everyone -
resolveRoleMentionformats role mention with ID - TwitchChannel created with guild association and queried correctly
- Channels with disabled guilds are filtered out
- TwitchNotification records created and queried correctly
- Multiple guilds monitoring same channel both have records
- Stream status fields updated on TwitchChannel
-
docker compose up -d postgres— PostgreSQL running -
bun test:integration— all ~95 integration tests pass -
bun test— all 523 unit tests still pass (no regression) - Integration tests properly excluded from
bun testvia rootvitest.config.ts
- Shows current stream title
- Shows "offline or unavailable" when no title
- Shows current game/category
- Shows "offline or unavailable" when no game
- Shows how long user has followed
- Shows "not following" when user doesn't follow
- Shows error when API fails
- Does nothing if user is not a mod
- Shows usage when no args
- Shouts out a user with game info
- Shouts out without game when none set
- Shows error when user not found
- Strips @ from username
- Sends AI shoutout when enabled for channel
- Does not send AI shoutout when disabled for channel
- Still sends standard shoutout when AI fails
- Shows random quote
- Shows specific quote by number
- Shows "no quotes" when empty
- Shows "not found" for missing number
- Adds a new quote (mod only)
- Removes a quote by number (mod only)
- Non-mods cannot add quotes
- Searches quotes by keyword
- Shows counter value
- Increments counter
- Decrements counter
- Sets counter to specific value
- Creates a new counter
- Deletes a counter
- Shows error for non-existent counter
- Shows error for unknown subcommand
-
listreturns quotes for user's bot channel -
listthrows PRECONDITION_FAILED when bot not enabled -
addcreates quote with auto-incrementing number -
addrejects USER role -
removedeletes quote by number -
removethrows NOT_FOUND for missing quote -
getreturns specific quote by number -
searchfinds quotes by keyword
-
listreturns all counters for bot channel -
createcreates a new counter -
createrejects duplicate names -
updateupdates counter value -
updatethrows NOT_FOUND for missing counter -
deletedeletes a counter -
deletethrows NOT_FOUND for missing counter
- Loads timers from database for a channel
- Starts interval timers for enabled timers
- Does not start disabled timers
- Respects chat lines threshold
- Only fires when stream is live
- Reloads timers when called
- Stops all timers for a channel
- Stops all timers globally
- Handles empty timer list
- Supports variable substitution in timer messages
-
listreturns timers for bot channel -
listthrows PRECONDITION_FAILED when bot not enabled -
createcreates a new timer -
createrejects duplicate names -
createrejects USER role -
updateupdates timer settings -
updatethrows NOT_FOUND for missing timer -
deletedeletes a timer -
deletethrows NOT_FOUND for missing timer -
toggleEnabledtoggles timer enabled state
- Loads spam filter config from database
- Returns null config when none exists
- Caches filter config per channel
-
checkCapsdetects excessive uppercase -
checkCapsallows short messages -
checkCapsallows messages under threshold -
checkLinksdetects URLs -
checkLinksallows subs when configured -
checkSymbolsdetects excessive symbols -
checkSymbolsallows messages under threshold -
checkEmotesdetects excessive emotes -
checkEmotesallows messages under limit -
checkRepetitiondetects repeated characters -
checkRepetitionallows messages under threshold -
checkBannedWordsdetects banned words (case-insensitive) -
checkBannedWordsallows clean messages -
checkMessageruns all enabled filters -
checkMessageskips exempt users -
checkMessageskips mods and broadcasters -
checkMessagechecks active permits -
handleViolationsends timeout and warning via say - Reloads filter config for channel
- Creates a permit with default duration
- Creates a permit with custom duration
- Caps duration at 3600 seconds
- Shows usage when no username given
- Does nothing if user is not a mod
- Timeouts users matching phrase
- Uses custom timeout duration from last arg
- Excludes the issuing mod from timeout
- Shows usage when no phrase given
- Does nothing if user is not a mod
- Self-timeouts for 1 second
- Works for any user
- Creates a clip and returns URL
- Shows error when clip creation fails
- Shows error on API error
-
getreturns filter config or defaults -
getthrows PRECONDITION_FAILED when bot not enabled -
updateupserts filter config -
updatepublishes spam-filter:updated event -
updatelogs audit action -
updaterejects USER role
- Returns AI-generated message on success
- Returns null when streamer not found
- Returns null when Gemini API fails
- Returns null when GEMINI_API_KEY is not set
- Uses cache for repeated calls
- Run
bun test— all 523 tests pass across 52 test files - No test file has import/mock errors
-
SongRequestmodel withposition,title,requestedBy,botChannelId -
SongRequestSettingsmodel withenabled,maxQueueSize,maxPerUser,minAccessLevel -
bun db:generatesucceeds
-
song-request:updatedevent type defined -
song-request:settings-updatedevent type defined
-
srcommand registered with aliasessongrequest,song
-
loadSettings/reloadSettings/clearCache— settings cache management -
addRequest— validates enabled, queue size, per-user limit, access level -
removeRequest— removes by position, reorders -
removeByUser— removes all of a user's requests -
skipRequest— removes position 1, reorders -
listRequests— returns ordered entries -
currentRequest— returns position 1 entry -
clearRequests— deletes all for channel
-
!sr <title>— request a song -
!sr list/!sr queue— show next 5 songs -
!sr current— show current song -
!sr remove— viewer removes own requests -
!sr remove <position>— mod removes by position -
!sr skip— mod skips current song -
!sr clear— mod clears queue - Non-mod rejected for mod-only subcommands
-
list— returns queue ordered by position -
current— returns first entry -
skip— removes first entry, publishes event, logs audit -
remove— removes by id, publishes event, logs audit -
clear— deletes all, publishes event, logs audit -
getSettings— returns settings (with defaults) -
updateSettings— upserts settings, publishes event, logs audit
- Settings card with enable toggle, max queue size, max per user, access level
- Queue table with position, title, requester, time, actions
- Skip/remove/clear actions work
- Bot-not-enabled guard shown
- Run
bun test— all 564 tests pass across 55 test files -
bun turbo build --filter="!web"succeeds -
bun run --filter docs buildsucceeds