From f9389cfbaff1a0d1e77430740c95078dad0ed2fb Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 17 Apr 2026 03:27:03 -0500 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20production=20audit=20remediation=20?= =?UTF-8?q?=E2=80=94=20correctness,=20reliability,=20perf,=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH production bugs - Atomic motivation delivery gate prevents double-sends across overlapping worker ticks (claim via UPDATE ... WHERE lastMotivationSentAt IS NULL OR < periodStart before send). - Single-query random quote fetch (ORDER BY random() LIMIT 1) with empty-table guard and fresh embed per guild. - BullMQ repeatables deduplicated on startup; removeOnFail capped; explicit WORKER_CONCURRENCY. - SIGTERM/SIGINT graceful shutdown: HTTP -> shards -> postgres -> redis; explicit respawn:true; shard disconnect no longer exits the process; redis transient errors downgraded to warnings. - Deep health endpoint probes DB + Redis with 1.5s timeout; returns 503 when degraded. - Dockerfile HEALTHCHECK added; migration wrapped in postgres advisory lock so concurrent replica boots are safe. - DB pool max configurable; schema adds indexes on Guild.motivationChannelId and SuggestionQuote.status; SuggestionStatus is now a pgEnum. - Env schema hardened with snowflake regex on all Discord ID vars. - Error logs carry interactionId + guildId. Dedup refactor - New helpers: quoteHelpers, replyHelpers, suggestionHelpers; premium.buildPremiumUpsell, ownerGuard.requireApplication, commandErrors.{logCommandError, withCommandLogging}. - Removed trimArray.ts and applied the new helpers across admin, owner, premium, setup, and quote command handlers. Tests - 243 passing. New coverage for quoteHelpers, replyHelpers, suggestionHelpers, commandErrors wrapper, parseHourMinute. - Extended sendMotivation tests for atomic-gate race behavior, worker/index for repeatable dedup, health endpoint for 503 probes, shardDisconnect for no-exit behavior, envSchema for snowflake checks. --- .env.example | 5 + Dockerfile | 3 + drizzle/0000_loud_mother_askani.sql | 48 ++++++ src/api/routes/health.ts | 42 +++++- src/app.ts | 74 +++++++-- src/bot.ts | 13 +- src/commands/admin/activity/list.ts | 69 ++------- src/commands/admin/quote/list.ts | 67 ++------- src/commands/admin/suggestion/approve.ts | 66 ++------ src/commands/admin/suggestion/list.ts | 88 +++-------- src/commands/admin/suggestion/reject.ts | 84 ++--------- src/commands/admin/suggestion/stats.ts | 3 +- src/commands/owner/premium/testCreate.ts | 109 +++++--------- src/commands/owner/premium/testDelete.ts | 65 ++------ src/commands/owner/premium/testList.ts | 116 +++++--------- src/commands/premium.ts | 94 +++--------- src/commands/quote.ts | 104 ++----------- src/commands/setup/schedule.ts | 71 +++------ src/database/index.ts | 10 +- src/database/migrate.ts | 22 ++- src/database/schema.ts | 64 +++++--- src/events/interactionCreate.ts | 99 +++--------- src/events/shardDisconnect.ts | 12 +- src/utils/commandErrors.ts | 52 +++++++ src/utils/envSchema.ts | 35 +++-- src/utils/ownerGuard.ts | 22 ++- src/utils/permissions.ts | 26 +++- src/utils/premium.ts | 57 ++++++- src/utils/quoteHelpers.ts | 57 +++++++ src/utils/replyHelpers.ts | 42 ++++++ src/utils/scheduleEvaluator.ts | 25 ++- src/utils/suggestionHelpers.ts | 40 +++++ src/utils/trimArray.ts | 3 - src/worker/index.ts | 69 +++++---- src/worker/jobs/sendMotivation.ts | 121 +++++++++------ tests/api/health.test.ts | 53 +++++-- tests/commands/admin/activity/create.test.ts | 4 +- tests/commands/admin/activity/list.test.ts | 4 +- tests/commands/admin/activity/remove.test.ts | 4 +- tests/commands/admin/quote/create.test.ts | 4 +- tests/commands/admin/quote/list.test.ts | 4 +- tests/commands/admin/quote/remove.test.ts | 4 +- .../commands/admin/suggestion/approve.test.ts | 2 +- tests/commands/admin/suggestion/list.test.ts | 4 +- .../commands/admin/suggestion/reject.test.ts | 142 +++++------------- tests/commands/admin/suggestion/stats.test.ts | 2 +- tests/commands/owner/testCreate.test.ts | 13 +- tests/commands/premium.test.ts | 3 +- tests/commands/quote.test.ts | 48 +++--- tests/commands/setup/channel.test.ts | 2 +- tests/commands/setup/schedule.test.ts | 8 +- tests/commands/suggestion.test.ts | 2 +- tests/events/entitlementCreate.test.ts | 6 +- tests/events/entitlementDelete.test.ts | 6 +- tests/events/entitlementUpdate.test.ts | 8 +- tests/events/guildCreate.test.ts | 4 +- tests/events/guildDelete.test.ts | 4 +- tests/events/interactionCreate.test.ts | 1 - tests/events/shardDisconnect.test.ts | 9 +- tests/helpers.ts | 34 ++++- tests/utils/commandErrors.test.ts | 51 +++++++ tests/utils/env.test.ts | 45 +++++- tests/utils/guildDatabase.test.ts | 2 +- tests/utils/quoteHelpers.test.ts | 55 +++++++ tests/utils/replyHelpers.test.ts | 46 ++++++ .../scheduleEvaluator.parseHourMinute.test.ts | 27 ++++ tests/utils/suggestionHelpers.test.ts | 46 ++++++ tests/utils/trimArray.test.ts | 24 --- tests/worker/index.test.ts | 77 +++++----- tests/worker/sendMotivation.test.ts | 141 +++++++++-------- tests/worker/setActivity.test.ts | 2 +- 71 files changed, 1488 insertions(+), 1280 deletions(-) create mode 100644 drizzle/0000_loud_mother_askani.sql create mode 100644 src/utils/quoteHelpers.ts create mode 100644 src/utils/replyHelpers.ts create mode 100644 src/utils/suggestionHelpers.ts delete mode 100644 src/utils/trimArray.ts create mode 100644 tests/utils/commandErrors.test.ts create mode 100644 tests/utils/quoteHelpers.test.ts create mode 100644 tests/utils/replyHelpers.test.ts create mode 100644 tests/utils/scheduleEvaluator.parseHourMinute.test.ts create mode 100644 tests/utils/suggestionHelpers.test.ts delete mode 100644 tests/utils/trimArray.test.ts diff --git a/.env.example b/.env.example index 4202381..0c28a74 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,14 @@ # PostgreSQL connection string for the database DATABASE_URL="postgres://r00t:t00r@localhost:5432/postgres" +# Max postgres pool size per process (default 10) +DATABASE_POOL_MAX=10 # Redis connection string REDIS_URL="redis://localhost:6379/0" +# BullMQ worker concurrency per shard (default 4) +WORKER_CONCURRENCY=4 + # Discord application details DISCORD_APPLICATION_ID="" DISCORD_APPLICATION_PUBLIC_KEY="" diff --git a/Dockerfile b/Dockerfile index d6d82d6..da642f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,7 @@ ENV NODE_ENV=production EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS "http://127.0.0.1:${PORT:-3000}/api/health" || exit 1 + ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/drizzle/0000_loud_mother_askani.sql b/drizzle/0000_loud_mother_askani.sql new file mode 100644 index 0000000..a55b81b --- /dev/null +++ b/drizzle/0000_loud_mother_askani.sql @@ -0,0 +1,48 @@ +CREATE TYPE "public"."DiscordActivityType" AS ENUM('Custom', 'Listening', 'Streaming', 'Playing');--> statement-breakpoint +CREATE TYPE "public"."MotivationFrequency" AS ENUM('Daily', 'Weekly', 'Monthly');--> statement-breakpoint +CREATE TYPE "public"."SuggestionStatus" AS ENUM('Pending', 'Approved', 'Rejected');--> statement-breakpoint +CREATE TABLE "DiscordActivity" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "activity" text NOT NULL, + "type" "DiscordActivityType" DEFAULT 'Custom' NOT NULL, + "url" text, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "Guild" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "guildId" text NOT NULL, + "motivationChannelId" text, + "motivationFrequency" "MotivationFrequency" DEFAULT 'Daily' NOT NULL, + "motivationTime" text DEFAULT '08:00' NOT NULL, + "motivationDay" integer, + "timezone" text DEFAULT 'America/Chicago' NOT NULL, + "lastMotivationSentAt" timestamp, + "isPremium" boolean DEFAULT false NOT NULL, + "joinedAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "Guild_guildId_unique" UNIQUE("guildId") +); +--> statement-breakpoint +CREATE TABLE "MotivationQuote" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "quote" text NOT NULL, + "author" text NOT NULL, + "addedBy" text NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "SuggestionQuote" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "quote" text NOT NULL, + "author" text NOT NULL, + "addedBy" text NOT NULL, + "status" "SuggestionStatus" DEFAULT 'Pending' NOT NULL, + "reviewedBy" text, + "reviewedAt" timestamp, + "createdAt" timestamp DEFAULT now() NOT NULL, + "updatedAt" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "guild_motivation_channel_idx" ON "Guild" USING btree ("motivationChannelId");--> statement-breakpoint +CREATE INDEX "suggestion_status_idx" ON "SuggestionQuote" USING btree ("status"); \ No newline at end of file diff --git a/src/api/routes/health.ts b/src/api/routes/health.ts index ab54979..541edad 100644 --- a/src/api/routes/health.ts +++ b/src/api/routes/health.ts @@ -1,9 +1,47 @@ import express from "express"; +import { queryClient } from "../../database/index.js"; +import redisClient from "../../redis/index.js"; + const router: express.Router = express.Router(); -router.get("/", (_req, res) => { - res.json({ status: "ok" }); +const PROBE_TIMEOUT_MS = 1500; + +function withTimeout(promise: PromiseLike, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + Promise.resolve(promise).then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + } + ); + }); +} + +router.get("/", async (_req, res) => { + const [dbResult, redisResult] = await Promise.allSettled([ + withTimeout(queryClient`SELECT 1`, PROBE_TIMEOUT_MS, "db"), + withTimeout(redisClient.ping(), PROBE_TIMEOUT_MS, "redis"), + ]); + + const db = dbResult.status === "fulfilled" ? "ok" : "error"; + const redis = redisResult.status === "fulfilled" ? "ok" : "error"; + const status = db === "ok" && redis === "ok" ? "ok" : "degraded"; + + const body: Record = { status, db, redis }; + if (dbResult.status === "rejected") { + body["dbError"] = (dbResult.reason as Error)?.message ?? String(dbResult.reason); + } + if (redisResult.status === "rejected") { + body["redisError"] = (redisResult.reason as Error)?.message ?? String(redisResult.reason); + } + + res.status(status === "ok" ? 200 : 503).json(body); }); export default router; diff --git a/src/app.ts b/src/app.ts index 0da0aca..4999d25 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,14 +6,10 @@ import redis from "./redis/index.js"; import env from "./utils/env.js"; import logger from "./utils/logger.js"; -/** - * Load environment variables from .env file. - */ config(); -/** - * Verify database connectivity via a simple query. - */ +let redisReady = false; + queryClient`SELECT 1` .then(() => { logger.database.connected("PostgreSQL"); @@ -23,16 +19,22 @@ queryClient`SELECT 1` process.exit(1); }); -/** - * Load Redis connection and connect to Redis Server if failed to connect, throw error. - */ redis - .on("connect", () => { + .on("ready", () => { + redisReady = true; logger.database.connected("Redis"); }) + .on("end", () => { + logger.warn("Database", "Redis connection closed"); + }) .on("error", (err: Error) => { - logger.database.error("Redis", err); - process.exit(1); + // ioredis emits transient errors during reconnect attempts; only escalate + // if we never managed to connect at all. + if (!redisReady) { + logger.database.error("Redis", err); + } else { + logger.warn("Database", `Redis transient error: ${err.message}`); + } }); const server = api.listen(api.get("port"), () => { @@ -44,12 +46,10 @@ server.on("error", (err: unknown) => { process.exit(1); }); -/** - * Discord.js Sharding Manager - */ const manager = new ShardingManager("./src/bot.ts", { token: env.DISCORD_APPLICATION_BOT_TOKEN, totalShards: "auto", + respawn: true, }); manager.on("shardCreate", (shard) => { @@ -60,4 +60,46 @@ manager.on("shardCreate", (shard) => { } }); -manager.spawn(); +void manager.spawn(); + +let shuttingDown = false; + +async function shutdown(signal: string): Promise { + if (shuttingDown) {return;} + shuttingDown = true; + logger.info("App", `Received ${signal}, shutting down gracefully`); + + // Stop accepting new HTTP work first. + await new Promise((resolve) => { + server.close(() => resolve()); + setTimeout(resolve, 5000).unref(); + }); + logger.info("App", "HTTP server closed"); + + // Tell every shard to log out cleanly. + try { + await Promise.all(manager.shards.map((s) => s.kill())); + logger.info("App", "Shards terminated"); + } catch (err) { + logger.warn("App", "Error terminating shards", { error: err }); + } + + try { + await queryClient.end({ timeout: 5 }); + logger.info("App", "Postgres pool closed"); + } catch (err) { + logger.warn("App", "Error closing Postgres", { error: err }); + } + + try { + redis.disconnect(); + logger.info("App", "Redis disconnected"); + } catch (err) { + logger.warn("App", "Error disconnecting Redis", { error: err }); + } + + process.exit(0); +} + +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); diff --git a/src/bot.ts b/src/bot.ts index c56aefb..e14b9ef 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -99,16 +99,19 @@ client.login(env.DISCORD_APPLICATION_BOT_TOKEN); * Initialize BullMQ worker to handle background jobs. */ import { Queue } from "bullmq"; -import worker from "./worker/index.js"; +import type { ConnectionOptions } from "bullmq"; +import startWorker from "./worker/index.js"; +import redisClient from "./redis/index.js"; const queueName = "fluffboost-jobs"; const queue = new Queue(queueName, { - connection: env.REDIS_URL - ? { url: env.REDIS_URL } - : { host: "localhost", port: 6379 }, + connection: redisClient as unknown as ConnectionOptions, }); -worker(queue); +startWorker(queue).catch((err) => { + logger.error("Worker", "Failed to start worker", err); + process.exit(1); +}); export default client; diff --git a/src/commands/admin/activity/list.ts b/src/commands/admin/activity/list.ts index 142eb84..32c09a6 100644 --- a/src/commands/admin/activity/list.ts +++ b/src/commands/admin/activity/list.ts @@ -1,74 +1,33 @@ -import { Client, CommandInteraction, MessageFlags } from "discord.js"; +import type { Client, CommandInteraction } from "discord.js"; import { desc } from "drizzle-orm"; -import type { DiscordActivity } from "../../../database/schema.js"; - -import logger from "../../../utils/logger.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; import { isUserPermitted } from "../../../utils/permissions.js"; import { db } from "../../../database/index.js"; import { discordActivities } from "../../../database/schema.js"; +import { replyWithTextFile } from "../../../utils/replyHelpers.js"; export default async function ( _client: Client, interaction: CommandInteraction ): Promise { - try { - logger.commands.executing( - "admin activity list", - interaction.user.username, - interaction.user.id - ); - - const isAllowed = await isUserPermitted(interaction); - - if (!isAllowed) { - return; - } + await withCommandLogging("admin activity list", interaction, async () => { + if (!(await isUserPermitted(interaction))) {return;} const activities = await db .select() .from(discordActivities) .orderBy(desc(discordActivities.createdAt)); - if (activities.length === 0) { - await interaction.reply({ - content: "No activities found at the moment. Feel free to add some!", - flags: MessageFlags.Ephemeral, - }); - return; - } - - let text = "ID - Activity - Type - URL\n"; - activities.forEach((activity: DiscordActivity) => { - text += `${activity.id} - ${activity.activity} - ${activity.type} - ${ - activity.url || "N/A" - }\n`; + await replyWithTextFile({ + interaction, + rows: activities, + header: "ID - Activity - Type - URL", + formatRow: (a) => `${a.id} - ${a.activity} - ${a.type} - ${a.url || "N/A"}`, + filename: "activities.txt", + emptyMessage: "No activities found at the moment. Feel free to add some!", + ephemeral: false, }); - - await interaction.reply({ - files: [ - { - attachment: Buffer.from(text), - name: "activities.txt", - }, - ], - }); - - logger.commands.success( - "admin activity list", - interaction.user.username, - interaction.user.id - ); - } catch (err) { - logger.commands.error( - "admin activity list", - interaction.user.username, - interaction.user.id, - err - ); - - await safeErrorReply(interaction); - } + }); } diff --git a/src/commands/admin/quote/list.ts b/src/commands/admin/quote/list.ts index 0eb1202..65fc506 100644 --- a/src/commands/admin/quote/list.ts +++ b/src/commands/admin/quote/list.ts @@ -1,73 +1,32 @@ -import { Client, CommandInteraction, MessageFlags } from "discord.js"; +import type { Client, CommandInteraction } from "discord.js"; import { desc } from "drizzle-orm"; -import type { MotivationQuote } from "../../../database/schema.js"; - -import logger from "../../../utils/logger.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; import { isUserPermitted } from "../../../utils/permissions.js"; import { db } from "../../../database/index.js"; import { motivationQuotes } from "../../../database/schema.js"; +import { replyWithTextFile } from "../../../utils/replyHelpers.js"; export default async function ( _client: Client, interaction: CommandInteraction ): Promise { - try { - logger.commands.executing( - "admin quote list", - interaction.user.username, - interaction.user.id - ); - - const isAllowed = await isUserPermitted(interaction); - - if (!isAllowed) { - return; - } + await withCommandLogging("admin quote list", interaction, async () => { + if (!(await isUserPermitted(interaction))) {return;} const quotes = await db .select() .from(motivationQuotes) .orderBy(desc(motivationQuotes.createdAt)); - if (quotes.length === 0) { - await interaction.reply({ - content: "No quotes found. Feel free to add some!", - flags: MessageFlags.Ephemeral, - }); - return; - } - - let text = "ID - Quote - Author\n"; - quotes.forEach((quote: MotivationQuote) => { - text += `${quote.id} - ${quote.quote} - ${quote.author}\n`; + await replyWithTextFile({ + interaction, + rows: quotes, + header: "ID - Quote - Author", + formatRow: (q) => `${q.id} - ${q.quote} - ${q.author}`, + filename: "quotes.txt", + emptyMessage: "No quotes found. Feel free to add some!", }); - - await interaction.reply({ - files: [ - { - attachment: Buffer.from(text), - name: "quotes.txt", - }, - ], - flags: MessageFlags.Ephemeral, - }); - - logger.commands.success( - "admin quote list", - interaction.user.username, - interaction.user.id - ); - } catch (err) { - logger.commands.error( - "admin quote list", - interaction.user.username, - interaction.user.id, - err - ); - - await safeErrorReply(interaction); - } + }); } diff --git a/src/commands/admin/suggestion/approve.ts b/src/commands/admin/suggestion/approve.ts index b86f2ac..370c206 100644 --- a/src/commands/admin/suggestion/approve.ts +++ b/src/commands/admin/suggestion/approve.ts @@ -1,11 +1,6 @@ -import { - Client, - CommandInteraction, - EmbedBuilder, - MessageFlags, -} from "discord.js"; +import { EmbedBuilder, MessageFlags } from "discord.js"; -import type { CommandInteractionOptionResolver } from "discord.js"; +import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; import { eq } from "drizzle-orm"; @@ -14,49 +9,21 @@ import { db } from "../../../database/index.js"; import { motivationQuotes, suggestionQuotes } from "../../../database/schema.js"; import logger from "../../../utils/logger.js"; import { sendToMainChannel } from "../../../utils/mainChannel.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; +import { fetchPendingSuggestion } from "../../../utils/suggestionHelpers.js"; export default async function ( client: Client, interaction: CommandInteraction, options: CommandInteractionOptionResolver, ): Promise { - try { - logger.commands.executing( - "admin suggestion approve", - interaction.user.username, - interaction.user.id, - ); - - const isAllowed = await isUserPermitted(interaction); - - if (!isAllowed) { - return; - } + await withCommandLogging("admin suggestion approve", interaction, async () => { + if (!(await isUserPermitted(interaction))) {return;} const suggestionId = options.getString("suggestion_id", true); - const [suggestion] = await db - .select() - .from(suggestionQuotes) - .where(eq(suggestionQuotes.id, suggestionId)) - .limit(1); - - if (!suggestion) { - await interaction.reply({ - content: `Suggestion with ID ${suggestionId} not found.`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - if (suggestion.status !== "Pending") { - await interaction.reply({ - content: `This suggestion has already been ${suggestion.status.toLowerCase()}.`, - flags: MessageFlags.Ephemeral, - }); - return; - } + const suggestion = await fetchPendingSuggestion(suggestionId, interaction); + if (!suggestion) {return;} await db.transaction(async (tx) => { await tx.insert(motivationQuotes).values({ @@ -117,20 +84,5 @@ export default async function ( content: `Suggestion ${suggestionId} approved and added to motivation quotes.`, flags: MessageFlags.Ephemeral, }); - - logger.commands.success( - "admin suggestion approve", - interaction.user.username, - interaction.user.id, - ); - } catch (err) { - logger.commands.error( - "admin suggestion approve", - interaction.user.username, - interaction.user.id, - err, - ); - - await safeErrorReply(interaction); - } + }); } diff --git a/src/commands/admin/suggestion/list.ts b/src/commands/admin/suggestion/list.ts index a783853..7657417 100644 --- a/src/commands/admin/suggestion/list.ts +++ b/src/commands/admin/suggestion/list.ts @@ -1,79 +1,41 @@ -import { Client, CommandInteraction, MessageFlags } from "discord.js"; +import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; -import type { CommandInteractionOptionResolver } from "discord.js"; import { eq, desc } from "drizzle-orm"; -import type { SuggestionQuote } from "../../../database/schema.js"; - -import logger from "../../../utils/logger.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; import { isUserPermitted } from "../../../utils/permissions.js"; import { db } from "../../../database/index.js"; import { suggestionQuotes } from "../../../database/schema.js"; +import type { SuggestionStatus } from "../../../database/schema.js"; +import { replyWithTextFile } from "../../../utils/replyHelpers.js"; + +const VALID_STATUSES: SuggestionStatus[] = ["Pending", "Approved", "Rejected"]; export default async function ( _client: Client, interaction: CommandInteraction, options: CommandInteractionOptionResolver, ): Promise { - try { - logger.commands.executing( - "admin suggestion list", - interaction.user.username, - interaction.user.id, - ); - - const isAllowed = await isUserPermitted(interaction); - - if (!isAllowed) { - return; - } + await withCommandLogging("admin suggestion list", interaction, async () => { + if (!(await isUserPermitted(interaction))) {return;} const status = options.getString("status"); - - const query = db.select().from(suggestionQuotes).orderBy(desc(suggestionQuotes.createdAt)); - const suggestions = status - ? await query.where(eq(suggestionQuotes.status, status)) - : await query; - - if (suggestions.length === 0) { - await interaction.reply({ - content: status - ? `No suggestions found with status: ${status}` - : "No suggestions found.", - flags: MessageFlags.Ephemeral, - }); - return; - } - - let text = "ID - Quote - Author - Status - Submitted By\n"; - suggestions.forEach((s: SuggestionQuote) => { - text += `${s.id} - ${s.quote} - ${s.author} - ${s.status} - ${s.addedBy}\n`; - }); - - await interaction.reply({ - files: [ - { - attachment: Buffer.from(text), - name: "suggestions.txt", - }, - ], - flags: MessageFlags.Ephemeral, + const validStatus = status && (VALID_STATUSES as string[]).includes(status) + ? (status as SuggestionStatus) + : null; + + const baseQuery = db.select().from(suggestionQuotes).orderBy(desc(suggestionQuotes.createdAt)); + const suggestions = validStatus + ? await baseQuery.where(eq(suggestionQuotes.status, validStatus)) + : await baseQuery; + + await replyWithTextFile({ + interaction, + rows: suggestions, + header: "ID - Quote - Author - Status - Submitted By", + formatRow: (s) => `${s.id} - ${s.quote} - ${s.author} - ${s.status} - ${s.addedBy}`, + filename: "suggestions.txt", + emptyMessage: status ? `No suggestions found with status: ${status}` : "No suggestions found.", }); - - logger.commands.success( - "admin suggestion list", - interaction.user.username, - interaction.user.id, - ); - } catch (err) { - logger.commands.error( - "admin suggestion list", - interaction.user.username, - interaction.user.id, - err, - ); - - await safeErrorReply(interaction); - } + }); } diff --git a/src/commands/admin/suggestion/reject.ts b/src/commands/admin/suggestion/reject.ts index 000a847..da24a11 100644 --- a/src/commands/admin/suggestion/reject.ts +++ b/src/commands/admin/suggestion/reject.ts @@ -1,82 +1,39 @@ -import { - Client, - CommandInteraction, - EmbedBuilder, - MessageFlags, -} from "discord.js"; +import { EmbedBuilder, MessageFlags } from "discord.js"; -import type { CommandInteractionOptionResolver } from "discord.js"; +import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { isUserPermitted } from "../../../utils/permissions.js"; import { db } from "../../../database/index.js"; import { suggestionQuotes } from "../../../database/schema.js"; import logger from "../../../utils/logger.js"; import { sendToMainChannel } from "../../../utils/mainChannel.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; +import { fetchPendingSuggestion } from "../../../utils/suggestionHelpers.js"; export default async function ( client: Client, interaction: CommandInteraction, options: CommandInteractionOptionResolver, ): Promise { - try { - logger.commands.executing( - "admin suggestion reject", - interaction.user.username, - interaction.user.id, - ); - - const isAllowed = await isUserPermitted(interaction); - - if (!isAllowed) { - return; - } + await withCommandLogging("admin suggestion reject", interaction, async () => { + if (!(await isUserPermitted(interaction))) {return;} const suggestionId = options.getString("suggestion_id", true); const reason = options.getString("reason"); - const result = await db + const suggestion = await fetchPendingSuggestion(suggestionId, interaction); + if (!suggestion) {return;} + + await db .update(suggestionQuotes) .set({ status: "Rejected", reviewedBy: interaction.user.id, reviewedAt: new Date(), }) - .where(and(eq(suggestionQuotes.id, suggestionId), eq(suggestionQuotes.status, "Pending"))) - .returning(); - - if (result.length === 0) { - const [existing] = await db - .select() - .from(suggestionQuotes) - .where(eq(suggestionQuotes.id, suggestionId)) - .limit(1); - - if (!existing) { - await interaction.reply({ - content: `Suggestion with ID ${suggestionId} not found.`, - flags: MessageFlags.Ephemeral, - }); - } else { - await interaction.reply({ - content: `This suggestion has already been ${existing.status.toLowerCase()}.`, - flags: MessageFlags.Ephemeral, - }); - } - return; - } - - const [suggestion] = await db - .select() - .from(suggestionQuotes) - .where(eq(suggestionQuotes.id, suggestionId)) - .limit(1); - - if (!suggestion) { - return; - } + .where(eq(suggestionQuotes.id, suggestionId)); const embedFields = [ { name: "Quote", value: suggestion.quote }, @@ -130,20 +87,5 @@ export default async function ( content: `Suggestion ${suggestionId} has been rejected.`, flags: MessageFlags.Ephemeral, }); - - logger.commands.success( - "admin suggestion reject", - interaction.user.username, - interaction.user.id, - ); - } catch (err) { - logger.commands.error( - "admin suggestion reject", - interaction.user.username, - interaction.user.id, - err, - ); - - await safeErrorReply(interaction); - } + }); } diff --git a/src/commands/admin/suggestion/stats.ts b/src/commands/admin/suggestion/stats.ts index 239a248..ecd9621 100644 --- a/src/commands/admin/suggestion/stats.ts +++ b/src/commands/admin/suggestion/stats.ts @@ -5,6 +5,7 @@ import { eq, count } from "drizzle-orm"; import { isUserPermitted } from "../../../utils/permissions.js"; import { db } from "../../../database/index.js"; import { suggestionQuotes } from "../../../database/schema.js"; +import type { SuggestionStatus } from "../../../database/schema.js"; import logger from "../../../utils/logger.js"; import { safeErrorReply } from "../../../utils/commandErrors.js"; @@ -25,7 +26,7 @@ export default async function ( return; } - const countByStatus = async (status: string) => { + const countByStatus = async (status: SuggestionStatus) => { const [result] = await db .select({ value: count() }) .from(suggestionQuotes) diff --git a/src/commands/owner/premium/testCreate.ts b/src/commands/owner/premium/testCreate.ts index 0f67c62..e203825 100644 --- a/src/commands/owner/premium/testCreate.ts +++ b/src/commands/owner/premium/testCreate.ts @@ -2,92 +2,55 @@ import { MessageFlags } from "discord.js"; import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; -import logger from "../../../utils/logger.js"; import { getPremiumSkuId } from "../../../utils/premium.js"; -import { requireOwner } from "../../../utils/ownerGuard.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { requireApplication, requireOwner } from "../../../utils/ownerGuard.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; export default async function ( client: Client, interaction: CommandInteraction, options: CommandInteractionOptionResolver ): Promise { - try { - logger.commands.executing( - "owner premium test-create", - interaction.user.username, - interaction.user.id - ); - - if (!(await requireOwner(interaction, "owner premium test-create"))) { - return; - } + await withCommandLogging( + "owner premium test-create", + interaction, + async () => { + if (!(await requireOwner(interaction, "owner premium test-create"))) {return;} + + const skuId = getPremiumSkuId(); + if (!skuId) { + await interaction.reply({ + content: "DISCORD_PREMIUM_SKU_ID is not configured.", + flags: MessageFlags.Ephemeral, + }); + return; + } - const skuId = getPremiumSkuId(); - if (!skuId) { - await interaction.reply({ - content: "DISCORD_PREMIUM_SKU_ID is not configured.", - flags: MessageFlags.Ephemeral, - }); - return; - } + const guildId = options.getString("guild") ?? interaction.guildId; + if (!guildId) { + await interaction.reply({ + content: "Could not determine guild. Run this in a server or pass a guild ID.", + flags: MessageFlags.Ephemeral, + }); + return; + } - const guildId = options.getString("guild") ?? interaction.guildId; + const application = await requireApplication(client, interaction); + if (!application) {return;} - if (!guildId) { - await interaction.reply({ - content: "Could not determine guild. Run this in a server or pass a guild ID.", - flags: MessageFlags.Ephemeral, + const entitlement = await application.entitlements.createTest({ + sku: skuId, + guild: guildId, }); - return; - } - if (!client.application) { await interaction.reply({ - content: "Bot application is not ready. Please try again in a moment.", + content: + `Test entitlement created for guild \`${guildId}\`\n` + + `Entitlement ID: \`${entitlement.id}\`\n` + + `SKU: \`${entitlement.skuId}\``, flags: MessageFlags.Ephemeral, }); - return; - } - - const entitlement = await client.application.entitlements.createTest({ - sku: skuId, - guild: guildId, - }); - - await interaction.reply({ - content: - `Test entitlement created for guild \`${guildId}\`\n` + - `Entitlement ID: \`${entitlement.id}\`\n` + - `SKU: \`${entitlement.skuId}\``, - flags: MessageFlags.Ephemeral, - }); - - logger.commands.success( - "owner premium test-create", - interaction.user.username, - interaction.user.id - ); - } catch (err) { - logger.commands.error( - "owner premium test-create", - interaction.user.username, - interaction.user.id, - err - ); - logger.error( - "Discord - Command", - "Error executing owner premium test-create command", - err, - { - user: { username: interaction.user.username, id: interaction.user.id }, - command: "owner premium test-create", - } - ); - - await safeErrorReply( - interaction, - `Failed to create test entitlement: ${err instanceof Error ? err.message : String(err)}` - ); - } + }, + "Failed to create test entitlement. Check bot logs for details." + ); } diff --git a/src/commands/owner/premium/testDelete.ts b/src/commands/owner/premium/testDelete.ts index 284bebc..6979d01 100644 --- a/src/commands/owner/premium/testDelete.ts +++ b/src/commands/owner/premium/testDelete.ts @@ -2,65 +2,32 @@ import { MessageFlags } from "discord.js"; import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; -import logger from "../../../utils/logger.js"; -import { requireOwner } from "../../../utils/ownerGuard.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { requireApplication, requireOwner } from "../../../utils/ownerGuard.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; export default async function ( client: Client, interaction: CommandInteraction, options: CommandInteractionOptionResolver ): Promise { - try { - logger.commands.executing( - "owner premium test-delete", - interaction.user.username, - interaction.user.id - ); + await withCommandLogging( + "owner premium test-delete", + interaction, + async () => { + if (!(await requireOwner(interaction, "owner premium test-delete"))) {return;} - if (!(await requireOwner(interaction, "owner premium test-delete"))) { - return; - } + const entitlementId = options.getString("entitlement_id", true); - const entitlementId = options.getString("entitlement_id", true); + const application = await requireApplication(client, interaction); + if (!application) {return;} + + await application.entitlements.deleteTest(entitlementId); - if (!client.application) { await interaction.reply({ - content: "Bot application is not ready. Please try again in a moment.", + content: `Test entitlement \`${entitlementId}\` deleted.`, flags: MessageFlags.Ephemeral, }); - return; - } - - await client.application.entitlements.deleteTest(entitlementId); - - await interaction.reply({ - content: `Test entitlement \`${entitlementId}\` deleted.`, - flags: MessageFlags.Ephemeral, - }); - - logger.commands.success( - "owner premium test-delete", - interaction.user.username, - interaction.user.id - ); - } catch (err) { - logger.commands.error( - "owner premium test-delete", - interaction.user.username, - interaction.user.id, - err - ); - logger.error( - "Discord - Command", - "Error executing owner premium test-delete command", - err, - { - user: { username: interaction.user.username, id: interaction.user.id }, - command: "owner premium test-delete", - } - ); - - await safeErrorReply(interaction, "Failed to delete test entitlement. Check bot logs for details."); - } + }, + "Failed to delete test entitlement. Check bot logs for details." + ); } diff --git a/src/commands/owner/premium/testList.ts b/src/commands/owner/premium/testList.ts index b2fe9e2..da9f3e2 100644 --- a/src/commands/owner/premium/testList.ts +++ b/src/commands/owner/premium/testList.ts @@ -2,88 +2,54 @@ import { MessageFlags } from "discord.js"; import type { Client, CommandInteraction } from "discord.js"; -import logger from "../../../utils/logger.js"; -import { requireOwner } from "../../../utils/ownerGuard.js"; -import { safeErrorReply } from "../../../utils/commandErrors.js"; +import { requireApplication, requireOwner } from "../../../utils/ownerGuard.js"; +import { withCommandLogging } from "../../../utils/commandErrors.js"; export default async function (client: Client, interaction: CommandInteraction): Promise { - try { - logger.commands.executing( - "owner premium test-list", - interaction.user.username, - interaction.user.id - ); - - if (!(await requireOwner(interaction, "owner premium test-list"))) { - return; - } + await withCommandLogging( + "owner premium test-list", + interaction, + async () => { + if (!(await requireOwner(interaction, "owner premium test-list"))) {return;} + const application = await requireApplication(client, interaction); + if (!application) {return;} + + const entitlements = await application.entitlements.fetch(); + + if (entitlements.size === 0) { + await interaction.reply({ + content: "No entitlements found.", + flags: MessageFlags.Ephemeral, + }); + return; + } - if (!client.application) { - await interaction.reply({ - content: "Bot application is not ready. Please try again in a moment.", - flags: MessageFlags.Ephemeral, + const lines = Array.from(entitlements.values()).map((e) => { + const test = e.isTest() ? " *(test)*" : ""; + return `• \`${e.id}\` — guild: \`${e.guildId ?? "N/A"}\` — SKU: \`${e.skuId}\`${test}`; }); - return; - } - const entitlements = await client.application.entitlements.fetch(); + const header = `**Entitlements (${entitlements.size}):**\n`; + const maxLength = 2000 - header.length; + const truncatedLines: string[] = []; + let currentLength = 0; + + for (const line of lines) { + const addition = (truncatedLines.length > 0 ? "\n" : "") + line; + if (currentLength + addition.length > maxLength) { + const remaining = lines.length - truncatedLines.length; + truncatedLines.push(`\n...and ${remaining} more`); + break; + } + truncatedLines.push(line); + currentLength += addition.length; + } - if (entitlements.size === 0) { await interaction.reply({ - content: "No entitlements found.", + content: `${header}${truncatedLines.join("\n")}`, flags: MessageFlags.Ephemeral, }); - return; - } - - const lines = Array.from(entitlements.values()).map((e) => { - const test = e.isTest() ? " *(test)*" : ""; - return `• \`${e.id}\` — guild: \`${e.guildId ?? "N/A"}\` — SKU: \`${e.skuId}\`${test}`; - }); - - const header = `**Entitlements (${entitlements.size}):**\n`; - const maxLength = 2000 - header.length; - const truncatedLines: string[] = []; - let currentLength = 0; - - for (const line of lines) { - const addition = (truncatedLines.length > 0 ? "\n" : "") + line; - if (currentLength + addition.length > maxLength) { - const remaining = lines.length - truncatedLines.length; - truncatedLines.push(`\n...and ${remaining} more`); - break; - } - truncatedLines.push(line); - currentLength += addition.length; - } - - await interaction.reply({ - content: `${header}${truncatedLines.join("\n")}`, - flags: MessageFlags.Ephemeral, - }); - - logger.commands.success( - "owner premium test-list", - interaction.user.username, - interaction.user.id - ); - } catch (err) { - logger.commands.error( - "owner premium test-list", - interaction.user.username, - interaction.user.id, - err - ); - logger.error( - "Discord - Command", - "Error executing owner premium test-list command", - err, - { - user: { username: interaction.user.username, id: interaction.user.id }, - command: "owner premium test-list", - } - ); - - await safeErrorReply(interaction, "Failed to list entitlements. Check bot logs for details."); - } + }, + "Failed to list entitlements. Check bot logs for details." + ); } diff --git a/src/commands/premium.ts b/src/commands/premium.ts index c541664..06b7f94 100644 --- a/src/commands/premium.ts +++ b/src/commands/premium.ts @@ -1,33 +1,21 @@ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - EmbedBuilder, - MessageFlags, - SlashCommandBuilder, -} from "discord.js"; +import { EmbedBuilder, MessageFlags, SlashCommandBuilder } from "discord.js"; import type { Client, CommandInteraction } from "discord.js"; -import logger from "../utils/logger.js"; -import { safeErrorReply } from "../utils/commandErrors.js"; -import { isPremiumEnabled, hasEntitlement, getPremiumSkuId } from "../utils/premium.js"; +import { withCommandLogging } from "../utils/commandErrors.js"; +import { buildPremiumUpsell, hasEntitlement, isPremiumEnabled } from "../utils/premium.js"; export const slashCommand = new SlashCommandBuilder() .setName("premium") .setDescription("View premium subscription info and status"); -export async function execute(_client: Client, interaction: CommandInteraction) { - try { - logger.commands.executing("premium", interaction.user.username, interaction.user.id); - +export async function execute(_client: Client, interaction: CommandInteraction): Promise { + await withCommandLogging("premium", interaction, async () => { if (!isPremiumEnabled()) { await interaction.reply({ content: "Premium subscriptions are not currently available.", flags: MessageFlags.Ephemeral, }); - - logger.commands.success("premium", interaction.user.username, interaction.user.id); return; } @@ -36,66 +24,28 @@ export async function execute(_client: Client, interaction: CommandInteraction) .setColor(0x57f287) .setTitle("Premium Active") .setDescription("You have an active premium subscription! Thank you for supporting FluffBoost.") - .addFields({ - name: "Status", - value: "Active", - inline: true, - }) + .addFields({ name: "Status", value: "Active", inline: true }) .setFooter({ text: "Manage your subscription in User Settings > Subscriptions" }); - await interaction.reply({ - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } else { - const skuId = getPremiumSkuId(); - - const embed = new EmbedBuilder() - .setColor(0xfadb7f) - .setTitle("FluffBoost Premium") - .setDescription("Upgrade to Premium to unlock exclusive features!") - .addFields( - { name: "Price", value: "$1.99/month", inline: true }, - { - name: "Benefits", - value: [ - "- Priority quote delivery", - "- Exclusive premium quotes", - "- Early access to new features", - ].join("\n"), - } - ) - .setFooter({ text: "Subscribe to support FluffBoost development!" }); - - const components: ActionRowBuilder[] = []; - if (skuId) { - components.push( - new ActionRowBuilder().addComponents( - new ButtonBuilder().setStyle(ButtonStyle.Premium).setSKUId(skuId) - ) - ); - } - - await interaction.reply({ - embeds: [embed], - components, - flags: MessageFlags.Ephemeral, - }); + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + return; } - logger.commands.success("premium", interaction.user.username, interaction.user.id); - } catch (err) { - logger.commands.error("premium", interaction.user.username, interaction.user.id, err); - logger.error("Discord - Command", "Error executing premium command", err, { - user: { username: interaction.user.username, id: interaction.user.id }, - command: "premium", + const upsell = buildPremiumUpsell({ + title: "FluffBoost Premium", + description: "Upgrade to Premium to unlock exclusive features!", + fields: [ + { name: "Price", value: "$1.99/month", inline: true }, + { + name: "Benefits", + value: ["- Priority quote delivery", "- Exclusive premium quotes", "- Early access to new features"].join("\n"), + }, + ], + footerText: "Subscribe to support FluffBoost development!", }); - await safeErrorReply(interaction); - } + await interaction.reply({ ...upsell, flags: MessageFlags.Ephemeral }); + }); } -export default { - slashCommand, - execute, -}; +export default { slashCommand, execute }; diff --git a/src/commands/quote.ts b/src/commands/quote.ts index c4f0a7b..dee51e7 100644 --- a/src/commands/quote.ts +++ b/src/commands/quote.ts @@ -1,106 +1,32 @@ -import { SlashCommandBuilder, EmbedBuilder } from "discord.js"; +import { SlashCommandBuilder } from "discord.js"; import type { Client, ChatInputCommandInteraction } from "discord.js"; -import { count } from "drizzle-orm"; - -import logger from "../utils/logger.js"; -import { safeErrorReply } from "../utils/commandErrors.js"; -import { db } from "../database/index.js"; -import { motivationQuotes } from "../database/schema.js"; +import { withCommandLogging } from "../utils/commandErrors.js"; +import { + buildMotivationEmbed, + getRandomMotivationQuote, + resolveQuoteAuthor, +} from "../utils/quoteHelpers.js"; export const slashCommand = new SlashCommandBuilder() .setName("quote") .setDescription("Get an instant dose of motivation"); export async function execute(client: Client, interaction: ChatInputCommandInteraction): Promise { - try { - logger.commands.executing( - "quote", - interaction.user.username, - interaction.user.id - ); - - /** - * Find a random motivation quote from the database. - */ - const [countResult] = await db.select({ value: count() }).from(motivationQuotes); - const motivationQuoteCount = countResult?.value ?? 0; - const skip = Math.floor(Math.random() * motivationQuoteCount); - const motivationQuote = await db.select().from(motivationQuotes).offset(skip).limit(1); - - if (!motivationQuote[0]) { - await interaction.reply( - "No motivation quote found. Please try again later!" - ); + await withCommandLogging("quote", interaction, async () => { + const quote = await getRandomMotivationQuote(); + if (!quote) { + await interaction.reply("No motivation quote found. Please try again later!"); return; } - /** - * Create a custom embed for the motivation message. - */ - const addedBy = await client.users.fetch(motivationQuote[0].addedBy); - if (!addedBy) { - logger.error( - "Command", - "Could not fetch user for quote", - new Error(`User ID not found: ${motivationQuote[0].addedBy}`), - { - userId: motivationQuote[0].addedBy, - command: "quote", - } - ); - await interaction.reply( - "Failed to fetch quote information. Please try again later!" - ); - return; - } + const addedBy = await resolveQuoteAuthor(client, quote.addedBy); - const motivationEmbed = new EmbedBuilder() - .setColor(0xfadb7f) - .setTitle("Motivation quote of the day 📅") - .setDescription( - `**"${motivationQuote[0].quote}"**\n by ${motivationQuote[0].author}` - ) - .setAuthor({ - name: addedBy.username, - url: addedBy.displayAvatarURL(), - iconURL: addedBy.displayAvatarURL(), - }) - .setFooter({ - text: "Powered by MrDemonWolf, Inc.", - iconURL: client.user?.displayAvatarURL(), - }); - - /** - * Send the motivation message. - */ await interaction.reply({ - embeds: [motivationEmbed], - }); - - logger.commands.success( - "quote", - interaction.user.username, - interaction.user.id - ); - } catch (err) { - logger.commands.error( - "quote", - interaction.user.username, - interaction.user.id, - err - ); - logger.error("Discord - Command", "Error executing quote command", err, { - user: { username: interaction.user.username, id: interaction.user.id }, - command: "quote", + embeds: [buildMotivationEmbed(quote, addedBy, client)], }); - - await safeErrorReply(interaction); - } + }); } -export default { - slashCommand, - execute, -}; +export default { slashCommand, execute }; diff --git a/src/commands/setup/schedule.ts b/src/commands/setup/schedule.ts index 768d72c..527a37f 100644 --- a/src/commands/setup/schedule.ts +++ b/src/commands/setup/schedule.ts @@ -4,12 +4,12 @@ import type { Client, ChatInputCommandInteraction, AutocompleteInteraction } fro import { eq } from "drizzle-orm"; import logger from "../../utils/logger.js"; -import { safeErrorReply } from "../../utils/commandErrors.js"; +import { withCommandLogging } from "../../utils/commandErrors.js"; import { db } from "../../database/index.js"; import { guilds } from "../../database/schema.js"; import type { MotivationFrequency } from "../../database/schema.js"; import { guildExists } from "../../utils/guildDatabase.js"; -import { hasEntitlement, isPremiumEnabled, getPremiumSkuId } from "../../utils/premium.js"; +import { buildPremiumUpsell, hasEntitlement, isPremiumEnabled } from "../../utils/premium.js"; import { isValidTimezone, filterTimezones } from "../../utils/timezones.js"; const DAY_OF_WEEK_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; @@ -26,45 +26,26 @@ function formatScheduleDescription(frequency: string, time: string, timezone: st return parts.join("\n"); } -export default async function schedule(_client: Client, interaction: ChatInputCommandInteraction) { - try { - logger.commands.executing("setup schedule", interaction.user.username, interaction.user.id); - - if (!interaction.guildId) { - return; - } +export default async function schedule(_client: Client, interaction: ChatInputCommandInteraction): Promise { + await withCommandLogging("setup schedule", interaction, async () => { + if (!interaction.guildId) {return;} - // Premium gate if (isPremiumEnabled() && !hasEntitlement(interaction)) { - const skuId = getPremiumSkuId(); - const embed = new EmbedBuilder() - .setColor(0xfadb7f) - .setTitle("Premium Feature") - .setDescription( + const upsell = buildPremiumUpsell({ + title: "Premium Feature", + description: "Custom quote scheduling is a premium feature! " + - "Subscribe to FluffBoost Premium to customize when your server receives motivational quotes." - ) - .addFields( - { name: "Default Schedule", value: "Daily at 8:00 AM (America/Chicago)", inline: false }, + "Subscribe to FluffBoost Premium to customize when your server receives motivational quotes.", + fields: [ + { name: "Default Schedule", value: "Daily at 8:00 AM (America/Chicago)" }, { name: "Premium Unlocks", value: "- Custom delivery time\n- Custom timezone\n- Weekly or monthly frequency", - inline: false, - } - ); - - await interaction.reply({ - embeds: [embed], - components: skuId - ? [ - { - type: 1, - components: [{ type: 2, style: 6, sku_id: skuId }], - }, - ] - : [], - flags: MessageFlags.Ephemeral, + }, + ], }); + + await interaction.reply({ ...upsell, flags: MessageFlags.Ephemeral }); return; } @@ -74,7 +55,6 @@ export default async function schedule(_client: Client, interaction: ChatInputCo const timezone = options.getString("timezone") ?? "America/Chicago"; const day = options.getInteger("day"); - // Validate time format (HH:mm) const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; if (!timeRegex.test(time)) { await interaction.reply({ @@ -84,7 +64,6 @@ export default async function schedule(_client: Client, interaction: ChatInputCo return; } - // Validate timezone if (!isValidTimezone(timezone)) { await interaction.reply({ content: "Invalid timezone. Please use a valid IANA timezone (e.g., `America/New_York`, `Europe/London`).", @@ -93,7 +72,6 @@ export default async function schedule(_client: Client, interaction: ChatInputCo return; } - // Validate day based on frequency if (frequency === "Weekly") { if (day === null || day < 0 || day > 6) { await interaction.reply({ @@ -129,24 +107,11 @@ export default async function schedule(_client: Client, interaction: ChatInputCo .setTitle("Schedule Updated") .setDescription(formatScheduleDescription(frequency, time, timezone, frequency === "Daily" ? null : day)); - await interaction.reply({ - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - - logger.commands.success("setup schedule", interaction.user.username, interaction.user.id); - } catch (err) { - logger.commands.error("setup schedule", interaction.user.username, interaction.user.id, err); - logger.error("Discord - Command", "Error executing setup schedule command", err, { - user: { username: interaction.user.username, id: interaction.user.id }, - command: "setup schedule", - }); - - await safeErrorReply(interaction); - } + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + }); } -export async function autocomplete(interaction: AutocompleteInteraction) { +export async function autocomplete(interaction: AutocompleteInteraction): Promise { try { const focused = interaction.options.getFocused(true); diff --git a/src/database/index.ts b/src/database/index.ts index 5afc851..585f3e8 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -7,7 +7,15 @@ const globalForDb = global as unknown as { queryClient: ReturnType | undefined; }; -export const queryClient = globalForDb.queryClient ?? postgres(env.DATABASE_URL); +const POOL_MAX = env.DATABASE_POOL_MAX; + +export const queryClient = + globalForDb.queryClient ?? + postgres(env.DATABASE_URL, { + max: POOL_MAX, + idle_timeout: 30, + connect_timeout: 10, + }); export const db = drizzle(queryClient, { schema, logger: env.NODE_ENV !== "production" }); if (env.NODE_ENV !== "production") { diff --git a/src/database/migrate.ts b/src/database/migrate.ts index e7140c7..1b8d166 100644 --- a/src/database/migrate.ts +++ b/src/database/migrate.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { sql } from "drizzle-orm"; import postgres from "postgres"; const migrationsFolder = "./drizzle"; @@ -17,10 +18,23 @@ if (!connectionString) { process.exit(1); } -const sql = postgres(connectionString, { max: 1 }); -const db = drizzle(sql); +// Stable advisory lock key — any constant int8 works as long as it's stable +// across replicas. Picked from `select hashtext('fluffboost:migrations')::bigint`. +const LOCK_KEY = 7261972598341205n; -await migrate(db, { migrationsFolder }); -await sql.end(); +const sqlClient = postgres(connectionString, { max: 1 }); +const db = drizzle(sqlClient); + +try { + await db.execute(sql`SELECT pg_advisory_lock(${LOCK_KEY})`); + await migrate(db, { migrationsFolder }); +} finally { + try { + await db.execute(sql`SELECT pg_advisory_unlock(${LOCK_KEY})`); + } catch { + // unlock failure is non-fatal — connection close releases it + } + await sqlClient.end(); +} console.log("Migrations complete."); diff --git a/src/database/schema.ts b/src/database/schema.ts index e3f071a..952cbf6 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -1,22 +1,29 @@ -import { pgTable, pgEnum, uuid, text, boolean, timestamp, integer } from "drizzle-orm/pg-core"; +import { pgTable, pgEnum, uuid, text, boolean, timestamp, integer, index } from "drizzle-orm/pg-core"; import type { InferSelectModel } from "drizzle-orm"; export const motivationFrequencyEnum = pgEnum("MotivationFrequency", ["Daily", "Weekly", "Monthly"]); export const discordActivityTypeEnum = pgEnum("DiscordActivityType", ["Custom", "Listening", "Streaming", "Playing"]); +export const suggestionStatusEnum = pgEnum("SuggestionStatus", ["Pending", "Approved", "Rejected"]); -export const guilds = pgTable("Guild", { - id: uuid("id").primaryKey().defaultRandom(), - guildId: text("guildId").notNull().unique(), - motivationChannelId: text("motivationChannelId"), - motivationFrequency: motivationFrequencyEnum("motivationFrequency").notNull().default("Daily"), - motivationTime: text("motivationTime").notNull().default("08:00"), - motivationDay: integer("motivationDay"), - timezone: text("timezone").notNull().default("America/Chicago"), - lastMotivationSentAt: timestamp("lastMotivationSentAt", { mode: "date" }), - isPremium: boolean("isPremium").notNull().default(false), - joinedAt: timestamp("joinedAt", { mode: "date" }).notNull().defaultNow(), - updatedAt: timestamp("updatedAt", { mode: "date" }).notNull().defaultNow().$onUpdate(() => new Date()), -}); +export const guilds = pgTable( + "Guild", + { + id: uuid("id").primaryKey().defaultRandom(), + guildId: text("guildId").notNull().unique(), + motivationChannelId: text("motivationChannelId"), + motivationFrequency: motivationFrequencyEnum("motivationFrequency").notNull().default("Daily"), + motivationTime: text("motivationTime").notNull().default("08:00"), + motivationDay: integer("motivationDay"), + timezone: text("timezone").notNull().default("America/Chicago"), + lastMotivationSentAt: timestamp("lastMotivationSentAt", { mode: "date" }), + isPremium: boolean("isPremium").notNull().default(false), + joinedAt: timestamp("joinedAt", { mode: "date" }).notNull().defaultNow(), + updatedAt: timestamp("updatedAt", { mode: "date" }).notNull().defaultNow().$onUpdate(() => new Date()), + }, + (table) => ({ + motivationChannelIdx: index("guild_motivation_channel_idx").on(table.motivationChannelId), + }) +); export const motivationQuotes = pgTable("MotivationQuote", { id: uuid("id").primaryKey().defaultRandom(), @@ -26,17 +33,23 @@ export const motivationQuotes = pgTable("MotivationQuote", { createdAt: timestamp("createdAt", { mode: "date" }).notNull().defaultNow(), }); -export const suggestionQuotes = pgTable("SuggestionQuote", { - id: uuid("id").primaryKey().defaultRandom(), - quote: text("quote").notNull(), - author: text("author").notNull(), - addedBy: text("addedBy").notNull(), - status: text("status").notNull().default("Pending"), - reviewedBy: text("reviewedBy"), - reviewedAt: timestamp("reviewedAt", { mode: "date" }), - createdAt: timestamp("createdAt", { mode: "date" }).notNull().defaultNow(), - updatedAt: timestamp("updatedAt", { mode: "date" }).notNull().defaultNow().$onUpdate(() => new Date()), -}); +export const suggestionQuotes = pgTable( + "SuggestionQuote", + { + id: uuid("id").primaryKey().defaultRandom(), + quote: text("quote").notNull(), + author: text("author").notNull(), + addedBy: text("addedBy").notNull(), + status: suggestionStatusEnum("status").notNull().default("Pending"), + reviewedBy: text("reviewedBy"), + reviewedAt: timestamp("reviewedAt", { mode: "date" }), + createdAt: timestamp("createdAt", { mode: "date" }).notNull().defaultNow(), + updatedAt: timestamp("updatedAt", { mode: "date" }).notNull().defaultNow().$onUpdate(() => new Date()), + }, + (table) => ({ + statusIdx: index("suggestion_status_idx").on(table.status), + }) +); export const discordActivities = pgTable("DiscordActivity", { id: uuid("id").primaryKey().defaultRandom(), @@ -52,3 +65,4 @@ export type SuggestionQuote = InferSelectModel; export type DiscordActivity = InferSelectModel; export type MotivationFrequency = "Daily" | "Weekly" | "Monthly"; export type DiscordActivityType = "Custom" | "Listening" | "Streaming" | "Playing"; +export type SuggestionStatus = "Pending" | "Approved" | "Rejected"; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 50f4e37..e03e0eb 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -4,9 +4,6 @@ import type { Client, Interaction, CommandInteraction } from "discord.js"; import logger from "../utils/logger.js"; -/** - * Import slash commands from the commands folder. - */ import help from "../commands/help.js"; import about from "../commands/about.js"; import changelog from "../commands/changelog.js"; @@ -21,9 +18,8 @@ import owner from "../commands/owner/index.js"; export async function interactionCreateEvent( client: Client, interaction: Interaction -) { +): Promise { try { - // Handle autocomplete interactions if (interaction.isAutocomplete()) { if (interaction.commandName === "setup") { await setupAutocomplete(interaction); @@ -35,104 +31,39 @@ export async function interactionCreateEvent( return; } - logger.commands.executing( - "interactionCreate", - interaction.user.username, - interaction.user.id - ); - const { commandName } = interaction; - - if (!commandName) { - return; - } + if (!commandName) {return;} switch (commandName) { case "help": await help.execute(client, interaction); - logger.commands.success( - "interactionCreate - help", - interaction.user.username, - interaction.user.id - ); break; - case "about": await about.execute(client, interaction); - logger.commands.success( - "interactionCreate - about", - interaction.user.username, - interaction.user.id - ); break; - case "changelog": await changelog.execute(client, interaction); - logger.commands.success( - "interactionCreate - changelog", - interaction.user.username, - interaction.user.id - ); break; case "quote": - if (interaction.isChatInputCommand()) { - await quote.execute(client, interaction); - } - logger.commands.success( - "interactionCreate - quote", - interaction.user.username, - interaction.user.id - ); + if (interaction.isChatInputCommand()) {await quote.execute(client, interaction);} break; case "invite": await invite.execute(client, interaction); - logger.commands.success( - "interactionCreate - invite", - interaction.user.username, - interaction.user.id - ); break; case "suggestion": - if (interaction.isChatInputCommand()) { - await suggestion.execute(client, interaction); - } - logger.commands.success( - "interactionCreate - suggestion", - interaction.user.username, - interaction.user.id - ); + if (interaction.isChatInputCommand()) {await suggestion.execute(client, interaction);} break; case "admin": await admin.execute(client, interaction); - logger.commands.success( - "interactionCreate - admin", - interaction.user.username, - interaction.user.id - ); break; case "setup": await setup.execute(client, interaction); - logger.commands.success( - "interactionCreate - setup", - interaction.user.username, - interaction.user.id - ); break; case "premium": await premium.execute(client, interaction); - logger.commands.success( - "interactionCreate - premium", - interaction.user.username, - interaction.user.id - ); break; case "owner": await owner.execute(client, interaction); - logger.commands.success( - "interactionCreate - owner", - interaction.user.username, - interaction.user.id - ); break; default: logger.commands.warn( @@ -143,27 +74,35 @@ export async function interactionCreateEvent( ); } } catch (err) { + const cmd = interaction.isCommand() ? interaction.commandName : "unknown"; + const interactionId = "id" in interaction ? interaction.id : undefined; + const guildId = "guildId" in interaction ? interaction.guildId : null; + logger.error("Discord - Command", "Error executing command", err, { user: { username: interaction.user.username, id: interaction.user.id }, - command: interaction.isCommand() ? interaction.commandName : "unknown", + command: cmd, + interactionId, + guildId, }); try { - const interactionWithError = interaction as CommandInteraction; - - if (interactionWithError.replied || interactionWithError.deferred) { - await interactionWithError.followUp({ + const errInteraction = interaction as CommandInteraction; + if (errInteraction.replied || errInteraction.deferred) { + await errInteraction.followUp({ content: "There was an error while executing this command!", flags: MessageFlags.Ephemeral, }); } else { - await interactionWithError.reply({ + await errInteraction.reply({ content: "There was an error while executing this command!", flags: MessageFlags.Ephemeral, }); } } catch (replyErr) { - logger.error("Discord - Command", "Failed to send error response to user", replyErr); + logger.error("Discord - Command", "Failed to send error response to user", replyErr, { + interactionId, + guildId, + }); } } } diff --git a/src/events/shardDisconnect.ts b/src/events/shardDisconnect.ts index 5ebe509..c184fd7 100644 --- a/src/events/shardDisconnect.ts +++ b/src/events/shardDisconnect.ts @@ -1,9 +1,13 @@ import logger from "../utils/logger.js"; -export function shardDisconnectEvent() { - logger.error( +/** + * Discord.js handles gateway reconnection automatically. We log the event + * and let the client recover instead of killing the entire process on every + * transient disconnect. + */ +export function shardDisconnectEvent(): void { + logger.warn( "Discord - Event (Shard Disconnect)", - "Shard disconnected - exiting process" + "Shard disconnected — Discord.js will attempt to reconnect" ); - process.exit(1); } diff --git a/src/utils/commandErrors.ts b/src/utils/commandErrors.ts index db11556..066d4cf 100644 --- a/src/utils/commandErrors.ts +++ b/src/utils/commandErrors.ts @@ -2,6 +2,8 @@ import { MessageFlags } from "discord.js"; import type { CommandInteraction, ChatInputCommandInteraction } from "discord.js"; +import logger from "./logger.js"; + /** * Safely reply with an ephemeral error message if the interaction * hasn't already been replied to or deferred. @@ -17,3 +19,53 @@ export async function safeErrorReply( }); } } + +/** + * Single entry point for logging a command error. + * Replaces the duplicated pair of `logger.commands.error` + `logger.error` calls + * scattered across catch blocks. + */ +export function logCommandError( + commandName: string, + interaction: CommandInteraction | ChatInputCommandInteraction, + err: unknown +): void { + logger.commands.error( + commandName, + interaction.user.username, + interaction.user.id, + err, + interaction.guildId ?? undefined + ); +} + +/** + * Wrap a command handler with the standard executing/success/error/safeErrorReply + * lifecycle so individual handlers don't repeat boilerplate. + */ +export async function withCommandLogging( + commandName: string, + interaction: CommandInteraction | ChatInputCommandInteraction, + handler: () => Promise, + errorMessage?: string +): Promise { + logger.commands.executing( + commandName, + interaction.user.username, + interaction.user.id, + interaction.guildId ?? undefined + ); + + try { + await handler(); + logger.commands.success( + commandName, + interaction.user.username, + interaction.user.id, + interaction.guildId ?? undefined + ); + } catch (err) { + logCommandError(commandName, interaction, err); + await safeErrorReply(interaction, errorMessage); + } +} diff --git a/src/utils/envSchema.ts b/src/utils/envSchema.ts index 4def5f3..8a657ac 100644 --- a/src/utils/envSchema.ts +++ b/src/utils/envSchema.ts @@ -1,5 +1,8 @@ import { z } from "zod"; +const SNOWFLAKE = /^\d{17,20}$/; +const SNOWFLAKE_LIST = /^\d{17,20}(,\d{17,20})*$/; + export const envSchema = z.object({ DATABASE_URL: z .string() @@ -16,14 +19,15 @@ export const envSchema = z.object({ return false; } }, "Invalid PostgreSQL database URL"), + DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(100).default(10), REDIS_URL: z .string() .min(1, "Redis URL is required") .refine((url) => { try { - const parsedUrl = new URL(url); // Ensure it's a valid URL + const parsedUrl = new URL(url); return ( - parsedUrl.protocol === "redis:" || parsedUrl.protocol === "rediss:" // Support both redis and rediss protocols + parsedUrl.protocol === "redis:" || parsedUrl.protocol === "rediss:" ); } catch { return false; @@ -31,7 +35,7 @@ export const envSchema = z.object({ }, "Invalid Redis URL"), DISCORD_APPLICATION_ID: z .string() - .min(1, "Discord application ID is required"), + .regex(SNOWFLAKE, "DISCORD_APPLICATION_ID must be a Discord snowflake"), DISCORD_APPLICATION_PUBLIC_KEY: z .string() .min(1, "Discord application public key is required"), @@ -51,13 +55,19 @@ export const envSchema = z.object({ .max(1440) .default(15), DISCORD_DEFAULT_MOTIVATIONAL_DAILY_TIME: z.string().default("0 8 * * *"), - ALLOWED_USERS: z.string().optional(), - OWNER_ID: z.string().min(1, "Owner ID is required"), - MAIN_GUILD_ID: z.string().min(1, "Main guild ID is required"), - MAIN_CHANNEL_ID: z.string().min(1, "Main channel ID is required"), + ALLOWED_USERS: z + .string() + .optional() + .refine( + (v) => !v || SNOWFLAKE_LIST.test(v.split(",").map((s) => s.trim()).join(",")), + "ALLOWED_USERS must be a comma-separated list of Discord snowflakes" + ), + OWNER_ID: z.string().regex(SNOWFLAKE, "OWNER_ID must be a Discord snowflake"), + MAIN_GUILD_ID: z.string().regex(SNOWFLAKE, "MAIN_GUILD_ID must be a Discord snowflake"), + MAIN_CHANNEL_ID: z.string().regex(SNOWFLAKE, "MAIN_CHANNEL_ID must be a Discord snowflake"), HOST: z.string().optional(), PORT: z.string().optional(), - VERSION: z.string().default("1.9.0"), + VERSION: z.string().default("0.0.0-dev"), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), @@ -65,7 +75,11 @@ export const envSchema = z.object({ .string() .default("false") .transform((val) => val.toLowerCase() === "true"), - DISCORD_PREMIUM_SKU_ID: z.string().optional(), + DISCORD_PREMIUM_SKU_ID: z + .string() + .regex(SNOWFLAKE, "DISCORD_PREMIUM_SKU_ID must be a Discord snowflake") + .optional(), + WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(100).default(4), }) .refine( (data) => !data.PREMIUM_ENABLED || data.DISCORD_PREMIUM_SKU_ID, @@ -73,5 +87,4 @@ export const envSchema = z.object({ message: "DISCORD_PREMIUM_SKU_ID is required when PREMIUM_ENABLED is true", path: ["DISCORD_PREMIUM_SKU_ID"], } - ) -; + ); diff --git a/src/utils/ownerGuard.ts b/src/utils/ownerGuard.ts index 858bac7..c009c46 100644 --- a/src/utils/ownerGuard.ts +++ b/src/utils/ownerGuard.ts @@ -1,6 +1,6 @@ import { MessageFlags } from "discord.js"; -import type { CommandInteraction } from "discord.js"; +import type { ClientApplication, Client, CommandInteraction } from "discord.js"; import env from "./env.js"; import logger from "./logger.js"; @@ -26,3 +26,23 @@ export async function requireOwner( }); return false; } + +/** + * Ensure `client.application` is populated before invoking application-scoped APIs + * (entitlements, commands, etc). Returns the application if ready, otherwise replies + * and returns `null`. + */ +export async function requireApplication( + client: Client, + interaction: CommandInteraction +): Promise { + if (client.application) { + return client.application; + } + + await interaction.reply({ + content: "Bot application is not ready. Please try again in a moment.", + flags: MessageFlags.Ephemeral, + }); + return null; +} diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index ed2e4b4..87da8d3 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -1,12 +1,28 @@ -import { CommandInteraction, MessageFlags } from "discord.js"; +import { MessageFlags } from "discord.js"; +import type { CommandInteraction } from "discord.js"; -import { trimArray } from "./trimArray.js"; import env from "./env.js"; import logger from "./logger.js"; -export async function isUserPermitted(interaction: CommandInteraction) { - const allowedUsersArray = env.ALLOWED_USERS?.split(",") ?? []; - const allowedUsers = trimArray(allowedUsersArray); +let warned = false; + +function getAllowedUsers(): string[] { + const raw = env.ALLOWED_USERS?.trim() ?? ""; + if (!raw) { + if (!warned) { + logger.warn( + "Security", + "ALLOWED_USERS is empty — every admin command will be rejected. Set ALLOWED_USERS to enable admin access." + ); + warned = true; + } + return []; + } + return raw.split(",").map((s) => s.trim()).filter(Boolean); +} + +export async function isUserPermitted(interaction: CommandInteraction): Promise { + const allowedUsers = getAllowedUsers(); if (!allowedUsers.includes(interaction.user.id)) { logger.unauthorized( diff --git a/src/utils/premium.ts b/src/utils/premium.ts index c03a780..9c428ec 100644 --- a/src/utils/premium.ts +++ b/src/utils/premium.ts @@ -1,4 +1,14 @@ -import type { ChatInputCommandInteraction, CommandInteraction } from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from "discord.js"; + +import type { + ChatInputCommandInteraction, + CommandInteraction, +} from "discord.js"; import env from "./env.js"; @@ -26,3 +36,48 @@ export function hasEntitlement(interaction: CommandInteraction | ChatInputComman } return interaction.entitlements.some((entitlement) => entitlement.skuId === skuId); } + +interface UpsellEmbedOptions { + title?: string; + description?: string; + fields?: { name: string; value: string; inline?: boolean }[]; + footerText?: string; +} + +/** + * Build a consistent premium-upsell embed + SKU button row. Both are returned + * so callers can spread them into `interaction.reply({...})`. + */ +export function buildPremiumUpsell(options: UpsellEmbedOptions = {}): { + embeds: EmbedBuilder[]; + components: ActionRowBuilder[]; +} { + const skuId = getPremiumSkuId(); + + const embed = new EmbedBuilder() + .setColor(0xfadb7f) + .setTitle(options.title ?? "FluffBoost Premium") + .setDescription( + options.description ?? + "Upgrade to Premium to unlock exclusive features and support FluffBoost development!" + ); + + if (options.fields && options.fields.length > 0) { + embed.addFields(options.fields); + } + + if (options.footerText) { + embed.setFooter({ text: options.footerText }); + } + + const components: ActionRowBuilder[] = []; + if (skuId) { + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder().setStyle(ButtonStyle.Premium).setSKUId(skuId) + ) + ); + } + + return { embeds: [embed], components }; +} diff --git a/src/utils/quoteHelpers.ts b/src/utils/quoteHelpers.ts new file mode 100644 index 0000000..dcb8232 --- /dev/null +++ b/src/utils/quoteHelpers.ts @@ -0,0 +1,57 @@ +import { EmbedBuilder } from "discord.js"; +import type { Client, User } from "discord.js"; +import { sql } from "drizzle-orm"; + +import { db } from "../database/index.js"; +import { motivationQuotes } from "../database/schema.js"; +import type { MotivationQuote } from "../database/schema.js"; + +/** + * Fetch a single random motivation quote in one round-trip. + * Returns null if the table is empty. + */ +export async function getRandomMotivationQuote(): Promise { + const rows = await db + .select() + .from(motivationQuotes) + .orderBy(sql`random()`) + .limit(1); + return rows[0] ?? null; +} + +/** + * Resolve the user who added a quote, returning null if Discord lookup fails. + */ +export async function resolveQuoteAuthor( + client: Client, + addedById: string +): Promise { + try { + return await client.users.fetch(addedById); + } catch { + return null; + } +} + +/** + * Build the standard motivation quote embed used by both the slash command + * and the worker job. Always returns a fresh instance — do not share across sends. + */ +export function buildMotivationEmbed( + quote: MotivationQuote, + addedBy: User | null, + client: Client +): EmbedBuilder { + return new EmbedBuilder() + .setColor(0xfadb7f) + .setTitle("Motivation quote of the day \u{1F4C5}") + .setDescription(`**"${quote.quote}"**\n by ${quote.author}`) + .setAuthor({ + name: addedBy ? addedBy.username : "Unknown User", + iconURL: addedBy ? addedBy.displayAvatarURL() : undefined, + }) + .setFooter({ + text: "Powered by MrDemonWolf, Inc.", + iconURL: client.user?.displayAvatarURL(), + }); +} diff --git a/src/utils/replyHelpers.ts b/src/utils/replyHelpers.ts new file mode 100644 index 0000000..b818f83 --- /dev/null +++ b/src/utils/replyHelpers.ts @@ -0,0 +1,42 @@ +import { MessageFlags } from "discord.js"; +import type { CommandInteraction } from "discord.js"; + +interface ReplyWithTextFileOptions { + interaction: CommandInteraction; + rows: T[]; + header: string; + formatRow: (row: T) => string; + filename: string; + emptyMessage: string; + ephemeral?: boolean; +} + +/** + * Reply to a slash command with either an empty-state message or + * the rows formatted as a plain-text file attachment. + */ +export async function replyWithTextFile({ + interaction, + rows, + header, + formatRow, + filename, + emptyMessage, + ephemeral = true, +}: ReplyWithTextFileOptions): Promise { + if (rows.length === 0) { + await interaction.reply({ + content: emptyMessage, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const body = rows.map(formatRow).join("\n"); + const text = `${header}\n${body}\n`; + + await interaction.reply({ + files: [{ attachment: Buffer.from(text), name: filename }], + ...(ephemeral ? { flags: MessageFlags.Ephemeral } : {}), + }); +} diff --git a/src/utils/scheduleEvaluator.ts b/src/utils/scheduleEvaluator.ts index 48d6e7a..ebcc46e 100644 --- a/src/utils/scheduleEvaluator.ts +++ b/src/utils/scheduleEvaluator.ts @@ -15,8 +15,25 @@ interface GuildSchedule { lastMotivationSentAt: Date | null; } +/** + * Parse an "HH:mm" string into validated hour/minute components. + * Returns null on any malformed or out-of-range input — the caller should + * treat that guild as not-due rather than coercing to a default time. + */ +export function parseHourMinute(value: string): { hour: number; minute: number } | null { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value); + if (!match) { + return null; + } + return { hour: Number(match[1]), minute: Number(match[2]) }; +} + /** * Get the current time components in a specific timezone using dayjs. + * + * Note on weekly dedup: dayjs's `isSame(now, "week")` uses Sunday as the + * week start regardless of guild locale. This is intentional and consistent + * for the bot's purpose (guarding against duplicate sends within a 7-day window). */ export function getCurrentTimeInTimezone(tz: string) { const now = dayjs().tz(tz); @@ -38,9 +55,11 @@ export function getCurrentTimeInTimezone(tz: string) { export function isGuildDueForMotivation(guild: Pick): boolean { const { motivationFrequency, motivationTime, motivationDay, timezone: tz, lastMotivationSentAt } = guild; - const [targetHourStr, targetMinuteStr] = motivationTime.split(":"); - const targetHour = parseInt(targetHourStr ?? "8", 10); - const targetMinute = parseInt(targetMinuteStr ?? "0", 10); + const parsed = parseHourMinute(motivationTime); + if (!parsed) { + return false; + } + const { hour: targetHour, minute: targetMinute } = parsed; const current = getCurrentTimeInTimezone(tz); diff --git a/src/utils/suggestionHelpers.ts b/src/utils/suggestionHelpers.ts new file mode 100644 index 0000000..eeb3f3e --- /dev/null +++ b/src/utils/suggestionHelpers.ts @@ -0,0 +1,40 @@ +import { MessageFlags } from "discord.js"; +import type { CommandInteraction } from "discord.js"; +import { eq } from "drizzle-orm"; + +import { db } from "../database/index.js"; +import { suggestionQuotes } from "../database/schema.js"; +import type { SuggestionQuote } from "../database/schema.js"; + +/** + * Load a suggestion that must be in Pending status. Replies ephemerally and + * returns null if missing or already reviewed. + */ +export async function fetchPendingSuggestion( + suggestionId: string, + interaction: CommandInteraction +): Promise { + const [suggestion] = await db + .select() + .from(suggestionQuotes) + .where(eq(suggestionQuotes.id, suggestionId)) + .limit(1); + + if (!suggestion) { + await interaction.reply({ + content: `Suggestion with ID ${suggestionId} not found.`, + flags: MessageFlags.Ephemeral, + }); + return null; + } + + if (suggestion.status !== "Pending") { + await interaction.reply({ + content: `This suggestion has already been ${suggestion.status.toLowerCase()}.`, + flags: MessageFlags.Ephemeral, + }); + return null; + } + + return suggestion; +} diff --git a/src/utils/trimArray.ts b/src/utils/trimArray.ts deleted file mode 100644 index 1e251ce..0000000 --- a/src/utils/trimArray.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function trimArray(arr: string[]) { - return arr.map((item) => item.trim()); -} diff --git a/src/worker/index.ts b/src/worker/index.ts index 364a45a..0111fc3 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1,21 +1,41 @@ -import type { Queue, ConnectionOptions } from "bullmq"; - import { Worker, Job } from "bullmq"; +import type { ConnectionOptions, Queue } from "bullmq"; import client from "../bot.js"; import redisClient from "../redis/index.js"; import env from "../utils/env.js"; import logger from "../utils/logger.js"; -/** - * Import worker jobs - */ import setActivity from "./jobs/setActivity.js"; import sendMotivation from "./jobs/sendMotivation.js"; -export default (queue: Queue) => { +const QUEUE_NAME = "fluffboost-jobs"; + +async function ensureRepeatable( + queue: Queue, + name: string, + data: unknown, + intervalMs: number +): Promise { + // Drop any prior repeatables for this name so a changed interval doesn't + // leave a stale schedule running alongside the new one. + const existing = await queue.getRepeatableJobs(); + for (const job of existing) { + if (job.name === name) { + await queue.removeRepeatableByKey(job.key); + } + } + + await queue.add(name, data, { + repeat: { every: intervalMs }, + removeOnComplete: { count: 50 }, + removeOnFail: { count: 100 }, + }); +} + +export default async function startWorker(queue: Queue): Promise { const worker = new Worker( - "fluffboost-jobs", + QUEUE_NAME, async (job: Job) => { switch (job.name) { case "set-activity": @@ -27,7 +47,10 @@ export default (queue: Queue) => { } }, { + // ioredis instance type clashes with bullmq's bundled ioredis types, + // but bullmq accepts the runtime instance directly. connection: redisClient as unknown as ConnectionOptions, + concurrency: env.WORKER_CONCURRENCY, } ); @@ -39,34 +62,20 @@ export default (queue: Queue) => { logger.error("Worker", `Job "${job?.name}" failed (${job?.id}): ${err.message}`, err); }); - // Add jobs to the queue - queue.add( + await ensureRepeatable( + queue, "set-activity", - { client: null }, // client will be set in the job processor - { - repeat: { - every: env.DISCORD_ACTIVITY_INTERVAL_MINUTES * 60 * 1000, // minutes to ms - }, - removeOnComplete: true, - removeOnFail: false, - } + { client: null }, + env.DISCORD_ACTIVITY_INTERVAL_MINUTES * 60 * 1000 ); - // Run every minute to evaluate per-guild schedules - queue.add( - "send-motivation", - {}, - { - repeat: { - every: 60 * 1000, // every minute - }, - removeOnComplete: true, - removeOnFail: false, - } - ); + await ensureRepeatable(queue, "send-motivation", {}, 60 * 1000); logger.info("Worker", "Jobs registered", { activityInterval: `${env.DISCORD_ACTIVITY_INTERVAL_MINUTES}m`, motivationCheck: "every 1m (per-guild schedule evaluation)", + concurrency: env.WORKER_CONCURRENCY, }); -}; + + return worker; +} diff --git a/src/worker/jobs/sendMotivation.ts b/src/worker/jobs/sendMotivation.ts index 1126ed1..782b20b 100644 --- a/src/worker/jobs/sendMotivation.ts +++ b/src/worker/jobs/sendMotivation.ts @@ -1,76 +1,93 @@ -import { EmbedBuilder } from "discord.js"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc.js"; +import timezone from "dayjs/plugin/timezone.js"; import type { Client } from "discord.js"; -import { eq, isNotNull, asc, count } from "drizzle-orm"; +import { and, eq, isNotNull, or, lt, isNull } from "drizzle-orm"; import { db } from "../../database/index.js"; -import { guilds, motivationQuotes } from "../../database/schema.js"; +import { guilds } from "../../database/schema.js"; +import type { Guild } from "../../database/schema.js"; import { isGuildDueForMotivation } from "../../utils/scheduleEvaluator.js"; +import { buildMotivationEmbed, getRandomMotivationQuote, resolveQuoteAuthor } from "../../utils/quoteHelpers.js"; import logger from "../../utils/logger.js"; -export default async function sendMotivation(client: Client) { +dayjs.extend(utc); +dayjs.extend(timezone); + +/** + * Compute the start of the current delivery period in the guild's timezone. + * Returns a UTC `Date` suitable for comparing against `lastMotivationSentAt`. + */ +function periodStart(guild: Guild): Date { + const now = dayjs().tz(guild.timezone); + switch (guild.motivationFrequency) { + case "Daily": + return now.startOf("day").utc().toDate(); + case "Weekly": + return now.startOf("week").utc().toDate(); + case "Monthly": + return now.startOf("month").utc().toDate(); + } +} + +/** + * Atomically claim a guild for delivery this period. Returns true if this + * worker won the race, false if another worker (or a previous job tick) + * already updated the row. + */ +async function claimGuild(guild: Guild): Promise { + const claimed = await db + .update(guilds) + .set({ lastMotivationSentAt: new Date() }) + .where( + and( + eq(guilds.id, guild.id), + or(isNull(guilds.lastMotivationSentAt), lt(guilds.lastMotivationSentAt, periodStart(guild))) + ) + ) + .returning({ id: guilds.id }); + + return claimed.length > 0; +} + +export default async function sendMotivation(client: Client): Promise { const allGuilds = await db .select() .from(guilds) - .where(isNotNull(guilds.motivationChannelId)) - .orderBy(asc(guilds.guildId)); + .where(isNotNull(guilds.motivationChannelId)); if (allGuilds.length === 0) { return; } - // Filter to only guilds that are due for a motivation quote right now - const dueGuilds = allGuilds.filter((g) => isGuildDueForMotivation(g)); - + const dueGuilds = allGuilds.filter(isGuildDueForMotivation); if (dueGuilds.length === 0) { return; } logger.info("Worker", `${dueGuilds.length} guild(s) due for motivation out of ${allGuilds.length} total`); - const [countResult] = await db.select({ value: count() }).from(motivationQuotes); - const motivationQuoteCount = countResult?.value ?? 0; - const skip = Math.floor(Math.random() * motivationQuoteCount); - const motivationQuote = await db.select().from(motivationQuotes).offset(skip).limit(1); - - if (!motivationQuote[0]) { - logger.error("Worker", "No motivation quote found in the database"); + const quote = await getRandomMotivationQuote(); + if (!quote) { + logger.warn("Worker", "Motivation table is empty — nothing to send"); return; } - let addedBy; - try { - addedBy = await client.users.fetch(motivationQuote[0].addedBy); - } catch (error) { - logger.error("Worker", "Failed to fetch user who added the quote", error, { - userId: motivationQuote[0].addedBy, - quoteId: motivationQuote[0].id, - }); - addedBy = null; - } - - const motivationEmbed = new EmbedBuilder() - .setColor(0xfadb7f) - .setTitle("Motivation quote of the day \u{1F4C5}") - .setDescription( - `**"${motivationQuote[0].quote}"**\n by ${motivationQuote[0].author}` - ) - .setAuthor({ - name: addedBy ? addedBy.username : "Unknown User", - iconURL: addedBy ? addedBy.displayAvatarURL() : undefined, - }) - .setFooter({ - text: "Powered by MrDemonWolf, Inc.", - iconURL: client.user?.displayAvatarURL(), - }); + const author = await resolveQuoteAuthor(client, quote.addedBy); const results = await Promise.allSettled( - dueGuilds.map(async (g): Promise<"sent" | "skipped"> => { + dueGuilds.map(async (g): Promise<"sent" | "skipped" | "raced"> => { if (!g.motivationChannelId) { return "skipped"; } - const channel = await client.channels.fetch(g.motivationChannelId); + // Atomic claim: only the worker that flips lastMotivationSentAt wins. + const won = await claimGuild(g); + if (!won) { + return "raced"; + } + const channel = await client.channels.fetch(g.motivationChannelId); if (!channel || !channel.isTextBased() || channel.isDMBased()) { logger.warn("Worker", "Motivation channel is not a valid text channel", { guildId: g.guildId, @@ -79,16 +96,15 @@ export default async function sendMotivation(client: Client) { return "skipped"; } - await channel.send({ embeds: [motivationEmbed] }); - - // Update lastMotivationSentAt after successful send - await db.update(guilds).set({ lastMotivationSentAt: new Date() }).where(eq(guilds.guildId, g.guildId)); - + // Fresh embed per guild so Discord.js cannot mutate a shared instance. + await channel.send({ embeds: [buildMotivationEmbed(quote, author, client)] }); return "sent"; }) ); let sent = 0; + let skipped = 0; + let raced = 0; let failed = 0; for (const result of results) { @@ -97,8 +113,15 @@ export default async function sendMotivation(client: Client) { logger.error("Worker", "Failed to send motivation to a guild", result.reason); } else if (result.value === "sent") { sent++; + } else if (result.value === "raced") { + raced++; + } else { + skipped++; } } - logger.success("Worker", `Motivation sent to ${sent} guild(s), ${failed} failed`); + logger.success( + "Worker", + `Motivation: sent=${sent} skipped=${skipped} raced=${raced} failed=${failed}` + ); } diff --git a/tests/api/health.test.ts b/tests/api/health.test.ts index fdd7e50..e92933a 100644 --- a/tests/api/health.test.ts +++ b/tests/api/health.test.ts @@ -1,31 +1,64 @@ import { describe, it, expect, beforeAll, mock } from "bun:test"; +import express from "express"; import supertest from "supertest"; +import sinon from "sinon"; import { mockEnv } from "../helpers.js"; describe("Health API", () => { let request: supertest.Agent; + let dbStub: sinon.SinonStub; + let redisPingStub: sinon.SinonStub; beforeAll(async () => { - // Load app with mocked env to avoid Zod validation of real env vars mock.module("../../src/utils/env.js", () => ({ default: mockEnv() })); - const app = await import("../../src/api/index.js"); - request = supertest(app.default); + + dbStub = sinon.stub().resolves([{ "?column?": 1 }]); + mock.module("../../src/database/index.js", () => ({ queryClient: dbStub, db: {} })); + + redisPingStub = sinon.stub().resolves("PONG"); + mock.module("../../src/redis/index.js", () => ({ default: { ping: redisPingStub } })); + + const route = (await import("../../src/api/routes/health.js")).default; + const app = express(); + app.use("/api/health", route); + request = supertest(app); }); - it("should return 200 with status ok", async () => { + it("should return 200 and status ok when db and redis healthy", async () => { + dbStub.resolves([{ "?column?": 1 }]); + redisPingStub.resolves("PONG"); + const res = await request.get("/api/health"); expect(res.status).toBe(200); - expect(res.body).toEqual({ status: "ok" }); + expect(res.body.status).toBe("ok"); + expect(res.body.db).toBe("ok"); + expect(res.body.redis).toBe("ok"); }); - it("should return application/json content type", async () => { + it("should return 503 when db probe rejects", async () => { + dbStub.rejects(new Error("db down")); + redisPingStub.resolves("PONG"); + const res = await request.get("/api/health"); - expect(res.headers["content-type"]).toContain("application/json"); + expect(res.status).toBe(503); + expect(res.body.status).toBe("degraded"); + expect(res.body.db).toBe("error"); }); - it("should return correct JSON body shape", async () => { + it("should return 503 when redis ping rejects", async () => { + dbStub.resolves([{ "?column?": 1 }]); + redisPingStub.rejects(new Error("redis down")); + const res = await request.get("/api/health"); - expect(res.body).toHaveProperty("status"); - expect(typeof res.body.status).toBe("string"); + expect(res.status).toBe(503); + expect(res.body.redis).toBe("error"); + }); + + it("should return application/json content type", async () => { + dbStub.resolves([{ "?column?": 1 }]); + redisPingStub.resolves("PONG"); + + const res = await request.get("/api/health"); + expect(res.headers["content-type"]).toContain("application/json"); }); }); diff --git a/tests/commands/admin/activity/create.test.ts b/tests/commands/admin/activity/create.test.ts index 36ec16c..fa2a52e 100644 --- a/tests/commands/admin/activity/create.test.ts +++ b/tests/commands/admin/activity/create.test.ts @@ -12,7 +12,7 @@ describe("admin activity create command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(true) })); const mod = await import("../../../../src/commands/admin/activity/create.js"); @@ -25,7 +25,7 @@ describe("admin activity create command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(false) })); const mod = await import("../../../../src/commands/admin/activity/create.js"); diff --git a/tests/commands/admin/activity/list.test.ts b/tests/commands/admin/activity/list.test.ts index 2a2cef4..dce4fb6 100644 --- a/tests/commands/admin/activity/list.test.ts +++ b/tests/commands/admin/activity/list.test.ts @@ -12,7 +12,7 @@ describe("admin activity list command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(true) })); const mod = await import("../../../../src/commands/admin/activity/list.js"); @@ -25,7 +25,7 @@ describe("admin activity list command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(false) })); const mod = await import("../../../../src/commands/admin/activity/list.js"); diff --git a/tests/commands/admin/activity/remove.test.ts b/tests/commands/admin/activity/remove.test.ts index 1a5d22a..07ae80a 100644 --- a/tests/commands/admin/activity/remove.test.ts +++ b/tests/commands/admin/activity/remove.test.ts @@ -12,7 +12,7 @@ describe("admin activity remove command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(true) })); const mod = await import("../../../../src/commands/admin/activity/remove.js"); @@ -25,7 +25,7 @@ describe("admin activity remove command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(false) })); const mod = await import("../../../../src/commands/admin/activity/remove.js"); diff --git a/tests/commands/admin/quote/create.test.ts b/tests/commands/admin/quote/create.test.ts index 3f655a0..a862204 100644 --- a/tests/commands/admin/quote/create.test.ts +++ b/tests/commands/admin/quote/create.test.ts @@ -13,7 +13,7 @@ describe("admin quote create command", () => { const env = mockEnv(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(true) })); @@ -28,7 +28,7 @@ describe("admin quote create command", () => { const env = mockEnv(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(false) })); diff --git a/tests/commands/admin/quote/list.test.ts b/tests/commands/admin/quote/list.test.ts index 0896c9d..1430ba9 100644 --- a/tests/commands/admin/quote/list.test.ts +++ b/tests/commands/admin/quote/list.test.ts @@ -12,7 +12,7 @@ describe("admin quote list command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(true) })); const mod = await import("../../../../src/commands/admin/quote/list.js"); @@ -25,7 +25,7 @@ describe("admin quote list command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(false) })); const mod = await import("../../../../src/commands/admin/quote/list.js"); diff --git a/tests/commands/admin/quote/remove.test.ts b/tests/commands/admin/quote/remove.test.ts index f6aa607..821f313 100644 --- a/tests/commands/admin/quote/remove.test.ts +++ b/tests/commands/admin/quote/remove.test.ts @@ -13,7 +13,7 @@ describe("admin quote remove command", () => { const env = mockEnv(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(true) })); @@ -28,7 +28,7 @@ describe("admin quote remove command", () => { const env = mockEnv(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(false) })); diff --git a/tests/commands/admin/suggestion/approve.test.ts b/tests/commands/admin/suggestion/approve.test.ts index 9d84545..8b164bd 100644 --- a/tests/commands/admin/suggestion/approve.test.ts +++ b/tests/commands/admin/suggestion/approve.test.ts @@ -13,7 +13,7 @@ describe("admin suggestion approve command", () => { const env = mockEnv(overrides.env); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(true) })); diff --git a/tests/commands/admin/suggestion/list.test.ts b/tests/commands/admin/suggestion/list.test.ts index a44a545..e46b226 100644 --- a/tests/commands/admin/suggestion/list.test.ts +++ b/tests/commands/admin/suggestion/list.test.ts @@ -13,7 +13,7 @@ describe("admin suggestion list command", () => { const env = mockEnv(overrides.env); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(true) })); @@ -27,7 +27,7 @@ describe("admin suggestion list command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: mockEnv() })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(false) })); diff --git a/tests/commands/admin/suggestion/reject.test.ts b/tests/commands/admin/suggestion/reject.test.ts index b5bc6f1..a146308 100644 --- a/tests/commands/admin/suggestion/reject.test.ts +++ b/tests/commands/admin/suggestion/reject.test.ts @@ -7,19 +7,31 @@ describe("admin suggestion reject command", () => { sinon.restore(); }); - async function loadModule(overrides: { env?: Record } = {}) { + async function loadModule( + fetchResult: { status?: string; id?: string; quote?: string; author?: string; addedBy?: string } | null + ) { const logger = mockLogger(); const db = mockDb(); - const env = mockEnv(overrides.env); + const env = mockEnv(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(true) })); - const mod = await import("../../../../src/commands/admin/suggestion/reject.js"); + // fetchPendingSuggestion mocked to return the pre-validated suggestion or null. + mock.module("../../../../src/utils/suggestionHelpers.js", () => ({ + fetchPendingSuggestion: sinon.stub().callsFake(async (_id: string, interaction: { reply: sinon.SinonStub }) => { + if (fetchResult === null) { + await interaction.reply({ content: "not found", flags: 64 }); + return null; + } + return fetchResult; + }), + })); - return { handler: mod.default, logger, db, env }; + const mod = await import("../../../../src/commands/admin/suggestion/reject.js"); + return { handler: mod.default, logger, db }; } function makeInteraction(suggestionId: string, reason: string | null = null) { @@ -36,152 +48,80 @@ describe("admin suggestion reject command", () => { isDMBased: sinon.stub().returns(false), send: sinon.stub().resolves(), }; - const submitter = { - send: sinon.stub().resolves(), - }; + const submitter = { send: sinon.stub().resolves() }; const client = mockClient(); (client.channels.fetch as sinon.SinonStub).resolves(channel); (client.users.fetch as sinon.SinonStub).resolves(submitter); return { client, channel, submitter }; } - it("should return error when suggestion not found", async () => { - const { handler, db } = await loadModule(); + it("should return early when helper reports missing/non-pending", async () => { + const { handler, db } = await loadModule(null); const interaction = makeInteraction("nonexistent"); - // update().set().where().returning() returns empty array (no rows matched) - db.update.returns(mockDbChain([])); - // select().from().where().limit(1) returns empty (not found) - db.select.returns(mockDbChain([])); - - await handler({} as never, interaction as never, interaction.options as never); - - expect((interaction.reply as sinon.SinonStub).calledOnce).toBe(true); - const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; - expect(replyArgs.content).toContain("not found"); - }); - - it("should return error when already rejected", async () => { - const { handler, db } = await loadModule(); - const interaction = makeInteraction("s1"); - - // update returns empty (no pending row matched) - db.update.returns(mockDbChain([])); - // select finds the existing row with Rejected status - db.select.returns(mockDbChain([{ - id: "s1", - quote: "Be kind", - author: "Anon", - addedBy: "user-1", - status: "Rejected", - }])); - await handler({} as never, interaction as never, interaction.options as never); expect((interaction.reply as sinon.SinonStub).calledOnce).toBe(true); - const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; - expect(replyArgs.content).toContain("already been rejected"); + expect(db.update.called).toBe(false); }); it("should reject suggestion with reason", async () => { - const { handler, db } = await loadModule(); - const interaction = makeInteraction("s1", "Not appropriate"); - const { client, channel, submitter } = makeClient(); - - // update().set().where().returning() returns non-empty (row was updated) - db.update.returns(mockDbChain([{ + const { handler, db } = await loadModule({ id: "s1", quote: "Bad quote", author: "Anon", addedBy: "user-1", - status: "Rejected", - reviewedBy: "user-123", - }])); - // After successful update, select fetches the full suggestion for embed - db.select.returns(mockDbChain([{ - id: "s1", - quote: "Bad quote", - author: "Anon", - addedBy: "user-1", - status: "Rejected", - }])); + status: "Pending", + }); + const { client, channel, submitter } = makeClient(); + db.update.returns(mockDbChain([])); + const interaction = makeInteraction("s1", "Not appropriate"); await handler(client as never, interaction as never, interaction.options as never); - // Updates suggestion status via update expect(db.update.calledOnce).toBe(true); - - // Sends embed to main channel with reason expect(channel.send.calledOnce).toBe(true); - - // DMs submitter with reason expect(submitter.send.calledOnce).toBe(true); const dmEmbed = submitter.send.firstCall.args[0].embeds[0]; expect(dmEmbed.data.description).toContain("Not appropriate"); - - // Ephemeral reply const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; expect(replyArgs.content).toContain("rejected"); }); - it("should reject suggestion without reason", async () => { - const { handler, db } = await loadModule(); - const interaction = makeInteraction("s1"); - const { client, submitter } = makeClient(); - - db.update.returns(mockDbChain([{ + it("should reject suggestion without reason (no reason in DM)", async () => { + const { handler, db } = await loadModule({ id: "s1", quote: "Some quote", author: "Anon", addedBy: "user-1", - status: "Rejected", - }])); - db.select.returns(mockDbChain([{ - id: "s1", - quote: "Some quote", - author: "Anon", - addedBy: "user-1", - status: "Rejected", - }])); + status: "Pending", + }); + const { client, submitter } = makeClient(); + db.update.returns(mockDbChain([])); + const interaction = makeInteraction("s1"); await handler(client as never, interaction as never, interaction.options as never); expect(db.update.calledOnce).toBe(true); - - // DM should not include "Reason" const dmEmbed = submitter.send.firstCall.args[0].embeds[0]; expect(dmEmbed.data.description).not.toContain("Reason"); - - // Reply should be ephemeral - const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; - expect(replyArgs.content).toContain("rejected"); }); - it("should not break if DM fails", async () => { - const { handler, db } = await loadModule(); - const interaction = makeInteraction("s1"); - const { client } = makeClient(); - - db.update.returns(mockDbChain([{ - id: "s1", - quote: "Some quote", - author: "Anon", - addedBy: "user-1", - status: "Rejected", - }])); - db.select.returns(mockDbChain([{ + it("should not break if submitter DM fails", async () => { + const { handler, db } = await loadModule({ id: "s1", quote: "Some quote", author: "Anon", addedBy: "user-1", - status: "Rejected", - }])); - + status: "Pending", + }); + const { client } = makeClient(); + db.update.returns(mockDbChain([])); (client.users.fetch as sinon.SinonStub).rejects(new Error("Cannot send DM")); + const interaction = makeInteraction("s1"); await handler(client as never, interaction as never, interaction.options as never); - expect((interaction.reply as sinon.SinonStub).calledOnce).toBe(true); const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; expect(replyArgs.content).toContain("rejected"); }); diff --git a/tests/commands/admin/suggestion/stats.test.ts b/tests/commands/admin/suggestion/stats.test.ts index 681553d..7d86e0f 100644 --- a/tests/commands/admin/suggestion/stats.test.ts +++ b/tests/commands/admin/suggestion/stats.test.ts @@ -12,7 +12,7 @@ describe("admin suggestion stats command", () => { const db = mockDb(); mock.module("../../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../../src/database/index.js", () => ({ db })); + mock.module("../../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../../src/utils/env.js", () => ({ default: mockEnv() })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().resolves(permitted) })); diff --git a/tests/commands/owner/testCreate.test.ts b/tests/commands/owner/testCreate.test.ts index 4b1562d..3f25394 100644 --- a/tests/commands/owner/testCreate.test.ts +++ b/tests/commands/owner/testCreate.test.ts @@ -111,13 +111,13 @@ describe("owner premium test-create command", () => { expect(replyArgs.content).toContain("Could not determine guild"); }); - it("should show actual error on failure", async () => { - const { testCreate } = await loadModule({ - OWNER_ID: "owner-123", - DISCORD_PREMIUM_SKU_ID: "sku-1", + it("should reply with generic failure message and log error on failure", async () => { + const { testCreate, logger } = await loadModule({ + OWNER_ID: "100000000000000999", + DISCORD_PREMIUM_SKU_ID: "200000000000000001", }); - const interaction = mockInteraction({ user: { id: "owner-123", username: "owner" } }); + const interaction = mockInteraction({ user: { id: "100000000000000999", username: "owner" } }); const options = { getString: sinon.stub().returns(null) }; const client = mockClient(); @@ -127,7 +127,8 @@ describe("owner premium test-create command", () => { await testCreate(client as never, interaction as never, options as never); + expect(logger.commands.error.called).toBe(true); const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; - expect(replyArgs.content).toContain("API Error: rate limited"); + expect(replyArgs.content).toContain("Failed to create test entitlement"); }); }); diff --git a/tests/commands/premium.test.ts b/tests/commands/premium.test.ts index 0163a65..b3e14fc 100644 --- a/tests/commands/premium.test.ts +++ b/tests/commands/premium.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, mock } from "bun:test"; import sinon from "sinon"; -import { mockLogger, mockClient, mockInteraction, mockEnv } from "../helpers.js"; +import { mockLogger, mockClient, mockInteraction, mockEnv, stubBuildPremiumUpsell } from "../helpers.js"; describe("premium command", () => { afterEach(() => { @@ -19,6 +19,7 @@ describe("premium command", () => { isPremiumEnabled: sinon.stub().returns(overrides.premiumEnabled ?? false), hasEntitlement: sinon.stub().returns(overrides.hasEntitlement ?? false), getPremiumSkuId: sinon.stub().returns(overrides.skuId), + buildPremiumUpsell: stubBuildPremiumUpsell(overrides.skuId), })); const mod = await import("../../src/commands/premium.js"); diff --git a/tests/commands/quote.test.ts b/tests/commands/quote.test.ts index 59c338f..ec9dcf8 100644 --- a/tests/commands/quote.test.ts +++ b/tests/commands/quote.test.ts @@ -1,31 +1,33 @@ import { describe, it, expect, afterEach, mock } from "bun:test"; import sinon from "sinon"; -import { mockLogger, mockDb, mockDbChain, mockClient, mockInteraction } from "../helpers.js"; +import { mockLogger, mockClient, mockInteraction } from "../helpers.js"; describe("quote command", () => { afterEach(() => { sinon.restore(); }); - async function loadModule() { + async function loadModule(overrides: { + quote?: unknown; + author?: unknown; + } = {}) { const logger = mockLogger(); - const db = mockDb(); + const randomStub = sinon.stub().resolves(overrides.quote ?? null); + const authorStub = sinon.stub().resolves(overrides.author ?? null); mock.module("../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/utils/quoteHelpers.js", () => ({ + getRandomMotivationQuote: randomStub, + resolveQuoteAuthor: authorStub, + buildMotivationEmbed: () => ({ fake: true }), + })); const mod = await import("../../src/commands/quote.js"); - - return { execute: mod.execute, logger, db }; + return { execute: mod.execute, logger, randomStub, authorStub }; } it("should reply when no quotes found", async () => { - const { execute, db } = await loadModule(); - // First select: count query returns 0 - db.select.onCall(0).returns(mockDbChain([{ value: 0 }])); - // Second select: findMany returns empty - db.select.onCall(1).returns(mockDbChain([])); - + const { execute } = await loadModule(); const interaction = mockInteraction(); await execute(mockClient() as never, interaction as never); @@ -34,28 +36,22 @@ describe("quote command", () => { expect(arg).toContain("No motivation quote found"); }); - it("should reply with quote embed", async () => { - const { execute, db } = await loadModule(); - db.select.onCall(0).returns(mockDbChain([{ value: 1 }])); - db.select.onCall(1).returns(mockDbChain([ - { id: "q1", quote: "Be brave", author: "Anon", addedBy: "user-1", createdAt: new Date() }, - ])); - - const client = mockClient(); + it("should reply with embed when a quote is found", async () => { + const { execute } = await loadModule({ + quote: { id: "q1", quote: "Be brave", author: "Anon", addedBy: "u1", createdAt: new Date() }, + author: { username: "u", displayAvatarURL: () => "x" }, + }); const interaction = mockInteraction(); - await execute(client as never, interaction as never); + await execute(mockClient() as never, interaction as never); expect((interaction.reply as sinon.SinonStub).calledOnce).toBe(true); const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; expect(Array.isArray(replyArgs.embeds)).toBe(true); - expect(replyArgs.embeds).toHaveLength(1); }); it("should reply with error on failure", async () => { - const { execute, db, logger } = await loadModule(); - const chain = mockDbChain(); - chain.rejects(new Error("DB error")); - db.select.returns(chain); + const { execute, logger, randomStub } = await loadModule(); + randomStub.rejects(new Error("DB error")); const interaction = mockInteraction(); await execute(mockClient() as never, interaction as never); diff --git a/tests/commands/setup/channel.test.ts b/tests/commands/setup/channel.test.ts index 4279e1b..f7b056d 100644 --- a/tests/commands/setup/channel.test.ts +++ b/tests/commands/setup/channel.test.ts @@ -12,7 +12,7 @@ describe("setup channel command", () => { const db = mockDb(); mock.module("../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../src/database/index.js", () => ({ db })); + mock.module("../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../src/utils/guildDatabase.js", () => ({ guildExists: sinon.stub().resolves(true) })); const mod = await import("../../../src/commands/setup/channel.js"); diff --git a/tests/commands/setup/schedule.test.ts b/tests/commands/setup/schedule.test.ts index eb39302..b59b66e 100644 --- a/tests/commands/setup/schedule.test.ts +++ b/tests/commands/setup/schedule.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach, mock } from "bun:test"; import sinon from "sinon"; -import { mockLogger, mockDb, mockDbChain, mockInteraction } from "../../helpers.js"; +import { mockLogger, mockDb, mockDbChain, mockInteraction, stubBuildPremiumUpsell } from "../../helpers.js"; describe("setup schedule command", () => { afterEach(() => { @@ -12,11 +12,12 @@ describe("setup schedule command", () => { const db = mockDb(); mock.module("../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../src/database/index.js", () => ({ db })); + mock.module("../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../src/utils/premium.js", () => ({ isPremiumEnabled: sinon.stub().returns(overrides.premiumEnabled ?? false), hasEntitlement: sinon.stub().returns(overrides.hasEntitlement ?? false), getPremiumSkuId: sinon.stub().returns("sku-1"), + buildPremiumUpsell: stubBuildPremiumUpsell("sku-1"), })); mock.module("../../../src/utils/guildDatabase.js", () => ({ guildExists: sinon.stub().resolves(true) })); mock.module("../../../src/utils/timezones.js", () => ({ @@ -159,11 +160,12 @@ describe("setup schedule command", () => { const db = mockDb(); mock.module("../../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../../src/database/index.js", () => ({ db })); + mock.module("../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../../src/utils/premium.js", () => ({ isPremiumEnabled: sinon.stub().returns(false), hasEntitlement: sinon.stub().returns(false), getPremiumSkuId: sinon.stub().returns("sku-1"), + buildPremiumUpsell: stubBuildPremiumUpsell("sku-1"), })); mock.module("../../../src/utils/guildDatabase.js", () => ({ guildExists: sinon.stub().resolves(true) })); mock.module("../../../src/utils/timezones.js", () => ({ diff --git a/tests/commands/suggestion.test.ts b/tests/commands/suggestion.test.ts index c025b14..34e54e0 100644 --- a/tests/commands/suggestion.test.ts +++ b/tests/commands/suggestion.test.ts @@ -13,7 +13,7 @@ describe("suggestion command", () => { const env = mockEnv(); mock.module("../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/env.js", () => ({ default: env })); const mod = await import("../../src/commands/suggestion.js"); diff --git a/tests/events/entitlementCreate.test.ts b/tests/events/entitlementCreate.test.ts index b71c549..30c46d0 100644 --- a/tests/events/entitlementCreate.test.ts +++ b/tests/events/entitlementCreate.test.ts @@ -11,7 +11,7 @@ describe("entitlementCreateEvent", () => { const db = mockDb(); const logger = mockLogger(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { entitlementCreateEvent } = await import("../../src/events/entitlementCreate.js"); @@ -23,7 +23,7 @@ describe("entitlementCreateEvent", () => { it("should not update DB for user-level entitlement (no guildId)", async () => { const db = mockDb(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: mockLogger() })); const { entitlementCreateEvent } = await import("../../src/events/entitlementCreate.js"); @@ -38,7 +38,7 @@ describe("entitlementCreateEvent", () => { chain.rejects(new Error("DB error")); db.update.returns(chain); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { entitlementCreateEvent } = await import("../../src/events/entitlementCreate.js"); diff --git a/tests/events/entitlementDelete.test.ts b/tests/events/entitlementDelete.test.ts index 27d0c5e..87fa2db 100644 --- a/tests/events/entitlementDelete.test.ts +++ b/tests/events/entitlementDelete.test.ts @@ -10,7 +10,7 @@ describe("entitlementDeleteEvent", () => { it("should update guild isPremium=false for guild-level entitlement", async () => { const db = mockDb(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: mockLogger() })); const { entitlementDeleteEvent } = await import("../../src/events/entitlementDelete.js"); @@ -22,7 +22,7 @@ describe("entitlementDeleteEvent", () => { it("should not update DB for user-level entitlement (no guildId)", async () => { const db = mockDb(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: mockLogger() })); const { entitlementDeleteEvent } = await import("../../src/events/entitlementDelete.js"); @@ -37,7 +37,7 @@ describe("entitlementDeleteEvent", () => { chain.rejects(new Error("DB error")); db.update.returns(chain); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { entitlementDeleteEvent } = await import("../../src/events/entitlementDelete.js"); diff --git a/tests/events/entitlementUpdate.test.ts b/tests/events/entitlementUpdate.test.ts index 200e284..51e90aa 100644 --- a/tests/events/entitlementUpdate.test.ts +++ b/tests/events/entitlementUpdate.test.ts @@ -10,7 +10,7 @@ describe("entitlementUpdateEvent", () => { it("should set isPremium=false when endsAt is not null (cancellation)", async () => { const db = mockDb(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: mockLogger() })); const { entitlementUpdateEvent } = await import("../../src/events/entitlementUpdate.js"); @@ -23,7 +23,7 @@ describe("entitlementUpdateEvent", () => { it("should set isPremium=true when endsAt is null (renewal)", async () => { const db = mockDb(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: mockLogger() })); const { entitlementUpdateEvent } = await import("../../src/events/entitlementUpdate.js"); @@ -36,7 +36,7 @@ describe("entitlementUpdateEvent", () => { it("should not update DB for user-level entitlement (no guildId)", async () => { const db = mockDb(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: mockLogger() })); const { entitlementUpdateEvent } = await import("../../src/events/entitlementUpdate.js"); @@ -51,7 +51,7 @@ describe("entitlementUpdateEvent", () => { chain.rejects(new Error("DB error")); db.update.returns(chain); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { entitlementUpdateEvent } = await import("../../src/events/entitlementUpdate.js"); diff --git a/tests/events/guildCreate.test.ts b/tests/events/guildCreate.test.ts index 9a2c6b5..ee59099 100644 --- a/tests/events/guildCreate.test.ts +++ b/tests/events/guildCreate.test.ts @@ -12,7 +12,7 @@ describe("guildCreateEvent", () => { const logger = mockLogger(); db.insert.returns(mockDbChain([{ guildId: "g1" }])); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { guildCreateEvent } = await import("../../src/events/guildCreate.js"); @@ -30,7 +30,7 @@ describe("guildCreateEvent", () => { chain.rejects(new Error("DB error")); db.insert.returns(chain); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { guildCreateEvent } = await import("../../src/events/guildCreate.js"); diff --git a/tests/events/guildDelete.test.ts b/tests/events/guildDelete.test.ts index 3ceac8c..4a785c4 100644 --- a/tests/events/guildDelete.test.ts +++ b/tests/events/guildDelete.test.ts @@ -11,7 +11,7 @@ describe("guildDeleteEvent", () => { const db = mockDb(); const logger = mockLogger(); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { guildDeleteEvent } = await import("../../src/events/guildDelete.js"); @@ -28,7 +28,7 @@ describe("guildDeleteEvent", () => { chain.rejects(new Error("DB error")); db.delete.returns(chain); - mock.module("../../src/database/index.js", () => ({ db })); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { guildDeleteEvent } = await import("../../src/events/guildDelete.js"); diff --git a/tests/events/interactionCreate.test.ts b/tests/events/interactionCreate.test.ts index 3d472cd..0a08357 100644 --- a/tests/events/interactionCreate.test.ts +++ b/tests/events/interactionCreate.test.ts @@ -69,7 +69,6 @@ describe("interactionCreateEvent", () => { await interactionCreateEvent(client, interaction); expect(executeStub.called).toBe(true); - expect(logger.commands.success.called).toBe(true); }); it("should handle autocomplete interactions for setup", async () => { diff --git a/tests/events/shardDisconnect.test.ts b/tests/events/shardDisconnect.test.ts index c48fbcc..27a8b37 100644 --- a/tests/events/shardDisconnect.test.ts +++ b/tests/events/shardDisconnect.test.ts @@ -7,7 +7,7 @@ describe("shardDisconnect event", () => { sinon.restore(); }); - it("should log error and exit process", async () => { + it("should log a warning without exiting the process", async () => { const logger = mockLogger(); const exitStub = sinon.stub(process, "exit"); @@ -16,9 +16,8 @@ describe("shardDisconnect event", () => { mod.shardDisconnectEvent(); - expect(logger.error.calledOnce).toBe(true); - expect(logger.error.firstCall.args[0]).toContain("Shard Disconnect"); - expect(exitStub.calledOnce).toBe(true); - expect(exitStub.firstCall.args[0]).toBe(1); + expect(logger.warn.calledOnce).toBe(true); + expect(logger.warn.firstCall.args[0]).toContain("Shard Disconnect"); + expect(exitStub.called).toBe(false); }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index b162146..700c3c1 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -204,8 +204,9 @@ export function mockEntitlement(overrides: Record = {}) { export function mockEnv(overrides: Record = {}) { return { DATABASE_URL: "postgres://user:pass@localhost:5432/test", + DATABASE_POOL_MAX: 10, REDIS_URL: "redis://localhost:6379", - DISCORD_APPLICATION_ID: "app-123", + DISCORD_APPLICATION_ID: "100000000000000001", DISCORD_APPLICATION_PUBLIC_KEY: "key-123", DISCORD_APPLICATION_BOT_TOKEN: "token-123", DISCORD_DEFAULT_STATUS: "Spreading Paw-sitivity", @@ -213,16 +214,17 @@ export function mockEnv(overrides: Record = {}) { DEFAULT_ACTIVITY_URL: undefined, DISCORD_ACTIVITY_INTERVAL_MINUTES: 15, DISCORD_DEFAULT_MOTIVATIONAL_DAILY_TIME: "0 8 * * *", - ALLOWED_USERS: "user-123, user-456", - OWNER_ID: "owner-123", - MAIN_GUILD_ID: "main-guild-123", - MAIN_CHANNEL_ID: "main-channel-123", + ALLOWED_USERS: "100000000000000123,100000000000000456", + OWNER_ID: "100000000000000999", + MAIN_GUILD_ID: "100000000000000100", + MAIN_CHANNEL_ID: "100000000000000200", HOST: "localhost", PORT: "3000", VERSION: "1.0.0", NODE_ENV: "test", PREMIUM_ENABLED: false, DISCORD_PREMIUM_SKU_ID: undefined, + WORKER_CONCURRENCY: 4, ...overrides, }; } @@ -232,3 +234,25 @@ export function mockEnv(overrides: Record = {}) { export type MockLogger = ReturnType; export type MockDb = ReturnType; export type StubFn = SinonStub; + +// ── Premium upsell stub (matches real premium.buildPremiumUpsell shape) ──── +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +export function stubBuildPremiumUpsell(skuId?: string) { + return (opts: { title?: string; description?: string; fields?: { name: string; value: string; inline?: boolean }[] } = {}) => { + const embed = new EmbedBuilder() + .setColor(0xfadb7f) + .setTitle(opts.title ?? "FluffBoost Premium") + .setDescription(opts.description ?? "upsell"); + if (opts.fields) embed.addFields(opts.fields); + const components: ActionRowBuilder[] = []; + if (skuId) { + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder().setStyle(ButtonStyle.Premium).setSKUId(skuId) + ) + ); + } + return { embeds: [embed], components }; + }; +} diff --git a/tests/utils/commandErrors.test.ts b/tests/utils/commandErrors.test.ts new file mode 100644 index 0000000..59b3390 --- /dev/null +++ b/tests/utils/commandErrors.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, afterEach, mock } from "bun:test"; +import sinon from "sinon"; +import { mockLogger, mockInteraction } from "../helpers.js"; + +describe("commandErrors.withCommandLogging", () => { + afterEach(() => { + sinon.restore(); + }); + + async function load() { + const logger = mockLogger(); + mock.module("../../src/utils/logger.js", () => ({ default: logger })); + const mod = await import("../../src/utils/commandErrors.js"); + return { logger, mod }; + } + + it("logs executing + success when handler resolves", async () => { + const { logger, mod } = await load(); + const interaction = mockInteraction(); + await mod.withCommandLogging("cmd", interaction as never, async () => { + // no-op + }); + + expect(logger.commands.executing.calledOnce).toBe(true); + expect(logger.commands.success.calledOnce).toBe(true); + expect(logger.commands.error.called).toBe(false); + }); + + it("logs error and replies safely when handler throws", async () => { + const { logger, mod } = await load(); + const interaction = mockInteraction(); + await mod.withCommandLogging("cmd", interaction as never, async () => { + throw new Error("boom"); + }); + + expect(logger.commands.error.calledOnce).toBe(true); + expect(logger.commands.success.called).toBe(false); + expect((interaction.reply as sinon.SinonStub).calledOnce).toBe(true); + }); + + it("uses custom errorMessage when provided", async () => { + const { mod } = await load(); + const interaction = mockInteraction(); + await mod.withCommandLogging("cmd", interaction as never, async () => { + throw new Error("boom"); + }, "Custom failure text"); + + const content = (interaction.reply as sinon.SinonStub).firstCall.args[0].content; + expect(content).toBe("Custom failure text"); + }); +}); diff --git a/tests/utils/env.test.ts b/tests/utils/env.test.ts index c5d1d15..6e39ae1 100644 --- a/tests/utils/env.test.ts +++ b/tests/utils/env.test.ts @@ -1,16 +1,18 @@ import { describe, it, expect } from "bun:test"; import { envSchema } from "../../src/utils/envSchema.js"; +const SKU = "200000000000000001"; + function validEnv(overrides: Record = {}) { return { DATABASE_URL: "postgres://user:pass@localhost:5432/testdb", REDIS_URL: "redis://localhost:6379", - DISCORD_APPLICATION_ID: "app-123", + DISCORD_APPLICATION_ID: "100000000000000001", DISCORD_APPLICATION_PUBLIC_KEY: "key-123", DISCORD_APPLICATION_BOT_TOKEN: "token-123", - OWNER_ID: "owner-123", - MAIN_GUILD_ID: "guild-123", - MAIN_CHANNEL_ID: "channel-123", + OWNER_ID: "100000000000000999", + MAIN_GUILD_ID: "100000000000000100", + MAIN_CHANNEL_ID: "100000000000000200", ...overrides, }; } @@ -44,14 +46,14 @@ describe("envSchema", () => { it("should pass when PREMIUM_ENABLED is true with DISCORD_PREMIUM_SKU_ID", () => { const result = envSchema.safeParse( - validEnv({ PREMIUM_ENABLED: "true", DISCORD_PREMIUM_SKU_ID: "sku-123" }) + validEnv({ PREMIUM_ENABLED: "true", DISCORD_PREMIUM_SKU_ID: SKU }) ); expect(result.success).toBe(true); }); it("should coerce PREMIUM_ENABLED string 'true' to boolean true", () => { const result = envSchema.safeParse( - validEnv({ PREMIUM_ENABLED: "true", DISCORD_PREMIUM_SKU_ID: "sku-123" }) + validEnv({ PREMIUM_ENABLED: "true", DISCORD_PREMIUM_SKU_ID: SKU }) ); expect(result.success).toBe(true); if (result.success) { @@ -81,4 +83,35 @@ describe("envSchema", () => { } }); + it("should reject non-snowflake OWNER_ID", () => { + const result = envSchema.safeParse(validEnv({ OWNER_ID: "owner-123" })); + expect(result.success).toBe(false); + }); + + it("should reject non-snowflake DISCORD_PREMIUM_SKU_ID", () => { + const result = envSchema.safeParse( + validEnv({ PREMIUM_ENABLED: "true", DISCORD_PREMIUM_SKU_ID: "sku-not-a-snowflake" }) + ); + expect(result.success).toBe(false); + }); + + it("should accept ALLOWED_USERS as comma-separated snowflakes", () => { + const result = envSchema.safeParse( + validEnv({ ALLOWED_USERS: "100000000000000123,100000000000000456" }) + ); + expect(result.success).toBe(true); + }); + + it("should reject ALLOWED_USERS containing non-snowflake entries", () => { + const result = envSchema.safeParse(validEnv({ ALLOWED_USERS: "user-123,user-456" })); + expect(result.success).toBe(false); + }); + + it("should default DATABASE_POOL_MAX to 10 when unset", () => { + const result = envSchema.safeParse(validEnv()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.DATABASE_POOL_MAX).toBe(10); + } + }); }); diff --git a/tests/utils/guildDatabase.test.ts b/tests/utils/guildDatabase.test.ts index 6cf1954..1ddfb5d 100644 --- a/tests/utils/guildDatabase.test.ts +++ b/tests/utils/guildDatabase.test.ts @@ -32,7 +32,7 @@ function createCollectionCache(entries: [string, V][] = []) { const db = mockDb(); const logger = mockLogger(); -mock.module("../../src/database/index.js", () => ({ db })); +mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); const { pruneGuilds, ensureGuildExists, guildExists } = await import("../../src/utils/guildDatabase.js"); diff --git a/tests/utils/quoteHelpers.test.ts b/tests/utils/quoteHelpers.test.ts new file mode 100644 index 0000000..f2a9c46 --- /dev/null +++ b/tests/utils/quoteHelpers.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, afterEach, mock } from "bun:test"; +import sinon from "sinon"; +import { mockDb, mockDbChain, mockClient } from "../helpers.js"; + +describe("quoteHelpers", () => { + afterEach(() => { + sinon.restore(); + }); + + async function load(db: ReturnType) { + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); + return import("../../src/utils/quoteHelpers.js"); + } + + it("getRandomMotivationQuote returns null when table is empty", async () => { + const db = mockDb(); + db.select.returns(mockDbChain([])); + const { getRandomMotivationQuote } = await load(db); + + const result = await getRandomMotivationQuote(); + expect(result).toBeNull(); + }); + + it("getRandomMotivationQuote returns first row when present", async () => { + const db = mockDb(); + const row = { id: "q1", quote: "hi", author: "a", addedBy: "u", createdAt: new Date() }; + db.select.returns(mockDbChain([row])); + const { getRandomMotivationQuote } = await load(db); + + const result = await getRandomMotivationQuote(); + expect(result).toEqual(row); + }); + + it("resolveQuoteAuthor returns null on fetch failure", async () => { + const db = mockDb(); + const { resolveQuoteAuthor } = await load(db); + const client = mockClient(); + (client.users.fetch as sinon.SinonStub).rejects(new Error("Unknown user")); + + const result = await resolveQuoteAuthor(client as never, "missing"); + expect(result).toBeNull(); + }); + + it("buildMotivationEmbed returns a fresh EmbedBuilder each call", async () => { + const db = mockDb(); + const { buildMotivationEmbed } = await load(db); + const client = mockClient(); + const quote = { id: "q1", quote: "hi", author: "a", addedBy: "u", createdAt: new Date() }; + + const a = buildMotivationEmbed(quote, null, client as never); + const b = buildMotivationEmbed(quote, null, client as never); + expect(a).not.toBe(b); + expect(a.data.title).toContain("Motivation"); + }); +}); diff --git a/tests/utils/replyHelpers.test.ts b/tests/utils/replyHelpers.test.ts new file mode 100644 index 0000000..5277b41 --- /dev/null +++ b/tests/utils/replyHelpers.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from "bun:test"; +import sinon from "sinon"; +import { mockInteraction } from "../helpers.js"; +import { replyWithTextFile } from "../../src/utils/replyHelpers.js"; + +describe("replyHelpers", () => { + afterEach(() => { + sinon.restore(); + }); + + it("replies with empty message when rows is empty", async () => { + const interaction = mockInteraction(); + await replyWithTextFile({ + interaction: interaction as never, + rows: [], + header: "H", + formatRow: () => "", + filename: "out.txt", + emptyMessage: "Nothing here", + }); + + const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; + expect(replyArgs.content).toBe("Nothing here"); + expect(replyArgs.files).toBeUndefined(); + }); + + it("attaches a file when rows has content", async () => { + const interaction = mockInteraction(); + await replyWithTextFile({ + interaction: interaction as never, + rows: [{ id: "1" }, { id: "2" }], + header: "ID", + formatRow: (r: { id: string }) => r.id, + filename: "ids.txt", + emptyMessage: "none", + }); + + const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; + expect(Array.isArray(replyArgs.files)).toBe(true); + expect(replyArgs.files[0].name).toBe("ids.txt"); + const body = (replyArgs.files[0].attachment as Buffer).toString("utf8"); + expect(body).toContain("ID"); + expect(body).toContain("1"); + expect(body).toContain("2"); + }); +}); diff --git a/tests/utils/scheduleEvaluator.parseHourMinute.test.ts b/tests/utils/scheduleEvaluator.parseHourMinute.test.ts new file mode 100644 index 0000000..6fae85e --- /dev/null +++ b/tests/utils/scheduleEvaluator.parseHourMinute.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "bun:test"; +import { parseHourMinute } from "../../src/utils/scheduleEvaluator.js"; + +describe("scheduleEvaluator.parseHourMinute", () => { + it("parses valid HH:mm", () => { + expect(parseHourMinute("08:00")).toEqual({ hour: 8, minute: 0 }); + expect(parseHourMinute("23:59")).toEqual({ hour: 23, minute: 59 }); + expect(parseHourMinute("00:00")).toEqual({ hour: 0, minute: 0 }); + }); + + it("rejects out-of-range hour", () => { + expect(parseHourMinute("25:00")).toBeNull(); + expect(parseHourMinute("24:01")).toBeNull(); + }); + + it("rejects out-of-range minute", () => { + expect(parseHourMinute("10:60")).toBeNull(); + expect(parseHourMinute("10:99")).toBeNull(); + }); + + it("rejects malformed input", () => { + expect(parseHourMinute("")).toBeNull(); + expect(parseHourMinute("garbage")).toBeNull(); + expect(parseHourMinute("8:00")).toBeNull(); + expect(parseHourMinute("08:0")).toBeNull(); + }); +}); diff --git a/tests/utils/suggestionHelpers.test.ts b/tests/utils/suggestionHelpers.test.ts new file mode 100644 index 0000000..187e1f2 --- /dev/null +++ b/tests/utils/suggestionHelpers.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach, mock } from "bun:test"; +import sinon from "sinon"; +import { mockDb, mockDbChain, mockInteraction } from "../helpers.js"; + +describe("suggestionHelpers.fetchPendingSuggestion", () => { + afterEach(() => { + sinon.restore(); + }); + + async function load(rows: unknown[]) { + const db = mockDb(); + db.select.returns(mockDbChain(rows)); + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); + const mod = await import("../../src/utils/suggestionHelpers.js"); + return { fetchPendingSuggestion: mod.fetchPendingSuggestion }; + } + + it("returns null and replies when suggestion is missing", async () => { + const { fetchPendingSuggestion } = await load([]); + const interaction = mockInteraction(); + const result = await fetchPendingSuggestion("x", interaction as never); + expect(result).toBeNull(); + const content = (interaction.reply as sinon.SinonStub).firstCall.args[0].content; + expect(content).toContain("not found"); + }); + + it("returns null and replies when suggestion is already reviewed", async () => { + const { fetchPendingSuggestion } = await load([ + { id: "s1", status: "Approved", quote: "q", author: "a", addedBy: "u" }, + ]); + const interaction = mockInteraction(); + const result = await fetchPendingSuggestion("s1", interaction as never); + expect(result).toBeNull(); + const content = (interaction.reply as sinon.SinonStub).firstCall.args[0].content; + expect(content).toContain("already been approved"); + }); + + it("returns the suggestion when status is Pending", async () => { + const suggestion = { id: "s1", status: "Pending", quote: "q", author: "a", addedBy: "u" }; + const { fetchPendingSuggestion } = await load([suggestion]); + const interaction = mockInteraction(); + const result = await fetchPendingSuggestion("s1", interaction as never); + expect(result).toEqual(suggestion); + expect((interaction.reply as sinon.SinonStub).called).toBe(false); + }); +}); diff --git a/tests/utils/trimArray.test.ts b/tests/utils/trimArray.test.ts deleted file mode 100644 index e6b3fed..0000000 --- a/tests/utils/trimArray.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { trimArray } from "../../src/utils/trimArray.js"; - -describe("trimArray", () => { - it("should trim whitespace from array elements", () => { - const result = trimArray([" hello ", " world "]); - expect(result).toEqual(["hello", "world"]); - }); - - it("should handle an empty array", () => { - const result = trimArray([]); - expect(result).toEqual([]); - }); - - it("should handle already-trimmed strings", () => { - const result = trimArray(["hello", "world"]); - expect(result).toEqual(["hello", "world"]); - }); - - it("should handle mixed whitespace (tabs, spaces, newlines)", () => { - const result = trimArray(["\thello\t", "\n world \n"]); - expect(result).toEqual(["hello", "world"]); - }); -}); diff --git a/tests/worker/index.test.ts b/tests/worker/index.test.ts index d149090..2226fe8 100644 --- a/tests/worker/index.test.ts +++ b/tests/worker/index.test.ts @@ -2,8 +2,6 @@ import { describe, it, expect, beforeEach, mock } from "bun:test"; import sinon from "sinon"; import { mockLogger, mockEnv } from "../helpers.js"; -// Top-level mocks for infrastructure deps only — NOT for job modules, -// since mocking them here prevents other test files from importing the real modules. const logger = mockLogger(); const env = mockEnv(); @@ -24,18 +22,21 @@ mock.module("../../src/bot.js", () => ({ default: mockClient })); mock.module("../../src/redis/index.js", () => ({ default: {} })); mock.module("bullmq", () => ({ Worker: WorkerStub, Job: class {} })); -// Import worker/index BEFORE mocking job modules — the real setActivity/sendMotivation -// are loaded now but will be swapped via live bindings in beforeEach. -const { default: registerWorker } = await import("../../src/worker/index.js"); +const { default: startWorker } = await import("../../src/worker/index.js"); + +function makeQueue(existingRepeatables: { name: string; key: string }[] = []) { + return { + add: sinon.stub().resolves(), + getRepeatableJobs: sinon.stub().resolves(existingRepeatables), + removeRepeatableByKey: sinon.stub().resolves(), + }; +} describe("worker index", () => { beforeEach(() => { - // Mock job modules in beforeEach (not top-level) so other test files - // can import the real modules during their top-level evaluation. mock.module("../../src/worker/jobs/setActivity.js", () => ({ default: setActivityStub })); mock.module("../../src/worker/jobs/sendMotivation.js", () => ({ default: sendMotivationStub })); - // Reset stubs workerOnStub.reset(); WorkerStub.resetHistory(); WorkerStub.callsFake((_name: string, processor: typeof jobProcessor) => { @@ -63,37 +64,54 @@ describe("worker index", () => { Object.assign(env, mockEnv()); }); - it("should register jobs with correct intervals", () => { + it("should register jobs with correct intervals", async () => { Object.assign(env, { DISCORD_ACTIVITY_INTERVAL_MINUTES: 10 }); + const queue = makeQueue(); - const addStub = sinon.stub(); - const mockQueue = { add: addStub }; - - registerWorker(mockQueue as never); - - expect(addStub.calledTwice).toBe(true); + await startWorker(queue as never); - const activityCall = addStub.firstCall; + expect(queue.add.calledTwice).toBe(true); + const activityCall = queue.add.firstCall; expect(activityCall.args[0]).toBe("set-activity"); expect(activityCall.args[2].repeat.every).toBe(10 * 60 * 1000); - const motivationCall = addStub.secondCall; + const motivationCall = queue.add.secondCall; expect(motivationCall.args[0]).toBe("send-motivation"); expect(motivationCall.args[2].repeat.every).toBe(60 * 1000); + }); + + it("should remove existing repeatables before re-adding", async () => { + const queue = makeQueue([ + { name: "set-activity", key: "old-activity-key" }, + { name: "send-motivation", key: "old-motivation-key" }, + ]); + + await startWorker(queue as never); - expect(logger.info.called).toBe(true); + expect(queue.removeRepeatableByKey.calledWith("old-activity-key")).toBe(true); + expect(queue.removeRepeatableByKey.calledWith("old-motivation-key")).toBe(true); + }); + + it("should set removeOnFail cap and concurrency", async () => { + const queue = makeQueue(); + await startWorker(queue as never); + + const opts = queue.add.firstCall.args[2]; + expect(opts.removeOnFail).toEqual({ count: 100 }); + expect(opts.removeOnComplete).toEqual({ count: 50 }); + + const workerOpts = WorkerStub.firstCall.args[2]; + expect(workerOpts.concurrency).toBe(env.WORKER_CONCURRENCY); }); it("should create Worker with correct job handler", async () => { - const addStub = sinon.stub(); - const mockQueue = { add: addStub }; - registerWorker(mockQueue as never); + const queue = makeQueue(); + await startWorker(queue as never); expect(typeof jobProcessor).toBe("function"); await jobProcessor!({ name: "set-activity" }); expect(setActivityStub.calledOnce).toBe(true); - expect(setActivityStub.firstCall.args[0]).toBe(mockClient); await jobProcessor!({ name: "send-motivation" }); expect(sendMotivationStub.calledOnce).toBe(true); @@ -106,20 +124,11 @@ describe("worker index", () => { } }); - it("should set up completed and failed event handlers", () => { - const addStub = sinon.stub(); - const mockQueue = { add: addStub }; - registerWorker(mockQueue as never); + it("should set up completed and failed event handlers", async () => { + const queue = makeQueue(); + await startWorker(queue as never); expect(workerOnStub.calledWith("completed")).toBe(true); expect(workerOnStub.calledWith("failed")).toBe(true); - - const completedHandler = workerOnStub.getCalls().find((c: sinon.SinonSpyCall) => c.args[0] === "completed"); - completedHandler!.args[1]({ name: "test-job", id: "123" }); - expect(logger.success.called).toBe(true); - - const failedHandler = workerOnStub.getCalls().find((c: sinon.SinonSpyCall) => c.args[0] === "failed"); - failedHandler!.args[1]({ name: "test-job", id: "123" }, new Error("fail")); - expect(logger.error.called).toBe(true); }); }); diff --git a/tests/worker/sendMotivation.test.ts b/tests/worker/sendMotivation.test.ts index 5afe2ac..6806d7b 100644 --- a/tests/worker/sendMotivation.test.ts +++ b/tests/worker/sendMotivation.test.ts @@ -11,162 +11,159 @@ describe("sendMotivation", () => { db?: ReturnType; logger?: ReturnType; isGuildDueForMotivation?: sinon.SinonStub; + getRandomMotivationQuote?: sinon.SinonStub; + resolveQuoteAuthor?: sinon.SinonStub; } = {}) { const db = overrides.db ?? mockDb(); const logger = overrides.logger ?? mockLogger(); const isGuildDueStub = overrides.isGuildDueForMotivation ?? sinon.stub().returns(true); - - mock.module("../../src/database/index.js", () => ({ db })); + const randomQuoteStub = + overrides.getRandomMotivationQuote ?? + sinon.stub().resolves({ id: "q1", quote: "Stay strong", author: "Author", addedBy: "u1", createdAt: new Date() }); + const authorStub = + overrides.resolveQuoteAuthor ?? + sinon.stub().resolves({ username: "authoruser", displayAvatarURL: () => "https://x/avatar.png" }); + + mock.module("../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/logger.js", () => ({ default: logger })); mock.module("../../src/utils/scheduleEvaluator.js", () => ({ isGuildDueForMotivation: isGuildDueStub })); + mock.module("../../src/utils/quoteHelpers.js", () => ({ + getRandomMotivationQuote: randomQuoteStub, + buildMotivationEmbed: () => ({}), + resolveQuoteAuthor: authorStub, + })); const mod = await import("../../src/worker/jobs/sendMotivation.js"); + return { sendMotivation: mod.default, db, logger, isGuildDueStub, randomQuoteStub }; + } - return { sendMotivation: mod.default, db, logger, isGuildDueStub }; + function configureAllGuildsQuery(db: ReturnType, rows: unknown[]) { + db.select.onCall(0).returns(mockDbChain(rows)); } it("should return early when no guilds have channels configured", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([])); + configureAllGuildsQuery(db, []); const { sendMotivation } = await loadModule({ db }); await sendMotivation(mockClient() as never); - // Count query (second select) should not be called since no guilds found expect(db.select.callCount).toBe(1); }); it("should return early when no guilds are due", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([{ guildId: "g1", motivationChannelId: "ch1" }])); + configureAllGuildsQuery(db, [{ id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }]); const isGuildDueStub = sinon.stub().returns(false); const { sendMotivation } = await loadModule({ db, isGuildDueForMotivation: isGuildDueStub }); await sendMotivation(mockClient() as never); - // Count query (second select) should not be called since no guilds are due - expect(db.select.callCount).toBe(1); + expect(db.update.called).toBe(false); }); - it("should return early when no quotes exist in database", async () => { + it("should warn and return when motivation table is empty", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([{ guildId: "g1", motivationChannelId: "ch1" }])); - db.select.onCall(1).returns(mockDbChain([{ value: 0 }])); - db.select.onCall(2).returns(mockDbChain([])); + configureAllGuildsQuery(db, [{ id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }]); - const { sendMotivation, logger } = await loadModule({ db }); + const { sendMotivation, logger } = await loadModule({ + db, + getRandomMotivationQuote: sinon.stub().resolves(null), + }); await sendMotivation(mockClient() as never); - expect(logger.error.called).toBe(true); + + expect(logger.warn.called).toBe(true); + expect(db.update.called).toBe(false); }); - it("should send embed to due guilds and update lastMotivationSentAt", async () => { + it("should atomically claim guild before sending and send embed on success", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([{ guildId: "g1", motivationChannelId: "ch1" }])); - db.select.onCall(1).returns(mockDbChain([{ value: 1 }])); - db.select.onCall(2).returns(mockDbChain([{ id: "q1", quote: "Stay strong", author: "Author", addedBy: "u1" }])); + configureAllGuildsQuery(db, [{ id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }]); - const sendStub = sinon.stub().resolves(); - const channel = { - isTextBased: () => true, - isDMBased: () => false, - send: sendStub, - }; + // claimGuild update returns a row — we won the claim. + db.update.returns(mockDbChain([{ id: "uuid1" }])); + const sendStub = sinon.stub().resolves(); + const channel = { isTextBased: () => true, isDMBased: () => false, send: sendStub }; const client = mockClient(); (client.channels.fetch as sinon.SinonStub).resolves(channel); const { sendMotivation } = await loadModule({ db }); await sendMotivation(client as never); - expect(sendStub.calledOnce).toBe(true); expect(db.update.calledOnce).toBe(true); + expect(sendStub.calledOnce).toBe(true); }); - it("should skip guilds with invalid channels (not text-based)", async () => { + it("should skip send when another worker already claimed the guild (race)", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([{ guildId: "g1", motivationChannelId: "ch1" }])); - db.select.onCall(1).returns(mockDbChain([{ value: 1 }])); - db.select.onCall(2).returns(mockDbChain([{ id: "q1", quote: "Stay", author: "A", addedBy: "u1" }])); + configureAllGuildsQuery(db, [{ id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }]); - const channel = { - isTextBased: () => false, - isDMBased: () => false, - send: sinon.stub(), - }; + // Empty returning() — another worker won the race first. + db.update.returns(mockDbChain([])); + const sendStub = sinon.stub().resolves(); + const channel = { isTextBased: () => true, isDMBased: () => false, send: sendStub }; const client = mockClient(); (client.channels.fetch as sinon.SinonStub).resolves(channel); - const { sendMotivation, logger } = await loadModule({ db }); + const { sendMotivation } = await loadModule({ db }); await sendMotivation(client as never); - expect(channel.send.called).toBe(false); - expect(logger.warn.called).toBe(true); + expect(sendStub.called).toBe(false); }); - it("should skip guilds with DM-based channels", async () => { + it("should skip guilds with invalid channels after winning claim", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([{ guildId: "g1", motivationChannelId: "ch1" }])); - db.select.onCall(1).returns(mockDbChain([{ value: 1 }])); - db.select.onCall(2).returns(mockDbChain([{ id: "q1", quote: "Stay", author: "A", addedBy: "u1" }])); - - const channel = { - isTextBased: () => true, - isDMBased: () => true, - send: sinon.stub(), - }; + configureAllGuildsQuery(db, [{ id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }]); + db.update.returns(mockDbChain([{ id: "uuid1" }])); + const channel = { isTextBased: () => false, isDMBased: () => false, send: sinon.stub() }; const client = mockClient(); (client.channels.fetch as sinon.SinonStub).resolves(channel); - const { sendMotivation } = await loadModule({ db }); + const { sendMotivation, logger } = await loadModule({ db }); await sendMotivation(client as never); expect(channel.send.called).toBe(false); + expect(logger.warn.called).toBe(true); }); - it("should handle per-guild send failures via Promise.allSettled", async () => { + it("should isolate per-guild send failures via Promise.allSettled", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([ - { guildId: "g1", motivationChannelId: "ch1" }, - { guildId: "g2", motivationChannelId: "ch2" }, - ])); - db.select.onCall(1).returns(mockDbChain([{ value: 1 }])); - db.select.onCall(2).returns(mockDbChain([{ id: "q1", quote: "Stay", author: "A", addedBy: "u1" }])); + configureAllGuildsQuery(db, [ + { id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }, + { id: "uuid2", guildId: "g2", motivationChannelId: "ch2", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }, + ]); + db.update.returns(mockDbChain([{ id: "uuid1" }])); const sendStub = sinon.stub(); sendStub.onFirstCall().rejects(new Error("channel error")); sendStub.onSecondCall().resolves(); - - const channel = { - isTextBased: () => true, - isDMBased: () => false, - send: sendStub, - }; - + const channel = { isTextBased: () => true, isDMBased: () => false, send: sendStub }; const client = mockClient(); (client.channels.fetch as sinon.SinonStub).resolves(channel); const { sendMotivation, logger } = await loadModule({ db }); await sendMotivation(client as never); - // Should not throw, both guilds attempted expect(logger.error.called).toBe(true); }); - it("should handle user fetch failure for addedBy gracefully", async () => { + it("should tolerate user fetch failure for addedBy", async () => { const db = mockDb(); - db.select.onCall(0).returns(mockDbChain([{ guildId: "g1", motivationChannelId: "ch1" }])); - db.select.onCall(1).returns(mockDbChain([{ value: 1 }])); - db.select.onCall(2).returns(mockDbChain([{ id: "q1", quote: "Stay", author: "A", addedBy: "u-missing" }])); + configureAllGuildsQuery(db, [{ id: "uuid1", guildId: "g1", motivationChannelId: "ch1", timezone: "UTC", motivationFrequency: "Daily", lastMotivationSentAt: null }]); + db.update.returns(mockDbChain([{ id: "uuid1" }])); - const channel = { isTextBased: () => true, isDMBased: () => false, send: sinon.stub().resolves() }; + const sendStub = sinon.stub().resolves(); + const channel = { isTextBased: () => true, isDMBased: () => false, send: sendStub }; const client = mockClient(); (client.channels.fetch as sinon.SinonStub).resolves(channel); - (client.users.fetch as sinon.SinonStub).rejects(new Error("Unknown User")); - const { sendMotivation } = await loadModule({ db }); + const { sendMotivation } = await loadModule({ + db, + resolveQuoteAuthor: sinon.stub().resolves(null), + }); await sendMotivation(client as never); - // Should still send the embed even if user fetch fails - expect(channel.send.calledOnce).toBe(true); + expect(sendStub.calledOnce).toBe(true); }); }); diff --git a/tests/worker/setActivity.test.ts b/tests/worker/setActivity.test.ts index e34296b..1d20c65 100644 --- a/tests/worker/setActivity.test.ts +++ b/tests/worker/setActivity.test.ts @@ -3,7 +3,7 @@ import sinon from "sinon"; import { mockLogger, mockDb, mockDbChain, mockEnv, mockClient } from "../helpers.js"; // Mock schema to prevent real DB connection during import -mock.module("../../src/database/index.js", () => ({ db: {} })); +mock.module("../../src/database/index.js", () => ({ db: {}, queryClient: () => Promise.resolve([]) })); mock.module("../../src/utils/env.js", () => ({ default: {} })); mock.module("../../src/utils/logger.js", () => ({ default: {} })); From 7b4ebc4997ac9025467bff643a65061055d86a7f Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:04:56 -0500 Subject: [PATCH 2/5] fix(tests): resolve cross-file mock leakage breaking CI bun:test mock.module mutates the global ES registry and persists across files. Partial mocks that omitted exports used by other tests caused TypeErrors like "execute is not a function" and "guildExists is not a function" when test files ran together in CI (serialized differently than local). - Extract setActivityCore into its own module so worker/index.test.ts can mock the setActivity.js wrapper without clobbering setActivityCore imports used by setActivity.test.ts. - Fill out partial mocks to include every export the real module provides (guildDatabase: guildExists + pruneGuilds + ensureGuildExists; command modules: default + named execute + slashCommand). --- src/worker/jobs/setActivity.ts | 95 ++------------------------ src/worker/jobs/setActivityCore.ts | 78 +++++++++++++++++++++ tests/commands/setup/channel.test.ts | 6 +- tests/commands/setup/schedule.test.ts | 12 +++- tests/events/interactionCreate.test.ts | 20 +++--- tests/events/ready.test.ts | 6 +- tests/worker/setActivity.test.ts | 5 +- 7 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 src/worker/jobs/setActivityCore.ts diff --git a/src/worker/jobs/setActivity.ts b/src/worker/jobs/setActivity.ts index 2490153..ab7c6c3 100644 --- a/src/worker/jobs/setActivity.ts +++ b/src/worker/jobs/setActivity.ts @@ -1,99 +1,12 @@ -import { ActivityType } from "discord.js"; - import type { Client } from "discord.js"; -import { desc } from "drizzle-orm"; import { db } from "../../database/index.js"; -import { discordActivities } from "../../database/schema.js"; import env from "../../utils/env.js"; import logger from "../../utils/logger.js"; +import { setActivityCore } from "./setActivityCore.js"; -// Safe lookup for ActivityType enum with fallback to Playing -const getActivityType = (activityTypeString: string): ActivityType => { - const activityType = - ActivityType[activityTypeString as keyof typeof ActivityType]; - return activityType !== undefined ? activityType : ActivityType.Playing; +export default async (client: Client): Promise => { + await setActivityCore(client, { db, env, logger }); }; -export interface SetActivityDeps { - db: typeof db; - env: typeof env; - logger: typeof logger; -} - -export async function setActivityCore(client: Client, { db: _db, env: _env, logger: _logger }: SetActivityDeps) { - try { - const defaultActivity = _env.DISCORD_DEFAULT_STATUS; - const defaultActivityType = _env.DISCORD_DEFAULT_ACTIVITY_TYPE; - const defaultActivityUrl = _env.DEFAULT_ACTIVITY_URL; - - if (!client.user) { - return _logger.warn( - "Worker", - "Client user is not defined, cannot set activity" - ); - } - const randomActivity = async () => { - const activities = await _db - .select() - .from(discordActivities) - .orderBy(desc(discordActivities.createdAt)); - - if (activities.length === 0) { - return null; - } - - activities.push({ - id: "default", - activity: defaultActivity, - type: defaultActivityType, - url: defaultActivityUrl ? defaultActivityUrl : null, - createdAt: new Date(), - }); - - const randomIndex = Math.floor(Math.random() * activities.length); - - return activities[randomIndex]; - }; - - const activity = await randomActivity(); - - if (!activity) { - _logger.warn( - "Worker", - "No custom discord activity found, using default activity" - ); - const safeActivityType = getActivityType(defaultActivityType); - client.user.setActivity(defaultActivity, { - type: safeActivityType, - url: defaultActivityUrl, - }); - _logger.success("Worker", "Activity has been set", { - activity: defaultActivity, - type: safeActivityType, - url: defaultActivityUrl, - }); - return true; - } - - const safeActivityType = getActivityType(activity.type); - client.user.setActivity(activity.activity, { - type: safeActivityType, - url: activity.url ? activity.url : undefined, - }); - - _logger.success("Worker", "Activity has been set", { - activity: activity.activity, - type: safeActivityType, - }); - return true; - } catch (err) { - _logger.error( - "Worker", - "Error setting custom discord activity", - err - ); - } -} - -export default async (client: Client) => setActivityCore(client, { db, env, logger }); +export { setActivityCore }; diff --git a/src/worker/jobs/setActivityCore.ts b/src/worker/jobs/setActivityCore.ts new file mode 100644 index 0000000..9a21839 --- /dev/null +++ b/src/worker/jobs/setActivityCore.ts @@ -0,0 +1,78 @@ +import { ActivityType } from "discord.js"; + +import type { Client } from "discord.js"; +import { desc } from "drizzle-orm"; + +import { db } from "../../database/index.js"; +import { discordActivities } from "../../database/schema.js"; +import env from "../../utils/env.js"; +import logger from "../../utils/logger.js"; + +const getActivityType = (activityTypeString: string): ActivityType => { + const activityType = ActivityType[activityTypeString as keyof typeof ActivityType]; + return activityType !== undefined ? activityType : ActivityType.Playing; +}; + +export interface SetActivityDeps { + db: typeof db; + env: typeof env; + logger: typeof logger; +} + +export async function setActivityCore(client: Client, { db: _db, env: _env, logger: _logger }: SetActivityDeps) { + try { + const defaultActivity = _env.DISCORD_DEFAULT_STATUS; + const defaultActivityType = _env.DISCORD_DEFAULT_ACTIVITY_TYPE; + const defaultActivityUrl = _env.DEFAULT_ACTIVITY_URL; + + if (!client.user) { + return _logger.warn("Worker", "Client user is not defined, cannot set activity"); + } + + const activities = await _db + .select() + .from(discordActivities) + .orderBy(desc(discordActivities.createdAt)); + + if (activities.length === 0) { + _logger.warn("Worker", "No custom discord activity found, using default activity"); + const safeActivityType = getActivityType(defaultActivityType); + client.user.setActivity(defaultActivity, { + type: safeActivityType, + url: defaultActivityUrl, + }); + _logger.success("Worker", "Activity has been set", { + activity: defaultActivity, + type: safeActivityType, + url: defaultActivityUrl, + }); + return true; + } + + activities.push({ + id: "default", + activity: defaultActivity, + type: defaultActivityType, + url: defaultActivityUrl ? defaultActivityUrl : null, + createdAt: new Date(), + }); + + const randomIndex = Math.floor(Math.random() * activities.length); + const activity = activities[randomIndex]; + if (!activity) {return;} + + const safeActivityType = getActivityType(activity.type); + client.user.setActivity(activity.activity, { + type: safeActivityType, + url: activity.url ? activity.url : undefined, + }); + + _logger.success("Worker", "Activity has been set", { + activity: activity.activity, + type: safeActivityType, + }); + return true; + } catch (err) { + _logger.error("Worker", "Error setting custom discord activity", err); + } +} diff --git a/tests/commands/setup/channel.test.ts b/tests/commands/setup/channel.test.ts index f7b056d..1b2ccae 100644 --- a/tests/commands/setup/channel.test.ts +++ b/tests/commands/setup/channel.test.ts @@ -13,7 +13,11 @@ describe("setup channel command", () => { mock.module("../../../src/utils/logger.js", () => ({ default: logger })); mock.module("../../../src/database/index.js", () => ({ db, queryClient: () => Promise.resolve([]) })); - mock.module("../../../src/utils/guildDatabase.js", () => ({ guildExists: sinon.stub().resolves(true) })); + mock.module("../../../src/utils/guildDatabase.js", () => ({ + guildExists: sinon.stub().resolves(true), + pruneGuilds: sinon.stub().resolves(), + ensureGuildExists: sinon.stub().resolves(), + })); const mod = await import("../../../src/commands/setup/channel.js"); diff --git a/tests/commands/setup/schedule.test.ts b/tests/commands/setup/schedule.test.ts index b59b66e..4ebf8d3 100644 --- a/tests/commands/setup/schedule.test.ts +++ b/tests/commands/setup/schedule.test.ts @@ -19,7 +19,11 @@ describe("setup schedule command", () => { getPremiumSkuId: sinon.stub().returns("sku-1"), buildPremiumUpsell: stubBuildPremiumUpsell("sku-1"), })); - mock.module("../../../src/utils/guildDatabase.js", () => ({ guildExists: sinon.stub().resolves(true) })); + mock.module("../../../src/utils/guildDatabase.js", () => ({ + guildExists: sinon.stub().resolves(true), + pruneGuilds: sinon.stub().resolves(), + ensureGuildExists: sinon.stub().resolves(), + })); mock.module("../../../src/utils/timezones.js", () => ({ isValidTimezone: sinon.stub().callsFake((tz: string) => { return ["America/Chicago", "America/New_York", "Europe/London", "UTC"].includes(tz); @@ -167,7 +171,11 @@ describe("setup schedule command", () => { getPremiumSkuId: sinon.stub().returns("sku-1"), buildPremiumUpsell: stubBuildPremiumUpsell("sku-1"), })); - mock.module("../../../src/utils/guildDatabase.js", () => ({ guildExists: sinon.stub().resolves(true) })); + mock.module("../../../src/utils/guildDatabase.js", () => ({ + guildExists: sinon.stub().resolves(true), + pruneGuilds: sinon.stub().resolves(), + ensureGuildExists: sinon.stub().resolves(), + })); mock.module("../../../src/utils/timezones.js", () => ({ isValidTimezone: sinon.stub().returns(true), filterTimezones: sinon.stub().throws(new Error("timezone error")), diff --git a/tests/events/interactionCreate.test.ts b/tests/events/interactionCreate.test.ts index 0a08357..9bf41eb 100644 --- a/tests/events/interactionCreate.test.ts +++ b/tests/events/interactionCreate.test.ts @@ -9,19 +9,21 @@ const setupAutocomplete = sinon.stub().resolves(); // Top-level mocks to avoid cross-file interference mock.module("../../src/utils/logger.js", () => ({ default: logger })); -mock.module("../../src/commands/help.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/about.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/changelog.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/quote.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/suggestion.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/invite.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/admin/index.js", () => ({ default: { execute: executeStub } })); +mock.module("../../src/commands/help.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "help" } })); +mock.module("../../src/commands/about.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "about" } })); +mock.module("../../src/commands/changelog.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "changelog" } })); +mock.module("../../src/commands/quote.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "quote" } })); +mock.module("../../src/commands/suggestion.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "suggestion" } })); +mock.module("../../src/commands/invite.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "invite" } })); +mock.module("../../src/commands/admin/index.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "admin" } })); mock.module("../../src/commands/setup/index.js", () => ({ default: { execute: executeStub }, + execute: executeStub, setupAutocomplete, + slashCommand: { name: "setup" }, })); -mock.module("../../src/commands/premium.js", () => ({ default: { execute: executeStub } })); -mock.module("../../src/commands/owner/index.js", () => ({ default: { execute: executeStub } })); +mock.module("../../src/commands/premium.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "premium" } })); +mock.module("../../src/commands/owner/index.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "owner" } })); const { interactionCreateEvent } = await import("../../src/events/interactionCreate.js"); diff --git a/tests/events/ready.test.ts b/tests/events/ready.test.ts index 15c7742..1b594c9 100644 --- a/tests/events/ready.test.ts +++ b/tests/events/ready.test.ts @@ -14,7 +14,11 @@ describe("ready event", () => { const setActivity = sinon.stub().resolves(); mock.module("../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../src/utils/guildDatabase.js", () => ({ pruneGuilds, ensureGuildExists })); + mock.module("../../src/utils/guildDatabase.js", () => ({ + pruneGuilds, + ensureGuildExists, + guildExists: sinon.stub().resolves(true), + })); mock.module("../../src/worker/jobs/setActivity.js", () => ({ default: setActivity })); mock.module("../../src/commands/help.js", () => ({ default: { slashCommand: { name: "help" } } })); mock.module("../../src/commands/about.js", () => ({ default: { slashCommand: { name: "about" } } })); diff --git a/tests/worker/setActivity.test.ts b/tests/worker/setActivity.test.ts index 1d20c65..f3515ef 100644 --- a/tests/worker/setActivity.test.ts +++ b/tests/worker/setActivity.test.ts @@ -7,8 +7,9 @@ mock.module("../../src/database/index.js", () => ({ db: {}, queryClient: () => P mock.module("../../src/utils/env.js", () => ({ default: {} })); mock.module("../../src/utils/logger.js", () => ({ default: {} })); -// Import the core function that accepts deps directly — no mock.module needed for testing -const { setActivityCore } = await import("../../src/worker/jobs/setActivity.js"); +// setActivityCore lives in its own module so it cannot be clobbered by test +// files that mock `setActivity.js` to verify worker job dispatch. +const { setActivityCore } = await import("../../src/worker/jobs/setActivityCore.js"); describe("setActivity", () => { it("should warn and return when client.user is undefined", async () => { From fe4d577b60d2f58c83dfa4887481a45c7a7e5eb5 Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:50:36 -0500 Subject: [PATCH 3/5] fix(tests): introduce commandRegistry module to eliminate mock leakage bun:test's mock.module registry is process-global. When interactionCreate.test.ts or ready.test.ts top-level-mocked the ten individual command modules, those mocks leaked into each command's own test file and the stub-`execute` (or missing `execute`) caused 50 assertion failures on CI Linux (which walks tests/events/ before tests/commands/). - Extract command dispatch table to src/events/commandRegistry.ts. interactionCreate.ts and ready.ts now import from this single module instead of ten command modules. - interactionCreate.test.ts and ready.test.ts now mock only the registry. Command test files are no longer poisoned. --- src/events/commandRegistry.ts | 52 +++++++++++++++++++++ src/events/interactionCreate.ts | 65 +++++++------------------- src/events/ready.ts | 28 +---------- tests/events/interactionCreate.test.ts | 31 ++++++------ tests/events/ready.test.ts | 18 ++++--- 5 files changed, 95 insertions(+), 99 deletions(-) create mode 100644 src/events/commandRegistry.ts diff --git a/src/events/commandRegistry.ts b/src/events/commandRegistry.ts new file mode 100644 index 0000000..fe32dd1 --- /dev/null +++ b/src/events/commandRegistry.ts @@ -0,0 +1,52 @@ +import type { Client, ChatInputCommandInteraction, CommandInteraction } from "discord.js"; + +import help from "../commands/help.js"; +import about from "../commands/about.js"; +import changelog from "../commands/changelog.js"; +import quote from "../commands/quote.js"; +import suggestion from "../commands/suggestion.js"; +import invite from "../commands/invite.js"; +import admin from "../commands/admin/index.js"; +import setup, { setupAutocomplete as _setupAutocomplete } from "../commands/setup/index.js"; +import premium from "../commands/premium.js"; +import owner from "../commands/owner/index.js"; + +/** + * Command registry keyed by slash-command name. The event router imports this + * single object, so tests can mock one module instead of ten — which avoids + * cross-file `mock.module` leakage clobbering the real command modules used + * by each command's own test file. + */ +export type CommandHandler = ( + client: Client, + interaction: CommandInteraction | ChatInputCommandInteraction +) => Promise; + +export const commandRegistry: Record = { + help: { execute: (c, i) => help.execute(c, i as CommandInteraction) }, + about: { execute: (c, i) => about.execute(c, i as CommandInteraction) }, + changelog: { execute: (c, i) => changelog.execute(c, i as CommandInteraction) }, + quote: { execute: (c, i) => quote.execute(c, i as ChatInputCommandInteraction), requiresChatInput: true }, + invite: { execute: (c, i) => invite.execute(c, i as CommandInteraction) }, + suggestion: { execute: (c, i) => suggestion.execute(c, i as ChatInputCommandInteraction), requiresChatInput: true }, + admin: { execute: (c, i) => admin.execute(c, i as CommandInteraction) }, + setup: { execute: (c, i) => setup.execute(c, i as CommandInteraction) }, + premium: { execute: (c, i) => premium.execute(c, i as CommandInteraction) }, + owner: { execute: (c, i) => owner.execute(c, i as CommandInteraction) }, +}; + +export const setupAutocomplete = _setupAutocomplete; + +/** All slash-command definitions for Discord API registration. */ +export const slashCommands = [ + help.slashCommand, + about.slashCommand, + quote.slashCommand, + suggestion.slashCommand, + invite.slashCommand, + setup.slashCommand, + admin.slashCommand, + changelog.slashCommand, + premium.slashCommand, + owner.slashCommand, +]; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index e03e0eb..6a354f4 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -3,17 +3,7 @@ import { MessageFlags } from "discord.js"; import type { Client, Interaction, CommandInteraction } from "discord.js"; import logger from "../utils/logger.js"; - -import help from "../commands/help.js"; -import about from "../commands/about.js"; -import changelog from "../commands/changelog.js"; -import quote from "../commands/quote.js"; -import suggestion from "../commands/suggestion.js"; -import invite from "../commands/invite.js"; -import admin from "../commands/admin/index.js"; -import setup, { setupAutocomplete } from "../commands/setup/index.js"; -import premium from "../commands/premium.js"; -import owner from "../commands/owner/index.js"; +import { commandRegistry, setupAutocomplete } from "./commandRegistry.js"; export async function interactionCreateEvent( client: Client, @@ -34,45 +24,22 @@ export async function interactionCreateEvent( const { commandName } = interaction; if (!commandName) {return;} - switch (commandName) { - case "help": - await help.execute(client, interaction); - break; - case "about": - await about.execute(client, interaction); - break; - case "changelog": - await changelog.execute(client, interaction); - break; - case "quote": - if (interaction.isChatInputCommand()) {await quote.execute(client, interaction);} - break; - case "invite": - await invite.execute(client, interaction); - break; - case "suggestion": - if (interaction.isChatInputCommand()) {await suggestion.execute(client, interaction);} - break; - case "admin": - await admin.execute(client, interaction); - break; - case "setup": - await setup.execute(client, interaction); - break; - case "premium": - await premium.execute(client, interaction); - break; - case "owner": - await owner.execute(client, interaction); - break; - default: - logger.commands.warn( - "interactionCreate", - interaction.user.username, - interaction.user.id, - "Command not found" - ); + const handler = commandRegistry[commandName]; + if (!handler) { + logger.commands.warn( + "interactionCreate", + interaction.user.username, + interaction.user.id, + "Command not found" + ); + return; + } + + if (handler.requiresChatInput && !interaction.isChatInputCommand()) { + return; } + + await handler.execute(client, interaction); } catch (err) { const cmd = interaction.isCommand() ? interaction.commandName : "unknown"; const interactionId = "id" in interaction ? interaction.id : undefined; diff --git a/src/events/ready.ts b/src/events/ready.ts index 1d3b435..624b266 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -3,20 +3,7 @@ import type { Client } from "discord.js"; import setActivity from "../worker/jobs/setActivity.js"; import { pruneGuilds, ensureGuildExists } from "../utils/guildDatabase.js"; import logger from "../utils/logger.js"; - -/** - * Import slash commands from the commands folder. - */ -import help from "../commands/help.js"; -import about from "../commands/about.js"; -import quote from "../commands/quote.js"; -import suggestion from "../commands/suggestion.js"; -import invite from "../commands/invite.js"; -import setup from "../commands/setup/index.js"; -import admin from "../commands/admin/index.js"; -import changelog from "../commands/changelog.js"; -import premium from "../commands/premium.js"; -import owner from "../commands/owner/index.js"; +import { slashCommands } from "./commandRegistry.js"; export async function readyEvent(client: Client) { try { @@ -43,18 +30,7 @@ export async function readyEvent(client: Client) { */ logger.info("Discord - Slash Commands", "Registering commands"); - await client.application?.commands.set([ - help.slashCommand, - about.slashCommand, - quote.slashCommand, - suggestion.slashCommand, - invite.slashCommand, - setup.slashCommand, - admin.slashCommand, - changelog.slashCommand, - premium.slashCommand, - owner.slashCommand, - ]); + await client.application?.commands.set(slashCommands); const commands = await client.application?.commands.fetch(); const commandNames = commands?.map((command) => command.name) || []; diff --git a/tests/events/interactionCreate.test.ts b/tests/events/interactionCreate.test.ts index 9bf41eb..e1bfa5a 100644 --- a/tests/events/interactionCreate.test.ts +++ b/tests/events/interactionCreate.test.ts @@ -7,23 +7,26 @@ const logger = mockLogger(); const executeStub = sinon.stub().resolves(); const setupAutocomplete = sinon.stub().resolves(); -// Top-level mocks to avoid cross-file interference +// Mock only the registry + logger. Command modules themselves are NOT mocked +// here, so other test files that import real command modules aren't affected +// by bun:test's process-global mock.module registry. mock.module("../../src/utils/logger.js", () => ({ default: logger })); -mock.module("../../src/commands/help.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "help" } })); -mock.module("../../src/commands/about.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "about" } })); -mock.module("../../src/commands/changelog.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "changelog" } })); -mock.module("../../src/commands/quote.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "quote" } })); -mock.module("../../src/commands/suggestion.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "suggestion" } })); -mock.module("../../src/commands/invite.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "invite" } })); -mock.module("../../src/commands/admin/index.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "admin" } })); -mock.module("../../src/commands/setup/index.js", () => ({ - default: { execute: executeStub }, - execute: executeStub, +mock.module("../../src/events/commandRegistry.js", () => ({ + commandRegistry: { + help: { execute: executeStub }, + about: { execute: executeStub }, + changelog: { execute: executeStub }, + quote: { execute: executeStub, requiresChatInput: true }, + invite: { execute: executeStub }, + suggestion: { execute: executeStub, requiresChatInput: true }, + admin: { execute: executeStub }, + setup: { execute: executeStub }, + premium: { execute: executeStub }, + owner: { execute: executeStub }, + }, setupAutocomplete, - slashCommand: { name: "setup" }, + slashCommands: Array.from({ length: 10 }, (_, i) => ({ name: `cmd${i}` })), })); -mock.module("../../src/commands/premium.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "premium" } })); -mock.module("../../src/commands/owner/index.js", () => ({ default: { execute: executeStub }, execute: executeStub, slashCommand: { name: "owner" } })); const { interactionCreateEvent } = await import("../../src/events/interactionCreate.js"); diff --git a/tests/events/ready.test.ts b/tests/events/ready.test.ts index 1b594c9..a612b7c 100644 --- a/tests/events/ready.test.ts +++ b/tests/events/ready.test.ts @@ -20,16 +20,14 @@ describe("ready event", () => { guildExists: sinon.stub().resolves(true), })); mock.module("../../src/worker/jobs/setActivity.js", () => ({ default: setActivity })); - mock.module("../../src/commands/help.js", () => ({ default: { slashCommand: { name: "help" } } })); - mock.module("../../src/commands/about.js", () => ({ default: { slashCommand: { name: "about" } } })); - mock.module("../../src/commands/quote.js", () => ({ default: { slashCommand: { name: "quote" } } })); - mock.module("../../src/commands/suggestion.js", () => ({ default: { slashCommand: { name: "suggestion" } } })); - mock.module("../../src/commands/invite.js", () => ({ default: { slashCommand: { name: "invite" } } })); - mock.module("../../src/commands/setup/index.js", () => ({ default: { slashCommand: { name: "setup" } }, setupAutocomplete: sinon.stub() })); - mock.module("../../src/commands/admin/index.js", () => ({ default: { slashCommand: { name: "admin" } } })); - mock.module("../../src/commands/changelog.js", () => ({ default: { slashCommand: { name: "changelog" } } })); - mock.module("../../src/commands/premium.js", () => ({ default: { slashCommand: { name: "premium" } } })); - mock.module("../../src/commands/owner/index.js", () => ({ default: { slashCommand: { name: "owner" } } })); + // Mock the command registry (single module) instead of each command module + // so command test files aren't poisoned by bun:test's process-global + // mock.module registry. + mock.module("../../src/events/commandRegistry.js", () => ({ + commandRegistry: {}, + setupAutocomplete: sinon.stub(), + slashCommands: Array.from({ length: 10 }, (_, i) => ({ name: `cmd${i}` })), + })); const mod = await import("../../src/events/ready.js"); return { readyEvent: mod.readyEvent, logger, pruneGuilds, ensureGuildExists, setActivity }; From b35eefd232e8a4444146e298a1f17379a4d194b9 Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:54:39 -0500 Subject: [PATCH 4/5] fix: address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health.ts: gate raw probe error details behind NODE_ENV !== production and log failures server-side via logger.error("API", ...). Note added about withTimeout not cancelling the underlying probe — acceptable for low-QPS probe cadence. - bot.ts: gate startWorker(queue) on Events.ClientReady so BullMQ cannot dequeue jobs before the Discord client is authenticated. - admin/suggestion/list.ts: reject invalid status values up front with a clear ephemeral error; base emptyMessage on validStatus so users never get a misleading "filtered" response for an ignored filter. - admin/suggestion/reject.ts: fold the Pending check into the UPDATE (WHERE id=? AND status='Pending' RETURNING id) and short-circuit when no rows were affected so concurrent rejects can't double-post the embed + DM + reply. - owner/premium/testList.ts: reserve OVERFLOW_RESERVE=32 chars in the truncation budget so "\n...and N more" cannot push the reply past Discord's 2000-char cap. - events/interactionCreate.ts: narrow with interaction.isCommand() before reply/followUp so autocomplete errors don't hit the inner catch as "Failed to send error response to user". - utils/quoteHelpers.ts: log resolveQuoteAuthor failures via logger.warn instead of silent catch. - utils/replyHelpers.ts: empty-state branch now respects the `ephemeral` option, matching the populated-list branch. Tests: new reject.test.ts case covers the zero-rows TOCTOU short-circuit. --- src/api/routes/health.ts | 22 ++++++++++- src/bot.ts | 12 ++++-- src/commands/admin/suggestion/list.ts | 14 +++++-- src/commands/admin/suggestion/reject.ts | 17 +++++++-- src/commands/owner/premium/testList.ts | 5 ++- src/events/interactionCreate.ts | 37 ++++++++++--------- src/utils/quoteHelpers.ts | 6 ++- src/utils/replyHelpers.ts | 2 +- .../commands/admin/suggestion/reject.test.ts | 27 ++++++++++++-- 9 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/api/routes/health.ts b/src/api/routes/health.ts index 541edad..02e3bae 100644 --- a/src/api/routes/health.ts +++ b/src/api/routes/health.ts @@ -2,11 +2,21 @@ import express from "express"; import { queryClient } from "../../database/index.js"; import redisClient from "../../redis/index.js"; +import env from "../../utils/env.js"; +import logger from "../../utils/logger.js"; const router: express.Router = express.Router(); const PROBE_TIMEOUT_MS = 1500; +/** + * Note: on timeout the underlying probe query keeps running until the driver + * gives up (postgres-js `connect_timeout: 10`, ioredis default command behavior). + * The health endpoint is expected to be called infrequently (Coolify/k8s probe + * cadence, seconds-apart), so a backed-up probe is tolerable. If this endpoint + * ever moves to high-QPS monitoring, switch to `postgres().cancel()` on the + * pending query and a dedicated ioredis connection with command timeout. + */ function withTimeout(promise: PromiseLike, ms: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); @@ -34,11 +44,19 @@ router.get("/", async (_req, res) => { const status = db === "ok" && redis === "ok" ? "ok" : "degraded"; const body: Record = { status, db, redis }; + const includeDetails = env.NODE_ENV !== "production"; + if (dbResult.status === "rejected") { - body["dbError"] = (dbResult.reason as Error)?.message ?? String(dbResult.reason); + logger.error("API", "Health probe failed (db)", dbResult.reason); + if (includeDetails) { + body["dbError"] = (dbResult.reason as Error)?.message ?? String(dbResult.reason); + } } if (redisResult.status === "rejected") { - body["redisError"] = (redisResult.reason as Error)?.message ?? String(redisResult.reason); + logger.error("API", "Health probe failed (redis)", redisResult.reason); + if (includeDetails) { + body["redisError"] = (redisResult.reason as Error)?.message ?? String(redisResult.reason); + } } res.status(status === "ok" ? 200 : 503).json(body); diff --git a/src/bot.ts b/src/bot.ts index e14b9ef..cdbbf3f 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -109,9 +109,15 @@ const queue = new Queue(queueName, { connection: redisClient as unknown as ConnectionOptions, }); -startWorker(queue).catch((err) => { - logger.error("Worker", "Failed to start worker", err); - process.exit(1); +// Gate worker startup on ClientReady. Otherwise BullMQ can dequeue jobs +// (e.g. send-motivation) before Discord login completes, causing +// client.channels.fetch / client.users.fetch calls inside job handlers to +// fail against an un-authenticated client. +client.once(Events.ClientReady, () => { + startWorker(queue).catch((err) => { + logger.error("Worker", "Failed to start worker", err); + process.exit(1); + }); }); export default client; diff --git a/src/commands/admin/suggestion/list.ts b/src/commands/admin/suggestion/list.ts index 7657417..ff0ccf3 100644 --- a/src/commands/admin/suggestion/list.ts +++ b/src/commands/admin/suggestion/list.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; import { eq, desc } from "drizzle-orm"; @@ -20,9 +21,14 @@ export default async function ( if (!(await isUserPermitted(interaction))) {return;} const status = options.getString("status"); - const validStatus = status && (VALID_STATUSES as string[]).includes(status) - ? (status as SuggestionStatus) - : null; + if (status && !(VALID_STATUSES as string[]).includes(status)) { + await interaction.reply({ + content: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(", ")}.`, + flags: MessageFlags.Ephemeral, + }); + return; + } + const validStatus = status ? (status as SuggestionStatus) : null; const baseQuery = db.select().from(suggestionQuotes).orderBy(desc(suggestionQuotes.createdAt)); const suggestions = validStatus @@ -35,7 +41,7 @@ export default async function ( header: "ID - Quote - Author - Status - Submitted By", formatRow: (s) => `${s.id} - ${s.quote} - ${s.author} - ${s.status} - ${s.addedBy}`, filename: "suggestions.txt", - emptyMessage: status ? `No suggestions found with status: ${status}` : "No suggestions found.", + emptyMessage: validStatus ? `No suggestions found with status: ${validStatus}` : "No suggestions found.", }); }); } diff --git a/src/commands/admin/suggestion/reject.ts b/src/commands/admin/suggestion/reject.ts index da24a11..0e78c17 100644 --- a/src/commands/admin/suggestion/reject.ts +++ b/src/commands/admin/suggestion/reject.ts @@ -2,7 +2,7 @@ import { EmbedBuilder, MessageFlags } from "discord.js"; import type { Client, CommandInteraction, CommandInteractionOptionResolver } from "discord.js"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { isUserPermitted } from "../../../utils/permissions.js"; import { db } from "../../../database/index.js"; @@ -26,14 +26,25 @@ export default async function ( const suggestion = await fetchPendingSuggestion(suggestionId, interaction); if (!suggestion) {return;} - await db + // Atomic conditional UPDATE so two concurrent rejects can't both proceed + // to post the embed + DM + reply. + const updated = await db .update(suggestionQuotes) .set({ status: "Rejected", reviewedBy: interaction.user.id, reviewedAt: new Date(), }) - .where(eq(suggestionQuotes.id, suggestionId)); + .where(and(eq(suggestionQuotes.id, suggestionId), eq(suggestionQuotes.status, "Pending"))) + .returning({ id: suggestionQuotes.id }); + + if (updated.length === 0) { + await interaction.reply({ + content: "This suggestion was just reviewed by someone else.", + flags: MessageFlags.Ephemeral, + }); + return; + } const embedFields = [ { name: "Quote", value: suggestion.quote }, diff --git a/src/commands/owner/premium/testList.ts b/src/commands/owner/premium/testList.ts index da9f3e2..1af6459 100644 --- a/src/commands/owner/premium/testList.ts +++ b/src/commands/owner/premium/testList.ts @@ -30,7 +30,10 @@ export default async function (client: Client, interaction: CommandInteraction): }); const header = `**Entitlements (${entitlements.size}):**\n`; - const maxLength = 2000 - header.length; + // Reserve headroom for the "\n...and N more" overflow marker so the + // final payload can't exceed Discord's 2000-char limit. + const OVERFLOW_RESERVE = 32; + const maxLength = 2000 - header.length - OVERFLOW_RESERVE; const truncatedLines: string[] = []; let currentLength = 0; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 6a354f4..2266058 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,6 +1,6 @@ import { MessageFlags } from "discord.js"; -import type { Client, Interaction, CommandInteraction } from "discord.js"; +import type { Client, Interaction } from "discord.js"; import logger from "../utils/logger.js"; import { commandRegistry, setupAutocomplete } from "./commandRegistry.js"; @@ -52,24 +52,27 @@ export async function interactionCreateEvent( guildId, }); - try { - const errInteraction = interaction as CommandInteraction; - if (errInteraction.replied || errInteraction.deferred) { - await errInteraction.followUp({ - content: "There was an error while executing this command!", - flags: MessageFlags.Ephemeral, - }); - } else { - await errInteraction.reply({ - content: "There was an error while executing this command!", - flags: MessageFlags.Ephemeral, + // Only command interactions are repliable; autocomplete errors are logged + // but cannot prompt a user reply. + if (interaction.isCommand()) { + try { + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: "There was an error while executing this command!", + flags: MessageFlags.Ephemeral, + }); + } else { + await interaction.reply({ + content: "There was an error while executing this command!", + flags: MessageFlags.Ephemeral, + }); + } + } catch (replyErr) { + logger.error("Discord - Command", "Failed to send error response to user", replyErr, { + interactionId, + guildId, }); } - } catch (replyErr) { - logger.error("Discord - Command", "Failed to send error response to user", replyErr, { - interactionId, - guildId, - }); } } } diff --git a/src/utils/quoteHelpers.ts b/src/utils/quoteHelpers.ts index dcb8232..58993c4 100644 --- a/src/utils/quoteHelpers.ts +++ b/src/utils/quoteHelpers.ts @@ -5,6 +5,7 @@ import { sql } from "drizzle-orm"; import { db } from "../database/index.js"; import { motivationQuotes } from "../database/schema.js"; import type { MotivationQuote } from "../database/schema.js"; +import logger from "./logger.js"; /** * Fetch a single random motivation quote in one round-trip. @@ -28,7 +29,10 @@ export async function resolveQuoteAuthor( ): Promise { try { return await client.users.fetch(addedById); - } catch { + } catch (err) { + // Log rather than swallow so transient outages, rate limits, and token + // issues are distinguishable from the expected unknown/deleted-user case. + logger.warn("Discord", "Failed to resolve quote author", { addedById, error: err }); return null; } } diff --git a/src/utils/replyHelpers.ts b/src/utils/replyHelpers.ts index b818f83..e3d42c4 100644 --- a/src/utils/replyHelpers.ts +++ b/src/utils/replyHelpers.ts @@ -27,7 +27,7 @@ export async function replyWithTextFile({ if (rows.length === 0) { await interaction.reply({ content: emptyMessage, - flags: MessageFlags.Ephemeral, + ...(ephemeral ? { flags: MessageFlags.Ephemeral } : {}), }); return; } diff --git a/tests/commands/admin/suggestion/reject.test.ts b/tests/commands/admin/suggestion/reject.test.ts index a146308..23d9052 100644 --- a/tests/commands/admin/suggestion/reject.test.ts +++ b/tests/commands/admin/suggestion/reject.test.ts @@ -74,7 +74,7 @@ describe("admin suggestion reject command", () => { status: "Pending", }); const { client, channel, submitter } = makeClient(); - db.update.returns(mockDbChain([])); + db.update.returns(mockDbChain([{ id: "s1" }])); const interaction = makeInteraction("s1", "Not appropriate"); await handler(client as never, interaction as never, interaction.options as never); @@ -97,7 +97,7 @@ describe("admin suggestion reject command", () => { status: "Pending", }); const { client, submitter } = makeClient(); - db.update.returns(mockDbChain([])); + db.update.returns(mockDbChain([{ id: "s1" }])); const interaction = makeInteraction("s1"); await handler(client as never, interaction as never, interaction.options as never); @@ -107,6 +107,27 @@ describe("admin suggestion reject command", () => { expect(dmEmbed.data.description).not.toContain("Reason"); }); + it("should short-circuit when atomic UPDATE affects zero rows (concurrent review)", async () => { + const { handler, db } = await loadModule({ + id: "s1", + quote: "Some quote", + author: "Anon", + addedBy: "user-1", + status: "Pending", + }); + const { client, channel, submitter } = makeClient(); + db.update.returns(mockDbChain([])); // zero rows — another admin beat us + + const interaction = makeInteraction("s1"); + await handler(client as never, interaction as never, interaction.options as never); + + expect(db.update.calledOnce).toBe(true); + expect(channel.send.called).toBe(false); + expect(submitter.send.called).toBe(false); + const replyArgs = (interaction.reply as sinon.SinonStub).firstCall.args[0]; + expect(replyArgs.content).toContain("just reviewed"); + }); + it("should not break if submitter DM fails", async () => { const { handler, db } = await loadModule({ id: "s1", @@ -116,7 +137,7 @@ describe("admin suggestion reject command", () => { status: "Pending", }); const { client } = makeClient(); - db.update.returns(mockDbChain([])); + db.update.returns(mockDbChain([{ id: "s1" }])); (client.users.fetch as sinon.SinonStub).rejects(new Error("Cannot send DM")); const interaction = makeInteraction("s1"); From 180624ab78265b8ee9ca8de77807cdbc0c09cc34 Mon Sep 17 00:00:00 2001 From: Nathanial Henniges <19924836+nathanialhenniges@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:59:56 -0500 Subject: [PATCH 5/5] fix(tests): stop leaking guildDatabase + suggestionHelpers mocks on CI Two more sources of bun:test cross-file mock leakage surfaced on the Ubuntu CI runner that macOS's test discovery order happened to avoid: - ready.test.ts top-level-mocked guildDatabase.js, poisoning utils/guildDatabase.test.ts. Fix: introduce src/events/readyDeps.ts (thin re-export shim for pruneGuilds/ensureGuildExists/setActivity); ready.ts imports from it; ready.test.ts mocks only the shim. - reject.test.ts top-level-mocked suggestionHelpers.js, poisoning admin/suggestion/approve.test.ts. Fix: rewrite reject.test.ts to drive fetchPendingSuggestion via db.select mocks (same pattern approve.test.ts already uses), so suggestionHelpers.js stays real for every consumer. --- src/events/ready.ts | 3 +- src/events/readyDeps.ts | 8 ++ .../commands/admin/suggestion/reject.test.ts | 75 +++++++------------ tests/events/ready.test.ts | 11 ++- 4 files changed, 40 insertions(+), 57 deletions(-) create mode 100644 src/events/readyDeps.ts diff --git a/src/events/ready.ts b/src/events/ready.ts index 624b266..f1f7478 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,9 +1,8 @@ import type { Client } from "discord.js"; -import setActivity from "../worker/jobs/setActivity.js"; -import { pruneGuilds, ensureGuildExists } from "../utils/guildDatabase.js"; import logger from "../utils/logger.js"; import { slashCommands } from "./commandRegistry.js"; +import { pruneGuilds, ensureGuildExists, setActivity } from "./readyDeps.js"; export async function readyEvent(client: Client) { try { diff --git a/src/events/readyDeps.ts b/src/events/readyDeps.ts new file mode 100644 index 0000000..427a678 --- /dev/null +++ b/src/events/readyDeps.ts @@ -0,0 +1,8 @@ +/** + * Thin re-export shim for ready.ts's dependencies. Tests for the ready event + * mock THIS module instead of the underlying shared utilities, so that + * process-global `mock.module` calls from ready.test.ts don't clobber the real + * implementations used by each utility's own test file. + */ +export { pruneGuilds, ensureGuildExists } from "../utils/guildDatabase.js"; +export { default as setActivity } from "../worker/jobs/setActivity.js"; diff --git a/tests/commands/admin/suggestion/reject.test.ts b/tests/commands/admin/suggestion/reject.test.ts index 23d9052..b90ec3f 100644 --- a/tests/commands/admin/suggestion/reject.test.ts +++ b/tests/commands/admin/suggestion/reject.test.ts @@ -7,9 +7,7 @@ describe("admin suggestion reject command", () => { sinon.restore(); }); - async function loadModule( - fetchResult: { status?: string; id?: string; quote?: string; author?: string; addedBy?: string } | null - ) { + async function loadModule() { const logger = mockLogger(); const db = mockDb(); const env = mockEnv(); @@ -19,17 +17,6 @@ describe("admin suggestion reject command", () => { mock.module("../../../../src/utils/env.js", () => ({ default: env })); mock.module("../../../../src/utils/permissions.js", () => ({ isUserPermitted: sinon.stub().returns(true) })); - // fetchPendingSuggestion mocked to return the pre-validated suggestion or null. - mock.module("../../../../src/utils/suggestionHelpers.js", () => ({ - fetchPendingSuggestion: sinon.stub().callsFake(async (_id: string, interaction: { reply: sinon.SinonStub }) => { - if (fetchResult === null) { - await interaction.reply({ content: "not found", flags: 64 }); - return null; - } - return fetchResult; - }), - })); - const mod = await import("../../../../src/commands/admin/suggestion/reject.js"); return { handler: mod.default, logger, db }; } @@ -55,10 +42,19 @@ describe("admin suggestion reject command", () => { return { client, channel, submitter }; } - it("should return early when helper reports missing/non-pending", async () => { - const { handler, db } = await loadModule(null); - const interaction = makeInteraction("nonexistent"); + const PENDING_ROW = { + id: "s1", + quote: "Bad quote", + author: "Anon", + addedBy: "user-1", + status: "Pending", + }; + it("should return early when suggestion not found", async () => { + const { handler, db } = await loadModule(); + db.select.returns(mockDbChain([])); + + const interaction = makeInteraction("nonexistent"); await handler({} as never, interaction as never, interaction.options as never); expect((interaction.reply as sinon.SinonStub).calledOnce).toBe(true); @@ -66,16 +62,11 @@ describe("admin suggestion reject command", () => { }); it("should reject suggestion with reason", async () => { - const { handler, db } = await loadModule({ - id: "s1", - quote: "Bad quote", - author: "Anon", - addedBy: "user-1", - status: "Pending", - }); - const { client, channel, submitter } = makeClient(); + const { handler, db } = await loadModule(); + db.select.returns(mockDbChain([PENDING_ROW])); db.update.returns(mockDbChain([{ id: "s1" }])); + const { client, channel, submitter } = makeClient(); const interaction = makeInteraction("s1", "Not appropriate"); await handler(client as never, interaction as never, interaction.options as never); @@ -89,16 +80,11 @@ describe("admin suggestion reject command", () => { }); it("should reject suggestion without reason (no reason in DM)", async () => { - const { handler, db } = await loadModule({ - id: "s1", - quote: "Some quote", - author: "Anon", - addedBy: "user-1", - status: "Pending", - }); - const { client, submitter } = makeClient(); + const { handler, db } = await loadModule(); + db.select.returns(mockDbChain([PENDING_ROW])); db.update.returns(mockDbChain([{ id: "s1" }])); + const { client, submitter } = makeClient(); const interaction = makeInteraction("s1"); await handler(client as never, interaction as never, interaction.options as never); @@ -108,16 +94,11 @@ describe("admin suggestion reject command", () => { }); it("should short-circuit when atomic UPDATE affects zero rows (concurrent review)", async () => { - const { handler, db } = await loadModule({ - id: "s1", - quote: "Some quote", - author: "Anon", - addedBy: "user-1", - status: "Pending", - }); - const { client, channel, submitter } = makeClient(); + const { handler, db } = await loadModule(); + db.select.returns(mockDbChain([PENDING_ROW])); db.update.returns(mockDbChain([])); // zero rows — another admin beat us + const { client, channel, submitter } = makeClient(); const interaction = makeInteraction("s1"); await handler(client as never, interaction as never, interaction.options as never); @@ -129,15 +110,11 @@ describe("admin suggestion reject command", () => { }); it("should not break if submitter DM fails", async () => { - const { handler, db } = await loadModule({ - id: "s1", - quote: "Some quote", - author: "Anon", - addedBy: "user-1", - status: "Pending", - }); - const { client } = makeClient(); + const { handler, db } = await loadModule(); + db.select.returns(mockDbChain([PENDING_ROW])); db.update.returns(mockDbChain([{ id: "s1" }])); + + const { client } = makeClient(); (client.users.fetch as sinon.SinonStub).rejects(new Error("Cannot send DM")); const interaction = makeInteraction("s1"); diff --git a/tests/events/ready.test.ts b/tests/events/ready.test.ts index a612b7c..b5fba5d 100644 --- a/tests/events/ready.test.ts +++ b/tests/events/ready.test.ts @@ -14,15 +14,14 @@ describe("ready event", () => { const setActivity = sinon.stub().resolves(); mock.module("../../src/utils/logger.js", () => ({ default: logger })); - mock.module("../../src/utils/guildDatabase.js", () => ({ + // Mock the thin dep-shim instead of guildDatabase.js / setActivity.js so + // utility test files aren't poisoned by bun:test's process-global + // mock.module registry. + mock.module("../../src/events/readyDeps.js", () => ({ pruneGuilds, ensureGuildExists, - guildExists: sinon.stub().resolves(true), + setActivity, })); - mock.module("../../src/worker/jobs/setActivity.js", () => ({ default: setActivity })); - // Mock the command registry (single module) instead of each command module - // so command test files aren't poisoned by bun:test's process-global - // mock.module registry. mock.module("../../src/events/commandRegistry.js", () => ({ commandRegistry: {}, setupAutocomplete: sinon.stub(),