diff --git a/package.json b/package.json index 955a6e3..29dd307 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "analyze": "ANALYZE=true npm run build" + "analyze": "ANALYZE=true npm run build", + "test:refactor": "tsx --test scripts/tests/sprintSelection.test.ts && bash scripts/tests/refactor-cli-api.sh" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/tests/refactor-cli-api.sh b/scripts/tests/refactor-cli-api.sh new file mode 100755 index 0000000..7490df6 --- /dev/null +++ b/scripts/tests/refactor-cli-api.sh @@ -0,0 +1,290 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TMP_DIR="$(mktemp -d)" +MOCK_BIN="$TMP_DIR/mockbin" +MOCK_LOG="$TMP_DIR/mock-curl.log" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +mkdir -p "$MOCK_BIN" +touch "$MOCK_LOG" + +cat > "$MOCK_BIN/curl" <<'MOCK_CURL' +#!/bin/bash +set -euo pipefail + +method="GET" +url="" +data="" +cookie_out="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -X) + method="$2" + shift 2 + ;; + --data|-d) + data="$2" + shift 2 + ;; + -c) + cookie_out="$2" + shift 2 + ;; + -b|-H|-w) + shift 2 + ;; + -s|-sS) + shift + ;; + http://*|https://*) + url="$1" + shift + ;; + *) + shift + ;; + esac +done + +if [[ -n "$cookie_out" ]]; then + mkdir -p "$(dirname "$cookie_out")" + touch "$cookie_out" +fi + +echo "${method} ${url} ${data}" >> "${MOCK_CURL_LOG:?}" + +respond() { + printf '%s\n%s\n' "$1" "$2" +} + +case "${method} ${url}" in + "GET http://localhost:3000/api/projects") + respond '{"projects":[{"id":"p1","name":"Proj","description":"Demo","color":"#3b82f6"}]}' 200 + ;; + "GET http://localhost:3000/api/projects/p1") + respond '{"project":{"id":"p1","name":"Proj","description":"Demo","color":"#3b82f6"}}' 200 + ;; + "POST http://localhost:3000/api/projects") + respond '{"success":true,"project":{"id":"p2","name":"New Proj"}}' 200 + ;; + "PATCH http://localhost:3000/api/projects") + respond '{"success":true}' 200 + ;; + "DELETE http://localhost:3000/api/projects") + respond '{"success":true}' 200 + ;; + "GET http://localhost:3000/api/auth/users") + respond '{"users":[{"id":"u1","name":"Max","email":"max@example.com"}]}' 200 + ;; + "POST http://localhost:3000/api/auth/login") + respond '{"success":true}' 200 + ;; + "POST http://localhost:3000/api/auth/logout") + respond '{"success":true}' 200 + ;; + "GET http://localhost:3000/api/auth/session") + respond '{"authenticated":true}' 200 + ;; + "POST http://localhost:3000/api/auth/register") + respond '{"success":true}' 200 + ;; + "POST http://localhost:3000/api/auth/forgot-password") + respond '{"success":true}' 200 + ;; + "POST http://localhost:3000/api/auth/reset-password") + respond '{"success":true}' 200 + ;; + "PATCH http://localhost:3000/api/auth/account") + respond '{"success":true}' 200 + ;; + "GET http://localhost:3000/api/sprints"|\ + "GET http://localhost:3000/api/sprints?status=active") + respond '{"sprints":[{"id":"s1","name":"Sprint 1","projectId":"p1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}]}' 200 + ;; + "GET http://localhost:3000/api/sprints/s1") + respond '{"sprint":{"id":"s1","name":"Sprint 1","projectId":"p1","status":"active","startDate":"2026-02-20","endDate":"2026-03-01"}}' 200 + ;; + "POST http://localhost:3000/api/sprints") + respond '{"success":true,"sprint":{"id":"s2","name":"Sprint 2","projectId":"p1"}}' 200 + ;; + "PATCH http://localhost:3000/api/sprints") + respond '{"success":true}' 200 + ;; + "DELETE http://localhost:3000/api/sprints") + respond '{"success":true}' 200 + ;; + "GET http://localhost:3000/api/sprints/current") + respond '{"sprint":{"id":"s1","name":"Sprint 1"}}' 200 + ;; + "GET http://localhost:3000/api/sprints/current?projectId=p1") + respond '{"sprint":{"id":"s1","name":"Sprint 1","projectId":"p1"}}' 200 + ;; + "POST http://localhost:3000/api/sprints/close") + respond '{"success":true}' 200 + ;; + "GET http://localhost:3000/api/tasks"|\ + "GET http://localhost:3000/api/tasks?scope=all") + respond '{"tasks":[{"id":"t1","title":"Demo Task","status":"open","priority":"medium","type":"task","projectId":"p1","assigneeId":"u1","sprintId":"s1","comments":[],"tags":[],"attachments":[]}]}' 200 + ;; + "GET http://localhost:3000/api/tasks?taskId=t1&include=detail") + respond '{"tasks":[{"id":"t1","title":"Demo Task","status":"open","priority":"medium","type":"task","projectId":"p1","assigneeId":"u1","sprintId":"s1","comments":[],"tags":[],"attachments":[]}]}' 200 + ;; + "POST http://localhost:3000/api/tasks") + respond '{"success":true,"task":{"id":"t1"}}' 200 + ;; + "DELETE http://localhost:3000/api/tasks") + respond '{"success":true}' 200 + ;; + "POST http://localhost:3000/api/tasks/natural") + respond '{"success":true,"task":{"id":"t2","title":"Natural"}}' 200 + ;; + "GET http://localhost:3000/api/debug") + respond '{"ok":true}' 200 + ;; + *) + respond "{\"error\":\"Unhandled mock request: ${method} ${url}\"}" 500 + ;; +esac +MOCK_CURL + +chmod +x "$MOCK_BIN/curl" + +assert_log_contains() { + local expected="$1" + if ! grep -F "$expected" "$MOCK_LOG" >/dev/null 2>&1; then + echo "Expected mock curl log to contain: $expected" >&2 + echo "Actual log:" >&2 + cat "$MOCK_LOG" >&2 + exit 1 + fi +} + +export HOME="$TMP_DIR/home" +export PATH="$MOCK_BIN:$PATH" +export MOCK_CURL_LOG="$MOCK_LOG" +export API_URL="http://localhost:3000/api" + +ATTACH_FILE="$TMP_DIR/attachment.txt" +BULK_FILE="$TMP_DIR/tasks.json" +cat > "$ATTACH_FILE" <<'ATTACH_EOF' +refactor-test +ATTACH_EOF +cat > "$BULK_FILE" <<'BULK_EOF' +[ + { + "title": "Bulk task", + "project": "Proj", + "assignee": "Max", + "sprint": "Sprint 1" + } +] +BULK_EOF + +"$ROOT_DIR/scripts/task.sh" list --json >/dev/null +"$ROOT_DIR/scripts/task.sh" get t1 >/dev/null +"$ROOT_DIR/scripts/task.sh" current-sprint >/dev/null +"$ROOT_DIR/scripts/task.sh" current-sprint --project Proj >/dev/null +"$ROOT_DIR/scripts/task.sh" create --title "API Task" --project "Proj" --assignee "Max" --sprint current --status todo --priority high >/dev/null +"$ROOT_DIR/scripts/task.sh" update t1 --status done --tags "api,refactor" --add-comment "done" >/dev/null +"$ROOT_DIR/scripts/task.sh" delete t1 >/dev/null +"$ROOT_DIR/scripts/task.sh" bulk-create "$BULK_FILE" >/dev/null + +"$ROOT_DIR/scripts/project.sh" list --json >/dev/null +"$ROOT_DIR/scripts/project.sh" get "Proj" >/dev/null +"$ROOT_DIR/scripts/project.sh" create --name "New Proj" --description "Desc" --color "#ffffff" >/dev/null +"$ROOT_DIR/scripts/project.sh" update "Proj" --name "Renamed Proj" >/dev/null +"$ROOT_DIR/scripts/project.sh" delete "Proj" >/dev/null + +"$ROOT_DIR/scripts/sprint.sh" list --json >/dev/null +"$ROOT_DIR/scripts/sprint.sh" list --active --json >/dev/null +"$ROOT_DIR/scripts/sprint.sh" get "Sprint 1" >/dev/null +"$ROOT_DIR/scripts/sprint.sh" create --name "Sprint 2" --project "Proj" --goal "Ship" --start-date "2026-02-24" --end-date "2026-03-03" >/dev/null +"$ROOT_DIR/scripts/sprint.sh" update "Sprint 1" --status completed >/dev/null +"$ROOT_DIR/scripts/sprint.sh" close "Sprint 1" >/dev/null +"$ROOT_DIR/scripts/sprint.sh" delete "Sprint 1" >/dev/null + +"$ROOT_DIR/scripts/gantt.sh" task list open >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task get t1 >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task create "From wrapper" todo high p1 >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task natural "Create natural task" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task update t1 status done >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task comment t1 "Looks good" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task attach t1 "$ATTACH_FILE" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" task delete t1 >/dev/null + +"$ROOT_DIR/scripts/gantt.sh" project list >/dev/null +"$ROOT_DIR/scripts/gantt.sh" project get p1 >/dev/null +"$ROOT_DIR/scripts/gantt.sh" project create "Wrapper Project" "Desc" "#111111" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" project update p1 name "Renamed" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" project delete p1 >/dev/null + +"$ROOT_DIR/scripts/gantt.sh" sprint list >/dev/null +"$ROOT_DIR/scripts/gantt.sh" sprint get s1 >/dev/null +"$ROOT_DIR/scripts/gantt.sh" sprint create "Wrapper Sprint" p1 "2026-02-24" "2026-03-03" "Goal" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" sprint update s1 status completed >/dev/null +"$ROOT_DIR/scripts/gantt.sh" sprint close s1 >/dev/null +"$ROOT_DIR/scripts/gantt.sh" sprint delete s1 >/dev/null + +"$ROOT_DIR/scripts/gantt.sh" auth login "max@example.com" "secret" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth logout >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth session >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth register "new@example.com" "secret" "New User" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth forgot-password "new@example.com" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth reset-password "tok123" "newsecret" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth account name "Renamed User" >/dev/null +"$ROOT_DIR/scripts/gantt.sh" auth users >/dev/null +"$ROOT_DIR/scripts/gantt.sh" debug >/dev/null + +if [[ ! -f "$HOME/.config/gantt-board/cookies.txt" ]]; then + echo "Expected cookie file to be created at $HOME/.config/gantt-board/cookies.txt" >&2 + exit 1 +fi + +assert_log_contains "GET http://localhost:3000/api/projects" +assert_log_contains "POST http://localhost:3000/api/projects" +assert_log_contains "PATCH http://localhost:3000/api/projects" +assert_log_contains "DELETE http://localhost:3000/api/projects" +assert_log_contains "GET http://localhost:3000/api/projects/p1" + +assert_log_contains "GET http://localhost:3000/api/sprints" +assert_log_contains "GET http://localhost:3000/api/sprints?status=active" +assert_log_contains "GET http://localhost:3000/api/sprints/s1" +assert_log_contains "GET http://localhost:3000/api/sprints/current" +assert_log_contains "GET http://localhost:3000/api/sprints/current?projectId=p1" +assert_log_contains "POST http://localhost:3000/api/sprints" +assert_log_contains "PATCH http://localhost:3000/api/sprints" +assert_log_contains "POST http://localhost:3000/api/sprints/close" +assert_log_contains "DELETE http://localhost:3000/api/sprints" + +assert_log_contains "GET http://localhost:3000/api/tasks" +assert_log_contains "GET http://localhost:3000/api/tasks?scope=all" +assert_log_contains "GET http://localhost:3000/api/tasks?taskId=t1&include=detail" +assert_log_contains "POST http://localhost:3000/api/tasks" +assert_log_contains "DELETE http://localhost:3000/api/tasks" +assert_log_contains "POST http://localhost:3000/api/tasks/natural" + +assert_log_contains "POST http://localhost:3000/api/auth/login" +assert_log_contains "POST http://localhost:3000/api/auth/logout" +assert_log_contains "GET http://localhost:3000/api/auth/session" +assert_log_contains "POST http://localhost:3000/api/auth/register" +assert_log_contains "POST http://localhost:3000/api/auth/forgot-password" +assert_log_contains "POST http://localhost:3000/api/auth/reset-password" +assert_log_contains "PATCH http://localhost:3000/api/auth/account" +assert_log_contains "GET http://localhost:3000/api/auth/users" +assert_log_contains "GET http://localhost:3000/api/debug" + +if rg -n --hidden -S "rest/v1|SUPABASE_URL|SERVICE_KEY|ANON_KEY|qnatchrjlpehiijwtreh" "$ROOT_DIR/scripts" --glob "!scripts/tests/*" >/dev/null 2>&1; then + echo "Direct Supabase references were found in scripts/" >&2 + rg -n --hidden -S "rest/v1|SUPABASE_URL|SERVICE_KEY|ANON_KEY|qnatchrjlpehiijwtreh" "$ROOT_DIR/scripts" --glob "!scripts/tests/*" >&2 + exit 1 +fi + +echo "CLI API passthrough tests passed" diff --git a/scripts/tests/sprintSelection.test.ts b/scripts/tests/sprintSelection.test.ts new file mode 100644 index 0000000..074943a --- /dev/null +++ b/scripts/tests/sprintSelection.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { findCurrentSprint, isSprintInProgress } from "../../src/lib/server/sprintSelection" + +const NOW = new Date("2026-02-24T15:30:00") + +test("isSprintInProgress uses inclusive boundaries", () => { + assert.equal(isSprintInProgress("2026-02-24", "2026-02-24", NOW), true) + assert.equal(isSprintInProgress("2026-02-25", "2026-02-26", NOW), false) +}) + +test("findCurrentSprint prefers active sprint in range", () => { + const sprint = findCurrentSprint( + [ + { id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }, + { id: "active", status: "active", startDate: "2026-02-22", endDate: "2026-02-26", projectId: "p1" }, + ], + { now: NOW, projectId: "p1" } + ) + + assert.equal(sprint?.id, "active") +}) + +test("findCurrentSprint falls back to non-completed in range", () => { + const sprint = findCurrentSprint( + [{ id: "planning", status: "planning", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }], + { now: NOW, projectId: "p1" } + ) + + assert.equal(sprint?.id, "planning") +}) + +test("findCurrentSprint returns null for completed-only unless fallback enabled", () => { + const sprints = [{ id: "done", status: "completed", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }] as const + + const withoutFallback = findCurrentSprint(sprints, { now: NOW, projectId: "p1" }) + const withFallback = findCurrentSprint(sprints, { now: NOW, projectId: "p1", includeCompletedFallback: true }) + + assert.equal(withoutFallback, null) + assert.equal(withFallback?.id, "done") +}) + +test("findCurrentSprint respects project scoping", () => { + const sprint = findCurrentSprint( + [ + { id: "p1-active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p1" }, + { id: "p2-active", status: "active", startDate: "2026-02-20", endDate: "2026-02-28", projectId: "p2" }, + ], + { now: NOW, projectId: "p2" } + ) + + assert.equal(sprint?.id, "p2-active") +}) + +test("findCurrentSprint returns null when no sprint is in range", () => { + const sprint = findCurrentSprint( + [{ id: "future", status: "active", startDate: "2026-03-01", endDate: "2026-03-07", projectId: "p1" }], + { now: NOW, projectId: "p1" } + ) + + assert.equal(sprint, null) +})