Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion __tests__/services/config-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,27 +69,56 @@ describe('Config Schema', () => {
});

describe('settingsMetadata', () => {
it('has a non-empty label, description, and category for every key in defaultConfig', () => {
it('has a non-empty label, description, category, and type for every key in defaultConfig', () => {
const missingLabel: string[] = [];
const missingDescription: string[] = [];
const missingCategory: string[] = [];
const missingType: string[] = [];
for (const key of Object.keys(defaultConfig)) {
const meta = settingsMetadata[key as keyof typeof settingsMetadata];
if (!meta) {
missingLabel.push(key);
missingDescription.push(key);
missingCategory.push(key);
missingType.push(key);
continue;
}
if (!meta.label || meta.label.trim() === '') missingLabel.push(key);
if (!meta.description || meta.description.trim() === '')
missingDescription.push(key);
if (!meta.category || meta.category.trim() === '')
missingCategory.push(key);
if (!meta.type || (meta.type as string).trim() === '')
missingType.push(key);
}
expect(missingLabel).toEqual([]);
expect(missingDescription).toEqual([]);
expect(missingCategory).toEqual([]);
expect(missingType).toEqual([]);
});

it('declares a `type` consistent with the runtime defaultConfig value shape', () => {
// The schema-declared type must not contradict the runtime shape:
// a `boolean`-typed key has a boolean default, a `number`-typed key
// has a numeric default, and every other kind ("string", "cron",
// "channel"/"category"/"role" and their list variants) stores a
// string. Catches accidental drift between the declared metadata and
// the underlying default value.
const mismatches: string[] = [];
for (const [key, defaultValue] of Object.entries(defaultConfig)) {
const meta = settingsMetadata[key as keyof typeof settingsMetadata];
if (!meta) continue;
const dv = typeof defaultValue;
if (meta.type === 'boolean' && dv !== 'boolean') mismatches.push(key);
else if (meta.type === 'number' && dv !== 'number') mismatches.push(key);
else if (
meta.type !== 'boolean' &&
meta.type !== 'number' &&
dv !== 'string'
)
mismatches.push(key);
}
expect(mismatches).toEqual([]);
});

it('does not have stale entries for keys that no longer exist in defaultConfig', () => {
Expand Down
248 changes: 248 additions & 0 deletions __tests__/web/admin-views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,254 @@ describe("renderSettingsPage", () => {
/type="checkbox" name="value" value="true" checked/,
);
});

it("renders a channel-type setting as a single-select dropdown populated from textChannels", () => {
const html = renderSettingsPage({
...COMMON,
textChannels: [
{ id: "111", name: "general" },
{ id: "222", name: "voice-stats" },
],
categoryChannels: [],
roles: [],
groups: [
{
category: "voicetracking",
rows: [
{
key: "voicetracking.announcements.channel_id",
label: "Announcement channel",
current: "222",
defaultValue: "",
type: "channel",
description: "",
category: "voicetracking",
},
],
},
],
});
expect(html).toContain('<select name="value">');
expect(html).toContain('<option value="111">#general</option>');
expect(html).toContain('<option value="222" selected>#voice-stats</option>');
});

it("renders a category-type setting from categoryChannels", () => {
const html = renderSettingsPage({
...COMMON,
textChannels: [],
categoryChannels: [{ id: "cat-1", name: "Voice Channels" }],
roles: [],
groups: [
{
category: "voicechannels",
rows: [
{
key: "voicechannels.category.name",
label: "Managed category",
current: "cat-1",
defaultValue: "",
type: "category",
description: "",
category: "voicechannels",
},
],
},
],
});
expect(html).toContain(
'<option value="cat-1" selected>#Voice Channels</option>',
);
});

it("renders a role-type setting from roles with the @ prefix", () => {
const html = renderSettingsPage({
...COMMON,
textChannels: [],
categoryChannels: [],
roles: [
{ id: "r1", name: "Admin" },
{ id: "r2", name: "Member" },
],
groups: [
{
category: "amikool",
rows: [
{
key: "amikool.role_id",
label: "Kool role",
current: "r1",
defaultValue: "",
type: "role",
description: "",
category: "amikool",
},
],
},
],
});
expect(html).toContain('<option value="r1" selected>@Admin</option>');
expect(html).toContain('<option value="r2">@Member</option>');
});

it("renders a channel_list-type setting as a multi-select with CSV pre-selection", () => {
const html = renderSettingsPage({
...COMMON,
textChannels: [
{ id: "111", name: "general" },
{ id: "222", name: "afk" },
{ id: "333", name: "other" },
],
categoryChannels: [],
roles: [],
groups: [
{
category: "voicetracking",
rows: [
{
key: "voicetracking.excluded_channels",
label: "Excluded channels",
current: "111,333",
defaultValue: "",
type: "channel_list",
description: "",
category: "voicetracking",
},
],
},
],
});
expect(html).toMatch(/<select name="value" multiple/);
expect(html).toContain('<option value="111" selected>#general</option>');
expect(html).toContain('<option value="222">#afk</option>');
expect(html).toContain('<option value="333" selected>#other</option>');
});

it("renders a role_list-type setting as a multi-select with CSV pre-selection", () => {
const html = renderSettingsPage({
...COMMON,
textChannels: [],
categoryChannels: [],
roles: [
{ id: "r1", name: "Admin" },
{ id: "r2", name: "Mod" },
],
groups: [
{
category: "quotes",
rows: [
{
key: "quotes.delete_roles",
label: "Roles allowed to delete quotes",
current: "r2",
defaultValue: "",
type: "role_list",
description: "",
category: "quotes",
},
],
},
],
});
expect(html).toMatch(/<select name="value" multiple/);
expect(html).toContain('<option value="r1">@Admin</option>');
expect(html).toContain('<option value="r2" selected>@Mod</option>');
});

it("surfaces stored IDs that aren't in the live options as `(missing) <id>` and keeps them selected", () => {
// The configured channel was deleted from Discord (or the bot's
// cache is stale). The dropdown must still preserve the stored value
// so saving the form doesn't silently clear the setting.
const singleSelect = renderSettingsPage({
...COMMON,
textChannels: [{ id: "111", name: "general" }],
categoryChannels: [],
roles: [],
groups: [
{
category: "voicetracking",
rows: [
{
key: "voicetracking.announcements.channel_id",
label: "Announcement channel",
current: "999-deleted",
defaultValue: "",
type: "channel",
description: "",
category: "voicetracking",
},
],
},
],
});
expect(singleSelect).toContain(
'<option value="999-deleted" selected>(missing) 999-deleted</option>',
);
// And the "(none)" placeholder should NOT be the selected one in this
// case — the missing-ID option carries the selection.
expect(singleSelect).toMatch(/<option value=""[^>]*>\(none\)<\/option>/);
expect(singleSelect).not.toMatch(
/<option value="" selected>\(none\)<\/option>/,
);

// Multi-select case: one known + one missing.
const multiSelect = renderSettingsPage({
...COMMON,
textChannels: [{ id: "111", name: "general" }],
categoryChannels: [],
roles: [],
groups: [
{
category: "voicetracking",
rows: [
{
key: "voicetracking.excluded_channels",
label: "Excluded channels",
current: "111,999-deleted",
defaultValue: "",
type: "channel_list",
description: "",
category: "voicetracking",
},
],
},
],
});
expect(multiSelect).toContain(
'<option value="111" selected>#general</option>',
);
expect(multiSelect).toContain(
'<option value="999-deleted" selected>(missing) 999-deleted</option>',
);
});

it("falls back to a text input for cron-type settings (placeholder until #444)", () => {
const html = renderSettingsPage({
...COMMON,
textChannels: [],
categoryChannels: [],
roles: [],
groups: [
{
category: "voicetracking",
rows: [
{
key: "voicetracking.announcements.schedule",
label: "Schedule",
current: "0 16 * * 5",
defaultValue: "0 16 * * 5",
type: "cron",
description: "",
category: "voicetracking",
},
],
},
],
});
expect(html).toMatch(
/<input type="text" name="value" value="0 16 \* \* 5"/,
);
});
});

describe("renderPermissionsPage", () => {
Expand Down
43 changes: 43 additions & 0 deletions __tests__/web/write-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,47 @@ describe("coerceConfigValue", () => {
coerceConfigValue("voicechannels.lobby.name", null),
).toEqual({ ok: true, value: "" });
});

it("joins array input into a comma-separated string for *_list keys", () => {
// The Settings page renders channel_list / role_list as <select
// multiple>, which posts repeated `value=…` params and lands here as
// an array. Backend storage is CSV so we collapse on the way in.
expect(
coerceConfigValue("voicetracking.excluded_channels", ["111", "222"]),
).toEqual({ ok: true, value: "111,222" });
expect(
coerceConfigValue("quotes.delete_roles", ["roleA", "roleB", "roleC"]),
).toEqual({ ok: true, value: "roleA,roleB,roleC" });
});

it("drops empty strings from array input for *_list keys", () => {
// Browsers sometimes send a stray empty option in select-multiple
// payloads; ignore them rather than producing a CSV with a leading
// or interior empty token.
expect(
coerceConfigValue("voicetracking.excluded_channels", ["", "111", ""]),
).toEqual({ ok: true, value: "111" });
});

it("yields an empty string when nothing is selected in a multi-select", () => {
expect(
coerceConfigValue("voicetracking.excluded_channels", []),
).toEqual({ ok: true, value: "" });
});

it("rejects an array payload for a non-list string key", () => {
// A misconfigured YAML import or crafted form post mustn't silently
// CSV-join an accidental list into a string-typed key. Only the two
// *_list types accept array input.
const r = coerceConfigValue("voicechannels.lobby.name", ["a", "b"]);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toMatch(/array/i);
}
});

it("rejects an array payload for a number key", () => {
const r = coerceConfigValue("quotes.max_length", [500]);
expect(r.ok).toBe(false);
});
});
Loading
Loading