@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +252 -0
  3. package/SERVER.md +25 -0
  4. package/dist/index.d.ts +1536 -0
  5. package/dist/index.js +4103 -0
  6. package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
  7. package/drizzle/0001_sprint_markdown_content.sql +1 -0
  8. package/drizzle/0002_task_touched_files.sql +8 -0
  9. package/drizzle/meta/0000_snapshot.json +372 -0
  10. package/drizzle/meta/0001_snapshot.json +379 -0
  11. package/drizzle/meta/_journal.json +27 -0
  12. package/drizzle.config.ts +14 -0
  13. package/package.json +40 -0
  14. package/src/application/container.ts +44 -0
  15. package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
  16. package/src/application/plan.service.ts +123 -0
  17. package/src/application/sprint.service.ts +118 -0
  18. package/src/application/task.service.ts +389 -0
  19. package/src/db/connection.ts +25 -0
  20. package/src/db/migrator.ts +46 -0
  21. package/src/db/schema/index.ts +14 -0
  22. package/src/db/schema/plans.ts +18 -0
  23. package/src/db/schema/relations.ts +36 -0
  24. package/src/db/schema/sprints.ts +33 -0
  25. package/src/db/schema/tasks.ts +62 -0
  26. package/src/domain/entities/index.ts +30 -0
  27. package/src/domain/entities/plan.entity.ts +33 -0
  28. package/src/domain/entities/sprint.entity.ts +44 -0
  29. package/src/domain/entities/task.entity.ts +80 -0
  30. package/src/domain/repositories/index.ts +9 -0
  31. package/src/domain/repositories/plan.repository.ts +21 -0
  32. package/src/domain/repositories/sprint.repository.ts +19 -0
  33. package/src/domain/repositories/task.repository.ts +35 -0
  34. package/src/domain/services/index.ts +9 -0
  35. package/src/domain/services/plan-domain.service.ts +44 -0
  36. package/src/domain/services/sprint-domain.service.ts +44 -0
  37. package/src/domain/services/task-domain.service.ts +136 -0
  38. package/src/errors/backend-errors.ts +75 -0
  39. package/src/http/app-factory.ts +55 -0
  40. package/src/http/controllers/health.controller.ts +33 -0
  41. package/src/http/controllers/plan.controller.ts +153 -0
  42. package/src/http/controllers/sprint.controller.ts +111 -0
  43. package/src/http/controllers/task.controller.ts +158 -0
  44. package/src/http/express-augmentation.d.ts +20 -0
  45. package/src/http/middleware/cors.ts +41 -0
  46. package/src/http/middleware/error-handler.ts +50 -0
  47. package/src/http/middleware/request-id.ts +28 -0
  48. package/src/http/middleware/validate.ts +54 -0
  49. package/src/http/routes/v1/index.ts +39 -0
  50. package/src/http/routes/v1/plan.routes.ts +51 -0
  51. package/src/http/routes/v1/schemas.ts +175 -0
  52. package/src/http/routes/v1/sprint.routes.ts +49 -0
  53. package/src/http/routes/v1/task.routes.ts +64 -0
  54. package/src/index.ts +34 -0
  55. package/src/infrastructure/observability/audit-log.ts +34 -0
  56. package/src/infrastructure/observability/request-correlation.ts +20 -0
  57. package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
  58. package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
  59. package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
  60. package/src/infrastructure/repositories/drizzle/index.ts +16 -0
  61. package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
  62. package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
  63. package/src/infrastructure/repositories/repository-factory.ts +54 -0
  64. package/src/infrastructure/security/auth-context.ts +35 -0
  65. package/src/infrastructure/security/input-guard.ts +21 -0
  66. package/src/infrastructure/security/rate-limiter.ts +65 -0
  67. package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
  68. package/src/mcp/mcp-query-helpers.ts +89 -0
  69. package/src/mcp/mcp-text-formatters.ts +204 -0
  70. package/src/mcp/mcp-tool-error.ts +24 -0
  71. package/src/mcp/plugins/context-tools.plugin.ts +107 -0
  72. package/src/mcp/plugins/default-plugins.ts +23 -0
  73. package/src/mcp/plugins/index.ts +21 -0
  74. package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
  75. package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
  76. package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
  77. package/src/mcp/plugins/task-tools.plugin.ts +528 -0
  78. package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
  79. package/src/mcp/plugins/types.ts +45 -0
  80. package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
  81. package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
  82. package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
  83. package/src/mcp/tool-guard.ts +58 -0
  84. package/src/mcp/transports/http-app-factory.ts +31 -0
  85. package/src/mcp/transports/http-entry.ts +27 -0
  86. package/src/mcp/transports/stdio-entry.ts +17 -0
  87. package/tests/application/container.test.ts +36 -0
  88. package/tests/application/plan.service.test.ts +114 -0
  89. package/tests/application/sprint.service.test.ts +138 -0
  90. package/tests/application/task.service.test.ts +325 -0
  91. package/tests/db/test-db.test.ts +112 -0
  92. package/tests/domain/plan-domain.service.test.ts +44 -0
  93. package/tests/domain/sprint-domain.service.test.ts +38 -0
  94. package/tests/domain/task-domain.service.test.ts +105 -0
  95. package/tests/errors/backend-errors.test.ts +44 -0
  96. package/tests/helpers/test-db.ts +43 -0
  97. package/tests/http/error-handler.test.ts +37 -0
  98. package/tests/http/plan.routes.test.ts +128 -0
  99. package/tests/http/sprint.routes.test.ts +72 -0
  100. package/tests/http/task.routes.test.ts +130 -0
  101. package/tests/http/test-app.ts +17 -0
  102. package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
  103. package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
  104. package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
  105. package/tests/mcp/mcp-text-formatters.test.ts +246 -0
  106. package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
  107. package/tsconfig.json +9 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Application-layer tests for PlanService.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { NotFoundError, ValidationError } from "../../src/errors/backend-errors.js";
