@sprintdock/backend 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +252 -0
- package/SERVER.md +25 -0
- package/dist/index.d.ts +1536 -0
- package/dist/index.js +4103 -0
- package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
- package/drizzle/0001_sprint_markdown_content.sql +1 -0
- package/drizzle/0002_task_touched_files.sql +8 -0
- package/drizzle/meta/0000_snapshot.json +372 -0
- package/drizzle/meta/0001_snapshot.json +379 -0
- package/drizzle/meta/_journal.json +27 -0
- package/drizzle.config.ts +14 -0
- package/package.json +40 -0
- package/src/application/container.ts +44 -0
- package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
- package/src/application/plan.service.ts +123 -0
- package/src/application/sprint.service.ts +118 -0
- package/src/application/task.service.ts +389 -0
- package/src/db/connection.ts +25 -0
- package/src/db/migrator.ts +46 -0
- package/src/db/schema/index.ts +14 -0
- package/src/db/schema/plans.ts +18 -0
- package/src/db/schema/relations.ts +36 -0
- package/src/db/schema/sprints.ts +33 -0
- package/src/db/schema/tasks.ts +62 -0
- package/src/domain/entities/index.ts +30 -0
- package/src/domain/entities/plan.entity.ts +33 -0
- package/src/domain/entities/sprint.entity.ts +44 -0
- package/src/domain/entities/task.entity.ts +80 -0
- package/src/domain/repositories/index.ts +9 -0
- package/src/domain/repositories/plan.repository.ts +21 -0
- package/src/domain/repositories/sprint.repository.ts +19 -0
- package/src/domain/repositories/task.repository.ts +35 -0
- package/src/domain/services/index.ts +9 -0
- package/src/domain/services/plan-domain.service.ts +44 -0
- package/src/domain/services/sprint-domain.service.ts +44 -0
- package/src/domain/services/task-domain.service.ts +136 -0
- package/src/errors/backend-errors.ts +75 -0
- package/src/http/app-factory.ts +55 -0
- package/src/http/controllers/health.controller.ts +33 -0
- package/src/http/controllers/plan.controller.ts +153 -0
- package/src/http/controllers/sprint.controller.ts +111 -0
- package/src/http/controllers/task.controller.ts +158 -0
- package/src/http/express-augmentation.d.ts +20 -0
- package/src/http/middleware/cors.ts +41 -0
- package/src/http/middleware/error-handler.ts +50 -0
- package/src/http/middleware/request-id.ts +28 -0
- package/src/http/middleware/validate.ts +54 -0
- package/src/http/routes/v1/index.ts +39 -0
- package/src/http/routes/v1/plan.routes.ts +51 -0
- package/src/http/routes/v1/schemas.ts +175 -0
- package/src/http/routes/v1/sprint.routes.ts +49 -0
- package/src/http/routes/v1/task.routes.ts +64 -0
- package/src/index.ts +34 -0
- package/src/infrastructure/observability/audit-log.ts +34 -0
- package/src/infrastructure/observability/request-correlation.ts +20 -0
- package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
- package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
- package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
- package/src/infrastructure/repositories/drizzle/index.ts +16 -0
- package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
- package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
- package/src/infrastructure/repositories/repository-factory.ts +54 -0
- package/src/infrastructure/security/auth-context.ts +35 -0
- package/src/infrastructure/security/input-guard.ts +21 -0
- package/src/infrastructure/security/rate-limiter.ts +65 -0
- package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
- package/src/mcp/mcp-query-helpers.ts +89 -0
- package/src/mcp/mcp-text-formatters.ts +204 -0
- package/src/mcp/mcp-tool-error.ts +24 -0
- package/src/mcp/plugins/context-tools.plugin.ts +107 -0
- package/src/mcp/plugins/default-plugins.ts +23 -0
- package/src/mcp/plugins/index.ts +21 -0
- package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
- package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
- package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
- package/src/mcp/plugins/task-tools.plugin.ts +528 -0
- package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
- package/src/mcp/plugins/types.ts +45 -0
- package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
- package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
- package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
- package/src/mcp/tool-guard.ts +58 -0
- package/src/mcp/transports/http-app-factory.ts +31 -0
- package/src/mcp/transports/http-entry.ts +27 -0
- package/src/mcp/transports/stdio-entry.ts +17 -0
- package/tests/application/container.test.ts +36 -0
- package/tests/application/plan.service.test.ts +114 -0
- package/tests/application/sprint.service.test.ts +138 -0
- package/tests/application/task.service.test.ts +325 -0
- package/tests/db/test-db.test.ts +112 -0
- package/tests/domain/plan-domain.service.test.ts +44 -0
- package/tests/domain/sprint-domain.service.test.ts +38 -0
- package/tests/domain/task-domain.service.test.ts +105 -0
- package/tests/errors/backend-errors.test.ts +44 -0
- package/tests/helpers/test-db.ts +43 -0
- package/tests/http/error-handler.test.ts +37 -0
- package/tests/http/plan.routes.test.ts +128 -0
- package/tests/http/sprint.routes.test.ts +72 -0
- package/tests/http/task.routes.test.ts +130 -0
- package/tests/http/test-app.ts +17 -0
- package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
- package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
- package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
- package/tests/mcp/mcp-text-formatters.test.ts +246 -0
- package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Integration tests for DrizzlePlanRepository.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
|
|
9
|
+
import { createTestDb } from "../helpers/test-db.js";
|
|
10
|
+
|
|
11
|
+
test("plan CRUD and list ordering by created_at", async () => {
|
|
12
|
+
const db = await createTestDb();
|
|
13
|
+
const { plans } = createRepositories("sqlite", { sqlite: { db } });
|
|
14
|
+
const a = await plans.create({ slug: "a", title: "A" });
|
|
15
|
+
const b = await plans.create({ slug: "b", title: "B" });
|
|
16
|
+
assert.equal(a.slug, "a");
|
|
17
|
+
const listed = await plans.list();
|
|
18
|
+
assert.equal(listed.length, 2);
|
|
19
|
+
assert.equal(listed[0]?.id, a.id);
|
|
20
|
+
assert.equal(listed[1]?.id, b.id);
|
|
21
|
+
const bySlug = await plans.findBySlug("b");
|
|
22
|
+
assert.equal(bySlug?.title, "B");
|
|
23
|
+
const updated = await plans.update(b.id, { title: "B2" });
|
|
24
|
+
assert.equal(updated?.title, "B2");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("setActive clears other plans and findActive returns that plan", async () => {
|
|
28
|
+
const db = await createTestDb();
|
|
29
|
+
const { plans } = createRepositories("sqlite", { sqlite: { db } });
|
|
30
|
+
const p1 = await plans.create({ slug: "p1", title: "One" });
|
|
31
|
+
const p2 = await plans.create({ slug: "p2", title: "Two" });
|
|
32
|
+
await plans.setActive(p1.id);
|
|
33
|
+
let active = await plans.findActive();
|
|
34
|
+
assert.equal(active?.id, p1.id);
|
|
35
|
+
assert.equal((await plans.findById(p1.id))?.isActive, true);
|
|
36
|
+
await plans.setActive(p2.id);
|
|
37
|
+
active = await plans.findActive();
|
|
38
|
+
assert.equal(active?.id, p2.id);
|
|
39
|
+
assert.equal((await plans.findById(p1.id))?.isActive, false);
|
|
40
|
+
assert.equal((await plans.findById(p2.id))?.isActive, true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("deleting plan cascades sprints and tasks", async () => {
|
|
44
|
+
const db = await createTestDb();
|
|
45
|
+
const { plans, sprints, tasks } = createRepositories("sqlite", { sqlite: { db } });
|
|
46
|
+
const plan = await plans.create({ slug: "plan", title: "P" });
|
|
47
|
+
const sp = await sprints.create({
|
|
48
|
+
slug: "s",
|
|
49
|
+
planId: plan.id,
|
|
50
|
+
name: "S",
|
|
51
|
+
goal: "G"
|
|
52
|
+
});
|
|
53
|
+
const tk = await tasks.create({
|
|
54
|
+
sprintId: sp.id,
|
|
55
|
+
title: "T",
|
|
56
|
+
priority: "medium"
|
|
57
|
+
});
|
|
58
|
+
const deleted = await plans.delete(plan.id);
|
|
59
|
+
assert.equal(deleted, true);
|
|
60
|
+
assert.equal(await sprints.findById(sp.id), null);
|
|
61
|
+
assert.equal(await tasks.findById(tk.id), null);
|
|
62
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Integration tests for DrizzleSprintRepository.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
|
|
9
|
+
import { createTestDb } from "../helpers/test-db.js";
|
|
10
|
+
|
|
11
|
+
test("create auto-assigns incrementing order per plan", async () => {
|
|
12
|
+
const db = await createTestDb();
|
|
13
|
+
const { plans, sprints } = createRepositories("sqlite", { sqlite: { db } });
|
|
14
|
+
const plan = await plans.create({ slug: "p", title: "P" });
|
|
15
|
+
const s1 = await sprints.create({
|
|
16
|
+
slug: "s1",
|
|
17
|
+
planId: plan.id,
|
|
18
|
+
name: "S1",
|
|
19
|
+
goal: "G"
|
|
20
|
+
});
|
|
21
|
+
const s2 = await sprints.create({
|
|
22
|
+
slug: "s2",
|
|
23
|
+
planId: plan.id,
|
|
24
|
+
name: "S2",
|
|
25
|
+
goal: "G"
|
|
26
|
+
});
|
|
27
|
+
assert.equal(s1.order, 0);
|
|
28
|
+
assert.equal(s2.order, 1);
|
|
29
|
+
const listed = await sprints.listByPlanId(plan.id);
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
listed.map((s) => s.slug),
|
|
32
|
+
["s1", "s2"]
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("findBySlug uses plan_id and slug", async () => {
|
|
37
|
+
const db = await createTestDb();
|
|
38
|
+
const { plans, sprints } = createRepositories("sqlite", { sqlite: { db } });
|
|
39
|
+
const p = await plans.create({ slug: "p", title: "P" });
|
|
40
|
+
await sprints.create({
|
|
41
|
+
slug: "same",
|
|
42
|
+
planId: p.id,
|
|
43
|
+
name: "A",
|
|
44
|
+
goal: "G"
|
|
45
|
+
});
|
|
46
|
+
const found = await sprints.findBySlug(p.id, "same");
|
|
47
|
+
assert.equal(found?.name, "A");
|
|
48
|
+
assert.equal(await sprints.findBySlug(p.id, "missing"), null);
|
|
49
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Integration tests for DrizzleTaskRepository.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { ValidationError } from "../../src/errors/backend-errors.js";
|
|
9
|
+
import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
|
|
10
|
+
import { createTestDb } from "../helpers/test-db.js";
|
|
11
|
+
|
|
12
|
+
async function seedPlanWithTwoSprints(db: Awaited<ReturnType<typeof createTestDb>>) {
|
|
13
|
+
const { plans, sprints } = createRepositories("sqlite", { sqlite: { db } });
|
|
14
|
+
const plan = await plans.create({ slug: "p", title: "P" });
|
|
15
|
+
const sa = await sprints.create({
|
|
16
|
+
slug: "a",
|
|
17
|
+
planId: plan.id,
|
|
18
|
+
name: "A",
|
|
19
|
+
goal: "G"
|
|
20
|
+
});
|
|
21
|
+
const sb = await sprints.create({
|
|
22
|
+
slug: "b",
|
|
23
|
+
planId: plan.id,
|
|
24
|
+
name: "B",
|
|
25
|
+
goal: "G"
|
|
26
|
+
});
|
|
27
|
+
return { plan, sa, sb };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("create rejects duplicate order in same sprint", async () => {
|
|
31
|
+
const db = await createTestDb();
|
|
32
|
+
const { sa } = await seedPlanWithTwoSprints(db);
|
|
33
|
+
const { tasks } = createRepositories("sqlite", { sqlite: { db } });
|
|
34
|
+
await tasks.create({
|
|
35
|
+
sprintId: sa.id,
|
|
36
|
+
title: "T0",
|
|
37
|
+
priority: "low",
|
|
38
|
+
order: 0
|
|
39
|
+
});
|
|
40
|
+
await assert.rejects(
|
|
41
|
+
() =>
|
|
42
|
+
tasks.create({
|
|
43
|
+
sprintId: sa.id,
|
|
44
|
+
title: "T1",
|
|
45
|
+
priority: "low",
|
|
46
|
+
order: 0
|
|
47
|
+
}),
|
|
48
|
+
ValidationError
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("move assigns next order in target sprint", async () => {
|
|
53
|
+
const db = await createTestDb();
|
|
54
|
+
const { sa, sb } = await seedPlanWithTwoSprints(db);
|
|
55
|
+
const { tasks } = createRepositories("sqlite", { sqlite: { db } });
|
|
56
|
+
const t = await tasks.create({
|
|
57
|
+
sprintId: sa.id,
|
|
58
|
+
title: "Move me",
|
|
59
|
+
priority: "high"
|
|
60
|
+
});
|
|
61
|
+
await tasks.create({
|
|
62
|
+
sprintId: sb.id,
|
|
63
|
+
title: "Existing",
|
|
64
|
+
priority: "medium"
|
|
65
|
+
});
|
|
66
|
+
const moved = await tasks.move(t.id, sb.id);
|
|
67
|
+
assert.equal(moved?.sprintId, sb.id);
|
|
68
|
+
assert.ok(moved && moved.order >= 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("listDependentTaskIds returns tasks that depend on a prerequisite", async () => {
|
|
72
|
+
const db = await createTestDb();
|
|
73
|
+
const { sa } = await seedPlanWithTwoSprints(db);
|
|
74
|
+
const { tasks } = createRepositories("sqlite", { sqlite: { db } });
|
|
75
|
+
const t1 = await tasks.create({
|
|
76
|
+
sprintId: sa.id,
|
|
77
|
+
title: "Prereq",
|
|
78
|
+
priority: "medium"
|
|
79
|
+
});
|
|
80
|
+
const t2 = await tasks.create({
|
|
81
|
+
sprintId: sa.id,
|
|
82
|
+
title: "Dependent",
|
|
83
|
+
priority: "medium"
|
|
84
|
+
});
|
|
85
|
+
await tasks.setDependencies(t2.id, [t1.id]);
|
|
86
|
+
const ids = await tasks.listDependentTaskIds(t1.id);
|
|
87
|
+
assert.deepEqual(ids.sort(), [t2.id].sort());
|
|
88
|
+
assert.equal((await tasks.listDependentTaskIds(t2.id)).length, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("setDependencies and getDependencies round-trip", async () => {
|
|
92
|
+
const db = await createTestDb();
|
|
93
|
+
const { sa } = await seedPlanWithTwoSprints(db);
|
|
94
|
+
const { tasks } = createRepositories("sqlite", { sqlite: { db } });
|
|
95
|
+
const t1 = await tasks.create({
|
|
96
|
+
sprintId: sa.id,
|
|
97
|
+
title: "T1",
|
|
98
|
+
priority: "medium"
|
|
99
|
+
});
|
|
100
|
+
const t2 = await tasks.create({
|
|
101
|
+
sprintId: sa.id,
|
|
102
|
+
title: "T2",
|
|
103
|
+
priority: "medium"
|
|
104
|
+
});
|
|
105
|
+
await tasks.setDependencies(t1.id, [t2.id]);
|
|
106
|
+
const deps = await tasks.getDependencies(t1.id);
|
|
107
|
+
assert.equal(deps.length, 1);
|
|
108
|
+
assert.equal(deps[0]?.dependsOnTaskId, t2.id);
|
|
109
|
+
await tasks.setDependencies(t1.id, []);
|
|
110
|
+
assert.equal((await tasks.getDependencies(t1.id)).length, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("listByPlanId returns tasks across sprints with optional filter", async () => {
|
|
114
|
+
const db = await createTestDb();
|
|
115
|
+
const { plan, sa, sb } = await seedPlanWithTwoSprints(db);
|
|
116
|
+
const { tasks } = createRepositories("sqlite", { sqlite: { db } });
|
|
117
|
+
await tasks.create({
|
|
118
|
+
sprintId: sa.id,
|
|
119
|
+
title: "Todo A",
|
|
120
|
+
priority: "low"
|
|
121
|
+
});
|
|
122
|
+
await tasks.create({
|
|
123
|
+
sprintId: sb.id,
|
|
124
|
+
title: "Done B",
|
|
125
|
+
priority: "high"
|
|
126
|
+
});
|
|
127
|
+
const u = await tasks.listByPlanId(plan.id);
|
|
128
|
+
assert.equal(u.length, 2);
|
|
129
|
+
const highs = await tasks.listByPlanId(plan.id, { priority: "high" });
|
|
130
|
+
assert.equal(highs.length, 1);
|
|
131
|
+
assert.equal(highs[0]?.title, "Done B");
|
|
132
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Unit tests for MCP agent-facing text formatters.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import type { Plan } from "../../src/domain/entities/plan.entity";
|
|
9
|
+
import type { Sprint } from "../../src/domain/entities/sprint.entity";
|
|
10
|
+
import type { Task, TaskStatus } from "../../src/domain/entities/task.entity";
|
|
11
|
+
import {
|
|
12
|
+
formatExecutionContext,
|
|
13
|
+
formatPlanList,
|
|
14
|
+
formatPlanSummary,
|
|
15
|
+
formatSprintDetail,
|
|
16
|
+
formatSprintList,
|
|
17
|
+
formatTaskList,
|
|
18
|
+
shortId,
|
|
19
|
+
type SprintWithTaskCounts
|
|
20
|
+
} from "../../src/mcp/mcp-text-formatters.js";
|
|
21
|
+
|
|
22
|
+
const samplePlan = (over: Partial<Plan> = {}): Plan => ({
|
|
23
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
24
|
+
slug: "my-plan",
|
|
25
|
+
title: "My Project Plan",
|
|
26
|
+
description: null,
|
|
27
|
+
markdownContent: null,
|
|
28
|
+
status: "active",
|
|
29
|
+
isActive: true,
|
|
30
|
+
createdAt: "",
|
|
31
|
+
updatedAt: "",
|
|
32
|
+
...over
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const sampleSprint = (over: Partial<Sprint> = {}): Sprint => ({
|
|
36
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
37
|
+
slug: "sprint-1",
|
|
38
|
+
planId: "11111111-1111-4111-8111-111111111111",
|
|
39
|
+
name: "Sprint 1",
|
|
40
|
+
goal: "Ship",
|
|
41
|
+
markdownContent: null,
|
|
42
|
+
status: "active",
|
|
43
|
+
order: 0,
|
|
44
|
+
startDate: null,
|
|
45
|
+
endDate: null,
|
|
46
|
+
createdAt: "",
|
|
47
|
+
updatedAt: "",
|
|
48
|
+
...over
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const sampleTask = (over: Partial<Task> = {}): Task => ({
|
|
52
|
+
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
53
|
+
sprintId: "22222222-2222-4222-8222-222222222222",
|
|
54
|
+
title: "Set up CI pipeline",
|
|
55
|
+
description: null,
|
|
56
|
+
status: "todo",
|
|
57
|
+
priority: "high",
|
|
58
|
+
order: 0,
|
|
59
|
+
assignee: "vikash",
|
|
60
|
+
tags: null,
|
|
61
|
+
touchedFiles: [],
|
|
62
|
+
createdAt: "",
|
|
63
|
+
updatedAt: "",
|
|
64
|
+
...over
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("shortId returns first 8 hex chars without dashes", () => {
|
|
68
|
+
assert.equal(
|
|
69
|
+
shortId("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"),
|
|
70
|
+
"aaaaaaaa"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("formatPlanList includes slug, status, title, and active marker", () => {
|
|
75
|
+
const active = samplePlan({
|
|
76
|
+
slug: "my-plan",
|
|
77
|
+
title: "My Project Plan",
|
|
78
|
+
status: "active",
|
|
79
|
+
isActive: true
|
|
80
|
+
});
|
|
81
|
+
const archived = samplePlan({
|
|
82
|
+
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
83
|
+
slug: "old-plan",
|
|
84
|
+
title: "Old Plan",
|
|
85
|
+
status: "archived",
|
|
86
|
+
isActive: false
|
|
87
|
+
});
|
|
88
|
+
const text = formatPlanList([active, archived], active.id);
|
|
89
|
+
assert.ok(text.includes("Plans (2):"));
|
|
90
|
+
assert.ok(
|
|
91
|
+
text.includes(
|
|
92
|
+
'- my-plan [active] "My Project Plan" (active plan)'
|
|
93
|
+
)
|
|
94
|
+
);
|
|
95
|
+
assert.ok(text.includes('- old-plan [archived] "Old Plan"'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("formatSprintList includes task count breakdown per sprint", () => {
|
|
99
|
+
const sprints: SprintWithTaskCounts[] = [
|
|
100
|
+
{
|
|
101
|
+
...sampleSprint({ slug: "sprint-1", name: "Sprint 1", status: "active" }),
|
|
102
|
+
taskCounts: counts(2, 1, 0, 0)
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
...sampleSprint({
|
|
106
|
+
id: "33333333-3333-4333-8333-333333333333",
|
|
107
|
+
slug: "sprint-2",
|
|
108
|
+
name: "Sprint 2",
|
|
109
|
+
status: "planned"
|
|
110
|
+
}),
|
|
111
|
+
taskCounts: counts(3, 0, 0, 0)
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
const text = formatSprintList("my-plan", sprints);
|
|
115
|
+
assert.ok(text.includes("Sprints for 'my-plan' (2):"));
|
|
116
|
+
assert.ok(text.includes("sprint-1 [active]"));
|
|
117
|
+
assert.ok(text.includes("2 todo, 1 in_progress"));
|
|
118
|
+
assert.ok(text.includes("sprint-2 [planned]"));
|
|
119
|
+
assert.ok(text.includes("3 todo"));
|
|
120
|
+
assert.ok(text.includes("sprintId:22222222-2222-4222-8222-222222222222"));
|
|
121
|
+
assert.ok(text.includes("sprintId:33333333-3333-4333-8333-333333333333"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("formatTaskList includes status, title, priority, assignee, short id", () => {
|
|
125
|
+
const tasks = [
|
|
126
|
+
sampleTask({
|
|
127
|
+
title: "Set up CI pipeline",
|
|
128
|
+
status: "todo",
|
|
129
|
+
priority: "high",
|
|
130
|
+
assignee: "vikash"
|
|
131
|
+
}),
|
|
132
|
+
sampleTask({
|
|
133
|
+
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
134
|
+
title: "Write unit tests",
|
|
135
|
+
status: "in_progress",
|
|
136
|
+
priority: "medium",
|
|
137
|
+
assignee: null
|
|
138
|
+
})
|
|
139
|
+
];
|
|
140
|
+
const text = formatTaskList(tasks);
|
|
141
|
+
assert.ok(text.includes("Tasks (2):"));
|
|
142
|
+
assert.ok(
|
|
143
|
+
text.includes('[todo] "Set up CI pipeline"') &&
|
|
144
|
+
text.includes("priority: high") &&
|
|
145
|
+
text.includes("assignee: vikash")
|
|
146
|
+
);
|
|
147
|
+
assert.ok(text.includes('[in_progress] "Write unit tests"'));
|
|
148
|
+
assert.ok(text.includes(`id:${shortId(tasks[0]!.id)}`));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("formatPlanSummary includes sprint breakdown and short ids", () => {
|
|
152
|
+
const plan = samplePlan();
|
|
153
|
+
const sprintsOut = [
|
|
154
|
+
{
|
|
155
|
+
sprintSlug: "sprint-1",
|
|
156
|
+
sprintId: "22222222-2222-4222-8222-222222222222",
|
|
157
|
+
name: "Sprint 1",
|
|
158
|
+
status: "active" as const,
|
|
159
|
+
taskCounts: counts(2, 3, 0, 1)
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
sprintSlug: "backlog",
|
|
163
|
+
sprintId: "33333333-3333-4333-8333-333333333333",
|
|
164
|
+
name: "Backlog",
|
|
165
|
+
status: "planned" as const,
|
|
166
|
+
taskCounts: counts(1, 0, 0, 0)
|
|
167
|
+
}
|
|
168
|
+
];
|
|
169
|
+
const text = formatPlanSummary(plan, sprintsOut);
|
|
170
|
+
assert.ok(text.includes(`Plan 'my-plan' ("My Project Plan")`));
|
|
171
|
+
assert.ok(text.includes("7 tasks total"));
|
|
172
|
+
assert.ok(text.includes("Sprint breakdown:"));
|
|
173
|
+
assert.ok(text.includes("sprint-1 [active]:"));
|
|
174
|
+
assert.ok(text.includes("2 todo, 3 in_progress, 1 done"));
|
|
175
|
+
assert.ok(text.includes("id: 22222222"));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("formatExecutionContext lists tasks under each active sprint", () => {
|
|
179
|
+
const plan = samplePlan();
|
|
180
|
+
const sprint = sampleSprint();
|
|
181
|
+
const tasks = [
|
|
182
|
+
sampleTask({ title: "Task A", status: "todo", priority: "high" }),
|
|
183
|
+
sampleTask({
|
|
184
|
+
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
185
|
+
title: "Task B",
|
|
186
|
+
status: "in_progress",
|
|
187
|
+
priority: "medium",
|
|
188
|
+
assignee: "vikash"
|
|
189
|
+
})
|
|
190
|
+
];
|
|
191
|
+
const text = formatExecutionContext(plan.slug, [
|
|
192
|
+
{
|
|
193
|
+
sprintSlug: sprint.slug,
|
|
194
|
+
sprint,
|
|
195
|
+
tasks,
|
|
196
|
+
taskCounts: countFromTasks(tasks)
|
|
197
|
+
}
|
|
198
|
+
]);
|
|
199
|
+
assert.ok(text.includes("Execution context for 'my-plan':"));
|
|
200
|
+
assert.ok(text.includes("Active sprints (1):"));
|
|
201
|
+
assert.ok(text.includes(`Sprint 'sprint-1'`));
|
|
202
|
+
assert.ok(text.includes('"Task A"'));
|
|
203
|
+
assert.ok(text.includes('"Task B"'));
|
|
204
|
+
assert.ok(text.includes("Summary: 1 todo, 1 in_progress"));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("formatSprintDetail includes metadata and task lines", () => {
|
|
208
|
+
const sprint = sampleSprint({
|
|
209
|
+
goal: "Finish MVP",
|
|
210
|
+
startDate: "2025-01-01T00:00:00.000Z",
|
|
211
|
+
endDate: "2025-01-31T00:00:00.000Z"
|
|
212
|
+
});
|
|
213
|
+
const tasks = [sampleTask({ title: "Only task" })];
|
|
214
|
+
const text = formatSprintDetail(sprint, tasks);
|
|
215
|
+
assert.ok(text.includes("Sprint 'Sprint 1' (sprint-1) [active]"));
|
|
216
|
+
assert.ok(text.includes("Goal: Finish MVP"));
|
|
217
|
+
assert.ok(text.includes("Tasks (1):"));
|
|
218
|
+
assert.ok(text.includes('"Only task"'));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("formatSprintDetail appends markdown section when markdownContent is set", () => {
|
|
222
|
+
const sprint = sampleSprint({
|
|
223
|
+
markdownContent: "## Notes\nHello."
|
|
224
|
+
});
|
|
225
|
+
const text = formatSprintDetail(sprint, []);
|
|
226
|
+
assert.ok(text.includes("--- Markdown ---"));
|
|
227
|
+
assert.ok(text.includes("## Notes"));
|
|
228
|
+
assert.ok(text.includes("Hello."));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
function counts(
|
|
232
|
+
todo: number,
|
|
233
|
+
in_progress: number,
|
|
234
|
+
blocked: number,
|
|
235
|
+
done: number
|
|
236
|
+
): Record<TaskStatus, number> {
|
|
237
|
+
return { todo, in_progress, blocked, done };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function countFromTasks(ts: Task[]): Record<TaskStatus, number> {
|
|
241
|
+
const base = counts(0, 0, 0, 0);
|
|
242
|
+
for (const t of ts) {
|
|
243
|
+
base[t.status] += 1;
|
|
244
|
+
}
|
|
245
|
+
return base;
|
|
246
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Smoke tests for MCP tool registration (sql.js DB + private SDK registry shape).
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { createApplicationServices } from "../../src/application/container.js";
|
|
10
|
+
import { ConsoleAuditLog } from "../../src/infrastructure/observability/audit-log.js";
|
|
11
|
+
import { RequestCorrelation } from "../../src/infrastructure/observability/request-correlation.js";
|
|
12
|
+
import { LocalAuthContextResolver } from "../../src/infrastructure/security/auth-context.js";
|
|
13
|
+
import { InMemoryBackendRateLimiter } from "../../src/infrastructure/security/rate-limiter.js";
|
|
14
|
+
import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import {
|
|
17
|
+
defaultSprintdockMcpPlugins,
|
|
18
|
+
registerSprintdockMcpTools
|
|
19
|
+
} from "../../src/mcp/register-sprintdock-mcp-tools.js";
|
|
20
|
+
import type { SprintdockMcpToolPlugin } from "../../src/mcp/plugins/types.js";
|
|
21
|
+
import { SPRINTDOCK_MCP_CAPABILITIES } from "../../src/mcp/sprintdock-mcp-capabilities.js";
|
|
22
|
+
import { createTestDb } from "../helpers/test-db.js";
|
|
23
|
+
|
|
24
|
+
const EXPECTED_TOOL_NAMES = [
|
|
25
|
+
"assign_task",
|
|
26
|
+
"bulk_create_tasks",
|
|
27
|
+
"bulk_update_task_status",
|
|
28
|
+
"create_plan",
|
|
29
|
+
"create_sprint",
|
|
30
|
+
"create_task",
|
|
31
|
+
"delete_plan",
|
|
32
|
+
"delete_sprint",
|
|
33
|
+
"delete_task",
|
|
34
|
+
"get_active_plan",
|
|
35
|
+
"get_execution_context",
|
|
36
|
+
"get_plan_progress",
|
|
37
|
+
"get_plan_summary",
|
|
38
|
+
"get_sprint",
|
|
39
|
+
"get_sprint_detail",
|
|
40
|
+
"get_task",
|
|
41
|
+
"get_task_dependencies",
|
|
42
|
+
"list_plans",
|
|
43
|
+
"list_sprints",
|
|
44
|
+
"list_tasks",
|
|
45
|
+
"move_task",
|
|
46
|
+
"set_active_plan",
|
|
47
|
+
"update_plan",
|
|
48
|
+
"update_plan_markdown",
|
|
49
|
+
"update_sprint",
|
|
50
|
+
"update_sprint_markdown",
|
|
51
|
+
"update_sprint_status",
|
|
52
|
+
"update_task",
|
|
53
|
+
"update_task_dependencies",
|
|
54
|
+
"update_task_status"
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
type RegisteredToolEntry = {
|
|
58
|
+
handler: (args: Record<string, unknown>) => Promise<unknown>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function getToolRegistry(server: McpServer): Record<string, RegisteredToolEntry> {
|
|
62
|
+
return (server as unknown as { _registeredTools: Record<string, RegisteredToolEntry> })
|
|
63
|
+
._registeredTools;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toolEntry(
|
|
67
|
+
registry: Record<string, RegisteredToolEntry>,
|
|
68
|
+
name: (typeof EXPECTED_TOOL_NAMES)[number]
|
|
69
|
+
): RegisteredToolEntry {
|
|
70
|
+
const t = registry[name];
|
|
71
|
+
if (!t) {
|
|
72
|
+
throw new Error(`Missing registered tool: ${name}`);
|
|
73
|
+
}
|
|
74
|
+
return t;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function makeMcpWithTools(): Promise<{ server: McpServer; tools: Record<string, RegisteredToolEntry> }> {
|
|
78
|
+
const db = await createTestDb();
|
|
79
|
+
const repos = createRepositories("sqlite", { sqlite: { db } });
|
|
80
|
+
const services = createApplicationServices(repos);
|
|
81
|
+
const server = new McpServer(
|
|
82
|
+
{ name: "sprintdock-backend-test", version: "0.0.0-test" },
|
|
83
|
+
{
|
|
84
|
+
capabilities: SPRINTDOCK_MCP_CAPABILITIES,
|
|
85
|
+
instructions: "test"
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
registerSprintdockMcpTools(server, {
|
|
89
|
+
services,
|
|
90
|
+
auditLog: new ConsoleAuditLog(),
|
|
91
|
+
rateLimiter: new InMemoryBackendRateLimiter(),
|
|
92
|
+
requestCorrelation: new RequestCorrelation(),
|
|
93
|
+
authContextResolver: new LocalAuthContextResolver()
|
|
94
|
+
});
|
|
95
|
+
return { server, tools: getToolRegistry(server) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test("registers 30 Sprintdock MCP tools", async () => {
|
|
99
|
+
const { tools } = await makeMcpWithTools();
|
|
100
|
+
const names = Object.keys(tools).sort();
|
|
101
|
+
assert.deepEqual(names, [...EXPECTED_TOOL_NAMES].sort());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("list_plans returns empty plans on fresh DB", async () => {
|
|
105
|
+
const { tools } = await makeMcpWithTools();
|
|
106
|
+
const result = (await toolEntry(tools, "list_plans").handler({})) as {
|
|
107
|
+
content?: { type: string; text: string }[];
|
|
108
|
+
structuredContent?: { plans?: unknown[]; total?: number };
|
|
109
|
+
};
|
|
110
|
+
assert.ok(Array.isArray(result.structuredContent?.plans));
|
|
111
|
+
assert.equal(result.structuredContent?.plans?.length, 0);
|
|
112
|
+
assert.equal(result.structuredContent?.total, 0);
|
|
113
|
+
const text = result.content?.[0]?.text ?? "";
|
|
114
|
+
assert.ok(text.includes("Plans (0):"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("optional plugins append custom tools after defaults", async () => {
|
|
118
|
+
const customPlugin: SprintdockMcpToolPlugin = {
|
|
119
|
+
id: "test/custom-ping",
|
|
120
|
+
register(server) {
|
|
121
|
+
server.registerTool(
|
|
122
|
+
"demo_ping",
|
|
123
|
+
{
|
|
124
|
+
description: "Test-only ping tool.",
|
|
125
|
+
inputSchema: z.object({}).strict()
|
|
126
|
+
},
|
|
127
|
+
async () => ({
|
|
128
|
+
content: [{ type: "text", text: "pong" }]
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const db = await createTestDb();
|
|
134
|
+
const repos = createRepositories("sqlite", { sqlite: { db } });
|
|
135
|
+
const services = createApplicationServices(repos);
|
|
136
|
+
const server = new McpServer(
|
|
137
|
+
{ name: "sprintdock-plugin-test", version: "0.0.0-test" },
|
|
138
|
+
{
|
|
139
|
+
capabilities: SPRINTDOCK_MCP_CAPABILITIES,
|
|
140
|
+
instructions: "test"
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
registerSprintdockMcpTools(server, {
|
|
144
|
+
services,
|
|
145
|
+
auditLog: new ConsoleAuditLog(),
|
|
146
|
+
rateLimiter: new InMemoryBackendRateLimiter(),
|
|
147
|
+
requestCorrelation: new RequestCorrelation(),
|
|
148
|
+
authContextResolver: new LocalAuthContextResolver()
|
|
149
|
+
}, {
|
|
150
|
+
plugins: [...defaultSprintdockMcpPlugins, customPlugin]
|
|
151
|
+
});
|
|
152
|
+
const tools = getToolRegistry(server);
|
|
153
|
+
assert.ok(tools["demo_ping"]);
|
|
154
|
+
const ping = (await tools["demo_ping"]!.handler({})) as {
|
|
155
|
+
content?: { text: string }[];
|
|
156
|
+
};
|
|
157
|
+
assert.equal(ping.content?.[0]?.text, "pong");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("create_plan then list_plans sees the plan", async () => {
|
|
161
|
+
const { tools } = await makeMcpWithTools();
|
|
162
|
+
await toolEntry(tools, "create_plan").handler({
|
|
163
|
+
planSlug: "alpha-plan",
|
|
164
|
+
title: "Alpha"
|
|
165
|
+
});
|
|
166
|
+
const result = (await toolEntry(tools, "list_plans").handler({})) as {
|
|
167
|
+
content?: { type: string; text: string }[];
|
|
168
|
+
structuredContent?: { plans?: { slug: string }[]; total?: number };
|
|
169
|
+
};
|
|
170
|
+
assert.equal(result.structuredContent?.plans?.length, 1);
|
|
171
|
+
assert.equal(result.structuredContent?.plans?.[0]?.slug, "alpha-plan");
|
|
172
|
+
assert.equal(result.structuredContent?.total, 1);
|
|
173
|
+
const text = result.content?.[0]?.text ?? "";
|
|
174
|
+
assert.ok(text.includes("alpha-plan"));
|
|
175
|
+
assert.ok(text.includes("Alpha"));
|
|
176
|
+
assert.ok(text.includes("(active plan)"));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("create_task and bulk_create_tasks accept sprint slug via sprintIdOrSlug", async () => {
|
|
180
|
+
const { tools } = await makeMcpWithTools();
|
|
181
|
+
await toolEntry(tools, "create_plan").handler({
|
|
182
|
+
planSlug: "slug-plan",
|
|
183
|
+
title: "Slug Plan"
|
|
184
|
+
});
|
|
185
|
+
await toolEntry(tools, "create_sprint").handler({
|
|
186
|
+
planSlug: "slug-plan",
|
|
187
|
+
sprintSlug: "s1",
|
|
188
|
+
name: "Sprint One",
|
|
189
|
+
goal: "Goal"
|
|
190
|
+
});
|
|
191
|
+
const one = (await toolEntry(tools, "create_task").handler({
|
|
192
|
+
planSlug: "slug-plan",
|
|
193
|
+
sprintIdOrSlug: "s1",
|
|
194
|
+
title: "Task A",
|
|
195
|
+
description: ""
|
|
196
|
+
})) as { structuredContent?: { title: string; sprintId: string } };
|
|
197
|
+
assert.equal(one.structuredContent?.title, "Task A");
|
|
198
|
+
assert.ok(one.structuredContent?.sprintId);
|
|
199
|
+
|
|
200
|
+
const bulk = (await toolEntry(tools, "bulk_create_tasks").handler({
|
|
201
|
+
planSlug: "slug-plan",
|
|
202
|
+
sprintIdOrSlug: "s1",
|
|
203
|
+
tasks: [{ title: "B" }, { title: "C" }]
|
|
204
|
+
})) as { structuredContent?: { tasks?: { title: string }[] } };
|
|
205
|
+
assert.equal(bulk.structuredContent?.tasks?.length, 2);
|
|
206
|
+
assert.equal(bulk.structuredContent?.tasks?.[0]?.title, "B");
|
|
207
|
+
});
|