Preserve toss empty-response auth-doctor contract

The prior review identified the empty portfolio/watchlist promotion rule as an upstream-contract dependency worth making explicit. Add regression coverage for the non-invalid auth doctor path and document that only parsed JSON with session.valid false promotes empty results to TossSessionExpiredError.

Constraint: Scope is issue #126 / toss-securities only; public-restroom-nearby changes are excluded.
Rejected: Treat any auth doctor output as session-expiry evidence | false positives would relabel valid empty portfolio/watchlist responses.
Confidence: high
Scope-risk: narrow
Directive: Do not broaden empty-response promotion unless tossctl provides a stronger authenticated-empty-result contract.
Tested: npm run lint --workspace toss-securities
Tested: npm run test --workspace toss-securities (15/15)
Tested: npm run ci
Tested: Manual mock tossctl empty portfolio with session.valid true preserved []
Tested: Architect verification CLEAR
Not-tested: Live Toss Securities account session behavior.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-30 02:48:05 +09:00
commit 925aed904e
2 changed files with 55 additions and 0 deletions

View file

@ -40,6 +40,7 @@ npm install toss-securities
세션 만료 관련:
- `account summary` 등은 만료 시 에러를 던집니다.
- 일부 커맨드(`portfolio positions`, `watchlist list`)는 upstream에서 빈 배열(`[]`)을 반환할 수 있어, 이 패키지는 기본적으로 `auth doctor`를 추가 확인해 만료를 `TossSessionExpiredError`로 승격합니다.
- 이 승격은 `auth doctor`가 파싱 가능한 JSON을 반환하고 `session.valid === false`로 명시 확인될 때만 발생합니다. `auth doctor` 실패, 파싱 불가 출력, 또는 `session.valid !== false`는 세션 만료 판정으로 취급하지 않습니다.
- 필요하면 `verifySessionOnEmpty: false`로 기존 빈 배열 동작을 유지할 수 있습니다.
대응되는 대표 CLI 는 `tossctl account summary --output json`, `tossctl quote get TSLA --output json`, `tossctl watchlist list --output json` 입니다.

View file

@ -157,6 +157,33 @@ printf '{"ok":true}\\n'
assert.deepEqual(passthrough.data, []);
});
test("portfolio empty array is preserved when auth doctor does not confirm invalid session", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-empty-valid-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "portfolio" ] && [ "$4" = "positions" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":true}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
const result = await getPortfolioPositions({ env });
assert.deepEqual(result.data, []);
});
test("portfolio blank stdout with invalid auth doctor is promoted to TossSessionExpiredError", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-blank-"));
const binDir = path.join(tempDir, "bin");
@ -246,6 +273,33 @@ printf '{"ok":true}\\n'
assert.deepEqual(passthrough.data, []);
});
test("watchlist empty array is preserved when auth doctor does not confirm invalid session", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-watchlist-empty-valid-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "watchlist" ] && [ "$4" = "list" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":true}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
const result = await listWatchlist({ env });
assert.deepEqual(result.data, []);
});
test("quote 403 includes upstream hint", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-403-"));
const binDir = path.join(tempDir, "bin");