9
+ import { PlanDomainService } from "../../src/domain/services/plan-domain.service.js";
10
+ import { PlanService } from "../../src/application/plan.service.js";
11
+ import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
12
+ import { createTestDb } from "../helpers/test-db.js";
13
+
14
+ async function makePlanService() {
15
+ const db = await createTestDb();
16
+ const repos = createRepositories("sqlite", { sqlite: { db } });
17
+ const domain = new PlanDomainService();
18
+ const planService = new PlanService(repos.plans, domain);
19
+ return { db, repos, planService };
20
+ }
21
+
22
+ test("resolvePlan resolves by id", async () => {
23
+ const { planService, repos } = await makePlanService();
24
+ const p = await repos.plans.create({ slug: "alpha", title: "A" });
25
+ const resolved = await planService.resolvePlan(p.id);
26
+ assert.equal(resolved.id, p.id);
27
+ });
28
+
29
+ test("resolvePlan resolves by slug", async () => {
30
+ const { planService, repos } = await makePlanService();
31
+ const p = await repos.plans.create({ slug: "beta", title: "B" });
32
+ const resolved = await planService.resolvePlan("beta");
33
+ assert.equal(resolved.id, p.id);
34
+ });
35
+
36
+ test("resolvePlan without idOrSlug uses active plan", async () => {
37
+ const { planService, repos } = await makePlanService();
38
+ const p = await repos.plans.create({ slug: "g", title: "G" });
39
+ await repos.plans.setActive(p.id);
40
+ const resolved = await planService.resolvePlan(undefined);
41
+ assert.equal(resolved.id, p.id);
42
+ });
43
+
44
+ test("resolvePlan without idOrSlug throws when no active plan", async () => {
45
+ const { planService } = await makePlanService();
46
+ await assert.rejects(() => planService.resolvePlan(undefined), NotFoundError);
47
+ });
48
+
49
+ test("getPlan tries id then slug", async () => {
50
+ const { planService, repos } = await makePlanService();
51
+ const p = await repos.plans.create({ slug: "sluggy", title: "T" });
52
+ const byId = await planService.getPlan(p.id);
53
+ const bySlug = await planService.getPlan("sluggy");
54
+ assert.equal(byId.id, p.id);
55
+ assert.equal(bySlug.id, p.id);
56
+ });
57
+
58
+ test("getPlan throws NotFoundError when missing", async () => {
59
+ const { planService } = await makePlanService();
60
+ await assert.rejects(() => planService.getPlan("nope"), NotFoundError);
61
+ });
62
+
63
+ test("createPlan sets active when it is the first plan", async () => {
64
+ const { planService, repos } = await makePlanService();
65
+ const p = await planService.createPlan({ slug: "first", title: "First" });
66
+ const active = await repos.plans.findActive();
67
+ assert.ok(active);
68
+ assert.equal(active?.id, p.id);
69
+ assert.equal(active?.isActive, true);
70
+ });
71
+
72
+ test("createPlan does not change active when a plan already exists", async () => {
73
+ const { planService, repos } = await makePlanService();
74
+ const p1 = await planService.createPlan({ slug: "one", title: "One" });
75
+ const p2 = await planService.createPlan({ slug: "two", title: "Two" });
76
+ const active = await repos.plans.findActive();
77
+ assert.equal(active?.id, p1.id);
78
+ assert.notEqual(p2.id, p1.id);
79
+ });
80
+
81
+ test("setActivePlan resolves id or slug", async () => {
82
+ const { planService, repos } = await makePlanService();
83
+ const p = await repos.plans.create({ slug: "s", title: "S" });
84
+ await planService.setActivePlan(p.slug);
85
+ assert.equal((await repos.plans.findActive())?.id, p.id);
86
+ const p2 = await repos.plans.create({ slug: "t", title: "T" });
87
+ await planService.setActivePlan(p2.id);
88
+ assert.equal((await repos.plans.findActive())?.id, p2.id);
89
+ });
90
+
91
+ test("updatePlan validates status transition", async () => {
92
+ const { planService } = await makePlanService();
93
+ const p = await planService.createPlan({ slug: "st", title: "St" });
94
+ await assert.rejects(
95
+ () => planService.updatePlan(p.id, { status: "archived" }),
96
+ ValidationError
97
+ );
98
+ const updated = await planService.updatePlan(p.id, { status: "active" });
99
+ assert.equal(updated.status, "active");
100
+ });
101
+
102
+ test("deletePlan throws NotFoundError when missing", async () => {
103
+ const { planService } = await makePlanService();
104
+ await assert.rejects(
105
+ () => planService.deletePlan("00000000-0000-4000-8000-000000000000"),
106
+ NotFoundError
107
+ );
108
+ });
109
+
110
+ test("getActivePlan throws when none active", async () => {
111
+ const { planService, repos } = await makePlanService();
112
+ await repos.plans.create({ slug: "draft-only", title: "D" });
113
+ await assert.rejects(() => planService.getActivePlan(), NotFoundError);
114
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Application-layer tests for SprintService.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { NotFoundError, ValidationError } from "../../src/errors/backend-errors.js";
9
+ import { PlanDomainService } from "../../src/domain/services/plan-domain.service.js";
10
+ import { SprintDomainService } from "../../src/domain/services/sprint-domain.service.js";
11
+ import { PlanService } from "../../src/application/plan.service.js";
12
+ import { SprintService } from "../../src/application/sprint.service.js";
13
+ import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
14
+ import { createTestDb } from "../helpers/test-db.js";
15
+
16
+ async function makeSprintService() {
17
+ const db = await createTestDb();
18
+ const repos = createRepositories("sqlite", { sqlite: { db } });
19
+ const planDomain = new PlanDomainService();
20
+ const sprintDomain = new SprintDomainService();
21
+ const planService = new PlanService(repos.plans, planDomain);
22
+ const sprintService = new SprintService(
23
+ repos.sprints,
24
+ repos.plans,
25
+ planService,
26
+ sprintDomain
27
+ );
28
+ return { db, repos, planService, sprintService };
29
+ }
30
+
31
+ test("listSprints resolves plan by slug and lists sprints", async () => {
32
+ const { repos, sprintService } = await makeSprintService();
33
+ const plan = await repos.plans.create({ slug: "pl", title: "Pl" });
34
+ await repos.plans.setActive(plan.id);
35
+ await repos.sprints.create({
36
+ slug: "s1",
37
+ planId: plan.id,
38
+ name: "S1",
39
+ goal: "G"
40
+ });
41
+ const list = await sprintService.listSprints("pl");
42
+ assert.equal(list.length, 1);
43
+ assert.equal(list[0]?.slug, "s1");
44
+ });
45
+
46
+ test("createSprint resolves plan by slug", async () => {
47
+ const { repos, sprintService } = await makeSprintService();
48
+ const plan = await repos.plans.create({ slug: "p2", title: "P2" });
49
+ const sp = await sprintService.createSprint("p2", {
50
+ slug: "sp",
51
+ planId: plan.id,
52
+ name: "N",
53
+ goal: "G"
54
+ });
55
+ assert.equal(sp.planId, plan.id);
56
+ assert.equal(sp.slug, "sp");
57
+ });
58
+
59
+ test("updateSprintStatus rejects illegal transition", async () => {
60
+ const { repos, sprintService } = await makeSprintService();
61
+ const plan = await repos.plans.create({ slug: "p3", title: "P3" });
62
+ const sp = await repos.sprints.create({
63
+ slug: "sx",
64
+ planId: plan.id,
65
+ name: "N",
66
+ goal: "G"
67
+ });
68
+ await assert.rejects(
69
+ () => sprintService.updateSprintStatus(sp.id, "archived"),
70
+ ValidationError
71
+ );
72
+ });
73
+
74
+ test("updateSprint updates name and goal", async () => {
75
+ const { repos, sprintService } = await makeSprintService();
76
+ const plan = await repos.plans.create({ slug: "pu", title: "PU" });
77
+ const sp = await repos.sprints.create({
78
+ slug: "su",
79
+ planId: plan.id,
80
+ name: "Old",
81
+ goal: "G0"
82
+ });
83
+ const u = await sprintService.updateSprint(sp.id, {
84
+ name: "New name",
85
+ goal: "New goal"
86
+ });
87
+ assert.equal(u.name, "New name");
88
+ assert.equal(u.goal, "New goal");
89
+ });
90
+
91
+ test("updateSprint updates markdownContent", async () => {
92
+ const { repos, sprintService } = await makeSprintService();
93
+ const plan = await repos.plans.create({ slug: "pmd", title: "PMD" });
94
+ const sp = await repos.sprints.create({
95
+ slug: "sm",
96
+ planId: plan.id,
97
+ name: "S",
98
+ goal: "G"
99
+ });
100
+ const withMd = await sprintService.updateSprint(sp.id, {
101
+ markdownContent: "## Hi"
102
+ });
103
+ assert.equal(withMd.markdownContent, "## Hi");
104
+ const cleared = await sprintService.updateSprint(sp.id, {
105
+ markdownContent: null
106
+ });
107
+ assert.equal(cleared.markdownContent, null);
108
+ });
109
+
110
+ test("getSprintBySlug resolves plan and sprint", async () => {
111
+ const { repos, sprintService } = await makeSprintService();
112
+ const plan = await repos.plans.create({ slug: "p4", title: "P4" });
113
+ await repos.sprints.create({
114
+ slug: "found",
115
+ planId: plan.id,
116
+ name: "N",
117
+ goal: "G"
118
+ });
119
+ const s = await sprintService.getSprintBySlug("p4", "found");
120
+ assert.equal(s.slug, "found");
121
+ assert.equal(s.planId, plan.id);
122
+ });
123
+
124
+ test("getSprint throws NotFoundError when missing", async () => {
125
+ const { sprintService } = await makeSprintService();
126
+ await assert.rejects(
127
+ () => sprintService.getSprint("00000000-0000-4000-8000-000000000000"),
128
+ NotFoundError
129
+ );
130
+ });
131
+
132
+ test("deleteSprint throws NotFoundError when missing", async () => {
133
+ const { sprintService } = await makeSprintService();
134
+ await assert.rejects(
135
+ () => sprintService.deleteSprint("00000000-0000-4000-8000-000000000000"),
136
+ NotFoundError
137
+ );
138
+ });
@@ -0,0 +1,325 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Application-layer tests for TaskService.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { NotFoundError, ValidationError } from "../../src/errors/backend-errors.js";
9
+ import { PlanDomainService } from "../../src/domain/services/plan-domain.service.js";
10
+ import { SprintDomainService } from "../../src/domain/services/sprint-domain.service.js";
11
+ import { TaskDomainService } from "../../src/domain/services/task-domain.service.js";
12
+ import { PlanService } from "../../src/application/plan.service.js";
13
+ import { TaskService } from "../../src/application/task.service.js";
14
+ import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
15
+ import { createTestDb } from "../helpers/test-db.js";
16
+
17
+ async function seedTwoPlansWithSprints() {
18
+ const db = await createTestDb();
19
+ const repos = createRepositories("sqlite", { sqlite: { db } });
20
+ const planA = await repos.plans.create({ slug: "pa", title: "A" });
21
+ const planB = await repos.plans.create({ slug: "pb", title: "B" });
22
+ const sa = await repos.sprints.create({
23
+ slug: "sa",
24
+ planId: planA.id,
25
+ name: "SA",
26
+ goal: "G"
27
+ });
28
+ const sb = await repos.sprints.create({
29
+ slug: "sb",
30
+ planId: planB.id,
31
+ name: "SB",
32
+ goal: "G"
33
+ });
34
+ return { db, repos, planA, planB, sa, sb };
35
+ }
36
+
37
+ async function makeTaskService() {
38
+ const ctx = await seedTwoPlansWithSprints();
39
+ const planDomain = new PlanDomainService();
40
+ const sprintDomain = new SprintDomainService();
41
+ const taskDomain = new TaskDomainService();
42
+ const planService = new PlanService(ctx.repos.plans, planDomain);
43
+ const taskService = new TaskService(
44
+ ctx.repos.tasks,
45
+ ctx.repos.sprints,
46
+ planService,
47
+ taskDomain
48
+ );
49
+ return { ...ctx, planService, taskService };
50
+ }
51
+
52
+ test("createTask without planIdOrSlug resolves plan from sprint", async () => {
53
+ const { taskService, repos, sa } = await makeTaskService();
54
+ const t = await taskService.createTask({
55
+ sprintId: sa.id,
56
+ title: "NoPlanArg",
57
+ priority: "low"
58
+ });
59
+ assert.equal(t.sprintId, sa.id);
60
+ const listed = await repos.tasks.listBySprintId(sa.id);
61
+ assert.ok(listed.some((x) => x.id === t.id));
62
+ });
63
+
64
+ test("createTask rejects sprint that does not belong to resolved plan", async () => {
65
+ const { taskService, sa, sb } = await makeTaskService();
66
+ await assert.rejects(
67
+ () =>
68
+ taskService.createTask(
69
+ {
70
+ sprintId: sb.id,
71
+ title: "Wrong",
72
+ priority: "low"
73
+ },
74
+ "pa"
75
+ ),
76
+ ValidationError
77
+ );
78
+ const t = await taskService.createTask(
79
+ {
80
+ sprintId: sa.id,
81
+ title: "Ok",
82
+ priority: "low"
83
+ },
84
+ "pa"
85
+ );
86
+ assert.equal(t.sprintId, sa.id);
87
+ });
88
+
89
+ test("createTask validates dependency cycle", async () => {
90
+ const { taskService, repos, sa } = await makeTaskService();
91
+ const t1 = await repos.tasks.create({
92
+ sprintId: sa.id,
93
+ title: "T1",
94
+ priority: "low"
95
+ });
96
+ const t2 = await repos.tasks.create({
97
+ sprintId: sa.id,
98
+ title: "T2",
99
+ priority: "low"
100
+ });
101
+ await repos.tasks.setDependencies(t1.id, [t2.id]);
102
+ await repos.tasks.setDependencies(t2.id, [t1.id]);
103
+ await assert.rejects(
104
+ () =>
105
+ taskService.createTask(
106
+ {
107
+ sprintId: sa.id,
108
+ title: "T3",
109
+ priority: "low",
110
+ dependsOnTaskIds: [t1.id]
111
+ },
112
+ "pa"
113
+ ),
114
+ ValidationError
115
+ );
116
+ });
117
+
118
+ test("moveTask succeeds when both sprints share a plan", async () => {
119
+ const { taskService, repos, planA } = await makeTaskService();
120
+ const s2 = await repos.sprints.create({
121
+ slug: "s2",
122
+ planId: planA.id,
123
+ name: "S2",
124
+ goal: "G"
125
+ });
126
+ const sa = await repos.sprints.findBySlug(planA.id, "sa");
127
+ assert.ok(sa);
128
+ const t = await repos.tasks.create({
129
+ sprintId: sa!.id,
130
+ title: "Move",
131
+ priority: "medium"
132
+ });
133
+ const moved = await taskService.moveTask(t.id, s2.id);
134
+ assert.equal(moved.sprintId, s2.id);
135
+ });
136
+
137
+ test("moveTask rejects target sprint in another plan", async () => {
138
+ const { taskService, repos, sa, sb } = await makeTaskService();
139
+ const t = await repos.tasks.create({
140
+ sprintId: sa.id,
141
+ title: "X",
142
+ priority: "low"
143
+ });
144
+ await assert.rejects(() => taskService.moveTask(t.id, sb.id), ValidationError);
145
+ });
146
+
147
+ test("listTasks by sprintId ignores plan filter path", async () => {
148
+ const { taskService, repos, sa } = await makeTaskService();
149
+ await repos.tasks.create({ sprintId: sa.id, title: "A", priority: "low" });
150
+ const list = await taskService.listTasks(sa.id, "pa", { status: "todo" });
151
+ assert.equal(list.length, 1);
152
+ });
153
+
154
+ test("listTasks without sprintId lists tasks across plan sprints", async () => {
155
+ const { taskService, repos, planA } = await makeTaskService();
156
+ const sa = await repos.sprints.findBySlug(planA.id, "sa");
157
+ assert.ok(sa);
158
+ const s2 = await repos.sprints.create({
159
+ slug: "sx",
160
+ planId: planA.id,
161
+ name: "SX",
162
+ goal: "G"
163
+ });
164
+ await repos.tasks.create({ sprintId: sa!.id, title: "1", priority: "low" });
165
+ await repos.tasks.create({ sprintId: s2.id, title: "2", priority: "high" });
166
+ const all = await taskService.listTasks(undefined, "pa");
167
+ assert.equal(all.length, 2);
168
+ const highs = await taskService.listTasks(undefined, "pa", {
169
+ priority: "high"
170
+ });
171
+ assert.equal(highs.length, 1);
172
+ });
173
+
174
+ test("updateTaskStatus validates transition", async () => {
175
+ const { taskService, repos, sa } = await makeTaskService();
176
+ const t = await repos.tasks.create({
177
+ sprintId: sa.id,
178
+ title: "St",
179
+ priority: "low"
180
+ });
181
+ await taskService.updateTaskStatus(t.id, "done");
182
+ const reopened = await taskService.updateTaskStatus(t.id, "in_progress");
183
+ assert.equal(reopened.status, "in_progress");
184
+ await taskService.updateTaskStatus(t.id, "blocked");
185
+ await assert.rejects(
186
+ () => taskService.updateTaskStatus(t.id, "done"),
187
+ ValidationError
188
+ );
189
+ const unblocked = await taskService.updateTaskStatus(t.id, "in_progress");
190
+ assert.equal(unblocked.status, "in_progress");
191
+ const finished = await taskService.updateTaskStatus(t.id, "done");
192
+ assert.equal(finished.status, "done");
193
+ });
194
+
195
+ test("assignTask updates assignee", async () => {
196
+ const { taskService, repos, sa } = await makeTaskService();
197
+ const t = await repos.tasks.create({
198
+ sprintId: sa.id,
199
+ title: "A",
200
+ priority: "low"
201
+ });
202
+ const u = await taskService.assignTask(t.id, "alice");
203
+ assert.equal(u.assignee, "alice");
204
+ const cleared = await taskService.assignTask(t.id, null);
205
+ assert.equal(cleared.assignee, null);
206
+ });
207
+
208
+ test("updateTask updates title and priority without changing status", async () => {
209
+ const { taskService, repos, sa } = await makeTaskService();
210
+ const t = await repos.tasks.create({
211
+ sprintId: sa.id,
212
+ title: "Original",
213
+ priority: "low"
214
+ });
215
+ const u = await taskService.updateTask(t.id, {
216
+ title: "Renamed",
217
+ priority: "critical"
218
+ });
219
+ assert.equal(u.title, "Renamed");
220
+ assert.equal(u.priority, "critical");
221
+ assert.equal(u.status, "todo");
222
+ });
223
+
224
+ test("createTask and updateTask persist touchedFiles", async () => {
225
+ const { taskService, repos, sa } = await makeTaskService();
226
+ const t = await taskService.createTask(
227
+ {
228
+ sprintId: sa.id,
229
+ title: "With files",
230
+ priority: "low",
231
+ touchedFiles: [
232
+ { path: "packages/foo/src/a.ts", fileType: "implementation" },
233
+ { path: "packages/foo/tests/a.test.ts", fileType: "test" }
234
+ ]
235
+ },
236
+ "pa"
237
+ );
238
+ assert.equal(t.touchedFiles.length, 2);
239
+ assert.ok(
240
+ t.touchedFiles.some(
241
+ (f) => f.fileType === "test" && f.path.endsWith("a.test.ts")
242
+ )
243
+ );
244
+ const listed = await repos.tasks.listBySprintId(sa.id);
245
+ const again = listed.find((x) => x.id === t.id);
246
+ assert.ok(again);
247
+ assert.equal(again!.touchedFiles.length, 2);
248
+
249
+ const cleared = await taskService.updateTask(t.id, { touchedFiles: [] });
250
+ assert.equal(cleared.touchedFiles.length, 0);
251
+ });
252
+
253
+ test("setTaskDependencies replaces edges with validation", async () => {
254
+ const { taskService, repos, sa } = await makeTaskService();
255
+ const t1 = await repos.tasks.create({
256
+ sprintId: sa.id,
257
+ title: "T1",
258
+ priority: "low"
259
+ });
260
+ const t2 = await repos.tasks.create({
261
+ sprintId: sa.id,
262
+ title: "T2",
263
+ priority: "low"
264
+ });
265
+ await taskService.setTaskDependencies(t2.id, [t1.id], "pa");
266
+ const deps = await repos.tasks.getDependencies(t2.id);
267
+ assert.equal(deps.length, 1);
268
+ assert.equal(deps[0]?.dependsOnTaskId, t1.id);
269
+ });
270
+
271
+ test("getTask and deleteTask throw NotFoundError when missing", async () => {
272
+ const { taskService } = await makeTaskService();
273
+ const missing = "00000000-0000-4000-8000-000000000000";
274
+ await assert.rejects(() => taskService.getTask(missing), NotFoundError);
275
+ await assert.rejects(() => taskService.deleteTask(missing), NotFoundError);
276
+ });
277
+
278
+ test("getPlanSprintTaskAnalytics aggregates per sprint and rollup", async () => {
279
+ const { taskService, repos, planA } = await makeTaskService();
280
+ const sa = await repos.sprints.findBySlug(planA.id, "sa");
281
+ assert.ok(sa);
282
+ const s2 = await repos.sprints.create({
283
+ slug: "sx",
284
+ planId: planA.id,
285
+ name: "SX",
286
+ goal: "G"
287
+ });
288
+ const t1 = await repos.tasks.create({
289
+ sprintId: sa!.id,
290
+ title: "A",
291
+ priority: "low"
292
+ });
293
+ const t2 = await repos.tasks.create({
294
+ sprintId: sa!.id,
295
+ title: "B",
296
+ priority: "low"
297
+ });
298
+ const t3 = await repos.tasks.create({
299
+ sprintId: s2.id,
300
+ title: "C",
301
+ priority: "low"
302
+ });
303
+ await taskService.updateTaskStatus(t1.id, "done");
304
+ await taskService.updateTaskStatus(t2.id, "in_progress");
305
+ await taskService.updateTaskStatus(t3.id, "blocked");
306
+
307
+ const analytics = await taskService.getPlanSprintTaskAnalytics("pa");
308
+ assert.equal(analytics.planId, planA.id);
309
+ assert.equal(analytics.rollup.totalTasks, 3);
310
+ assert.equal(analytics.rollup.tasksByStatus.done, 1);
311
+ assert.equal(analytics.rollup.tasksByStatus.in_progress, 1);
312
+ assert.equal(analytics.rollup.tasksByStatus.blocked, 1);
313
+ assert.equal(analytics.rollup.tasksByStatus.todo, 0);
314
+ assert.equal(analytics.rollup.sprintCount, 2);
315
+
316
+ const rowSa = analytics.sprints.find((r) => r.sprint.id === sa!.id);
317
+ const rowS2 = analytics.sprints.find((r) => r.sprint.id === s2.id);
318
+ assert.ok(rowSa);
319
+ assert.ok(rowS2);
320
+ assert.equal(rowSa!.totalTasks, 2);
321
+ assert.equal(rowS2!.totalTasks, 1);
322
+ assert.equal(rowSa!.tasksByStatus.done, 1);
323
+ assert.equal(rowSa!.tasksByStatus.in_progress, 1);
324
+ assert.equal(rowS2!.tasksByStatus.blocked, 1);
325
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Integration tests for the in-memory test database helper.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { eq } from "drizzle-orm";
9
+ import {
10
+ plans,
11
+ sprints,
12
+ taskDependencies,
13
+ tasks
14
+ } from "../../src/db/schema/index.js";
15
+ import { createTestDb } from "../helpers/test-db.js";
16
+
17
+ test("createTestDb applies migrations so plans table exists and accepts rows", async () => {
18
+ const db = await createTestDb();
19
+ const now = new Date().toISOString();
20
+ db.insert(plans)
21
+ .values({
22
+ id: "plan-test-1",
23
+ slug: "test-plan",
24
+ title: "Test",
25
+ description: null,
26
+ markdownContent: null,
27
+ status: "draft",
28
+ isActive: false,
29
+ createdAt: now,
30
+ updatedAt: now
31
+ })
32
+ .run();
33
+
34
+ const rows = db.select().from(plans).where(eq(plans.id, "plan-test-1")).all();
35
+ assert.equal(rows.length, 1);
36
+ assert.equal(rows[0]?.slug, "test-plan");
37
+ });
38
+
39
+ test("createTestDb creates task_dependencies table (FK to tasks)", async () => {
40
+ const db = await createTestDb();
41
+ const now = new Date().toISOString();
42
+ db.insert(plans)
43
+ .values({
44
+ id: "p1",
45
+ slug: "p",
46
+ title: "P",
47
+ description: null,
48
+ markdownContent: null,
49
+ status: "draft",
50
+ isActive: false,
51
+ createdAt: now,
52
+ updatedAt: now
53
+ })
54
+ .run();
55
+ db.insert(sprints)
56
+ .values({
57
+ id: "s1",
58
+ slug: "s",
59
+ planId: "p1",
60
+ name: "S",
61
+ goal: "G",
62
+ markdownContent: null,
63
+ status: "planned",
64
+ sprintOrder: 0,
65
+ startDate: null,
66
+ endDate: null,
67
+ createdAt: now,
68
+ updatedAt: now
69
+ })
70
+ .run();
71
+ db.insert(tasks)
72
+ .values({
73
+ id: "t1",
74
+ sprintId: "s1",
75
+ title: "A",
76
+ description: null,
77
+ status: "todo",
78
+ priority: "medium",
79
+ taskOrder: 0,
80
+ assignee: null,
81
+ tags: null,
82
+ createdAt: now,
83
+ updatedAt: now
84
+ })
85
+ .run();
86
+ db.insert(tasks)
87
+ .values({
88
+ id: "t2",
89
+ sprintId: "s1",
90
+ title: "B",
91
+ description: null,
92
+ status: "todo",
93
+ priority: "medium",
94
+ taskOrder: 1,
95
+ assignee: null,
96
+ tags: null,
97
+ createdAt: now,
98
+ updatedAt: now
99
+ })
100
+ .run();
101
+ db.insert(taskDependencies)
102
+ .values({ taskId: "t1", dependsOnTaskId: "t2" })
103
+ .run();
104
+
105
+ const deps = db
106
+ .select()
107
+ .from(taskDependencies)
108
+ .where(eq(taskDependencies.taskId, "t1"))
109
+ .all();
110
+ assert.equal(deps.length, 1);
111
+ assert.equal(deps[0]?.dependsOnTaskId, "t2");
112
+ });