Skip to content

Commit 9dec391

Browse files
feat(tests): add unit tests for log sanitization and project commands
1 parent a3932f7 commit 9dec391

6 files changed

Lines changed: 281 additions & 6 deletions

File tree

bin/niftycli-mcp.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
#!/usr/bin/env node
22
import { startServer } from "../src/mcp/server.js";
3-
4-
function sanitizeForLog(text) {
5-
return Array.from(String(text), (ch) => (ch.charCodeAt(0) < 0x20 ? " " : ch)).join("");
6-
}
3+
import { sanitizeForLog } from "../src/logSanitizer.js";
74

85
try {
96
await startServer();

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
"node": ">=24"
3737
},
3838
"scripts": {
39-
"test": "node --test",
40-
"test:coverage": "node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info",
39+
"test": "node --experimental-test-module-mocks --test",
40+
"test:coverage": "node --experimental-test-module-mocks --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info",
4141
"lint": "eslint .",
4242
"lint:fix": "eslint . --fix",
4343
"format": "prettier --write .",

src/logSanitizer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function sanitizeForLog(text) {
2+
return Array.from(String(text), (ch) => (ch.charCodeAt(0) < 0x20 ? " " : ch)).join("");
3+
}

test/logSanitizer.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { test, describe } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { sanitizeForLog } from "../src/logSanitizer.js";
4+
5+
describe("sanitizeForLog", () => {
6+
test("leaves normal text untouched", () => {
7+
assert.equal(sanitizeForLog("connection failed"), "connection failed");
8+
});
9+
10+
test("replaces control characters with spaces", () => {
11+
assert.equal(sanitizeForLog("line1\nline2\rline3\ttab"), "line1 line2 line3 tab");
12+
});
13+
14+
test("coerces non-string input", () => {
15+
assert.equal(sanitizeForLog(404), "404");
16+
});
17+
});

test/mcp-server.test.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { test, describe, before, beforeEach, after, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
7+
let tmpHome;
8+
let configModule;
9+
let createServer;
10+
let sentEmails;
11+
12+
before(async () => {
13+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "niftycli-mcp-test-"));
14+
process.env.HOME = tmpHome;
15+
16+
const mailerUrl = new URL("../src/mailer.js", import.meta.url).href;
17+
sentEmails = [];
18+
mock.module(mailerUrl, {
19+
namedExports: {
20+
buildTaskEmail: ({ taskName, description, status = "To Do" }) => ({
21+
subject: `${taskName} [${status}]`,
22+
text: description || "",
23+
}),
24+
sendTaskEmail: async (smtp, toEmail, email) => {
25+
sentEmails.push({ smtp, toEmail, email });
26+
},
27+
},
28+
});
29+
30+
configModule = await import("../src/config.js");
31+
({ createServer } = await import("../src/mcp/server.js"));
32+
});
33+
34+
after(() => {
35+
fs.rmSync(tmpHome, { recursive: true, force: true });
36+
});
37+
38+
function callTool(server, name, args = {}) {
39+
return server._registeredTools[name].handler(args);
40+
}
41+
42+
describe("mcp server", () => {
43+
test("niftycli_hello greets by name", async () => {
44+
const server = createServer();
45+
const result = await callTool(server, "niftycli_hello", { name: "World" });
46+
assert.match(result.content[0].text, /Hello, World!/);
47+
});
48+
49+
test("niftycli_hello greets without a name", async () => {
50+
const server = createServer();
51+
const result = await callTool(server, "niftycli_hello");
52+
assert.match(result.content[0].text, /^Hello! niftycli-mcp/);
53+
});
54+
55+
test("niftycli_status reports not configured", async () => {
56+
const server = createServer();
57+
const result = await callTool(server, "niftycli_status");
58+
assert.match(result.content[0].text, /not configured yet/);
59+
});
60+
61+
describe("with a saved config", () => {
62+
beforeEach(() => {
63+
configModule.saveConfig({
64+
projects: [{ name: "Website", email: "web@example.com" }],
65+
defaultProject: "Website",
66+
smtp: { host: "smtp.example.com", user: "me@example.com" },
67+
});
68+
});
69+
70+
test("niftycli_status reports configured projects", async () => {
71+
const server = createServer();
72+
const result = await callTool(server, "niftycli_status");
73+
assert.match(result.content[0].text, /niftycli is configured/);
74+
assert.match(result.content[0].text, /Website/);
75+
});
76+
77+
test("niftycli_list_projects lists saved projects", async () => {
78+
const server = createServer();
79+
const result = await callTool(server, "niftycli_list_projects");
80+
assert.match(result.content[0].text, /Website <web@example.com> \(default\)/);
81+
});
82+
83+
test("niftycli_add_project adds and defaults the new project", async () => {
84+
const server = createServer();
85+
const result = await callTool(server, "niftycli_add_project", {
86+
name: "Mobile",
87+
email: "mobile@example.com",
88+
});
89+
assert.match(result.content[0].text, /Added project "Mobile"/);
90+
const config = configModule.loadConfig();
91+
assert.equal(config.defaultProject, "Mobile");
92+
assert.equal(config.projects.length, 2);
93+
});
94+
95+
test("niftycli_add_project rejects duplicate names", async () => {
96+
const server = createServer();
97+
await assert.rejects(
98+
callTool(server, "niftycli_add_project", { name: "Website", email: "dup@example.com" }),
99+
/already exists/,
100+
);
101+
});
102+
103+
test("niftycli_edit_project renames and updates email", async () => {
104+
const server = createServer();
105+
const result = await callTool(server, "niftycli_edit_project", {
106+
currentName: "Website",
107+
newName: "Website Revamp",
108+
newEmail: "revamp@example.com",
109+
});
110+
assert.match(result.content[0].text, /Website Revamp/);
111+
const config = configModule.loadConfig();
112+
assert.equal(config.projects[0].name, "Website Revamp");
113+
assert.equal(config.projects[0].email, "revamp@example.com");
114+
assert.equal(config.defaultProject, "Website Revamp");
115+
});
116+
117+
test("niftycli_edit_project throws for an unknown project", async () => {
118+
const server = createServer();
119+
await assert.rejects(
120+
callTool(server, "niftycli_edit_project", { currentName: "Nope" }),
121+
/No project named/,
122+
);
123+
});
124+
125+
test("niftycli_remove_project removes the project", async () => {
126+
const server = createServer();
127+
const result = await callTool(server, "niftycli_remove_project", { name: "Website" });
128+
assert.match(result.content[0].text, /Removed project "Website"/);
129+
const config = configModule.loadConfig();
130+
assert.equal(config.projects.length, 0);
131+
});
132+
133+
test("niftycli_create_task sends an email to the project", async () => {
134+
const server = createServer();
135+
const result = await callTool(server, "niftycli_create_task", {
136+
project: "Website",
137+
name: "Fix bug",
138+
description: "Details here",
139+
status: "In Progress",
140+
});
141+
assert.match(result.content[0].text, /Task "Fix bug" sent to project "Website"/);
142+
assert.equal(sentEmails.length, 1);
143+
assert.equal(sentEmails[0].toEmail, "web@example.com");
144+
assert.equal(sentEmails[0].email.subject, "Fix bug [In Progress]");
145+
});
146+
147+
test("niftycli_create_task throws for an unknown project", async () => {
148+
const server = createServer();
149+
await assert.rejects(
150+
callTool(server, "niftycli_create_task", { project: "Nope", name: "Task" }),
151+
/No project named/,
152+
);
153+
});
154+
});
155+
});

