@littlebearapps/platform-admin-sdk 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +86 -2
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/reports/DigestStats.tsx +151 -0
  6. package/templates/full/dashboard/src/components/reports/HealthTrendsReport.tsx +192 -0
  7. package/templates/full/dashboard/src/components/usage/AIModelBreakdown.tsx +364 -0
  8. package/templates/full/dashboard/src/components/usage/unified/Recommendations.tsx +149 -0
  9. package/templates/full/dashboard/src/lib/cloudflare/alerting.ts +486 -0
  10. package/templates/full/dashboard/src/lib/cloudflare/graphql.ts +4785 -0
  11. package/templates/full/dashboard/src/lib/cloudflare/project-registry.ts +451 -0
  12. package/templates/full/dashboard/src/lib/notifications/api.ts +197 -0
  13. package/templates/full/dashboard/src/lib/notifications/types.ts.hbs +97 -0
  14. package/templates/full/dashboard/src/lib/patterns/api.ts +120 -0
  15. package/templates/full/dashboard/src/lib/patterns/types.ts +127 -0
  16. package/templates/full/dashboard/src/lib/reports/types.ts +231 -0
  17. package/templates/full/dashboard/src/lib/search/api.ts +258 -0
  18. package/templates/full/dashboard/src/lib/search/types.ts.hbs +115 -0
  19. package/templates/full/dashboard/src/lib/settings/api.ts.hbs +201 -0
  20. package/templates/full/dashboard/src/lib/settings/types.ts.hbs +104 -0
  21. package/templates/full/dashboard/src/lib/usage/allowance-config.ts.hbs +547 -0
  22. package/templates/full/dashboard/src/lib/usage/providers.ts +331 -0
  23. package/templates/shared/dashboard/src/components/reports/ReportInfoButton.tsx +98 -0
  24. package/templates/shared/dashboard/src/components/usage/react/DashboardShell.tsx +263 -0
  25. package/templates/shared/dashboard/src/components/usage/react/StatusBadge.tsx +77 -0
  26. package/templates/shared/dashboard/src/components/usage/react/UsageChart.tsx +391 -0
  27. package/templates/shared/dashboard/src/components/usage/react/index.ts.hbs +30 -0
  28. package/templates/shared/dashboard/src/components/usage/react/types.ts +137 -0
  29. package/templates/shared/dashboard/src/components/usage/transformers.ts +478 -0
  30. package/templates/shared/dashboard/src/components/usage/unified/AlertBanner.tsx +172 -0
  31. package/templates/shared/dashboard/src/components/usage/unified/HeroCardsRow.tsx +757 -0
  32. package/templates/shared/dashboard/src/components/usage/unified/LiveHeader.tsx +169 -0
  33. package/templates/shared/dashboard/src/components/usage/unified/ProjectsTable.tsx +448 -0
  34. package/templates/shared/dashboard/src/components/usage/unified/ResourceBreakdown.tsx +236 -0
  35. package/templates/shared/dashboard/src/components/usage/unified/Sparkline.tsx +127 -0
  36. package/templates/shared/dashboard/src/components/usage/unified/UnifiedShell.tsx +893 -0
  37. package/templates/shared/dashboard/src/components/usage/unified/index.ts.hbs +50 -0
  38. package/templates/shared/dashboard/src/components/usage/unified/types.ts +416 -0
  39. package/templates/shared/dashboard/src/lib/cloudflare/analytics.ts +310 -0
  40. package/templates/shared/dashboard/src/lib/cloudflare/d1.ts +55 -0
  41. package/templates/shared/dashboard/src/lib/cloudflare/index.ts.hbs +120 -0
  42. package/templates/shared/dashboard/src/lib/infrastructure/types.ts +116 -0
  43. package/templates/shared/dashboard/src/lib/usage/fetchWithDedup.ts +101 -0
  44. package/templates/shared/dashboard/src/lib/usage/index.ts.hbs +12 -0
  45. package/templates/shared/tests/e2e/usage-api.test.ts +909 -0
  46. package/templates/shared/tests/helpers/mock-storage.ts +166 -0
  47. package/templates/shared/tests/integration/kv-cache.test.ts +252 -0
  48. package/templates/shared/tests/integration/platform-usage.test.ts +956 -0
  49. package/templates/shared/tests/unit/billing.test.ts +331 -0
  50. package/templates/shared/tests/unit/cloudflare/graphql.test.ts +217 -0
  51. package/templates/shared/tests/unit/components/usage-transformers.test.ts +473 -0
  52. package/templates/shared/tests/unit/control.test.ts +226 -0
  53. package/templates/shared/tests/unit/cost-calculator.test.ts +141 -0
  54. package/templates/shared/tests/unit/economics.test.ts +365 -0
  55. package/templates/shared/tests/unit/telemetry-sampling.test.ts +401 -0
  56. package/templates/standard/dashboard/src/components/reports/CircuitBreakerReport.tsx +474 -0
  57. package/templates/standard/dashboard/src/components/reports/CostTrendsReport.tsx +229 -0
  58. package/templates/standard/dashboard/src/components/reports/ErrorTrendsReport.tsx +244 -0
  59. package/templates/standard/dashboard/src/components/reports/ProjectHealthTable.tsx +251 -0
  60. package/templates/standard/dashboard/src/components/reports/WarningDigestsTable.tsx +298 -0
  61. package/templates/standard/dashboard/src/components/usage/react/UsageTable.tsx +385 -0
  62. package/templates/standard/dashboard/src/components/usage/unified/CircuitBreakerEvents.tsx +305 -0
  63. package/templates/standard/dashboard/src/components/usage/unified/FeatureBudgets.tsx +472 -0
  64. package/templates/standard/dashboard/src/lib/errors/api.ts +84 -0
  65. package/templates/standard/dashboard/src/lib/errors/types.ts +75 -0
  66. package/templates/standard/dashboard/src/lib/infrastructure/api.ts +141 -0
  67. package/templates/standard/dashboard/src/lib/infrastructure/gatus.ts.hbs +112 -0
  68. package/templates/standard/dashboard/src/lib/services/proxy/index.ts +20 -0
  69. package/templates/standard/dashboard/src/lib/services/proxy/proxy.ts +244 -0
  70. package/templates/standard/dashboard/src/lib/services/proxy/types.ts +81 -0
  71. package/templates/standard/tests/integration/platform-sentinel.test.ts +497 -0
  72. package/templates/standard/tests/unit/cloudflare/alerting.test.ts +480 -0
  73. package/templates/standard/tests/unit/error-collector/dedup.test.ts +350 -0
  74. package/templates/standard/tests/unit/error-collector/github.test.ts +187 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Tests for error collector deduplication logic
