Skip to content

Commit 8a2d715

Browse files
AddonoCopilot
andcommitted
test: close remaining coverage gaps in strategies, CLI upload, and MCP login
- Release-asset strategy: add tests for non-Error rate limit detection (String(err) branch in isRateLimitError) and asset listing failure catch block that falls through with original filename - Browser-session strategy: add test for generic Error wrapping through confirmUpload JSON parse failure path (CONFIRM_UPLOAD_FAILED code) - CLI upload command: add test for no-strategies-available path when config strategy-order yields only token-requiring strategies without auth - MCP login tool: add tests for elicitation decline action and empty token fallback Coverage: 97.05% → 97.5% statements, 92.16% → 92.76% branches upload.ts: 94.3% → 99.36%, releaseAsset.ts: 98.89% → 99.63% Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c8ea9e1 commit 8a2d715

5 files changed

Lines changed: 153 additions & 0 deletions

File tree

IMPLEMENTATION_PLAN.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,19 @@ This plan lists prioritized tasks required to bring the implementation into full
512512
- **Raised coverage thresholds**: lines 75→90%, functions 85→90%, branches 78→85%, statements 75→90%. All thresholds pass.
513513
- **Overall coverage**: statements 95.68→97.05%, branches 88.79→92.16%.
514514
- All validation passes: `typecheck`, `lint` (0 errors), `format:check`, `build`, `test` (418 tests), `npm audit --production` (0 vulnerabilities).
515+
516+
## 34. Coverage Gap Closure and Edge Case Testing
517+
518+
- **Task:** Close remaining coverage gaps in upload command, release-asset strategy, browser-session strategy, and MCP login tool edge cases. **[COMPLETE]**
519+
- **Spec:** Testing/spec.md (Unit Test Coverage ≥90%), Core/spec.md (Strategy error handling), MCP/spec.md (Login Tool), CLI/spec.md (Upload Command)
520+
- **Files:** test/unit/core/strategies/releaseAsset.test.ts, test/unit/core/strategies/browserSession.test.ts, test/unit/cli/commands/upload.test.ts, test/unit/mcp/handlers.test.ts
521+
- **Tests:** 6 new tests
522+
- **Dependencies:** None
523+
- **Notes:**
524+
- **Targets Test Coverage (30/100), Spec Compliance (0/100)** from Score-Maximisation Context.
525+
- **Release-asset strategy**: Added test for non-Error rate limit detection via `String(err).toLowerCase()` branch (line 36), and test for asset listing failure catch block (line 289) that verifies original filename is used on listing error.
526+
- **Browser-session strategy**: Added test for generic Error (non-Auth/Upload) wrapping through the confirmUpload JSON parse failure path, verifying CONFIRM_UPLOAD_FAILED error code.
527+
- **CLI upload command**: Added test for no-strategies-available path (lines 147-154) when config strategy-order yields only token-requiring strategies without a token set.
528+
- **MCP login tool**: Added tests for elicitation decline action and empty token elicitation fallback.
529+
- **Coverage improvements**: Overall 97.05→97.5% statements, 92.16→92.76% branches. upload.ts 94.3→99.36%, releaseAsset.ts 98.89→99.63%.
530+
- All validation passes: `typecheck`, `lint` (0 errors), `format:check`, `test` (424 tests), `npm audit --production` (0 vulnerabilities).

test/unit/cli/commands/upload.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,20 @@ describe("uploadCommand unit tests", () => {
248248
delete process.env.GH_TOKEN;
249249
});
250250

