@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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Unit tests for plan status transition rules.
|
|
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 { PlanDomainService } from "../../src/domain/services/plan-domain.service.js";
|
|
10
|
+
|
|
11
|
+
const svc = new PlanDomainService();
|
|
12
|
+
|
|
13
|
+
test("same plan status is a valid no-op transition", () => {
|
|
14
|
+
assert.equal(svc.canTransitionStatus("draft", "draft"), true);
|
|
15
|
+
svc.validateTransition("active", "active");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("draft may become active only among forward moves from draft", () => {
|
|
19
|
+
assert.equal(svc.canTransitionStatus("draft", "active"), true);
|
|
20
|
+
assert.equal(svc.canTransitionStatus("draft", "completed"), false);
|
|
21
|
+
assert.equal(svc.canTransitionStatus("draft", "archived"), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("active may complete or archive", () => {
|
|
25
|
+
assert.equal(svc.canTransitionStatus("active", "completed"), true);
|
|
26
|
+
assert.equal(svc.canTransitionStatus("active", "archived"), true);
|
|
27
|
+
assert.equal(svc.canTransitionStatus("active", "draft"), false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("completed may archive only", () => {
|
|
31
|
+
assert.equal(svc.canTransitionStatus("completed", "archived"), true);
|
|
32
|
+
assert.equal(svc.canTransitionStatus("completed", "active"), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("archived is terminal", () => {
|
|
36
|
+
assert.equal(svc.canTransitionStatus("archived", "draft"), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("validateTransition throws ValidationError for illegal plan move", () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => svc.validateTransition("draft", "archived"),
|
|
42
|
+
(err: unknown) => err instanceof ValidationError
|
|
43
|
+
);
|
|
44
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Unit tests for sprint status transition rules.
|
|
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 { SprintDomainService } from "../../src/domain/services/sprint-domain.service.js";
|
|
10
|
+
|
|
11
|
+
const svc = new SprintDomainService();
|
|
12
|
+
|
|
13
|
+
test("same sprint status is a valid no-op transition", () => {
|
|
14
|
+
assert.equal(svc.canTransitionStatus("planned", "planned"), true);
|
|
15
|
+
svc.validateTransition("active", "active");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("planned may become active", () => {
|
|
19
|
+
assert.equal(svc.canTransitionStatus("planned", "active"), true);
|
|
20
|
+
assert.equal(svc.canTransitionStatus("planned", "completed"), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("active may complete or archive", () => {
|
|
24
|
+
assert.equal(svc.canTransitionStatus("active", "completed"), true);
|
|
25
|
+
assert.equal(svc.canTransitionStatus("active", "archived"), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("completed may archive only", () => {
|
|
29
|
+
assert.equal(svc.canTransitionStatus("completed", "archived"), true);
|
|
30
|
+
assert.equal(svc.canTransitionStatus("completed", "planned"), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("validateTransition throws ValidationError for illegal sprint move", () => {
|
|
34
|
+
assert.throws(
|
|
35
|
+
() => svc.validateTransition("planned", "archived"),
|
|
36
|
+
(err: unknown) => err instanceof ValidationError
|
|
37
|
+
);
|
|
38
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Unit tests for task transitions, ordering, and dependency graph rules.
|
|
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 type { Task, TaskDependency } from "../../src/domain/entities/task.entity.js";
|
|
10
|
+
import { TaskDomainService } from "../../src/domain/services/task-domain.service.js";
|
|
11
|
+
|
|
12
|
+
const svc = new TaskDomainService();
|
|
13
|
+
|
|
14
|
+
function task(
|
|
15
|
+
partial: Pick<Task, "id" | "sprintId" | "order"> & Partial<Task>
|
|
16
|
+
): Task {
|
|
17
|
+
return {
|
|
18
|
+
title: "t",
|
|
19
|
+
description: null,
|
|
20
|
+
status: "todo",
|
|
21
|
+
priority: "medium",
|
|
22
|
+
assignee: null,
|
|
23
|
+
tags: null,
|
|
24
|
+
touchedFiles: [],
|
|
25
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
26
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
27
|
+
...partial
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test("task status transitions follow workflow rules", () => {
|
|
32
|
+
assert.equal(svc.canTransitionStatus("todo", "in_progress"), true);
|
|
33
|
+
assert.equal(svc.canTransitionStatus("todo", "done"), true);
|
|
34
|
+
assert.equal(svc.canTransitionStatus("in_progress", "done"), true);
|
|
35
|
+
assert.equal(svc.canTransitionStatus("done", "todo"), true);
|
|
36
|
+
assert.equal(svc.canTransitionStatus("done", "in_progress"), true);
|
|
37
|
+
assert.equal(svc.canTransitionStatus("done", "blocked"), true);
|
|
38
|
+
assert.equal(svc.canTransitionStatus("todo", "todo"), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("validateTransition throws for disallowed task status change", () => {
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => svc.validateTransition("blocked", "done"),
|
|
44
|
+
(err: unknown) => err instanceof ValidationError
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("validateOrderUniqueness throws when two tasks share order", () => {
|
|
49
|
+
const tasks = [
|
|
50
|
+
task({ id: "a", sprintId: "s1", order: 1 }),
|
|
51
|
+
task({ id: "b", sprintId: "s1", order: 1 })
|
|
52
|
+
];
|
|
53
|
+
assert.throws(
|
|
54
|
+
() => svc.validateOrderUniqueness(tasks),
|
|
55
|
+
(err: unknown) => err instanceof ValidationError
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("validateOrderUniqueness allows distinct orders", () => {
|
|
60
|
+
svc.validateOrderUniqueness([
|
|
61
|
+
task({ id: "a", sprintId: "s1", order: 0 }),
|
|
62
|
+
task({ id: "b", sprintId: "s1", order: 1 })
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("validateDependencyGraph accepts acyclic dependencies", () => {
|
|
67
|
+
const tasks = [
|
|
68
|
+
task({ id: "t1", sprintId: "s1", order: 0 }),
|
|
69
|
+
task({ id: "t2", sprintId: "s1", order: 1 })
|
|
70
|
+
];
|
|
71
|
+
const deps: TaskDependency[] = [
|
|
72
|
+
{ taskId: "t1", dependsOnTaskId: "t2" }
|
|
73
|
+
];
|
|
74
|
+
svc.validateDependencyGraph(tasks, deps);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("validateDependencyGraph throws with cycle path", () => {
|
|
78
|
+
const tasks = [
|
|
79
|
+
task({ id: "t1", sprintId: "s1", order: 0 }),
|
|
80
|
+
task({ id: "t2", sprintId: "s1", order: 1 })
|
|
81
|
+
];
|
|
82
|
+
const deps: TaskDependency[] = [
|
|
83
|
+
{ taskId: "t1", dependsOnTaskId: "t2" },
|
|
84
|
+
{ taskId: "t2", dependsOnTaskId: "t1" }
|
|
85
|
+
];
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => svc.validateDependencyGraph(tasks, deps),
|
|
88
|
+
(err: unknown) =>
|
|
89
|
+
err instanceof ValidationError &&
|
|
90
|
+
/cycle/i.test((err as ValidationError).message) &&
|
|
91
|
+
/t1/.test((err as ValidationError).message) &&
|
|
92
|
+
/t2/.test((err as ValidationError).message)
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("validateDependencyReferences rejects unknown prerequisite ids", () => {
|
|
97
|
+
assert.throws(
|
|
98
|
+
() => svc.validateDependencyReferences(["missing"], ["t1", "t2"]),
|
|
99
|
+
(err: unknown) => err instanceof ValidationError
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("validateDependencyReferences accepts known ids", () => {
|
|
104
|
+
svc.validateDependencyReferences(["t2", "t3"], ["t1", "t2", "t3"]);
|
|
105
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Tests for typed backend error hierarchy used by HTTP and MCP layers.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import {
|
|
9
|
+
BackendError,
|
|
10
|
+
ConflictError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
StorageError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
} from "../../src/errors/backend-errors.js";
|
|
15
|
+
|
|
16
|
+
test("BackendError exposes statusCode", () => {
|
|
17
|
+
const err = new BackendError("base", 500);
|
|
18
|
+
assert.ok(err instanceof Error);
|
|
19
|
+
assert.equal(err.statusCode, 500);
|
|
20
|
+
assert.equal(err.message, "base");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("NotFoundError is 404 and names entity and id", () => {
|
|
24
|
+
const err = new NotFoundError("Plan", "abc-123");
|
|
25
|
+
assert.ok(err instanceof BackendError);
|
|
26
|
+
assert.equal(err.statusCode, 404);
|
|
27
|
+
assert.match(err.message, /Plan/);
|
|
28
|
+
assert.match(err.message, /abc-123/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ValidationError is 400", () => {
|
|
32
|
+
const err = new ValidationError("bad input");
|
|
33
|
+
assert.equal(err.statusCode, 400);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("ConflictError is 409", () => {
|
|
37
|
+
const err = new ConflictError("duplicate slug");
|
|
38
|
+
assert.equal(err.statusCode, 409);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("StorageError is 500", () => {
|
|
42
|
+
const err = new StorageError("disk full");
|
|
43
|
+
assert.equal(err.statusCode, 500);
|
|
44
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: In-memory SQLite + migrations for integration tests (sql.js, no native addon).
|
|
5
|
+
*/
|
|
6
|
+
import { migrate } from "drizzle-orm/sql-js/migrator";
|
|
7
|
+
import { drizzle } from "drizzle-orm/sql-js";
|
|
8
|
+
import type { SQLJsDatabase } from "drizzle-orm/sql-js";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import initSqlJs from "sql.js";
|
|
12
|
+
import * as schema from "../../src/db/schema/index";
|
|
13
|
+
|
|
14
|
+
const migrationsFolder = join(
|
|
15
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
16
|
+
"../../drizzle"
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
let sqlJsModule: Awaited<ReturnType<typeof initSqlJs>> | undefined;
|
|
20
|
+
|
|
21
|
+
async function loadSqlJs(): Promise<NonNullable<typeof sqlJsModule>> {
|
|
22
|
+
if (!sqlJsModule) {
|
|
23
|
+
sqlJsModule = await initSqlJs();
|
|
24
|
+
}
|
|
25
|
+
return sqlJsModule;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates an in-memory database, enables foreign keys, applies Drizzle migrations.
|
|
30
|
+
*
|
|
31
|
+
* Uses sql.js so tests do not require a native better-sqlite3 build (for example on Node
|
|
32
|
+
* versions without prebuilt binaries). Runtime file databases still use better-sqlite3.
|
|
33
|
+
*
|
|
34
|
+
* @returns Drizzle client ready for repository tests.
|
|
35
|
+
*/
|
|
36
|
+
export async function createTestDb(): Promise<SQLJsDatabase<typeof schema>> {
|
|
37
|
+
const SQL = await loadSqlJs();
|
|
38
|
+
const sqlite = new SQL.Database();
|
|
39
|
+
sqlite.run("PRAGMA foreign_keys = ON;");
|
|
40
|
+
const db = drizzle(sqlite, { schema });
|
|
41
|
+
migrate(db, { migrationsFolder });
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Error handler stack exposure in non-production.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import request from "supertest";
|
|
9
|
+
import express from "express";
|
|
10
|
+
import { ConflictError } from "../../src/errors/backend-errors.js";
|
|
11
|
+
import { errorHandler } from "../../src/http/middleware/error-handler.js";
|
|
12
|
+
|
|
13
|
+
test("ConflictError maps to 409", async () => {
|
|
14
|
+
const app = express();
|
|
15
|
+
app.get("/boom", (_req, _res, next) => {
|
|
16
|
+
next(new ConflictError("duplicate"));
|
|
17
|
+
});
|
|
18
|
+
app.use(errorHandler);
|
|
19
|
+
const res = await request(app).get("/boom").expect(409);
|
|
20
|
+
assert.equal(res.body.error.statusCode, 409);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("unknown errors include stack when NODE_ENV is not production", async () => {
|
|
24
|
+
const prev = process.env.NODE_ENV;
|
|
25
|
+
process.env.NODE_ENV = "development";
|
|
26
|
+
try {
|
|
27
|
+
const app = express();
|
|
28
|
+
app.get("/x", (_req, _res, next) => {
|
|
29
|
+
next(new Error("fail"));
|
|
30
|
+
});
|
|
31
|
+
app.use(errorHandler);
|
|
32
|
+
const res = await request(app).get("/x").expect(500);
|
|
33
|
+
assert.ok(res.body.error.stack);
|
|
34
|
+
} finally {
|
|
35
|
+
process.env.NODE_ENV = prev;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: HTTP tests for plan routes, health, CORS, and request id headers.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import request from "supertest";
|
|
9
|
+
import { createHttpTestApp } from "./test-app.js";
|
|
10
|
+
|
|
11
|
+
const v1 = "/api/v1";
|
|
12
|
+
|
|
13
|
+
test("GET / returns API discovery JSON (not Cannot GET /)", async () => {
|
|
14
|
+
const { app } = await createHttpTestApp();
|
|
15
|
+
const res = await request(app).get("/").expect(200);
|
|
16
|
+
assert.equal(res.body.service, "sprintdock-rest");
|
|
17
|
+
assert.equal(res.body.apiBase, "/api/v1");
|
|
18
|
+
assert.equal(res.body.health, "/api/v1/health");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("GET /api/v1/dashboard-overview returns 200 with null plan when no active plan", async () => {
|
|
22
|
+
const { app } = await createHttpTestApp();
|
|
23
|
+
const res = await request(app).get(`${v1}/dashboard-overview`).expect(200);
|
|
24
|
+
assert.equal(res.body.execution.plan, null);
|
|
25
|
+
assert.deepEqual(res.body.execution.activeSprints, []);
|
|
26
|
+
assert.equal(res.body.throughput.todo, 0);
|
|
27
|
+
assert.equal(res.body.throughput.inProgress, 0);
|
|
28
|
+
assert.equal(res.body.throughput.done, 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("GET /api/v1/health returns ok, version, timestamp and X-Request-Id", async () => {
|
|
32
|
+
const { app } = await createHttpTestApp();
|
|
33
|
+
const res = await request(app).get(`${v1}/health`).expect(200);
|
|
34
|
+
assert.equal(res.body.status, "ok");
|
|
35
|
+
assert.ok(typeof res.body.version === "string");
|
|
36
|
+
assert.ok(typeof res.body.timestamp === "string");
|
|
37
|
+
assert.ok(res.headers["x-request-id"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("OPTIONS /api/v1/health returns CORS headers and 204", async () => {
|
|
41
|
+
const { app } = await createHttpTestApp();
|
|
42
|
+
const res = await request(app).options(`${v1}/health`).expect(204);
|
|
43
|
+
assert.ok(res.headers["access-control-allow-origin"]);
|
|
44
|
+
assert.ok(res.headers["access-control-allow-methods"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("X-Request-Id header is echoed when provided", async () => {
|
|
48
|
+
const { app } = await createHttpTestApp();
|
|
49
|
+
const res = await request(app)
|
|
50
|
+
.get(`${v1}/health`)
|
|
51
|
+
.set("X-Request-Id", "custom-req-id")
|
|
52
|
+
.expect(200);
|
|
53
|
+
assert.equal(res.headers["x-request-id"], "custom-req-id");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("POST /plans validates body", async () => {
|
|
57
|
+
const { app } = await createHttpTestApp();
|
|
58
|
+
const res = await request(app)
|
|
59
|
+
.post(`${v1}/plans`)
|
|
60
|
+
.send({})
|
|
61
|
+
.expect(400);
|
|
62
|
+
assert.equal(res.body.error.message, "Validation failed");
|
|
63
|
+
assert.ok(Array.isArray(res.body.error.details));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("plan CRUD and activate happy path", async () => {
|
|
67
|
+
const { app } = await createHttpTestApp();
|
|
68
|
+
const created = await request(app)
|
|
69
|
+
.post(`${v1}/plans`)
|
|
70
|
+
.send({ slug: "web", title: "Web", description: null })
|
|
71
|
+
.expect(201);
|
|
72
|
+
const plan = created.body.plan;
|
|
73
|
+
assert.ok(plan?.id);
|
|
74
|
+
|
|
75
|
+
const listed = await request(app).get(`${v1}/plans`).expect(200);
|
|
76
|
+
assert.ok(Array.isArray(listed.body.plans));
|
|
77
|
+
assert.ok(listed.body.plans.length >= 1);
|
|
78
|
+
|
|
79
|
+
const one = await request(app).get(`${v1}/plans/${plan.slug}`).expect(200);
|
|
80
|
+
assert.equal(one.body.plan.slug, "web");
|
|
81
|
+
|
|
82
|
+
const patched = await request(app)
|
|
83
|
+
.patch(`${v1}/plans/${plan.slug}`)
|
|
84
|
+
.send({ title: "Web 2" })
|
|
85
|
+
.expect(200);
|
|
86
|
+
assert.equal(patched.body.plan.title, "Web 2");
|
|
87
|
+
|
|
88
|
+
await request(app).post(`${v1}/plans/${plan.slug}/activate`).expect(200);
|
|
89
|
+
|
|
90
|
+
const ctx = await request(app).get(`${v1}/execution-context`).expect(200);
|
|
91
|
+
assert.equal(ctx.body.plan.id, plan.id);
|
|
92
|
+
assert.ok(Array.isArray(ctx.body.activeSprints));
|
|
93
|
+
|
|
94
|
+
const ax = await request(app)
|
|
95
|
+
.get(`${v1}/plans/${plan.slug}/analytics`)
|
|
96
|
+
.expect(200);
|
|
97
|
+
assert.equal(ax.body.analytics.planId, plan.id);
|
|
98
|
+
assert.ok(Array.isArray(ax.body.analytics.sprints));
|
|
99
|
+
assert.ok(ax.body.analytics.rollup);
|
|
100
|
+
assert.equal(typeof ax.body.analytics.rollup.totalTasks, "number");
|
|
101
|
+
|
|
102
|
+
const dash = await request(app).get(`${v1}/dashboard-overview`).expect(200);
|
|
103
|
+
assert.equal(dash.body.execution.plan.id, plan.id);
|
|
104
|
+
assert.ok(Array.isArray(dash.body.execution.activeSprints));
|
|
105
|
+
assert.equal(typeof dash.body.throughput.todo, "number");
|
|
106
|
+
assert.equal(typeof dash.body.throughput.inProgress, "number");
|
|
107
|
+
assert.equal(typeof dash.body.throughput.done, "number");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("GET /plans/:idOrSlug returns 404 when missing", async () => {
|
|
111
|
+
const { app } = await createHttpTestApp();
|
|
112
|
+
const res = await request(app).get(`${v1}/plans/missing-slug`).expect(404);
|
|
113
|
+
assert.equal(res.body.error.statusCode, 404);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("PATCH /plans/:idOrSlug returns 400 on illegal status transition", async () => {
|
|
117
|
+
const { app } = await createHttpTestApp();
|
|
118
|
+
const created = await request(app)
|
|
119
|
+
.post(`${v1}/plans`)
|
|
120
|
+
.send({ slug: "p", title: "P" })
|
|
121
|
+
.expect(201);
|
|
122
|
+
const id = created.body.plan.id as string;
|
|
123
|
+
const res = await request(app)
|
|
124
|
+
.patch(`${v1}/plans/${id}`)
|
|
125
|
+
.send({ status: "archived" })
|
|
126
|
+
.expect(400);
|
|
127
|
+
assert.equal(res.body.error.statusCode, 400);
|
|
128
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: HTTP tests for sprint routes.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import request from "supertest";
|
|
9
|
+
import { createHttpTestApp } from "./test-app.js";
|
|
10
|
+
|
|
11
|
+
const v1 = "/api/v1";
|
|
12
|
+
|
|
13
|
+
test("sprint list, create, getOne with tasks, status update", async () => {
|
|
14
|
+
const { app } = await createHttpTestApp();
|
|
15
|
+
const planRes = await request(app)
|
|
16
|
+
.post(`${v1}/plans`)
|
|
17
|
+
.send({ slug: "p", title: "P" })
|
|
18
|
+
.expect(201);
|
|
19
|
+
const planId = planRes.body.plan.id as string;
|
|
20
|
+
|
|
21
|
+
const list0 = await request(app)
|
|
22
|
+
.get(`${v1}/plans/${planId}/sprints`)
|
|
23
|
+
.expect(200);
|
|
24
|
+
assert.equal(list0.body.sprints.length, 0);
|
|
25
|
+
|
|
26
|
+
const spRes = await request(app)
|
|
27
|
+
.post(`${v1}/plans/${planId}/sprints`)
|
|
28
|
+
.send({ slug: "s1", name: "S1", goal: "G1" })
|
|
29
|
+
.expect(201);
|
|
30
|
+
const sprintId = spRes.body.sprint.id as string;
|
|
31
|
+
|
|
32
|
+
await request(app)
|
|
33
|
+
.post(`${v1}/sprints/${sprintId}/tasks`)
|
|
34
|
+
.send({ title: "T1", priority: "low" })
|
|
35
|
+
.expect(201);
|
|
36
|
+
|
|
37
|
+
const one = await request(app).get(`${v1}/sprints/${sprintId}`).expect(200);
|
|
38
|
+
assert.equal(one.body.sprint.id, sprintId);
|
|
39
|
+
assert.equal(one.body.tasks.length, 1);
|
|
40
|
+
|
|
41
|
+
const bad = await request(app)
|
|
42
|
+
.patch(`${v1}/sprints/${sprintId}/status`)
|
|
43
|
+
.send({ status: "archived" })
|
|
44
|
+
.expect(400);
|
|
45
|
+
assert.equal(bad.body.error.statusCode, 400);
|
|
46
|
+
|
|
47
|
+
const ok = await request(app)
|
|
48
|
+
.patch(`${v1}/sprints/${sprintId}/status`)
|
|
49
|
+
.send({ status: "active" })
|
|
50
|
+
.expect(200);
|
|
51
|
+
assert.equal(ok.body.sprint.status, "active");
|
|
52
|
+
|
|
53
|
+
const md = await request(app)
|
|
54
|
+
.patch(`${v1}/sprints/${sprintId}`)
|
|
55
|
+
.send({ markdownContent: "## Scope\nBoard notes." })
|
|
56
|
+
.expect(200);
|
|
57
|
+
assert.equal(md.body.sprint.markdownContent, "## Scope\nBoard notes.");
|
|
58
|
+
|
|
59
|
+
const cleared = await request(app)
|
|
60
|
+
.patch(`${v1}/sprints/${sprintId}`)
|
|
61
|
+
.send({ markdownContent: null })
|
|
62
|
+
.expect(200);
|
|
63
|
+
assert.equal(cleared.body.sprint.markdownContent, null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("GET /sprints/:id returns 404 when missing", async () => {
|
|
67
|
+
const { app } = await createHttpTestApp();
|
|
68
|
+
const res = await request(app)
|
|
69
|
+
.get(`${v1}/sprints/00000000-0000-4000-8000-000000000000`)
|
|
70
|
+
.expect(404);
|
|
71
|
+
assert.equal(res.body.error.statusCode, 404);
|
|
72
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: HTTP tests for task routes.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import request from "supertest";
|
|
9
|
+
import { createHttpTestApp } from "./test-app.js";
|
|
10
|
+
|
|
11
|
+
const v1 = "/api/v1";
|
|
12
|
+
|
|
13
|
+
async function seedPlanWithSprint(app: import("express").Express) {
|
|
14
|
+
const planRes = await request(app)
|
|
15
|
+
.post(`${v1}/plans`)
|
|
16
|
+
.send({ slug: "p", title: "P" })
|
|
17
|
+
.expect(201);
|
|
18
|
+
const planId = planRes.body.plan.id as string;
|
|
19
|
+
const spRes = await request(app)
|
|
20
|
+
.post(`${v1}/plans/${planId}/sprints`)
|
|
21
|
+
.send({ slug: "s1", name: "S1", goal: "G" })
|
|
22
|
+
.expect(201);
|
|
23
|
+
return { planId, sprintId: spRes.body.sprint.id as string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("task list, create without explicit plan slug, get, status, assign, move, delete", async () => {
|
|
27
|
+
const { app } = await createHttpTestApp();
|
|
28
|
+
const { planId, sprintId } = await seedPlanWithSprint(app);
|
|
29
|
+
|
|
30
|
+
const sp2 = await request(app)
|
|
31
|
+
.post(`${v1}/plans/${planId}/sprints`)
|
|
32
|
+
.send({ slug: "s2", name: "S2", goal: "G" })
|
|
33
|
+
.expect(201);
|
|
34
|
+
const sprint2Id = sp2.body.sprint.id as string;
|
|
35
|
+
|
|
36
|
+
const created = await request(app)
|
|
37
|
+
.post(`${v1}/sprints/${sprintId}/tasks`)
|
|
38
|
+
.send({ title: "Do work", priority: "high" })
|
|
39
|
+
.expect(201);
|
|
40
|
+
const taskId = created.body.task.id as string;
|
|
41
|
+
|
|
42
|
+
const withFiles = await request(app)
|
|
43
|
+
.post(`${v1}/sprints/${sprintId}/tasks`)
|
|
44
|
+
.send({
|
|
45
|
+
title: "Touched paths",
|
|
46
|
+
priority: "low",
|
|
47
|
+
touchedFiles: [
|
|
48
|
+
{ path: "packages/api/src/routes.ts", fileType: "implementation" },
|
|
49
|
+
{ path: "packages/api/tests/routes.test.ts", fileType: "test" }
|
|
50
|
+
]
|
|
51
|
+
})
|
|
52
|
+
.expect(201);
|
|
53
|
+
const touchedTaskId = withFiles.body.task.id as string;
|
|
54
|
+
assert.equal(withFiles.body.task.touchedFiles.length, 2);
|
|
55
|
+
|
|
56
|
+
const listed = await request(app)
|
|
57
|
+
.get(`${v1}/sprints/${sprintId}/tasks`)
|
|
58
|
+
.expect(200);
|
|
59
|
+
assert.equal(listed.body.tasks.length, 2);
|
|
60
|
+
|
|
61
|
+
const one = await request(app).get(`${v1}/tasks/${taskId}`).expect(200);
|
|
62
|
+
assert.equal(one.body.task.title, "Do work");
|
|
63
|
+
|
|
64
|
+
const fromList = listed.body.tasks.find(
|
|
65
|
+
(t: { id: string }) => t.id === touchedTaskId
|
|
66
|
+
);
|
|
67
|
+
assert.ok(fromList);
|
|
68
|
+
assert.equal(fromList.touchedFiles.length, 2);
|
|
69
|
+
|
|
70
|
+
const patchedFiles = await request(app)
|
|
71
|
+
.patch(`${v1}/tasks/${touchedTaskId}`)
|
|
72
|
+
.send({
|
|
73
|
+
touchedFiles: [{ path: "CHANGELOG.md", fileType: "doc" }]
|
|
74
|
+
})
|
|
75
|
+
.expect(200);
|
|
76
|
+
assert.equal(patchedFiles.body.task.touchedFiles.length, 1);
|
|
77
|
+
assert.equal(patchedFiles.body.task.touchedFiles[0].path, "CHANGELOG.md");
|
|
78
|
+
|
|
79
|
+
const st = await request(app)
|
|
80
|
+
.patch(`${v1}/tasks/${taskId}/status`)
|
|
81
|
+
.send({ status: "in_progress" })
|
|
82
|
+
.expect(200);
|
|
83
|
+
assert.equal(st.body.task.status, "in_progress");
|
|
84
|
+
|
|
85
|
+
const asg = await request(app)
|
|
86
|
+
.patch(`${v1}/tasks/${taskId}/assign`)
|
|
87
|
+
.send({ assignee: "bob" })
|
|
88
|
+
.expect(200);
|
|
89
|
+
assert.equal(asg.body.task.assignee, "bob");
|
|
90
|
+
|
|
91
|
+
const moved = await request(app)
|
|
92
|
+
.post(`${v1}/tasks/${taskId}/move`)
|
|
93
|
+
.send({ targetSprintId: sprint2Id })
|
|
94
|
+
.expect(200);
|
|
95
|
+
assert.equal(moved.body.task.sprintId, sprint2Id);
|
|
96
|
+
|
|
97
|
+
const badMove = await request(app)
|
|
98
|
+
.post(`${v1}/plans`)
|
|
99
|
+
.send({ slug: "other", title: "O" })
|
|
100
|
+
.expect(201);
|
|
101
|
+
const otherPlanId = badMove.body.plan.id as string;
|
|
102
|
+
const otherSp = await request(app)
|
|
103
|
+
.post(`${v1}/plans/${otherPlanId}/sprints`)
|
|
104
|
+
.send({ slug: "os", name: "OS", goal: "G" })
|
|
105
|
+
.expect(201);
|
|
106
|
+
const otherSprintId = otherSp.body.sprint.id as string;
|
|
107
|
+
|
|
108
|
+
const t2 = await request(app)
|
|
109
|
+
.post(`${v1}/sprints/${sprintId}/tasks`)
|
|
110
|
+
.send({ title: "T2", priority: "low" })
|
|
111
|
+
.expect(201);
|
|
112
|
+
const t2Id = t2.body.task.id as string;
|
|
113
|
+
|
|
114
|
+
const cross = await request(app)
|
|
115
|
+
.post(`${v1}/tasks/${t2Id}/move`)
|
|
116
|
+
.send({ targetSprintId: otherSprintId })
|
|
117
|
+
.expect(400);
|
|
118
|
+
assert.equal(cross.body.error.statusCode, 400);
|
|
119
|
+
|
|
120
|
+
const del = await request(app).delete(`${v1}/tasks/${taskId}`).expect(200);
|
|
121
|
+
assert.equal(del.body.deleted, true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("GET /tasks/:id returns 404 when missing", async () => {
|
|
125
|
+
const { app } = await createHttpTestApp();
|
|
126
|
+
const res = await request(app)
|
|
127
|
+
.get(`${v1}/tasks/00000000-0000-4000-8000-000000000000`)
|
|
128
|
+
.expect(404);
|
|
129
|
+
assert.equal(res.body.error.statusCode, 404);
|
|
130
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* package: @sprintdock/backend
|
|
3
|
+
* author: vikash sharma
|
|
4
|
+
* description: Shared helper to build an HTTP app backed by a migrated in-memory DB.
|
|
5
|
+
*/
|
|
6
|
+
import { createApplicationServices } from "../../src/application/container.js";
|
|
7
|
+
import { createHttpApp } from "../../src/http/app-factory.js";
|
|
8
|
+
import { createRepositories } from "../../src/infrastructure/repositories/repository-factory.js";
|
|
9
|
+
import { createTestDb } from "../helpers/test-db.js";
|
|
10
|
+
|
|
11
|
+
export async function createHttpTestApp() {
|
|
12
|
+
const db = await createTestDb();
|
|
13
|
+
const repos = createRepositories("sqlite", { sqlite: { db } });
|
|
14
|
+
const services = createApplicationServices(repos);
|
|
15
|
+
const app = createHttpApp(services);
|
|
16
|
+
return { app, services, repos, db };
|
|
17
|
+
}
|