diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07dc030..1bd7cfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,14 @@ name: CI on: push: branches: [ main ] + tags: + - 'v*' pull_request: branches: [ main ] +permissions: + contents: read + jobs: quality-gates: runs-on: ubuntu-latest @@ -49,8 +54,10 @@ jobs: release: needs: quality-gates - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.repository == 'IndexGrid/offline-first-sync-queue' runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 - name: Create Release Draft diff --git a/apps/web/src/lib/sync/__tests__/runner.test.ts b/apps/web/src/lib/sync/__tests__/runner.test.ts index 23ced91..1a0bfb1 100644 --- a/apps/web/src/lib/sync/__tests__/runner.test.ts +++ b/apps/web/src/lib/sync/__tests__/runner.test.ts @@ -159,4 +159,77 @@ describe('runSyncOnce', () => { const recoveredItem = await db.get('syncQueue', '1'); expect(recoveredItem?.status).toBe('SYNCED'); }); + + it('should transition to DEAD_LETTER after max retries', async () => { + const db = await getDB(); + const now = Date.now(); + + const tx = db.transaction('syncQueue', 'readwrite'); + await tx.objectStore('syncQueue').add({ + id: '1', + externalId: 'ext-dead', + entityType: 'order', + status: 'PENDING', + nextAttemptAt: now - 1000, + createdAt: now - 2000, + payload: { data: 'fail' }, + url: 'v1/pos/sync', + method: 'POST', + op: 'UPSERT', + retryCount: 10 // Max retries reached + }); + await tx.done; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + results: [{ externalId: 'ext-dead', status: 'error', reason: 'permanent_fail' }] + }) + }); + + const result = await runSyncOnce({ + batchSize: 10, + fetchImpl: mockFetch as unknown as typeof fetch, + maxRetries: 10 + }); + + expect(result.dead).toBe(1); + + const item = await db.get('syncQueue', '1'); + expect(item?.status).toBe('DEAD_LETTER'); + expect(item?.lastError).toBe('permanent_fail'); + }); + + it('should handle network errors with exponential backoff', async () => { + const db = await getDB(); + const now = Date.now(); + + const tx = db.transaction('syncQueue', 'readwrite'); + await tx.objectStore('syncQueue').add({ + id: '1', + externalId: 'ext-net', + entityType: 'order', + status: 'PENDING', + nextAttemptAt: now - 1000, + createdAt: now - 2000, + payload: { data: 'net' }, + url: 'v1/pos/sync', + method: 'POST', + op: 'UPSERT', + retryCount: 0 + }); + await tx.done; + + const mockFetch = vi.fn().mockRejectedValue(new Error('Network timeout')); + + await runSyncOnce({ + batchSize: 10, + fetchImpl: mockFetch as unknown as typeof fetch + }); + + const item = await db.get('syncQueue', '1'); + expect(item?.status).toBe('RETRYABLE_ERROR'); + expect(item?.retryCount).toBe(1); + expect(item?.nextAttemptAt).toBeGreaterThan(now); + }); });