forked from syntaxbullet/AuroraBot-discord
feat(dashboard): implement bot settings page with partial updates and serialization fixes
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { jsonReplacer } from './utils';
|
||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
@@ -191,14 +192,7 @@ export function saveConfig(newConfig: unknown) {
|
||||
// Validate and transform input
|
||||
const validatedConfig = configSchema.parse(newConfig);
|
||||
|
||||
const replacer = (key: string, value: any) => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(validatedConfig, replacer, 4);
|
||||
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
||||
writeFileSync(configPath, jsonString, 'utf-8');
|
||||
reloadConfig();
|
||||
}
|
||||
|
||||
@@ -9,3 +9,42 @@ import type { Command } from "./types";
|
||||
export function createCommand(command: Command): Command {
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Replacer function for serialization
|
||||
* Handles safe serialization of BigInt values to strings
|
||||
*/
|
||||
export const jsonReplacer = (_key: string, value: unknown): unknown => {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep merge utility
|
||||
*/
|
||||
export function deepMerge(target: any, source: any): any {
|
||||
if (typeof target !== 'object' || target === null) {
|
||||
return source;
|
||||
}
|
||||
if (typeof source !== 'object' || source === null) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
if (!(key in target)) {
|
||||
Object.assign(output, { [key]: source[key] });
|
||||
} else {
|
||||
output[key] = deepMerge(target[key], source[key]);
|
||||
}
|
||||
} else {
|
||||
Object.assign(output, { [key]: source[key] });
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,480 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Save, RefreshCw, Smartphone, Coins, Trophy, Shield, Users, Terminal, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
// Types matching the backend response
|
||||
interface RoleOption { id: string; name: string; color: string; }
|
||||
interface ChannelOption { id: string; name: string; type: number; }
|
||||
|
||||
interface SettingsMeta {
|
||||
roles: RoleOption[];
|
||||
channels: ChannelOption[];
|
||||
commands: string[];
|
||||
}
|
||||
|
||||
import { type GameConfigType } from "@shared/lib/config";
|
||||
|
||||
// Recursive partial type for nested updates
|
||||
type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
export function Settings() {
|
||||
// We use a DeepPartial type for the local form state to allow for safe updates
|
||||
const [config, setConfig] = useState<DeepPartial<GameConfigType> | null>(null);
|
||||
const [meta, setMeta] = useState<SettingsMeta>({ roles: [], channels: [], commands: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configRes, metaRes] = await Promise.all([
|
||||
fetch("/api/settings"),
|
||||
fetch("/api/settings/meta")
|
||||
]);
|
||||
|
||||
if (configRes.ok && metaRes.ok) {
|
||||
const configData = await configRes.json();
|
||||
const metaData = await metaRes.json();
|
||||
setConfig(configData);
|
||||
setMeta(metaData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to save");
|
||||
// Reload to satisfy any server-side transformations
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to update nested state
|
||||
const updateConfig = (path: string, value: any) => {
|
||||
if (!path) return;
|
||||
setConfig((prev: any) => {
|
||||
const newConfig = { ...prev };
|
||||
const parts = path.split('.');
|
||||
let current = newConfig;
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
if (part === undefined) return prev;
|
||||
// Clone nested objects to ensure immutability
|
||||
if (!current[part]) current[part] = {};
|
||||
current[part] = { ...current[part] };
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (lastPart !== undefined) {
|
||||
current[lastPart] = value;
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading || !config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[50vh]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "general", label: "General", icon: Smartphone },
|
||||
{ id: "economy", label: "Economy", icon: Coins },
|
||||
{ id: "leveling", label: "Leveling", icon: Trophy },
|
||||
{ id: "moderation", label: "Moderation", icon: Shield },
|
||||
{ id: "roles", label: "Roles", icon: Users },
|
||||
{ id: "system", label: "System", icon: Terminal },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
|
||||
<p className="text-muted-foreground">Manage bot configuration.</p>
|
||||
<div className="mt-6 rounded-xl border border-dashed p-8 text-center text-muted-foreground">
|
||||
Settings panel coming soon...
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white drop-shadow-md">Settings</h2>
|
||||
<p className="text-white/60">Manage global bot configuration</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchData} className="glass border-white/10 hover:bg-white/10">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-primary hover:bg-primary/80 text-primary-foreground shadow-[0_0_15px_rgba(var(--primary),0.3)]">
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all border ${activeTab === tab.id
|
||||
? "bg-primary/20 border-primary/50 text-white shadow-[0_0_10px_rgba(var(--primary),0.2)]"
|
||||
: "bg-white/5 border-white/5 text-white/50 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="glass border-white/10">
|
||||
<CardContent className="p-6">
|
||||
{activeTab === "general" && (
|
||||
<div className="space-y-6">
|
||||
<SectionTitle icon={MessageSquare} title="Channels" description="Default channels for bot interactions" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Welcome Channel"
|
||||
value={config?.welcomeChannelId}
|
||||
options={meta.channels}
|
||||
onChange={(v) => updateConfig("welcomeChannelId", v)}
|
||||
/>
|
||||
<SelectField
|
||||
label="Feedback Channel"
|
||||
value={config?.feedbackChannelId}
|
||||
options={meta.channels}
|
||||
onChange={(v) => updateConfig("feedbackChannelId", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle icon={Terminal} title="Terminal" description="Dedicated channel for CLI-like interactions" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Terminal Channel"
|
||||
value={config?.terminal?.channelId}
|
||||
options={meta.channels}
|
||||
onChange={(v) => updateConfig("terminal.channelId", v)}
|
||||
/>
|
||||
<InputField
|
||||
label="Terminal Message ID"
|
||||
value={config?.terminal?.messageId}
|
||||
onChange={(v) => updateConfig("terminal.messageId", v)}
|
||||
placeholder="ID of the pinned message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "economy" && (
|
||||
<div className="space-y-6">
|
||||
<SectionTitle icon={Coins} title="Daily Rewards" description="Configure daily currency claims" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<InputField
|
||||
label="Base Amount"
|
||||
type="number"
|
||||
value={config?.economy?.daily?.amount}
|
||||
onChange={(v) => updateConfig("economy.daily.amount", v)}
|
||||
/>
|
||||
<InputField
|
||||
label="Streak Bonus"
|
||||
type="number"
|
||||
value={config?.economy?.daily?.streakBonus}
|
||||
onChange={(v) => updateConfig("economy.daily.streakBonus", v)}
|
||||
/>
|
||||
<InputField
|
||||
label="Weekly Bonus"
|
||||
type="number"
|
||||
value={config?.economy?.daily?.weeklyBonus}
|
||||
onChange={(v) => updateConfig("economy.daily.weeklyBonus", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle icon={RefreshCw} title="Transfers" description="Rules for player-to-player transfers" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Minimum Amount"
|
||||
type="number"
|
||||
value={config?.economy?.transfers?.minAmount}
|
||||
onChange={(v) => updateConfig("economy.transfers.minAmount", v)}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-white/5 border border-white/5">
|
||||
<span className="text-sm font-medium">Allow Self Transfer</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config?.economy?.transfers?.allowSelfTransfer ?? false}
|
||||
onChange={(e) => updateConfig("economy.transfers.allowSelfTransfer", e.target.checked)}
|
||||
className="h-5 w-5 rounded border-white/10 bg-white/5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionTitle icon={Trophy} title="Lootdrops" description="Random event configuration" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<InputField
|
||||
label="Spawn Chance"
|
||||
type="number"
|
||||
value={config?.lootdrop?.spawnChance}
|
||||
onChange={(v) => updateConfig("lootdrop.spawnChance", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Cooldown (ms)"
|
||||
type="number"
|
||||
value={config?.lootdrop?.cooldownMs}
|
||||
onChange={(v) => updateConfig("lootdrop.cooldownMs", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Min Messages"
|
||||
type="number"
|
||||
value={config?.lootdrop?.minMessages}
|
||||
onChange={(v) => updateConfig("lootdrop.minMessages", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Reward Min"
|
||||
type="number"
|
||||
value={config?.lootdrop?.reward?.min}
|
||||
onChange={(v) => updateConfig("lootdrop.reward.min", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Reward Max"
|
||||
type="number"
|
||||
value={config?.lootdrop?.reward?.max}
|
||||
onChange={(v) => updateConfig("lootdrop.reward.max", Number(v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "leveling" && (
|
||||
<div className="space-y-6">
|
||||
<SectionTitle icon={Trophy} title="XP Formula" description="Calculate level requirements" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Base XP"
|
||||
type="number"
|
||||
value={config?.leveling?.base}
|
||||
onChange={(v) => updateConfig("leveling.base", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Exponent"
|
||||
type="number"
|
||||
value={config?.leveling?.exponent}
|
||||
onChange={(v) => updateConfig("leveling.exponent", Number(v))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle icon={MessageSquare} title="Chat XP" description="XP gained from text messages" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<InputField
|
||||
label="Min XP"
|
||||
type="number"
|
||||
value={config?.leveling?.chat?.minXp}
|
||||
onChange={(v) => updateConfig("leveling.chat.minXp", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Max XP"
|
||||
type="number"
|
||||
value={config?.leveling?.chat?.maxXp}
|
||||
onChange={(v) => updateConfig("leveling.chat.maxXp", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Cooldown (ms)"
|
||||
type="number"
|
||||
value={config?.leveling?.chat?.cooldownMs}
|
||||
onChange={(v) => updateConfig("leveling.chat.cooldownMs", Number(v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "roles" && (
|
||||
<div className="space-y-6">
|
||||
<SectionTitle icon={Users} title="System Roles" description="Map discord roles to bot functions" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Student Role"
|
||||
value={config?.studentRole}
|
||||
options={meta.roles}
|
||||
onChange={(v) => updateConfig("studentRole", v)}
|
||||
/>
|
||||
<SelectField
|
||||
label="Visitor Role"
|
||||
value={config?.visitorRole}
|
||||
options={meta.roles}
|
||||
onChange={(v) => updateConfig("visitorRole", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle icon={Terminal} title="Color Roles" description="Roles that players can buy/equip for username colors" />
|
||||
{/* Multi-select for color roles is complex, simpler impl for now */}
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/5 space-y-2">
|
||||
<p className="text-sm text-white/60 mb-2">Available Color Roles</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(config?.colorRoles || []).map((roleId: string | undefined) => {
|
||||
if (!roleId) return null;
|
||||
const role = meta.roles.find(r => r.id === roleId);
|
||||
return (
|
||||
<span key={roleId} className="px-2 py-1 rounded bg-white/10 text-xs flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: role?.color || '#999' }} />
|
||||
{role?.name || roleId}
|
||||
<button
|
||||
onClick={() => updateConfig("colorRoles", (config?.colorRoles || []).filter((id: string | undefined) => id !== roleId))}
|
||||
className="hover:text-red-400 ml-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
className="w-full bg-black/20 border border-white/10 rounded-md p-2 text-sm text-white mt-2"
|
||||
onChange={(e) => {
|
||||
if (e.target.value && !(config?.colorRoles || []).includes(e.target.value)) {
|
||||
updateConfig("colorRoles", [...(config?.colorRoles || []), e.target.value]);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">+ Add Color Role</option>
|
||||
{meta.roles.map(r => (
|
||||
<option key={r.id} value={r.id} style={{ color: r.color }}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "moderation" && (
|
||||
<div className="space-y-6">
|
||||
<SectionTitle icon={Shield} title="Pruning" description="Batch message deletion config" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
label="Max Prune Amount"
|
||||
type="number"
|
||||
value={config?.moderation?.prune?.maxAmount}
|
||||
onChange={(v) => updateConfig("moderation.prune.maxAmount", Number(v))}
|
||||
/>
|
||||
<InputField
|
||||
label="Confirmation Threshold"
|
||||
type="number"
|
||||
value={config?.moderation?.prune?.confirmThreshold}
|
||||
onChange={(v) => updateConfig("moderation.prune.confirmThreshold", Number(v))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "system" && (
|
||||
<div className="space-y-6">
|
||||
<SectionTitle icon={Terminal} title="Commands" description="Enable or disable specific bot commands" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{meta.commands.map(cmd => (
|
||||
<div key={cmd} className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">/{cmd}</span>
|
||||
<span className="text-[10px] text-white/40">
|
||||
{config?.commands?.[cmd] === false ? "Disabled" : "Enabled"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config?.commands?.[cmd] !== false}
|
||||
onChange={(e) => updateConfig(`commands.${cmd}`, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-white/10 bg-white/5 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{meta.commands.length === 0 && (
|
||||
<div className="col-span-full text-center py-8 text-white/30">
|
||||
No commands found in metadata.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sub-components for cleaner code
|
||||
|
||||
function SectionTitle({ icon: Icon, title, description }: { icon: any, title: string, description: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-b border-white/5 pb-2 mb-4">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white leading-none">{title}</h3>
|
||||
<p className="text-sm text-white/40 mt-1">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputField({ label, value, onChange, type = "text", placeholder }: { label: string, value: any, onChange: (val: string) => void, type?: string, placeholder?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-white/60 ml-1">{label}</label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="bg-black/20 border-white/10 text-white focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, onChange }: { label: string, value: string | undefined, options: any[], onChange: (val: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-white/60 ml-1">{label}</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-black/20 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 text-white appearance-none"
|
||||
>
|
||||
<option value="" className="bg-zinc-900 text-white/50">Select...</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.id} value={opt.id} className="bg-zinc-900">
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-white/50">
|
||||
<svg className="h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
160
web/src/server.settings.test.ts
Normal file
160
web/src/server.settings.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it, mock, beforeEach, afterEach, jest } from "bun:test";
|
||||
import { type WebServerInstance } from "./server";
|
||||
|
||||
// Mock the dependencies
|
||||
const mockConfig = {
|
||||
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 },
|
||||
exam: { multMin: 1.5, multMax: 2.5 }
|
||||
},
|
||||
inventory: { maxStackSize: 99n, maxSlots: 20 },
|
||||
lootdrop: {
|
||||
spawnChance: 0.1,
|
||||
cooldownMs: 3600000,
|
||||
minMessages: 10,
|
||||
reward: { min: 100, max: 500, currency: "gold" }
|
||||
},
|
||||
commands: { "help": true },
|
||||
system: {},
|
||||
moderation: {
|
||||
prune: { maxAmount: 100, confirmThreshold: 50, batchSize: 100, batchDelayMs: 1000 },
|
||||
cases: { dmOnWarn: true }
|
||||
}
|
||||
};
|
||||
|
||||
const mockSaveConfig = jest.fn();
|
||||
|
||||
// Mock @shared/lib/config using mock.module
|
||||
mock.module("@shared/lib/config", () => ({
|
||||
config: mockConfig,
|
||||
saveConfig: mockSaveConfig,
|
||||
GameConfigType: {}
|
||||
}));
|
||||
|
||||
// Mock BotClient
|
||||
const mockGuild = {
|
||||
roles: {
|
||||
cache: [
|
||||
{ id: "role1", name: "Admin", hexColor: "#ffffff", position: 1 },
|
||||
{ id: "role2", name: "User", hexColor: "#000000", position: 0 }
|
||||
]
|
||||
},
|
||||
channels: {
|
||||
cache: [
|
||||
{ id: "chan1", name: "general", type: 0 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
mock.module("../../bot/lib/BotClient", () => ({
|
||||
AuroraClient: {
|
||||
guilds: {
|
||||
cache: {
|
||||
get: () => mockGuild
|
||||
}
|
||||
},
|
||||
commands: [
|
||||
{ data: { name: "ping" } }
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
mock.module("@shared/lib/env", () => ({
|
||||
env: {
|
||||
DISCORD_GUILD_ID: "123456789"
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock spawn
|
||||
mock.module("bun", () => {
|
||||
return {
|
||||
spawn: jest.fn(() => ({
|
||||
unref: () => { }
|
||||
})),
|
||||
serve: Bun.serve
|
||||
};
|
||||
});
|
||||
|
||||
// Import createWebServer after mocks
|
||||
import { createWebServer } from "./server";
|
||||
|
||||
describe("Settings API", () => {
|
||||
let serverInstance: WebServerInstance;
|
||||
const PORT = 3009;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
serverInstance = await createWebServer({ port: PORT });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (serverInstance) {
|
||||
await serverInstance.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /api/settings should return current configuration", async () => {
|
||||
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
|
||||
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 res = await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(partialConfig)
|
||||
});
|
||||
|
||||
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
|
||||
}));
|
||||
});
|
||||
|
||||
it("POST /api/settings should return 400 when save fails", async () => {
|
||||
mockSaveConfig.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
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.details).toBe("Validation failed");
|
||||
});
|
||||
|
||||
it("GET /api/settings/meta should return simplified metadata", async () => {
|
||||
const res = await fetch(`${BASE_URL}/api/settings/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
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 });
|
||||
expect(data.commands).toContain("ping");
|
||||
});
|
||||
});
|
||||
@@ -168,6 +168,71 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Management
|
||||
if (url.pathname === "/api/settings") {
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const { config } = await import("@shared/lib/config");
|
||||
const { jsonReplacer } = await import("@shared/lib/utils");
|
||||
return new Response(JSON.stringify(config, jsonReplacer), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
const partialConfig = await req.json();
|
||||
const { saveConfig, config: currentConfig } = await import("@shared/lib/config");
|
||||
const { deepMerge } = await import("@shared/lib/utils");
|
||||
|
||||
// Merge partial update into current config
|
||||
const mergedConfig = deepMerge(currentConfig, partialConfig);
|
||||
|
||||
// saveConfig throws if validation fails
|
||||
saveConfig(mergedConfig);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Settings error:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to process settings request", details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/settings/meta") {
|
||||
try {
|
||||
const { AuroraClient } = await import("../../bot/lib/BotClient");
|
||||
const { env } = await import("@shared/lib/env");
|
||||
|
||||
if (!env.DISCORD_GUILD_ID) {
|
||||
return Response.json({ roles: [], channels: [] });
|
||||
}
|
||||
|
||||
const guild = AuroraClient.guilds.cache.get(env.DISCORD_GUILD_ID);
|
||||
if (!guild) {
|
||||
return Response.json({ roles: [], channels: [] });
|
||||
}
|
||||
|
||||
// Map roles and channels to a simplified format
|
||||
const roles = guild.roles.cache
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map(r => ({ id: r.id, name: r.name, color: r.hexColor }));
|
||||
|
||||
const channels = guild.channels.cache
|
||||
.map(c => ({ id: c.id, name: c.name, type: c.type }));
|
||||
|
||||
const commands = AuroraClient.commands.map(c => c.data.name);
|
||||
|
||||
return Response.json({ roles, channels, commands });
|
||||
} catch (error) {
|
||||
console.error("Error fetching settings meta:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch metadata" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Static File Serving
|
||||
let pathName = url.pathname;
|
||||
if (pathName === "/") pathName = "/index.html";
|
||||
|
||||
Reference in New Issue
Block a user