3
+ * @see docs/plans/task-86-deduplication-implementation-plan.md
4
+ *
5
+ * These tests verify the deduplication behavior by testing the helper
6
+ * functions and their interactions with KV and GitHub.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest';
9
+
10
+ /**
11
+ * Mock KV namespace for testing lock functionality
12
+ */
13
+ function createMockKV() {
14
+ const store = new Map<string, { value: string; expiry?: number }>();
15
+
16
+ return {
17
+ get: vi.fn(async (key: string) => {
18
+ const entry = store.get(key);
19
+ if (!entry) return null;
20
+ if (entry.expiry && Date.now() > entry.expiry) {
21
+ store.delete(key);
22
+ return null;
23
+ }
24
+ return entry.value;
25
+ }),
26
+ put: vi.fn(async (key: string, value: string, options?: { expirationTtl?: number }) => {
27
+ const expiry = options?.expirationTtl ? Date.now() + options.expirationTtl * 1000 : undefined;
28
+ store.set(key, { value, expiry });
29
+ }),
30
+ delete: vi.fn(async (key: string) => {
31
+ store.delete(key);
32
+ }),
33
+ _store: store, // For test inspection
34
+ };
35
+ }
36
+
37
+ describe('KV-based Issue Locking', () => {
38
+ describe('acquireIssueLock simulation', () => {
39
+ it('acquires lock when none exists', async () => {
40
+ const kv = createMockKV();
41
+ const fingerprint = 'abc123';
42
+ const lockKey = `ISSUE_LOCK:${fingerprint}`;
43
+
44
+ // Simulate acquireIssueLock
45
+ const existing = await kv.get(lockKey);
46
+ expect(existing).toBeNull();
47
+
48
+ await kv.put(lockKey, Date.now().toString(), { expirationTtl: 60 });
49
+
50
+ // Lock should now exist
51
+ const afterLock = await kv.get(lockKey);
52
+ expect(afterLock).not.toBeNull();
53
+ });
54
+
55
+ it('returns false when lock already held', async () => {
56
+ const kv = createMockKV();
57
+ const fingerprint = 'abc123';
58
+ const lockKey = `ISSUE_LOCK:${fingerprint}`;
59
+
60
+ // First worker acquires lock
61
+ await kv.put(lockKey, Date.now().toString(), { expirationTtl: 60 });
62
+
63
+ // Second worker tries to acquire
64
+ const existing = await kv.get(lockKey);
65
+ expect(existing).not.toBeNull();
66
+ // Second worker should skip (existing !== null means lock is held)
67
+ });
68
+
69
+ it('releases lock correctly', async () => {
70
+ const kv = createMockKV();
71
+ const fingerprint = 'abc123';
72
+ const lockKey = `ISSUE_LOCK:${fingerprint}`;
73
+
74
+ // Acquire lock
75
+ await kv.put(lockKey, Date.now().toString(), { expirationTtl: 60 });
76
+ expect(await kv.get(lockKey)).not.toBeNull();
77
+
78
+ // Release lock
79
+ await kv.delete(lockKey);
80
+ expect(await kv.get(lockKey)).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe('Lock key format', () => {
85
+ it('uses correct key format', () => {
86
+ const fingerprint = 'a1b2c3d4e5f6';
87
+ const lockKey = `ISSUE_LOCK:${fingerprint}`;
88
+ expect(lockKey).toBe('ISSUE_LOCK:a1b2c3d4e5f6');
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('findExistingIssueByFingerprint logic', () => {
94
+ const SKIP_REOPEN_LABELS = ['cf:muted', 'cf:wont-fix'];
95
+
96
+ describe('issue selection', () => {
97
+ it('returns null when no issues found', () => {
98
+ const issues: Array<{ number: number; state: string; labels: Array<{ name: string }> }> = [];
99
+ expect(issues.length).toBe(0);
100
+ // findExistingIssueByFingerprint returns null when issues.length === 0
101
+ });
102
+
103
+ it('prefers open over closed issues', () => {
104
+ const issues = [
105
+ { number: 100, state: 'closed', labels: [] },
106
+ { number: 101, state: 'open', labels: [] },
107
+ { number: 102, state: 'closed', labels: [] },
108
+ ];
109
+
110
+ const openIssue = issues.find((i) => i.state === 'open');
111
+ expect(openIssue).toBeDefined();
112
+ expect(openIssue?.number).toBe(101);
113
+ });
114
+
115
+ it('returns most recent closed issue when no open issues exist', () => {
116
+ const issues = [
117
+ { number: 100, state: 'closed', labels: [] },
118
+ { number: 99, state: 'closed', labels: [] },
119
+ ];
120
+
121
+ const openIssue = issues.find((i) => i.state === 'open');
122
+ expect(openIssue).toBeUndefined();
123
+
124
+ // Falls back to first result (most recent by search relevance)
125
+ const closedIssue = issues[0];
126
+ expect(closedIssue.number).toBe(100);
127
+ });
128
+ });
129
+
130
+ describe('label checking', () => {
131
+ it('sets shouldSkip=true for cf:muted issues', () => {
132
+ const issue = {
133
+ number: 100,
134
+ state: 'closed',
135
+ labels: [{ name: 'cf:muted' }, { name: 'cf:error:auto-generated' }],
136
+ };
137
+
138
+ const labelNames = issue.labels.map((l) => l.name);
139
+ const shouldSkip = SKIP_REOPEN_LABELS.some((l) => labelNames.includes(l));
140
+
141
+ expect(shouldSkip).toBe(true);
142
+ });
143
+
144
+ it('sets shouldSkip=true for cf:wont-fix issues', () => {
145
+ const issue = {
146
+ number: 100,
147
+ state: 'closed',
148
+ labels: [{ name: 'cf:wont-fix' }],
149
+ };
150
+
151
+ const labelNames = issue.labels.map((l) => l.name);
152
+ const shouldSkip = SKIP_REOPEN_LABELS.some((l) => labelNames.includes(l));
153
+
154
+ expect(shouldSkip).toBe(true);
155
+ });
156
+
157
+ it('sets shouldSkip=false for regular issues', () => {
158
+ const issue = {
159
+ number: 100,
160
+ state: 'closed',
161
+ labels: [{ name: 'cf:error:auto-generated' }, { name: 'cf:priority:p2' }],
162
+ };
163
+
164
+ const labelNames = issue.labels.map((l) => l.name);
165
+ const shouldSkip = SKIP_REOPEN_LABELS.some((l) => labelNames.includes(l));
166
+
167
+ expect(shouldSkip).toBe(false);
168
+ });
169
+ });
170
+ });
171
+
172
+ describe('formatRecurrenceComment', () => {
173
+ const mockEvent = {
174
+ scriptName: 'test-worker',
175
+ scriptVersion: { id: 'abc123def456' },
176
+ event: {
177
+ rayId: 'ray-id-123',
178
+ request: {
179
+ cf: { colo: 'SYD' },
180
+ headers: {},
181
+ url: 'https://example.com/test',
182
+ method: 'GET',
183
+ },
184
+ },
185
+ exceptions: [
186
+ {
187
+ name: 'Error',
188
+ message: 'Test error message that is quite long and should be truncated',
189
+ timestamp: Date.now(),
190
+ },
191
+ ],
192
+ logs: [],
193
+ outcome: 'exception' as const,
194
+ eventTimestamp: Date.now(),
195
+ };
196
+
197
+ it('formats reopen comment with regression context', () => {
198
+ const comment = formatRecurrenceCommentMock(mockEvent, 'exception', 5, true);
199
+
200
+ expect(comment).toContain('Error Recurrence (Reopened)');
201
+ expect(comment).toContain('reopened because the error recurred');
202
+ expect(comment).toContain('Total Occurrences');
203
+ expect(comment).toContain('5');
204
+ });
205
+
206
+ it('formats occurrence update comment', () => {
207
+ const comment = formatRecurrenceCommentMock(mockEvent, 'soft_error', 10, false);
208
+
209
+ expect(comment).toContain('New Occurrence');
210
+ expect(comment).not.toContain('reopened');
211
+ expect(comment).toContain('10');
212
+ });
213
+
214
+ it('includes stack trace for exceptions', () => {
215
+ const comment = formatRecurrenceCommentMock(mockEvent, 'exception', 1, true);
216
+
217
+ expect(comment).toContain('Exception');
218
+ expect(comment).toContain('Error:');
219
+ expect(comment).toContain('Test error message');
220
+ });
221
+
222
+ it('includes Ray ID when available', () => {
223
+ const comment = formatRecurrenceCommentMock(mockEvent, 'exception', 1, false);
224
+
225
+ expect(comment).toContain('Ray ID');
226
+ expect(comment).toContain('ray-id-123');
227
+ });
228
+
229
+ it('includes colo when available', () => {
230
+ const comment = formatRecurrenceCommentMock(mockEvent, 'exception', 1, false);
231
+
232
+ expect(comment).toContain('Colo');
233
+ expect(comment).toContain('SYD');
234
+ });
235
+ });
236
+
237
+ /**
238
+ * Mock implementation of formatRecurrenceComment for testing
239
+ * This mirrors the logic in error-collector.ts
240
+ */
241
+ function formatRecurrenceCommentMock(
242
+ event: {
243
+ scriptName: string;
244
+ scriptVersion?: { id?: string };
245
+ event?: {
246
+ rayId?: string;
247
+ request?: {
248
+ cf?: { colo?: string };
249
+ headers?: Record<string, string>;
250
+ };
251
+ };
252
+ exceptions: Array<{ name: string; message: string; timestamp: number }>;
253
+ },
254
+ errorType: string,
255
+ occurrenceCount: number,
256
+ isReopen: boolean
257
+ ): string {
258
+ const timestamp = new Date().toISOString();
259
+ const rayId = event.event?.rayId || event.event?.request?.headers?.['cf-ray'];
260
+
261
+ let comment = isReopen ? `## Error Recurrence (Reopened)\n\n` : `## New Occurrence\n\n`;
262
+
263
+ comment += `| | |\n|---|---|\n`;
264
+ comment += `| **Time** | ${timestamp} |\n`;
265
+ comment += `| **Total Occurrences** | ${occurrenceCount} |\n`;
266
+ comment += `| **Worker** | \`${event.scriptName}\` |\n`;
267
+
268
+ if (event.scriptVersion?.id) {
269
+ comment += `| **Version** | \`${event.scriptVersion.id.slice(0, 8)}\` |\n`;
270
+ }
271
+ if (rayId) {
272
+ comment += `| **Ray ID** | \`${rayId}\` |\n`;
273
+ }
274
+ if (event.event?.request?.cf?.colo) {
275
+ comment += `| **Colo** | ${event.event.request.cf.colo} |\n`;
276
+ }
277
+
278
+ if (errorType === 'exception' && event.exceptions.length > 0) {
279
+ const exc = event.exceptions[0];
280
+ const stackPreview = exc.message?.slice(0, 300) || 'N/A';
281
+ comment += `\n### Exception\n\`\`\`\n${exc.name}: ${stackPreview}${exc.message?.length > 300 ? '...' : ''}\n\`\`\`\n`;
282
+ }
283
+
284
+ if (isReopen) {
285
+ comment += `\n> This issue was reopened because the error recurred after being closed.\n`;
286
+ }
287
+
288
+ return comment;
289
+ }
290
+
291
+ describe('Decision Matrix', () => {
292
+ /**
293
+ * Test the decision matrix from the plan:
294
+ * | Scenario | Existing Issue State | Action |
295
+ * |----------|---------------------|--------|
296
+ * | Fingerprint match in D1 + KV | N/A | Current flow (update count) |
297
+ * | Fingerprint match in D1 only | N/A | Current flow (update count) |
298
+ * | No D1 match, GitHub has open issue | Open | Add comment, link D1 record |
299
+ * | No D1 match, GitHub has closed issue | Closed | Check labels -> Reopen + comment + regression label |
300
+ * | No D1 match, GitHub has muted/wontfix issue | Closed | Skip (don't reopen, don't create new) |
301
+ * | No match anywhere | N/A | Create new issue |
302
+ */
303
+
304
+ describe('fingerprint match scenarios', () => {
305
+ it('KV cache hit should return existing issue (no GitHub search)', () => {
306
+ // When KV cache has the fingerprint, we don't need to search GitHub
307
+ const kvCacheHit = true;
308
+ const shouldSearchGitHub = !kvCacheHit;
309
+ expect(shouldSearchGitHub).toBe(false);
310
+ });
311
+
312
+ it('D1 match with issue_number should return existing issue (no GitHub search)', () => {
313
+ const d1Record = { fingerprint: 'abc123', github_issue_number: 100 };
314
+ const hasIssueNumber = d1Record.github_issue_number !== undefined;
315
+ expect(hasIssueNumber).toBe(true);
316
+ });
317
+ });
318
+
319
+ describe('no D1 match scenarios', () => {
320
+ it('GitHub open issue found should add comment and link D1', () => {
321
+ const existingIssue = { number: 100, state: 'open', shouldSkip: false };
322
+ const action = existingIssue.state === 'open' ? 'add-comment' : 'reopen-and-comment';
323
+ expect(action).toBe('add-comment');
324
+ });
325
+
326
+ it('GitHub closed issue found should reopen with regression label', () => {
327
+ const existingIssue = { number: 100, state: 'closed', shouldSkip: false };
328
+ const action = existingIssue.state === 'closed' ? 'reopen-and-comment' : 'add-comment';
329
+ expect(action).toBe('reopen-and-comment');
330
+ });
331
+
332
+ it('GitHub muted/wontfix issue should be skipped', () => {
333
+ const existingIssue = { number: 100, state: 'closed', shouldSkip: true };
334
+ expect(existingIssue.shouldSkip).toBe(true);
335
+ // Action: link D1 record but don't reopen or create new
336
+ });
337
+
338
+ it('no match anywhere should create new issue', () => {
339
+ const searchResult = { found: false as const };
340
+ expect(searchResult.found).toBe(false);
341
+ // Action: create new issue
342
+ });
343
+
344
+ it('GitHub search failure should skip issue creation', () => {
345
+ const searchResult = { found: 'error' as const, error: new Error('502 Bad Gateway') };
346
+ expect(searchResult.found).toBe('error');
347
+ // Action: skip issue creation, error is still tracked in D1
348
+ });
349
+ });
350
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for GitHubClient searchIssues functionality
3
+ * @see docs/plans/task-86-deduplication-implementation-plan.md
4
+ */
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+
7
+ // Mock fetch globally using vitest
8
+ const mockFetch = vi.fn();
9
+ vi.stubGlobal('fetch', mockFetch);
10
+
11
+ // Mock crypto.subtle for JWT generation
12
+ const mockSubtle = {
13
+ importKey: vi.fn().mockResolvedValue({}),
14
+ sign: vi.fn().mockResolvedValue(new ArrayBuffer(256)),
15
+ };
16
+ vi.stubGlobal('crypto', { subtle: mockSubtle });
17
+
18
+ // Import after mocking
19
+ import { GitHubClient } from '../../../workers/lib/error-collector/github';
20
+
21
+ import type { Env } from '../../../workers/lib/error-collector/types';
22
+
23
+ const mockEnv = {
24
+ GITHUB_APP_ID: 'test-app-id',
25
+ GITHUB_APP_PRIVATE_KEY: btoa(
26
+ '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----'
27
+ ),
28
+ GITHUB_APP_INSTALLATION_ID: 'test-installation-id',
29
+ } as unknown as Env;
30
+
31
+ describe('GitHubClient.searchIssues', () => {
32
+ let client: GitHubClient;
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ client = new GitHubClient(mockEnv);
37
+
38
+ // Mock successful token fetch
39
+ mockFetch.mockResolvedValueOnce({
40
+ ok: true,
41
+ json: () => Promise.resolve({ token: 'test-token' }),
42
+ });
43
+ });
44
+
45
+ it('returns matching issues sorted by relevance', async () => {
46
+ const mockIssues = {
47
+ total_count: 2,
48
+ items: [
49
+ {
50
+ number: 123,
51
+ state: 'open',
52
+ title: 'Test Issue 1',
53
+ body: 'Fingerprint: `abc123`',
54
+ labels: [{ name: 'cf:error:auto-generated' }],
55
+ },
56
+ {
57
+ number: 122,
58
+ state: 'closed',
59
+ title: 'Test Issue 2',
60
+ body: 'Fingerprint: `abc123`',
61
+ labels: [{ name: 'cf:error:auto-generated' }],
62
+ },
63
+ ],
64
+ };
65
+
66
+ mockFetch.mockResolvedValueOnce({
67
+ ok: true,
68
+ json: () => Promise.resolve(mockIssues),
69
+ });
70
+
71
+ const result = await client.searchIssues('owner', 'repo', '"Fingerprint: `abc123`" in:body');
72
+
73
+ expect(result).toHaveLength(2);
74
+ expect(result[0].number).toBe(123);
75
+ expect(result[0].state).toBe('open');
76
+ expect(result[1].number).toBe(122);
77
+ expect(result[1].state).toBe('closed');
78
+ });
79
+
80
+ it('handles empty results', async () => {
81
+ mockFetch.mockResolvedValueOnce({
82
+ ok: true,
83
+ json: () => Promise.resolve({ total_count: 0, items: [] }),
84
+ });
85
+
86
+ const result = await client.searchIssues(
87
+ 'owner',
88
+ 'repo',
89
+ '"Fingerprint: `nonexistent`" in:body'
90
+ );
91
+
92
+ expect(result).toHaveLength(0);
93
+ });
94
+
95
+ it('retries once on 403 then throws', async () => {
96
+ // First call: 403 rate-limited
97
+ mockFetch.mockResolvedValueOnce({
98
+ ok: false,
99
+ status: 403,
100
+ text: () => Promise.resolve('Rate limited'),
101
+ });
102
+ // Retry: also 403
103
+ mockFetch.mockResolvedValueOnce({
104
+ ok: false,
105
+ status: 403,
106
+ text: () => Promise.resolve('Rate limited'),
107
+ });
108
+
109
+ await expect(client.searchIssues('owner', 'repo', '"test"')).rejects.toThrow(
110
+ 'GitHub API error: 403'
111
+ );
112
+ });
113
+
114
+ it('succeeds on retry after 403', async () => {
115
+ // First call: 403 rate-limited
116
+ mockFetch.mockResolvedValueOnce({
117
+ ok: false,
118
+ status: 403,
119
+ text: () => Promise.resolve('Rate limited'),
120
+ });
121
+ // Retry: success
122
+ mockFetch.mockResolvedValueOnce({
123
+ ok: true,
124
+ json: () =>
125
+ Promise.resolve({
126
+ total_count: 1,
127
+ items: [{ number: 42, state: 'open', title: 'Test', body: null, labels: [] }],
128
+ }),
129
+ });
130
+
131
+ const result = await client.searchIssues('owner', 'repo', '"test"');
132
+ expect(result).toHaveLength(1);
133
+ expect(result[0].number).toBe(42);
134
+ });
135
+
136
+ it('properly encodes query parameters', async () => {
137
+ mockFetch.mockResolvedValueOnce({
138
+ ok: true,
139
+ json: () => Promise.resolve({ total_count: 0, items: [] }),
140
+ });
141
+
142
+ await client.searchIssues('owner', 'repo', '"Fingerprint: `test`" in:body');
143
+
144
+ // Check that the second call (search) has the properly encoded URL
145
+ const searchCall = mockFetch.mock.calls[1];
146
+ expect(searchCall[0]).toContain('/search/issues?q=');
147
+ expect(searchCall[0]).toContain(encodeURIComponent('repo:owner/repo is:issue'));
148
+ });
149
+
150
+ it('includes labels in response', async () => {
151
+ const mockIssues = {
152
+ total_count: 1,
153
+ items: [
154
+ {
155
+ number: 100,
156
+ state: 'closed',
157
+ title: 'Test Issue',
158
+ body: 'Test body',
159
+ labels: [{ name: 'cf:error:auto-generated' }, { name: 'cf:muted' }],
160
+ },
161
+ ],
162
+ };
163
+
164
+ mockFetch.mockResolvedValueOnce({
165
+ ok: true,
166
+ json: () => Promise.resolve(mockIssues),
167
+ });
168
+
169
+ const result = await client.searchIssues('owner', 'repo', 'test');
170
+
171
+ expect(result[0].labels).toHaveLength(2);
172
+ expect(result[0].labels[0].name).toBe('cf:error:auto-generated');
173
+ expect(result[0].labels[1].name).toBe('cf:muted');
174
+ });
175
+
176
+ it('limits results to 5 items', async () => {
177
+ mockFetch.mockResolvedValueOnce({
178
+ ok: true,
179
+ json: () => Promise.resolve({ total_count: 0, items: [] }),
180
+ });
181
+
182
+ await client.searchIssues('owner', 'repo', 'test');
183
+
184
+ const searchCall = mockFetch.mock.calls[1];
185
+ expect(searchCall[0]).toContain('per_page=5');
186
+ });
187
+ });