test/project.test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { test, describe, before, beforeEach, after, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import fs from "node:fs";
4+
import os from "node:os";
5+
import path from "node:path";
6+
7+
let tmpHome;
8+
let configModule;
9+
let projectModule;
10+
let inputQueue;
11+
let selectQueue;
12+
let confirmQueue;
13+
14+
before(async () => {
15+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "niftycli-project-test-"));
16+
process.env.HOME = tmpHome;
17+
18+
mock.module("@inquirer/prompts", {
19+
namedExports: {
20+
input: async () => inputQueue.shift(),
21+
select: async () => selectQueue.shift(),
22+
confirm: async () => confirmQueue.shift(),
23+
},
24+
});
25+
26+
configModule = await import("../src/config.js");
27+
projectModule = await import("../src/commands/project.js");
28+
});
29+
30+
beforeEach(() => {
31+
inputQueue = [];
32+
selectQueue = [];
33+
confirmQueue = [];
34+
process.exitCode = 0;
35+
configModule.saveConfig({ projects: [], defaultProject: undefined });
36+
});
37+
38+
after(() => {
39+
fs.rmSync(tmpHome, { recursive: true, force: true });
40+
});
41+
42+
describe("project commands", () => {
43+
test("promptNewProject appends a project and sets it as default", async () => {
44+
const config = { projects: [] };
45+
inputQueue = ["Website", "web@example.com"];
46+
const project = await projectModule.promptNewProject(config);
47+
assert.deepEqual(project, { name: "Website", email: "web@example.com" });
48+
assert.equal(config.projects.length, 1);
49+
assert.equal(config.defaultProject, "Website");
50+
assert.deepEqual(configModule.loadConfig().projects, [project]);
51+
});
52+
53+
test("addProjectCommand exits with an error when unconfigured", async () => {
54+
fs.rmSync(configModule.getConfigPath(), { force: true });
55+
await projectModule.addProjectCommand();
56+
assert.equal(process.exitCode, 1);
57+
});
58+
59+
describe("with an existing project", () => {
60+
beforeEach(() => {
61+
configModule.saveConfig({
62+
projects: [{ name: "Website", email: "web@example.com" }],
63+
defaultProject: "Website",
64+
});
65+
});
66+
67+
test("listProjectsCommand does not throw for a populated config", async () => {
68+
await assert.doesNotReject(projectModule.listProjectsCommand());
69+
});
70+
71+
test("listProjectsCommand does not throw for an empty config", async () => {
72+
configModule.saveConfig({ projects: [], defaultProject: undefined });
73+
await assert.doesNotReject(projectModule.listProjectsCommand());
74+
});
75+
76+
test("editProjectCommand renames the selected project", async () => {
77+
selectQueue = ["Website"];
78+
inputQueue = ["Website Revamp", "revamp@example.com"];
79+
await projectModule.editProjectCommand();
80+
const config = configModule.loadConfig();
81+
assert.equal(config.projects[0].name, "Website Revamp");
82+
assert.equal(config.projects[0].email, "revamp@example.com");
83+
assert.equal(config.defaultProject, "Website Revamp");
84+
});
85+
86+
test("removeProjectCommand removes the project when confirmed", async () => {
87+
selectQueue = ["Website"];
88+
confirmQueue = [true];
89+
await projectModule.removeProjectCommand();
90+
const config = configModule.loadConfig();
91+
assert.equal(config.projects.length, 0);
92+
assert.equal(config.defaultProject, undefined);
93+
});
94+
95+
test("removeProjectCommand aborts when not confirmed", async () => {
96+
selectQueue = ["Website"];
97+
confirmQueue = [false];
98+
await projectModule.removeProjectCommand();
99+
const config = configModule.loadConfig();
100+
assert.equal(config.projects.length, 1);
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)