chore: improve DX scripts, fix test suite, and harden tooling
All checks were successful
Deploy to Production / test (push) Successful in 32s

Scripts:
- remote.sh: remove unused open_browser() function
- deploy-remote.sh: add DB backup before deploy, --skip-backup flag, step numbering
- db-backup.sh: fix macOS compat (xargs -r is GNU-only), use portable approach
- db-restore.sh: add safety backup before restore, SQL file validation, file size display
- logs.sh: default to no-follow with --tail=100, order-independent arg parsing
- docker-cleanup.sh: add Docker health check, colored output
- test-sequential.sh: exclude *.integration.test.ts by default, add --integration flag
- simulate-ci.sh: pass --integration flag (has real DB)

Tests:
- db.test.ts: fix mock path from ./DrizzleClient to @shared/db/DrizzleClient
- server.settings.test.ts: rewrite mocks for gameSettingsService (old config/saveConfig removed)
- server.test.ts: add missing config.lootdrop and BotClient mocks, complete DrizzleClient chain
- indexes.test.ts: rename to indexes.integration.test.ts (requires live DB)

Config:
- package.json: test script uses sequential runner, add test:ci and db:restore aliases
- deploy.yml: use --integration flag in CI (has Postgres service)
This commit is contained in:
syntaxbullet
2026-02-13 14:39:02 +01:00
parent f822d90dd3
commit aca5538d57
16 changed files with 263 additions and 119 deletions

View File

