@soundbi/sound-connect 0.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.
- package/README.md +111 -0
- package/dist/__tests__/ingest.test.d.ts +18 -0
- package/dist/__tests__/ingest.test.d.ts.map +1 -0
- package/dist/__tests__/ingest.test.js +639 -0
- package/dist/__tests__/ingest.test.js.map +1 -0
- package/dist/__tests__/isolation.test.d.ts +12 -0
- package/dist/__tests__/isolation.test.d.ts.map +1 -0
- package/dist/__tests__/isolation.test.js +149 -0
- package/dist/__tests__/isolation.test.js.map +1 -0
- package/dist/__tests__/retry-queue.test.d.ts +11 -0
- package/dist/__tests__/retry-queue.test.d.ts.map +1 -0
- package/dist/__tests__/retry-queue.test.js +458 -0
- package/dist/__tests__/retry-queue.test.js.map +1 -0
- package/dist/auth.d.ts +80 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +211 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +66 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +100 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +253 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +573 -0
- package/dist/ingest.js.map +1 -0
- package/dist/proxy.d.ts +79 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +217 -0
- package/dist/proxy.js.map +1 -0
- package/dist/retry-queue.d.ts +236 -0
- package/dist/retry-queue.d.ts.map +1 -0
- package/dist/retry-queue.js +461 -0
- package/dist/retry-queue.js.map +1 -0
- package/dist/tools.d.ts +75 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +368 -0
- package/dist/tools.js.map +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STORY-013 — Ingest retry queue unit tests.
|
|
3
|
+
*
|
|
4
|
+
* AC1: Failed /ingest POSTs (network/5xx) are written to the local queue with provenance + hash.
|
|
5
|
+
* AC2: Retry worker drains the queue with exponential backoff; idempotency makes re-sends safe.
|
|
6
|
+
* AC3: ingest_status reports pending/dead queue depth (tested via getQueueStatus).
|
|
7
|
+
* AC4: Queue survives restart (file-based); 4xx items go dead, not retried forever.
|
|
8
|
+
* AC5: Simulate backend 503, confirm queue + successful drain on recovery.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { enqueueFailedChunk, loadPendingItems, loadDeadItems, getQueueStatus, runRetrySweep, backoffMs, isReadyToRetry, clearQueue, attemptRetry, stopRetryWorker, MAX_RETRY_ATTEMPTS, BACKOFF_BASE_MS, BACKOFF_MAX_MS, } from '../retry-queue.js';
|
|
15
|
+
import { ingestMarkdownFile } from '../ingest.js';
|
|
16
|
+
// ── Test helpers ───────────────────────────────────────────────────────────────
|
|
17
|
+
const TEST_CLIENT_SLUG = 'test-client';
|
|
18
|
+
/** Unique tmpdir per test run to avoid cross-test pollution. */
|
|
19
|
+
function makeQueueDir() {
|
|
20
|
+
return join(tmpdir(), `sc-retry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
21
|
+
}
|
|
22
|
+
const SAMPLE_PAYLOAD = {
|
|
23
|
+
source_type: 'markdown',
|
|
24
|
+
filename: 'note.md',
|
|
25
|
+
content: '# Test\n\nHello world.',
|
|
26
|
+
sha256: 'abc123def456abc123def456abc123def456abc123def456abc123def456abc1',
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
author_email: 'test@example.com',
|
|
29
|
+
};
|
|
30
|
+
// ── backoffMs ─────────────────────────────────────────────────────────────────
|
|
31
|
+
describe('backoffMs()', () => {
|
|
32
|
+
it('returns BACKOFF_BASE_MS for attempt 1', () => {
|
|
33
|
+
expect(backoffMs(1)).toBe(BACKOFF_BASE_MS);
|
|
34
|
+
});
|
|
35
|
+
it('doubles for each subsequent attempt', () => {
|
|
36
|
+
expect(backoffMs(2)).toBe(BACKOFF_BASE_MS * 2);
|
|
37
|
+
expect(backoffMs(3)).toBe(BACKOFF_BASE_MS * 4);
|
|
38
|
+
});
|
|
39
|
+
it('caps at BACKOFF_MAX_MS', () => {
|
|
40
|
+
// After enough doublings it should hit the cap.
|
|
41
|
+
expect(backoffMs(100)).toBe(BACKOFF_MAX_MS);
|
|
42
|
+
});
|
|
43
|
+
it('never exceeds BACKOFF_MAX_MS', () => {
|
|
44
|
+
for (let i = 1; i <= 20; i++) {
|
|
45
|
+
expect(backoffMs(i)).toBeLessThanOrEqual(BACKOFF_MAX_MS);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
// ── isReadyToRetry ─────────────────────────────────────────────────────────────
|
|
50
|
+
describe('isReadyToRetry()', () => {
|
|
51
|
+
function makeItem(attempts, lastAttemptAt) {
|
|
52
|
+
return {
|
|
53
|
+
id: 'test-id',
|
|
54
|
+
backendUrl: 'https://example.com',
|
|
55
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
56
|
+
enqueuedAt: new Date().toISOString(),
|
|
57
|
+
attempts,
|
|
58
|
+
lastAttemptAt,
|
|
59
|
+
payload: SAMPLE_PAYLOAD,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
it('is always ready for an item with 0 attempts', () => {
|
|
63
|
+
expect(isReadyToRetry(makeItem(0))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('is ready when backoff window has elapsed', () => {
|
|
66
|
+
const longAgo = new Date(Date.now() - BACKOFF_BASE_MS * 2).toISOString();
|
|
67
|
+
expect(isReadyToRetry(makeItem(1, longAgo))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('is NOT ready when backoff window has not elapsed', () => {
|
|
70
|
+
// attempt 1 → backoff = BACKOFF_BASE_MS (1s). Set lastAttemptAt to 500ms ago.
|
|
71
|
+
const recentMs = Date.now() - Math.floor(BACKOFF_BASE_MS / 2);
|
|
72
|
+
const recent = new Date(recentMs).toISOString();
|
|
73
|
+
expect(isReadyToRetry(makeItem(1, recent))).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
it('is ready when lastAttemptAt is undefined (no prior attempt recorded)', () => {
|
|
76
|
+
// attempts=1 but no timestamp → treat as ready.
|
|
77
|
+
expect(isReadyToRetry(makeItem(1, undefined))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
// ── enqueueFailedChunk + loadPendingItems ─────────────────────────────────────
|
|
81
|
+
describe('enqueueFailedChunk() + loadPendingItems()', () => {
|
|
82
|
+
let queueDir;
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
queueDir = makeQueueDir();
|
|
85
|
+
process.env['SC_QUEUE_DIR'] = queueDir;
|
|
86
|
+
});
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
delete process.env['SC_QUEUE_DIR'];
|
|
89
|
+
await clearQueue(TEST_CLIENT_SLUG);
|
|
90
|
+
stopRetryWorker();
|
|
91
|
+
});
|
|
92
|
+
it('AC1: writes a .json file to the queue directory', async () => {
|
|
93
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'Network error: ECONNREFUSED');
|
|
94
|
+
const items = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
95
|
+
expect(items).toHaveLength(1);
|
|
96
|
+
});
|
|
97
|
+
it('AC1: stored item contains provenance + content hash', async () => {
|
|
98
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'timeout');
|
|
99
|
+
const [item] = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
100
|
+
expect(item).toBeDefined();
|
|
101
|
+
expect(item.payload.sha256).toBe(SAMPLE_PAYLOAD.sha256);
|
|
102
|
+
expect(item.payload.filename).toBe(SAMPLE_PAYLOAD.filename);
|
|
103
|
+
expect(item.payload.source_type).toBe('markdown');
|
|
104
|
+
expect(item.payload.author_email).toBe('test@example.com');
|
|
105
|
+
expect(item.payload.timestamp).toBeTruthy();
|
|
106
|
+
expect(item.clientSlug).toBe(TEST_CLIENT_SLUG);
|
|
107
|
+
});
|
|
108
|
+
it('AC4: queue survives a "restart" — items persist on disk and reload', async () => {
|
|
109
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'transient error');
|
|
110
|
+
// Simulate "restart" by calling loadPendingItems fresh (no in-memory state).
|
|
111
|
+
const items = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
112
|
+
expect(items).toHaveLength(1);
|
|
113
|
+
expect(items[0].payload.sha256).toBe(SAMPLE_PAYLOAD.sha256);
|
|
114
|
+
});
|
|
115
|
+
it('enqueuing multiple chunks produces multiple items', async () => {
|
|
116
|
+
const payload2 = { ...SAMPLE_PAYLOAD, sha256: 'b'.repeat(64) };
|
|
117
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'err1');
|
|
118
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, payload2, 'err2');
|
|
119
|
+
const items = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
120
|
+
expect(items).toHaveLength(2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
// ── attemptRetry ──────────────────────────────────────────────────────────────
|
|
124
|
+
describe('attemptRetry()', () => {
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
function makeQueueItem(overrides = {}) {
|
|
129
|
+
return {
|
|
130
|
+
id: 'test-id',
|
|
131
|
+
backendUrl: 'https://example.com',
|
|
132
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
133
|
+
enqueuedAt: new Date().toISOString(),
|
|
134
|
+
attempts: 1,
|
|
135
|
+
lastAttemptAt: new Date().toISOString(),
|
|
136
|
+
payload: SAMPLE_PAYLOAD,
|
|
137
|
+
...overrides,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function mockFetch(status, body, ok) {
|
|
141
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
142
|
+
ok,
|
|
143
|
+
status,
|
|
144
|
+
text: async () => JSON.stringify(body),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
it('AC5: returns success on 200 (backend recovered)', async () => {
|
|
148
|
+
mockFetch(200, { ok: true, deduped: false }, true);
|
|
149
|
+
const result = await attemptRetry(makeQueueItem(), 'token');
|
|
150
|
+
expect(result.outcome).toBe('success');
|
|
151
|
+
});
|
|
152
|
+
it('AC2: returns transient on 503 (backend down — retry later)', async () => {
|
|
153
|
+
mockFetch(503, {}, false);
|
|
154
|
+
const result = await attemptRetry(makeQueueItem(), 'token');
|
|
155
|
+
expect(result.outcome).toBe('transient');
|
|
156
|
+
});
|
|
157
|
+
it('AC2: returns transient on 502', async () => {
|
|
158
|
+
mockFetch(502, {}, false);
|
|
159
|
+
const result = await attemptRetry(makeQueueItem(), 'token');
|
|
160
|
+
expect(result.outcome).toBe('transient');
|
|
161
|
+
});
|
|
162
|
+
it('AC4: returns permanent on 401 (auth error — do not retry)', async () => {
|
|
163
|
+
mockFetch(401, {}, false);
|
|
164
|
+
const result = await attemptRetry(makeQueueItem(), 'bad-token');
|
|
165
|
+
expect(result.outcome).toBe('permanent');
|
|
166
|
+
if (result.outcome === 'permanent') {
|
|
167
|
+
expect(result.error).toMatch(/401/);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
it('AC4: returns permanent on 403 (access denied)', async () => {
|
|
171
|
+
mockFetch(403, { hint: 'Not a member' }, false);
|
|
172
|
+
const result = await attemptRetry(makeQueueItem(), 'tok');
|
|
173
|
+
expect(result.outcome).toBe('permanent');
|
|
174
|
+
if (result.outcome === 'permanent') {
|
|
175
|
+
expect(result.error).toMatch(/403/);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
it('AC2: returns transient on network error (ECONNREFUSED)', async () => {
|
|
179
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
|
180
|
+
const result = await attemptRetry(makeQueueItem(), 'tok');
|
|
181
|
+
expect(result.outcome).toBe('transient');
|
|
182
|
+
if (result.outcome === 'transient') {
|
|
183
|
+
expect(result.error).toMatch(/ECONNREFUSED/);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
it('success with deduped=true is still a success (idempotency, ADR-004)', async () => {
|
|
187
|
+
mockFetch(200, { ok: true, deduped: true }, true);
|
|
188
|
+
const result = await attemptRetry(makeQueueItem(), 'tok');
|
|
189
|
+
expect(result.outcome).toBe('success');
|
|
190
|
+
if (result.outcome === 'success') {
|
|
191
|
+
expect(result.deduped).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
// ── runRetrySweep — AC2 + AC5 (queue drain) ───────────────────────────────────
|
|
196
|
+
describe('runRetrySweep()', () => {
|
|
197
|
+
let queueDir;
|
|
198
|
+
beforeEach(async () => {
|
|
199
|
+
queueDir = makeQueueDir();
|
|
200
|
+
process.env['SC_QUEUE_DIR'] = queueDir;
|
|
201
|
+
vi.restoreAllMocks();
|
|
202
|
+
});
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
delete process.env['SC_QUEUE_DIR'];
|
|
205
|
+
await clearQueue(TEST_CLIENT_SLUG);
|
|
206
|
+
vi.restoreAllMocks();
|
|
207
|
+
stopRetryWorker();
|
|
208
|
+
});
|
|
209
|
+
it('AC5: 503 enqueues, then successful sweep drains the queue', async () => {
|
|
210
|
+
// Enqueue an item that previously failed with 503.
|
|
211
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'Backend HTTP 503');
|
|
212
|
+
// Confirm it is in the queue.
|
|
213
|
+
expect(await loadPendingItems(TEST_CLIENT_SLUG)).toHaveLength(1);
|
|
214
|
+
// Now backend recovers — mock fetch returns 200.
|
|
215
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
216
|
+
ok: true,
|
|
217
|
+
status: 200,
|
|
218
|
+
text: async () => JSON.stringify({ ok: true, deduped: false }),
|
|
219
|
+
}));
|
|
220
|
+
const result = await runRetrySweep(TEST_CLIENT_SLUG, async () => 'valid-token');
|
|
221
|
+
expect(result.delivered).toBe(1);
|
|
222
|
+
expect(result.exhausted).toBe(0);
|
|
223
|
+
expect(result.tokenMissing).toBe(false);
|
|
224
|
+
// Queue should be empty after drain.
|
|
225
|
+
expect(await loadPendingItems(TEST_CLIENT_SLUG)).toHaveLength(0);
|
|
226
|
+
});
|
|
227
|
+
it('AC2: transient failure increments attempt count on disk', async () => {
|
|
228
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'initial error');
|
|
229
|
+
// Mock persistent 503.
|
|
230
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
231
|
+
ok: false,
|
|
232
|
+
status: 503,
|
|
233
|
+
text: async () => '{}',
|
|
234
|
+
}));
|
|
235
|
+
await runRetrySweep(TEST_CLIENT_SLUG, async () => 'tok');
|
|
236
|
+
const items = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
237
|
+
expect(items).toHaveLength(1);
|
|
238
|
+
expect(items[0].attempts).toBe(1);
|
|
239
|
+
expect(items[0].lastAttemptAt).toBeTruthy();
|
|
240
|
+
expect(items[0].lastError).toMatch(/503/);
|
|
241
|
+
});
|
|
242
|
+
it('AC4: 4xx response marks item dead, not retried', async () => {
|
|
243
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'initial error');
|
|
244
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
245
|
+
ok: false,
|
|
246
|
+
status: 401,
|
|
247
|
+
text: async () => '{}',
|
|
248
|
+
}));
|
|
249
|
+
const result = await runRetrySweep(TEST_CLIENT_SLUG, async () => 'bad-token');
|
|
250
|
+
expect(result.exhausted).toBe(1);
|
|
251
|
+
expect(await loadPendingItems(TEST_CLIENT_SLUG)).toHaveLength(0);
|
|
252
|
+
expect(await loadDeadItems(TEST_CLIENT_SLUG)).toHaveLength(1);
|
|
253
|
+
});
|
|
254
|
+
it('AC4: item exhausting MAX_RETRY_ATTEMPTS goes dead', async () => {
|
|
255
|
+
// Enqueue an item that already has MAX_RETRY_ATTEMPTS - 1 attempts.
|
|
256
|
+
const item = await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'repeated transient');
|
|
257
|
+
// Manually update attempt count to one below the max so next sweep exhausts it.
|
|
258
|
+
const { writeFile: wf } = await import('node:fs/promises');
|
|
259
|
+
const { join: pjoin } = await import('node:path');
|
|
260
|
+
const { queueDir: getDir } = await import('../retry-queue.js');
|
|
261
|
+
const dir = getDir(TEST_CLIENT_SLUG);
|
|
262
|
+
const updatedItem = { ...item, attempts: MAX_RETRY_ATTEMPTS - 1 };
|
|
263
|
+
await wf(pjoin(dir, `${item.id}.json`), JSON.stringify(updatedItem, null, 2), 'utf8');
|
|
264
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
265
|
+
ok: false,
|
|
266
|
+
status: 503,
|
|
267
|
+
text: async () => '{}',
|
|
268
|
+
}));
|
|
269
|
+
const result = await runRetrySweep(TEST_CLIENT_SLUG, async () => 'tok');
|
|
270
|
+
expect(result.exhausted).toBe(1);
|
|
271
|
+
expect(await loadDeadItems(TEST_CLIENT_SLUG)).toHaveLength(1);
|
|
272
|
+
expect(await loadPendingItems(TEST_CLIENT_SLUG)).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
it('skips all items when token is null (not signed in)', async () => {
|
|
275
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'err');
|
|
276
|
+
const result = await runRetrySweep(TEST_CLIENT_SLUG, async () => null);
|
|
277
|
+
expect(result.tokenMissing).toBe(true);
|
|
278
|
+
expect(result.skipped).toBeGreaterThan(0);
|
|
279
|
+
// Item should still be in the queue — not removed or marked dead.
|
|
280
|
+
expect(await loadPendingItems(TEST_CLIENT_SLUG)).toHaveLength(1);
|
|
281
|
+
});
|
|
282
|
+
it('returns delivered=0, skipped=0 for an empty queue', async () => {
|
|
283
|
+
const result = await runRetrySweep(TEST_CLIENT_SLUG, async () => 'tok');
|
|
284
|
+
expect(result.delivered).toBe(0);
|
|
285
|
+
expect(result.skipped).toBe(0);
|
|
286
|
+
expect(result.exhausted).toBe(0);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
// ── getQueueStatus (AC3: ingest_status) ───────────────────────────────────────
|
|
290
|
+
describe('getQueueStatus()', () => {
|
|
291
|
+
let queueDir;
|
|
292
|
+
beforeEach(async () => {
|
|
293
|
+
queueDir = makeQueueDir();
|
|
294
|
+
process.env['SC_QUEUE_DIR'] = queueDir;
|
|
295
|
+
});
|
|
296
|
+
afterEach(async () => {
|
|
297
|
+
delete process.env['SC_QUEUE_DIR'];
|
|
298
|
+
await clearQueue(TEST_CLIENT_SLUG);
|
|
299
|
+
stopRetryWorker();
|
|
300
|
+
});
|
|
301
|
+
it('AC3: returns pending=0, dead=0 for an empty queue', async () => {
|
|
302
|
+
const status = await getQueueStatus(TEST_CLIENT_SLUG);
|
|
303
|
+
expect(status.pending).toBe(0);
|
|
304
|
+
expect(status.dead).toBe(0);
|
|
305
|
+
expect(status.pendingItems).toHaveLength(0);
|
|
306
|
+
expect(status.deadItems).toHaveLength(0);
|
|
307
|
+
});
|
|
308
|
+
it('AC3: counts pending items correctly', async () => {
|
|
309
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'err1');
|
|
310
|
+
const payload2 = { ...SAMPLE_PAYLOAD, sha256: 'b'.repeat(64) };
|
|
311
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, payload2, 'err2');
|
|
312
|
+
const status = await getQueueStatus(TEST_CLIENT_SLUG);
|
|
313
|
+
expect(status.pending).toBe(2);
|
|
314
|
+
expect(status.dead).toBe(0);
|
|
315
|
+
expect(status.pendingItems).toHaveLength(2);
|
|
316
|
+
});
|
|
317
|
+
it('AC4: dead items appear in status with error message', async () => {
|
|
318
|
+
// Enqueue and then mark dead via a 401 sweep.
|
|
319
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'initial');
|
|
320
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
321
|
+
ok: false,
|
|
322
|
+
status: 401,
|
|
323
|
+
text: async () => '{}',
|
|
324
|
+
}));
|
|
325
|
+
await runRetrySweep(TEST_CLIENT_SLUG, async () => 'bad-tok');
|
|
326
|
+
vi.restoreAllMocks();
|
|
327
|
+
const status = await getQueueStatus(TEST_CLIENT_SLUG);
|
|
328
|
+
expect(status.pending).toBe(0);
|
|
329
|
+
expect(status.dead).toBe(1);
|
|
330
|
+
expect(status.deadItems[0].lastError).toMatch(/401/);
|
|
331
|
+
expect(status.deadItems[0].filename).toBe('note.md');
|
|
332
|
+
});
|
|
333
|
+
it('AC3: pendingItems include filename, enqueuedAt, attempts fields', async () => {
|
|
334
|
+
await enqueueFailedChunk('https://example.com', TEST_CLIENT_SLUG, SAMPLE_PAYLOAD, 'err');
|
|
335
|
+
const status = await getQueueStatus(TEST_CLIENT_SLUG);
|
|
336
|
+
const item = status.pendingItems[0];
|
|
337
|
+
expect(item.filename).toBe('note.md');
|
|
338
|
+
expect(item.enqueuedAt).toBeTruthy();
|
|
339
|
+
expect(typeof item.attempts).toBe('number');
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
// ── AC1: ingestMarkdownFile with queueOnFailure=true (503 → queued) ───────────
|
|
343
|
+
describe('ingestMarkdownFile() with queueOnFailure=true — AC1 + AC5', () => {
|
|
344
|
+
let tmpDir;
|
|
345
|
+
let testFile;
|
|
346
|
+
let queueDir;
|
|
347
|
+
beforeEach(async () => {
|
|
348
|
+
tmpDir = join(tmpdir(), `sc-retry-ingest-${Date.now()}`);
|
|
349
|
+
await mkdir(tmpDir, { recursive: true });
|
|
350
|
+
testFile = join(tmpDir, 'note.md');
|
|
351
|
+
await writeFile(testFile, '# Test\n\nHello from Sound Connect.\n');
|
|
352
|
+
queueDir = makeQueueDir();
|
|
353
|
+
process.env['SC_QUEUE_DIR'] = queueDir;
|
|
354
|
+
vi.restoreAllMocks();
|
|
355
|
+
});
|
|
356
|
+
afterEach(async () => {
|
|
357
|
+
delete process.env['SC_QUEUE_DIR'];
|
|
358
|
+
await clearQueue(TEST_CLIENT_SLUG);
|
|
359
|
+
vi.restoreAllMocks();
|
|
360
|
+
stopRetryWorker();
|
|
361
|
+
});
|
|
362
|
+
it('AC1: 503 response queues the chunk instead of throwing', async () => {
|
|
363
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
364
|
+
ok: false,
|
|
365
|
+
status: 503,
|
|
366
|
+
text: async () => 'Service Unavailable',
|
|
367
|
+
}));
|
|
368
|
+
const summary = await ingestMarkdownFile({
|
|
369
|
+
filePath: testFile,
|
|
370
|
+
backendUrl: 'https://example.com',
|
|
371
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
372
|
+
token: 'tok',
|
|
373
|
+
queueOnFailure: true,
|
|
374
|
+
});
|
|
375
|
+
// Does NOT throw — returns summary with chunks_queued=1.
|
|
376
|
+
expect(summary.chunks_queued).toBe(1);
|
|
377
|
+
expect(summary.chunks_ingested).toBe(0);
|
|
378
|
+
expect(summary.chunks_sent).toBe(1);
|
|
379
|
+
// Chunk is on disk in the queue.
|
|
380
|
+
const pending = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
381
|
+
expect(pending).toHaveLength(1);
|
|
382
|
+
expect(pending[0].payload.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
383
|
+
});
|
|
384
|
+
it('AC1: network error queues the chunk instead of throwing', async () => {
|
|
385
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
|
|
386
|
+
const summary = await ingestMarkdownFile({
|
|
387
|
+
filePath: testFile,
|
|
388
|
+
backendUrl: 'https://example.com',
|
|
389
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
390
|
+
token: 'tok',
|
|
391
|
+
queueOnFailure: true,
|
|
392
|
+
});
|
|
393
|
+
expect(summary.chunks_queued).toBe(1);
|
|
394
|
+
const pending = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
395
|
+
expect(pending).toHaveLength(1);
|
|
396
|
+
});
|
|
397
|
+
it('AC4: 401 with queueOnFailure=true still throws (permanent failure)', async () => {
|
|
398
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
399
|
+
ok: false,
|
|
400
|
+
status: 401,
|
|
401
|
+
text: async () => '{}',
|
|
402
|
+
}));
|
|
403
|
+
await expect(ingestMarkdownFile({
|
|
404
|
+
filePath: testFile,
|
|
405
|
+
backendUrl: 'https://example.com',
|
|
406
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
407
|
+
token: 'bad',
|
|
408
|
+
queueOnFailure: true,
|
|
409
|
+
})).rejects.toThrow(/401/);
|
|
410
|
+
// Nothing should be queued for permanent errors.
|
|
411
|
+
const pending = await loadPendingItems(TEST_CLIENT_SLUG);
|
|
412
|
+
expect(pending).toHaveLength(0);
|
|
413
|
+
});
|
|
414
|
+
it('AC5: simulate 503 → queue → backend recovers → drain succeeds', async () => {
|
|
415
|
+
// Step 1: Backend returns 503 → chunk queued.
|
|
416
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
417
|
+
ok: false,
|
|
418
|
+
status: 503,
|
|
419
|
+
text: async () => 'Unavailable',
|
|
420
|
+
}));
|
|
421
|
+
const summary = await ingestMarkdownFile({
|
|
422
|
+
filePath: testFile,
|
|
423
|
+
backendUrl: 'https://example.com',
|
|
424
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
425
|
+
token: 'tok',
|
|
426
|
+
queueOnFailure: true,
|
|
427
|
+
});
|
|
428
|
+
expect(summary.chunks_queued).toBe(1);
|
|
429
|
+
// Step 2: Backend recovers → sweep delivers the queued chunk.
|
|
430
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
431
|
+
ok: true,
|
|
432
|
+
status: 200,
|
|
433
|
+
text: async () => JSON.stringify({ ok: true, deduped: false }),
|
|
434
|
+
}));
|
|
435
|
+
const sweepResult = await runRetrySweep(TEST_CLIENT_SLUG, async () => 'tok');
|
|
436
|
+
expect(sweepResult.delivered).toBe(1);
|
|
437
|
+
expect(sweepResult.exhausted).toBe(0);
|
|
438
|
+
// Step 3: Queue is now empty.
|
|
439
|
+
const status = await getQueueStatus(TEST_CLIENT_SLUG);
|
|
440
|
+
expect(status.pending).toBe(0);
|
|
441
|
+
expect(status.dead).toBe(0);
|
|
442
|
+
});
|
|
443
|
+
it('without queueOnFailure (default), 503 still throws', async () => {
|
|
444
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
445
|
+
ok: false,
|
|
446
|
+
status: 503,
|
|
447
|
+
text: async () => 'Unavailable',
|
|
448
|
+
}));
|
|
449
|
+
await expect(ingestMarkdownFile({
|
|
450
|
+
filePath: testFile,
|
|
451
|
+
backendUrl: 'https://example.com',
|
|
452
|
+
clientSlug: TEST_CLIENT_SLUG,
|
|
453
|
+
token: 'tok',
|
|
454
|
+
// queueOnFailure not set → default false
|
|
455
|
+
})).rejects.toThrow(/503/);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
//# sourceMappingURL=retry-queue.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retry-queue.test.js","sourceRoot":"","sources":["../../src/__tests__/retry-queue.test.ts"],"names":[],"mappings":"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"}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL authentication module for the Sound Connect bridge (STORY-007, ADR-003, ADR-010).
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - Device-code flow login (public client, Sound BI tenant)
|
|
6
|
+
* - Keytar-backed OS-protected token cache (DPAPI on Windows, Keychain on macOS)
|
|
7
|
+
* - Silent token acquisition with NO interactive fallback (STORY-010, ADR-011)
|
|
8
|
+
* - Logout (clears cached token)
|
|
9
|
+
*
|
|
10
|
+
* ADR-010: MSAL token cache is stored ONLY in the OS credential store — never plaintext on disk.
|
|
11
|
+
* ADR-011: Fails closed — never silently proceeds unauthenticated.
|
|
12
|
+
*
|
|
13
|
+
* STORY-010: acquireTokenSilent() does NOT fall back to device-code flow when called from
|
|
14
|
+
* the MCP server process. The device-code prompt is invisible from a Claude-spawned stdio
|
|
15
|
+
* process — triggering it causes a silent hang. Instead, return null so tool calls can
|
|
16
|
+
* return a clear, actionable message directing the user to the separate `login` terminal
|
|
17
|
+
* command.
|
|
18
|
+
*/
|
|
19
|
+
import { PublicClientApplication, type ICachePlugin, type AuthenticationResult } from '@azure/msal-node';
|
|
20
|
+
/** Sound BI tenant ID (ADR-003) */
|
|
21
|
+
export declare const TENANT_ID = "2536810f-20e1-4911-a453-4409fd96db8a";
|
|
22
|
+
/**
|
|
23
|
+
* Entra client ID for the Sound Connect bridge public client application.
|
|
24
|
+
* This must be created in the Sound BI tenant via app registration (ADR-003).
|
|
25
|
+
* Sourced from env SC_ENTRA_CLIENT_ID to allow override in tests / multiple environments.
|
|
26
|
+
* IMPORTANT: The actual clientId must be set before publishing. The default is a placeholder
|
|
27
|
+
* that will fail until replaced with a real app registration's client ID.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CLIENT_ID: string;
|
|
30
|
+
/** OAuth scopes requested — openid/profile/offline_access + the backend API audience */
|
|
31
|
+
export declare const DEFAULT_SCOPES: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Constructs a MSAL PublicClientApplication bound to the Sound BI tenant.
|
|
34
|
+
* Cache is wired to keytar so tokens survive process restarts.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildMsalClient(cachePlugin?: ICachePlugin): PublicClientApplication;
|
|
37
|
+
/**
|
|
38
|
+
* Run the MSAL device-code flow.
|
|
39
|
+
*
|
|
40
|
+
* IMPORTANT (STORY-010, ADR-011): This function MUST be called only from the
|
|
41
|
+
* standalone `login` terminal subcommand — never from the MCP server path.
|
|
42
|
+
* stdout from a Claude-spawned stdio process is not reliably visible to the user,
|
|
43
|
+
* so a device-code prompt there would cause a silent hang.
|
|
44
|
+
*
|
|
45
|
+
* Prints the verification URL + user code to stdout in a human-readable format
|
|
46
|
+
* that is easy to copy from any terminal.
|
|
47
|
+
*
|
|
48
|
+
* On success returns the AuthenticationResult (contains access_token, account, etc.).
|
|
49
|
+
* On failure throws — caller is responsible for presenting the error.
|
|
50
|
+
*/
|
|
51
|
+
export declare function login(): Promise<AuthenticationResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Attempt to acquire a token silently using the cached account.
|
|
54
|
+
*
|
|
55
|
+
* Returns null in any of these cases (caller must surface an actionable message):
|
|
56
|
+
* - No cached accounts (user has never logged in, or has run `logout`)
|
|
57
|
+
* - InteractionRequiredAuthError (refresh token expired / re-consent needed)
|
|
58
|
+
*
|
|
59
|
+
* STORY-010: This function deliberately does NOT fall back to device-code login.
|
|
60
|
+
* If the user needs to re-authenticate, they must run `npx @soundbi/sound-connect login`
|
|
61
|
+
* in a terminal — never from the MCP server process where stdout is invisible.
|
|
62
|
+
*
|
|
63
|
+
* On token expiry + successful silent refresh: returns a fresh AuthenticationResult.
|
|
64
|
+
* On any auth error that requires user interaction: returns null (fail closed, ADR-011).
|
|
65
|
+
*/
|
|
66
|
+
export declare function acquireTokenSilent(): Promise<AuthenticationResult | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Clears the cached MSAL token from the OS credential store.
|
|
69
|
+
*
|
|
70
|
+
* After this call, `acquireTokenSilent()` will return null.
|
|
71
|
+
*/
|
|
72
|
+
export declare function logout(): Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Returns true if a cached account exists (i.e. the user has logged in and
|
|
75
|
+
* the refresh token has not been explicitly deleted via logout).
|
|
76
|
+
*
|
|
77
|
+
* Does NOT validate that the token is still accepted by the backend.
|
|
78
|
+
*/
|
|
79
|
+
export declare function hasStoredAccount(): Promise<boolean>;
|
|
80
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,uBAAuB,EACvB,KAAK,YAAY,EAGjB,KAAK,oBAAoB,EAE1B,MAAM,kBAAkB,CAAC;AAK1B,mCAAmC;AACnC,eAAO,MAAM,SAAS,yCAAyC,CAAC;AAEhE;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,QAA0E,CAAC;AAEjG,wFAAwF;AACxF,eAAO,MAAM,cAAc,UAO1B,CAAC;AAuCF;;;GAGG;AACH,wBAAgB,eAAe,CAAC,WAAW,CAAC,EAAE,YAAY,GAAG,uBAAuB,CA0BnF;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAsB,KAAK,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAwB3D;AAID;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAsC/E;AAID;;;;GAIG;AACH,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAW5C;AAID;;;;;GAKG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC,CAIzD"}
|