mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) (#55)
* test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) ADR 0012 D83 row: comprehensive test coverage for the Phase 5 quota probe machinery (D80) and dashboard rendering (D82). ## Authorities cited - ADR 0012 D83 (D-day specification) - ADR 0013 Rules 2–6 (quota probe constraints being tested) - D80 PR #52 (anthropic.quotaStatus() + _probeOnce + _parseRateLimitHeaders) - D81 PR #53 (aggregateProviderQuota quota_v2 shape) - D82 PR #54 (dashboard.html Claude.ai-style restructure) - Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md ## Test seams added to lib/providers/anthropic.mjs Minimal test seams added — 0 production-logic changes: - `_setQuotaUrlsForTest(apiUrl, oauthUrl)` — redirects probe HTTP to local mock server (auto-detects http: vs https: to switch transport module) - `_resetQuotaProbeStateForTest()` — resets cache + backoff + URLs + auth seam - `_resetQuotaStateOnlyForTest()` — resets cache + backoff only (URLs stay) - `_getQuotaProbeStateForTest()` — returns direct reference to quotaProbeState for test assertions and controlled state mutation - `_setQuotaAuthReadFnForTest(fn)` — injects a mock auth reader into quotaStatus() so tests are not affected by real ~/.claude/.credentials.json on the machine (fixes 38f: no-auth test was finding real keychain credentials) ## Suite 38 — 20 quota-probe unit tests (38a–38t) 38a: all 13 ratelimit-unified-* headers parsed correctly (numeric types, null defaults) 38b: missing overage-reset → overage_reset: null 38c: missing all three overage fields → all three null 38d: new 5h-status + 7d-status fields (NEW vs OCP 2026-04) parsed correctly 38e: quota_probe_enabled: false → null without HTTP call 38f: auth returns null → quotaStatus returns null without HTTP call 38g: 200 + all 13 headers → full shape with stale:false + backoff reset 38h: cache hit within 5min TTL → no second HTTP call (request count stays 1) 38i: expired cache (6min > 5min TTL) → fires fresh HTTP probe 38j: 401 with no refreshToken → null (idempotent-failure per ADR 0002 Amendment 8 §3) 38k: 429 + stale cache → returns stale cache with stale:true + last_fresh_at 38l: 429 + no cache → null + backoff scheduled (backoffUntil in the future) 38m: exponential backoff growth: 60s→120s→240s→cap at 3600s 38n: successful probe resets backoffMs to 60s + backoffUntil to 0 38o: schemaVersion from models-registry.json (falls back to constant) 38p: doctor quota_probe_reachable disabled → ok with opt-in advisory 38q: doctor probe enabled + probe succeeds → ok with utilization in message 38r: doctor probe enabled + fails + stale cache → warn 38s: doctor probe enabled + fails + no cache → fail with fix_commands 38t: doctor probe enabled + no creds → fail with human_steps only ## Suite 39 — 8 dashboard rendering smoke tests (39a–39h) 39a: /dashboard with owner token → 200 + text/html 39b: /dashboard without token → 401 39c: /dashboard with guest key → 401 (owner-only_block enforcement per ADR 0008 §8) 39d: HTML contains "Plan Usage" header (D82 panel) 39e: HTML contains ↻ Refresh button (D82 manual refresh) 39f: HTML contains QUOTA_POLL_INTERVAL_MS = 60000 (D82 1-min refresh) 39g: HTML contains visibilitychange / visibilityState guard (D82 ADR 0012) 39h: HTML contains quota_v2 consumer + legacy renderQuota fallback code ## Test count: 727 → 755 (+28) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: D83 fold-in — fix 38j misleading name + add 38j2 positive-path + fix 38o ESM require Fresh-context reviewer (PR #55) flagged one blocking issue + 1 nit: BLOCKING (Q5) — 38j misleading name + uncovered 401→refresh→retry positive path. The original 38j test description said "401 → refresh succeeds → retry probe succeeds" but the test actually asserted the OPPOSITE behavior (401 with no refreshToken → null, no retry). The most important non-trivial control-flow branch in _probeOnce (anthropic.mjs:451-457 the refresh-and-retry on 401) was uncovered, and the misleading name actively masked the gap. Fix: - Renamed 38j to accurately describe what it tests: "401 with no refreshToken → null (idempotent-failure, no refresh attempt)". The test pins valuable behavior — idempotent-failure when refreshToken is absent — but now its name matches. - Added 38j2 (NEW) to exercise the actual positive-path: inject creds with refreshToken via _setQuotaAuthReadFnForTest, mock returns 401 on first API call + 200 with 13 headers on retry. Asserts: 2 API calls, 1 OAuth call, retry parsed full shape, OAuth body contains the injected refreshToken (Rule 1 credential reuse verification). NIT (Q5 dead assertion) — 38o tried to read models-registry.json via require('fs') which is undefined in ESM. The assert was silently never exercised. Replaced with the already-imported readFileSync from node:fs (added _readFileSync38 import). Also tightened: schemaVersion MUST equal the registry's quota_probe.schema_version (no longer conditional on "if expected" which was always falsy). Other reviewer nits deferred (non-blocking per reviewer): - N2: seam naming convention (_ vs __) — defer - N3: commit message "zero production changes" — note for v0.5.0 close - N4: _getQuotaProbeStateForTest returns mutable ref — defer - N5: test seam runtime guard — defer - N6: coverage gaps (concurrent probes, registry-missing fallback, network error path) — v1.x roadmap follow-ups - N7: Suite 39 stray error listener — defer (benign) Test count: 755 → 756 (+1 net, +2 new minus existing 38j rename). All 756 pass; 0 fail. Local re-run: stable. Authority: PR #55 review thread (D83 fresh-context opus reviewer) + ADR 0013 Rule 1 (credential reuse via refresh). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+72
-10
@@ -68,6 +68,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
|
import * as http from 'node:http';
|
||||||
import { ProviderError } from './base.mjs';
|
import { ProviderError } from './base.mjs';
|
||||||
|
|
||||||
// ── Binary resolution ─────────────────────────────────────────────────────
|
// ── Binary resolution ─────────────────────────────────────────────────────
|
||||||
@@ -139,8 +140,8 @@ export function readAuthArtifact() {
|
|||||||
// per `strings` output 2026-05-26).
|
// per `strings` output 2026-05-26).
|
||||||
const QUOTA_OAUTH_CLIENT_ID =
|
const QUOTA_OAUTH_CLIENT_ID =
|
||||||
process.env.CLAUDE_CODE_OAUTH_CLIENT_ID ?? '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
process.env.CLAUDE_CODE_OAUTH_CLIENT_ID ?? '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||||
const QUOTA_OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
const QUOTA_OAUTH_TOKEN_URL_DEFAULT = 'https://platform.claude.com/v1/oauth/token';
|
||||||
const QUOTA_API_URL = 'https://api.anthropic.com/v1/messages';
|
const QUOTA_API_URL_DEFAULT = 'https://api.anthropic.com/v1/messages';
|
||||||
const QUOTA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes (OCP USAGE_CACHE_TTL)
|
const QUOTA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes (OCP USAGE_CACHE_TTL)
|
||||||
const QUOTA_BACKOFF_MIN_MS = 60 * 1000; // 60s (OCP OAUTH_REFRESH_MIN_BACKOFF)
|
const QUOTA_BACKOFF_MIN_MS = 60 * 1000; // 60s (OCP OAUTH_REFRESH_MIN_BACKOFF)
|
||||||
const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MAX_BACKOFF)
|
const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MAX_BACKOFF)
|
||||||
@@ -149,6 +150,65 @@ const QUOTA_BACKOFF_MAX_MS = 60 * 60 * 1000; // 3600s (OCP OAUTH_REFRESH_MA
|
|||||||
// If the registry field is absent or fails to load, this constant takes over.
|
// If the registry field is absent or fails to load, this constant takes over.
|
||||||
const QUOTA_SCHEMA_VERSION = '2026-05-26';
|
const QUOTA_SCHEMA_VERSION = '2026-05-26';
|
||||||
|
|
||||||
|
// ── Test seams for quota probe (D83) ─────────────────────────────────────
|
||||||
|
// These variables allow tests to redirect HTTP requests to a local mock server
|
||||||
|
// without modifying production logic. In production, they always hold the
|
||||||
|
// DEFAULT values. _setQuotaUrlsForTest() / _resetQuotaProbeStateForTest() are
|
||||||
|
// the ONLY permitted mutations — production code must never call them.
|
||||||
|
//
|
||||||
|
// The _quotaHttpMod seam switches from https to http so test mock servers can
|
||||||
|
// be plain HTTP servers on ephemeral ports (no TLS cert management in tests).
|
||||||
|
//
|
||||||
|
// ADR 0012 D83: test seam discipline — injected via export, not monkey-patch.
|
||||||
|
let _quotaApiUrl = QUOTA_API_URL_DEFAULT;
|
||||||
|
let _quotaOauthTokenUrl = QUOTA_OAUTH_TOKEN_URL_DEFAULT;
|
||||||
|
let _quotaHttpMod = https; // switched to http by _setQuotaUrlsForTest when protocol is http:
|
||||||
|
|
||||||
|
export function _setQuotaUrlsForTest(apiUrl, oauthUrl) {
|
||||||
|
_quotaApiUrl = apiUrl;
|
||||||
|
_quotaOauthTokenUrl = oauthUrl ?? _quotaOauthTokenUrl;
|
||||||
|
// Auto-detect protocol: if test URL is http, use http module (no TLS)
|
||||||
|
const proto = new URL(apiUrl).protocol;
|
||||||
|
_quotaHttpMod = proto === 'http:' ? http : https;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetQuotaProbeStateForTest() {
|
||||||
|
// Resets probe runtime state (cache + backoff), restores production URLs,
|
||||||
|
// and clears the auth-read override. Call in test finally blocks.
|
||||||
|
_quotaApiUrl = QUOTA_API_URL_DEFAULT;
|
||||||
|
_quotaOauthTokenUrl = QUOTA_OAUTH_TOKEN_URL_DEFAULT;
|
||||||
|
_quotaHttpMod = https;
|
||||||
|
_quotaAuthReadFnForTest = null;
|
||||||
|
quotaProbeState.cache = null;
|
||||||
|
quotaProbeState.backoffUntil = 0;
|
||||||
|
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: reset state only, leaving URL overrides in place.
|
||||||
|
// Useful when a test sets URLs first, then wants a clean cache/backoff.
|
||||||
|
export function _resetQuotaStateOnlyForTest() {
|
||||||
|
quotaProbeState.cache = null;
|
||||||
|
quotaProbeState.backoffUntil = 0;
|
||||||
|
quotaProbeState.backoffMs = QUOTA_BACKOFF_MIN_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a direct reference to the internal quotaProbeState object.
|
||||||
|
// Tests MUST NOT store this reference across calls — the object is stable
|
||||||
|
// but its properties are mutated by the probe machinery. Direct mutation
|
||||||
|
// (e.g. state.backoffUntil = 0) is safe ONLY in test contexts where a
|
||||||
|
// controlled probe sequence is being driven. Production code never mutates
|
||||||
|
// this object from outside this module.
|
||||||
|
export function _getQuotaProbeStateForTest() {
|
||||||
|
return quotaProbeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional override for readAuthArtifact used by quotaStatus() and doctorChecks().
|
||||||
|
// When non-null, replaces readAuthArtifact() for probe calls. Reset to null via
|
||||||
|
// _resetQuotaProbeStateForTest() or explicitly by assigning null.
|
||||||
|
// This seam prevents real credential files from interfering with unit tests.
|
||||||
|
let _quotaAuthReadFnForTest = null;
|
||||||
|
export function _setQuotaAuthReadFnForTest(fn) { _quotaAuthReadFnForTest = fn; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read quota_probe.schema_version from models-registry.json (D81).
|
* Read quota_probe.schema_version from models-registry.json (D81).
|
||||||
* ADR 0013 Rule 5: schema_version lives in models-registry.json so downstream
|
* ADR 0013 Rule 5: schema_version lives in models-registry.json so downstream
|
||||||
@@ -272,10 +332,10 @@ async function _refreshAccessToken(refreshToken) {
|
|||||||
client_id: QUOTA_OAUTH_CLIENT_ID,
|
client_id: QUOTA_OAUTH_CLIENT_ID,
|
||||||
scope: 'user:inference user:profile',
|
scope: 'user:inference user:profile',
|
||||||
});
|
});
|
||||||
const url = new URL(QUOTA_OAUTH_TOKEN_URL);
|
const url = new URL(_quotaOauthTokenUrl);
|
||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: 443,
|
port: url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80),
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -283,7 +343,7 @@ async function _refreshAccessToken(refreshToken) {
|
|||||||
'Content-Length': Buffer.byteLength(body),
|
'Content-Length': Buffer.byteLength(body),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const req = https.request(options, (res) => {
|
const req = _quotaHttpMod.request(options, (res) => {
|
||||||
let raw = '';
|
let raw = '';
|
||||||
res.on('data', (chunk) => { raw += chunk; });
|
res.on('data', (chunk) => { raw += chunk; });
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
@@ -351,10 +411,10 @@ async function _probeOnce(creds) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const doRequest = (bearerToken) => new Promise((resolve, reject) => {
|
const doRequest = (bearerToken) => new Promise((resolve, reject) => {
|
||||||
const url = new URL(QUOTA_API_URL);
|
const url = new URL(_quotaApiUrl);
|
||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: 443,
|
port: url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80),
|
||||||
path: url.pathname,
|
path: url.pathname,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -365,7 +425,7 @@ async function _probeOnce(creds) {
|
|||||||
'Content-Length': Buffer.byteLength(body),
|
'Content-Length': Buffer.byteLength(body),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const req = https.request(options, (res) => {
|
const req = _quotaHttpMod.request(options, (res) => {
|
||||||
// Drain and discard the response body — ADR 0013 Rule 2: headers-only.
|
// Drain and discard the response body — ADR 0013 Rule 2: headers-only.
|
||||||
res.on('data', () => {});
|
res.on('data', () => {});
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
@@ -799,8 +859,10 @@ export async function quotaStatus(_authContext) {
|
|||||||
return null; // no stale cache
|
return null; // no stale cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read auth artifact (ADR 0013 Rule 1 — same credentials as spawn path)
|
// Read auth artifact (ADR 0013 Rule 1 — same credentials as spawn path).
|
||||||
const creds = readAuthArtifact();
|
// _quotaAuthReadFnForTest allows tests to inject a mock without real credential files.
|
||||||
|
const _authReadFn = _quotaAuthReadFnForTest ?? readAuthArtifact;
|
||||||
|
const creds = _authReadFn();
|
||||||
if (!creds?.accessToken) {
|
if (!creds?.accessToken) {
|
||||||
// No credentials at all — return stale cache or null; do not hammer API with 401s
|
// No credentials at all — return stale cache or null; do not hammer API with 401s
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+1124
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user