test(api): add unit tests for Jira/Confluence/GitLab helpers, drop obsolete shell test

This commit is contained in:
Aleksey Shakhmatov
2026-08-06 14:43:20 +03:00
parent f802f06930
commit 7804681356
5 changed files with 79 additions and 1285 deletions
+4 -1208
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -28,7 +28,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types test/guardrails.test.ts && bash test/shell.test.sh && bash test/uninstall.test.sh" "test": "node --experimental-strip-types test/guardrails.test.ts && node --experimental-strip-types test/api.test.ts && bash test/uninstall.test.sh"
}, },
"peerDependencies": { "peerDependencies": {
"@earendil-works/pi-ai": "*", "@earendil-works/pi-ai": "*",
@@ -37,9 +37,7 @@
"typebox": "*" "typebox": "*"
}, },
"dependencies": {}, "dependencies": {},
"optionalDependencies": { "optionalDependencies": {},
"@modelcontextprotocol/sdk": "^1.29.0"
},
"devDependencies": { "devDependencies": {
"typescript": "^5" "typescript": "^5"
} }
+73
View File
@@ -0,0 +1,73 @@
/**
* Unit tests for the Jira / Confluence / GitLab REST helpers.
* The lib modules are pure (no pi API), so we inject a mocked `fetchImpl`.
* Run with: node --experimental-strip-types test/api.test.ts
*/
process.env.PI_KIT_AUDIT_OFF = "1";
process.env.JIRA_TOKEN = "t-jira";
process.env.CONFLUENCE_TOKEN = "t-conf";
process.env.GITLAB_TOKEN = "t-glab";
import { buildUrl, requireToken, ApiError } from "../extensions/lib/atlassian.ts";
import { jiraBaseUrl, issueGet } from "../extensions/lib/jira.ts";
import { gitlabBaseUrl, projectId } from "../extensions/lib/gitlab.ts";
import { confluenceBaseUrl } from "../extensions/lib/confluence.ts";
let fails = 0;
function check(name: string, cond: boolean) {
console.log(`${cond ? "PASS" : "FAIL"} ${name}`);
if (!cond) fails++;
}
function okFetch(jsonBody: unknown): typeof fetch {
return (async () => ({
ok: true,
status: 200,
json: async () => jsonBody,
})) as any;
}
async function main() {
// --- atlassian helpers ---
check("buildUrl: appends query, drops empties", buildUrl("https://x/", "/a", { q: "1", e: undefined }) === "https://x/a?q=1");
check("buildUrl: no query when none", buildUrl("https://x", "/a") === "https://x/a");
check("requireToken: returns value", requireToken("JIRA_TOKEN", "h") === "t-jira");
check("requireToken: throws on missing", (() => {
try { requireToken("NO_SUCH_VAR_ZZZ", "h"); return false; } catch (e) { return e instanceof ApiError; }
})());
// --- config resolution (needs a real company.json present) ---
check("jiraBaseUrl resolves", typeof jiraBaseUrl() === "string" && jiraBaseUrl() !== "");
check("gitlabBaseUrl ends with api/v4", gitlabBaseUrl().endsWith("/api/v4"));
check("confluenceBaseUrl resolves", typeof confluenceBaseUrl() === "string" && confluenceBaseUrl() !== "");
// --- gitlab project id encoding ---
check("projectId encodes namespaces", projectId("grp/repo") === "grp%2Frepo");
check("projectId passes through numeric id", projectId("42") === "42");
// --- issueGet through a mocked fetch (field mapping) ---
const issueUrl = await issueGetByKey("PROJ-123");
check("issueGet hits /rest/api/2/issue/<key>", issueUrl.includes("/rest/api/2/issue/"));
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`);
process.exit(fails === 0 ? 0 : 1);
}
async function issueGetByKey(key: string): Promise<string> {
// Capture the URL by injecting a mocked fetch into the issueGet call path.
// We temporarily swap global.fetch.
const realFetch = globalThis.fetch;
let captured = "";
globalThis.fetch = (async (input: any) => {
captured = String(input);
return { ok: true, status: 200, json: async () => ({ key, fields: { summary: "s", status: { name: "Done" } } }) };
}) as any;
try {
await issueGet(key);
} finally {
globalThis.fetch = realFetch;
}
return captured;
}
main();
-13
View File
@@ -25,7 +25,6 @@ import secretScanner from "../extensions/secret-scanner.ts";
import commitGuard from "../extensions/commit-guard.ts"; import commitGuard from "../extensions/commit-guard.ts";
import llmRedaction from "../extensions/llm-redaction.ts"; import llmRedaction from "../extensions/llm-redaction.ts";
import companyContext from "../extensions/company-context.ts"; import companyContext from "../extensions/company-context.ts";
import mcpBridge from "../extensions/mcp-bridge.ts";
import { redact, scanSecrets } from "../extensions/lib/secrets.ts"; import { redact, scanSecrets } from "../extensions/lib/secrets.ts";
import { normalize, renderContext, fetchRemoteConfig, MAX_CONTEXT_LINES } from "../extensions/lib/company-config.ts"; import { normalize, renderContext, fetchRemoteConfig, MAX_CONTEXT_LINES } from "../extensions/lib/company-config.ts";
@@ -166,18 +165,6 @@ async function run() {
check("company-context: registers /kit-config", grabCommands(companyContext).includes("kit-config")); check("company-context: registers /kit-config", grabCommands(companyContext).includes("kit-config"));
} }
// --- mcp-bridge: disabled servers must not connect or error ---
{
const registered: string[] = [];
const tools: string[] = [];
await mcpBridge({
registerCommand: (name: string) => registered.push(name),
registerTool: () => tools.push("x"),
on() {},
} as any);
check("mcp-bridge: registers /mcp-status", registered.includes("mcp-status"));
check("mcp-bridge: no tools with all servers disabled", tools.length === 0);
}
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`); console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILED`);
process.exit(fails === 0 ? 0 : 1); process.exit(fails === 0 ? 0 : 1);
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Shell smoke tests for skills/jira-workflow/scripts/create-mr.sh.
# Network-free: uses MR_DRY_RUN=1 and a throwaway git repo.
# Run via `npm test` (after guardrails.test.ts) or directly: bash test/shell.test.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="$ROOT/skills/jira-workflow/scripts/create-mr.sh"
FAILS=0
check() { # <name> <expected-substring> <actual>
local name="$1" expected="$2" actual="$3"
if grep -qF -- "$expected" <<<"$actual"; then
echo "PASS $name"
else
echo "FAIL $name (expected substring: $expected)"
echo "--- actual ---"
echo "$actual"
FAILS=$((FAILS + 1))
fi
}
# Fresh throwaway repo with an empty commit.
DIR="$(mktemp -d)"
trap 'rm -rf "$DIR"' EXIT
cd "$DIR"
git init -q
git config user.email test@example.com
git config user.name Test
git remote add origin git@gitlab.tech.mvideo.ru:ai/foo.git
git commit --allow-empty -q -m "chore: init"
git checkout -q -b feature/PROJ-1-smoke
# 1. scp-like origin → API URL with encoded namespace.
OUT="$(GITLAB_TOKEN=dummy MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1)"
check "create-mr: scp-like origin parse" "POST https://gitlab.tech.mvideo.ru/api/v4/projects/ai%2Ffoo/merge_requests" "$OUT"
check "create-mr: dry-run shows would-push" "would push: git push -u origin feature/PROJ-1-smoke" "$OUT"
# 2. https-like origin → same API shape, different host.
git remote set-url origin "https://git.codelab.vc/ai/bar.git"
OUT="$(GITLAB_TOKEN=dummy MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1)"
check "create-mr: https origin parse" "POST https://git.codelab.vc/api/v4/projects/ai%2Fbar/merge_requests" "$OUT"
# 3. GITLAB_HOST overrides the derived host.
OUT="$(GITLAB_TOKEN=dummy GITLAB_HOST=gitlab.example.com MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1)"
check "create-mr: GITLAB_HOST override" "POST https://gitlab.example.com/api/v4/projects/ai%2Fbar/merge_requests" "$OUT"
# 4. Refuses to create MR from a protected/release source branch.
git checkout -q -b release-42
OUT="$(GITLAB_TOKEN=dummy MR_DRY_RUN=1 bash "$SCRIPT" "Test MR" 2>&1 || true)"
check "create-mr: refuses release-* source" "Refusing: source branch" "$OUT"
echo ""
if [ "$FAILS" -eq 0 ]; then
echo "shell tests: ALL PASS"
exit 0
else
echo "shell tests: $FAILS FAILED"
exit 1
fi