@@ -1,40 +1,57 @@
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
import { type WebServerInstance } from "./server";
// Mock the dependencies
const mockConfig = {
// Mock gameSettingsService — the route now uses this instead of config/saveConfig
const mockSettings = {
leveling: {
base: 100,
exponent: 1.5,
chat: { minXp: 10, maxXp: 20, cooldownMs: 60000 }
},
economy: {
daily: { amount: 100n, streakBonus: 10n, weeklyBonus: 50n, cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: 50n },
daily: { amount: "100", streakBonus: "10", weeklyBonus: "50", cooldownMs: 86400000 },
transfers: { allowSelfTransfer: false, minAmount: "1" },
exam: { multMin: 1.5, multMax: 2.5 }
},
inventory: { maxStackSize: 99n, maxSlots: 20 },
inventory: { maxStackSize: "99", maxSlots: 20 },
lootdrop: {
spawnChance: 0.1,
cooldownMs: 3600000,
minMessages: 10,
activityWindowMs: 300000,
reward: { min: 100, max: 500, currency: "gold" }
},
commands: { "help": true },
system: {},
moderation: {
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
cases: { dmOnWarn: true }
},
trivia: {
entryFee: "50",
rewardMultiplier: 1.5,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: "random"
}
};
const mockSaveConfig = jest.fn();
const mockGetSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockUpsertSettings = jest.fn(() => Promise.resolve(mockSettings));
const mockGetDefaults = jest.fn(() => mockSettings);
// Mock @shared/lib/config using mock.module
mock.module("@shared/lib/config", () => ({
config: mockConfig,
saveConfig: mockSaveConfig,
GameConfigType: {}
mock.module("@shared/modules/game-settings/game-settings.service", () => ({
gameSettingsService: {
getSettings: mockGetSettings,
upsertSettings: mockUpsertSettings,
getDefaults: mockGetDefaults,
invalidateCache: jest.fn(),
}
}));
// Mock DrizzleClient (dependency potentially imported transitively)
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {}
}));
// Mock @shared/lib/utils (deepMerge is used by settings API)
@@ -104,6 +121,8 @@ describe("Settings API", () => {
beforeEach(async () => {
jest.clearAllMocks();
mockGetSettings.mockImplementation(() => Promise.resolve(mockSettings));
mockUpsertSettings.mockImplementation(() => Promise.resolve(mockSettings));
serverInstance = await createWebServer({ port: PORT, hostname: HOSTNAME });
});
@@ -117,18 +136,14 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings`);
expect(res.status).toBe(200);
const data = await res.json();
// Check if BigInts are converted to strings
const data = await res.json() as any;
// Check values come through correctly
expect(data.economy.daily.amount).toBe("100");
expect(data.leveling.base).toBe(100);
});
it("POST /api/settings should save valid configuration via merge", async () => {
// We only send a partial update, expecting the server to merge it
// Note: For now the server implementation might still default to overwrite if we haven't updated it yet.
// But the user requested "partial vs full" fix.
// Let's assume we implement the merge logic.
const partialConfig = { studentRole: "new-role-partial" };
const partialConfig = { economy: { daily: { amount: "200" } } };
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
@@ -137,26 +152,27 @@ describe("Settings API", () => {
});
expect(res.status).toBe(200);
// Expect saveConfig to be called with the MERGED result
expect(mockSaveConfig).toHaveBeenCalledWith(expect.objectContaining({
studentRole: "new-role-partial",
leveling: mockConfig.leveling // Should keep existing values
}));
// upsertSettings should be called with the partial config
expect(mockUpsertSettings).toHaveBeenCalledWith(
expect.objectContaining({
economy: { daily: { amount: "200" } }
})
);
});
it("POST /api/settings should return 400 when save fails", async () => {
mockSaveConfig.mockImplementationOnce(() => {
mockUpsertSettings.mockImplementationOnce(() => {
throw new Error("Validation failed");
});
const res = await fetch(`${BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) // Empty might be valid partial, but mocks throw
body: JSON.stringify({})
});
expect(res.status).toBe(400);
const data = await res.json();
const data = await res.json() as any;
expect(data.details).toBe("Validation failed");
});
@@ -164,7 +180,7 @@ describe("Settings API", () => {
const res = await fetch(`${BASE_URL}/api/settings/meta`);
expect(res.status).toBe(200);
const data = await res.json();
const data = await res.json() as any;
expect(data.roles).toHaveLength(2);
expect(data.roles[0]).toEqual({ id: "role1", name: "Admin", color: "#ffffff" });
expect(data.channels[0]).toEqual({ id: "chan1", name: "general", type: 0 });

View File

@@ -1,6 +1,5 @@
import { describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
@@ -13,21 +12,21 @@ interface MockBotStats {
}
// 1. Mock DrizzleClient (dependency of dashboardService)
// Must provide full chainable builder for select().from().leftJoin().groupBy().orderBy().limit()
mock.module("@shared/db/DrizzleClient", () => {
const mockBuilder = {
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
orderBy: mock(() => mockBuilder), // Chainable
limit: mock(() => Promise.resolve([])), // Terminal
};
const mockFrom = {
from: mock(() => mockBuilder),
};
const mockBuilder: Record<string, any> = {};
// Every chainable method returns mock builder; terminal calls return resolved promise
mockBuilder.where = mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]));
mockBuilder.then = (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]);
mockBuilder.orderBy = mock(() => mockBuilder);
mockBuilder.limit = mock(() => Promise.resolve([]));
mockBuilder.leftJoin = mock(() => mockBuilder);
mockBuilder.groupBy = mock(() => mockBuilder);
mockBuilder.from = mock(() => mockBuilder);
return {
DrizzleClient: {
select: mock(() => mockFrom),
select: mock(() => mockBuilder),
query: {
transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) },
@@ -54,7 +53,31 @@ mock.module("../../bot/lib/clientStats", () => ({
})),
}));
// 3. System Events (No mock needed, use real events)
// 3. Mock config (used by lootdrop.service.getLootdropState)
mock.module("@shared/lib/config", () => ({
config: {
lootdrop: {
activityWindowMs: 120000,
minMessages: 1,
spawnChance: 1,
cooldownMs: 3000,
reward: { min: 40, max: 150, currency: "Astral Units" }
}
}
}));
// 4. Mock BotClient (used by stats helper for maintenanceMode)
mock.module("../../bot/lib/BotClient", () => ({
AuroraClient: {
maintenanceMode: false,
guilds: { cache: { get: () => null } },
commands: [],
knownCommands: new Map(),
}
}));
// Import after all mocks are set up
import { createWebServer } from "./server";
describe("WebServer Security & Limits", () => {
const port = 3001;