251+
it("throws NoStrategyAvailableError when config strategy-order yields no available strategies", async () => {
252+
delete process.env.GITHUB_TOKEN;
253+
delete process.env.GH_TOKEN;
254+
delete process.env.GH_ATTACH_COOKIES;
255+
// Only token-requiring strategies in the order, but no token is set
256+
vi.mocked(loadConfig).mockReturnValue({
257+
"strategy-order": ["release-asset", "repo-branch"],
258+
});
259+
260+
await expect(
261+
uploadCommand(["file.png"], { target: "owner/repo#1" }),
262+
).rejects.toThrow(NoStrategyAvailableError);
263+
});
264+
251265
it("handles stdin mode by writing temp file and cleaning up", async () => {
252266
const fakeStdin = new Readable({ read() {} });
253267
const origStdin = process.stdin;

test/unit/core/strategies/browserSession.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,49 @@ describe("Browser Session Strategy", () => {
526526
);
527527
});
528528

529+
it("wraps generic Error (non-Auth/Upload) in UploadError with BROWSER_SESSION_FAILED code", async () => {
530+
const strategy = createBrowserSessionStrategy("test-cookie");
531+
const mockFilePath = "/tmp/test.png";
532+
533+
// Simulate a successful 3-step flow where confirmUpload's json() throws a TypeError
534+
// Step 1: getRepositoryId succeeds
535+
(global.fetch as ReturnType<typeof vi.fn>)
536+
.mockResolvedValueOnce({
537+
ok: true,
538+
status: 200,
539+
json: async () => ({ id: 12345 }),
540+
})
541+
// Step 2: getUploadPolicy succeeds
542+
.mockResolvedValueOnce({
543+
ok: true,
544+
status: 200,
545+
json: async () => ({
546+
upload_url: "https://s3.example.com/upload",
547+
form: { key: "value" },
548+
token: "csrf-token",
549+
}),
550+
})
551+
// Step 3: uploadToS3 succeeds
552+
.mockResolvedValueOnce({
553+
ok: true,
554+
status: 204,
555+
})
556+
// Step 4: confirmUpload succeeds but json() throws TypeError
557+
.mockResolvedValueOnce({
558+
ok: true,
559+
status: 200,
560+
json: async () => {
561+
throw new TypeError("Cannot read properties of undefined");
562+
},
563+
});
564+
565+
const error = await strategy
566+
.upload(mockFilePath, mockTarget)
567+
.catch((e: unknown) => e);
568+
expect(error).toBeInstanceOf(UploadError);
569+
expect((error as UploadError).code).toBe("CONFIRM_UPLOAD_FAILED");
570+
});
571+
529572
it("re-throws AuthenticationError without wrapping", async () => {
530573
const strategy = createBrowserSessionStrategy("test-cookie");
531574
const mockFilePath = "/tmp/test.png";

test/unit/core/strategies/releaseAsset.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,5 +464,57 @@ describe("Release Asset Strategy", () => {
464464
err instanceof UploadError && err.code === "RELEASE_LOOKUP_FAILED",
465465
);
466466
});
467+
468+
it("detects rate limit from non-Error thrown value with rate limit message", async () => {
469+
const strategy = createReleaseAssetStrategy("token");
470+
// A non-Error object with status 403 and "rate limit" in its string form
471+
// This exercises the String(err).toLowerCase() branch in isRateLimitError
472+
const nonErrorObj = Object.create(null) as {
473+
status: number;
474+
message: string;
475+
toString: () => string;
476+
};
477+
nonErrorObj.status = 403;
478+
nonErrorObj.message = "rate limit exceeded";
479+
nonErrorObj.toString = () => "rate limit exceeded";
480+
mockOctokitInstance.rest.repos.getReleaseByTag.mockRejectedValue(
481+
nonErrorObj,
482+
);
483+
484+
await expect(
485+
strategy.upload("/tmp/test.png", mockTarget),
486+
).rejects.toSatisfy(
487+
(err: UploadError) =>
488+
err instanceof UploadError && err.code === "RATE_LIMIT_EXCEEDED",
489+
);
490+
});
491+
492+
it("continues with original filename when listing assets throws", async () => {
493+
const strategy = createReleaseAssetStrategy("valid-token");
494+
const mockFilePath = "/tmp/test.png";
495+
const mockRelease = { id: 123 };
496+
const mockAsset = {
497+
name: "test.png",
498+
browser_download_url: "https://github.com/releases/download/test.png",
499+
};
500+
501+
mockOctokitInstance.rest.repos.getReleaseByTag.mockResolvedValue({
502+
data: mockRelease,
503+
});
504+
// Listing assets fails — should silently fall through and use original filename
505+
mockOctokitInstance.rest.repos.listReleaseAssets.mockRejectedValue(
506+
new Error("Network failure"),
507+
);
508+
mockOctokitInstance.rest.repos.uploadReleaseAsset.mockResolvedValue({
509+
data: mockAsset,
510+
});
511+
512+
const result = await strategy.upload(mockFilePath, mockTarget);
513+
expect(result.url).toBe(mockAsset.browser_download_url);
514+
// Should use original filename since listing failed
515+
const uploadCall =
516+
mockOctokitInstance.rest.repos.uploadReleaseAsset.mock.calls[0][0];
517+
expect(uploadCall.name).toBe("test.png");
518+
});
467519
});
468520
});

test/unit/mcp/handlers.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,4 +606,32 @@ describe("MCP server handlers", () => {
606606
expect(response.isError).toBeUndefined();
607607
expect(response.content[0]?.text).toMatch(/GITHUB_TOKEN/);
608608
});
609+
610+
it("login tool handles elicitation decline action", async () => {
611+
hoisted.mockServerGetClientCapabilities.mockReturnValue({
612+
elicitation: { form: true },
613+
});
614+
hoisted.mockServerElicitInput.mockResolvedValue({ action: "decline" });
615+
616+
const { call } = await startServerAndGetHandlers();
617+
const response = await call({ params: { name: "login", arguments: {} } });
618+
619+
expect(response.content[0]?.text).toContain("cancelled");
620+
});
621+
622+
it("login tool handles elicitation with empty token", async () => {
623+
hoisted.mockServerGetClientCapabilities.mockReturnValue({
624+
elicitation: { form: true },
625+
});
626+
hoisted.mockServerElicitInput.mockResolvedValue({
627+
action: "accept",
628+
content: { token: "" },
629+
});
630+
631+
const { call } = await startServerAndGetHandlers();
632+
const response = await call({ params: { name: "login", arguments: {} } });
633+
634+
// Empty token should fall through to static guidance
635+
expect(response.content[0]?.text).toMatch(/GITHUB_TOKEN/);
636+
});
609637
});

0 commit comments

Comments
 (0)