From e25b9283aa2f9ec928a983f06e46df28a3210c5b Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Fri, 6 Feb 2026 13:57:44 -0800 Subject: [PATCH 1/2] feat(cas): implement M4 Compass lifecycle management Add readManifest(), deleteAsset(), and findOrphanedChunks() to CasService with facade pass-throughs. Includes 42 new unit tests and MANIFEST_NOT_FOUND / GIT_ERROR codes. --- CHANGELOG.md | 10 +- ROADMAP.md | 22 +- index.js | 24 + src/domain/services/CasService.js | 90 +++ .../services/CasService.deleteAsset.test.js | 384 +++++++++++++ .../CasService.findOrphanedChunks.test.js | 523 ++++++++++++++++++ .../services/CasService.readManifest.test.js | 282 ++++++++++ 7 files changed, 1323 insertions(+), 12 deletions(-) create mode 100644 test/unit/domain/services/CasService.deleteAsset.test.js create mode 100644 test/unit/domain/services/CasService.findOrphanedChunks.test.js create mode 100644 test/unit/domain/services/CasService.readManifest.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ad03382..c793d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] — M4 Compass + +### Added +- `CasService.readManifest({ treeOid })` — reads a Git tree, locates and decodes the manifest, returns a validated `Manifest` value object. +- `CasService.deleteAsset({ treeOid })` — returns logical deletion metadata (`{ slug, chunksOrphaned }`) without performing destructive Git operations. +- `CasService.findOrphanedChunks({ treeOids })` — aggregates referenced chunk blob OIDs across multiple assets, returning `{ referenced: Set, total: number }`. +- Facade pass-throughs for `readManifest`, `deleteAsset`, and `findOrphanedChunks` on `ContentAddressableStore`. +- New error codes: `MANIFEST_NOT_FOUND`, `GIT_ERROR`. +- 42 new unit tests across three new test suites. ## [1.3.0] — M3 Launchpad (2026-02-06) diff --git a/ROADMAP.md b/ROADMAP.md index ceb0573..59d79bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,10 +128,10 @@ Return and throw semantics for every public method (current and planned). | Version | Milestone | Codename | Theme | |--------:|-----------|----------|-------| -| v1.1.0 | M1 | Bedrock | Foundation hardening | -| v1.2.0 | M2 | Boomerang| File retrieval round trip + CLI | -| v1.3.0 | M3 | Launchpad| CI/CD pipeline | -| v1.4.0 | M4 | Compass | Lifecycle management | +| v1.1.0 | M1 | Bedrock | Foundation hardening | ✅ | +| v1.2.0 | M2 | Boomerang| File retrieval round trip + CLI | ✅ | +| v1.3.0 | M3 | Launchpad| CI/CD pipeline | ✅ | +| v1.4.0 | M4 | Compass | Lifecycle management | ✅ | | v1.5.0 | M5 | Sonar | Observability | | v1.6.0 | M6 | Cartographer | Documentation | | v2.0.0 | M7 | Horizon | Advanced features | @@ -178,7 +178,7 @@ M3 Launchpad (v1.3.0) M4 Compass (v1.4.0) --- -# M1 — Bedrock (v1.1.0) +# M1 — Bedrock (v1.1.0) ✅ **Theme:** Close compliance gaps, harden validation, expand test coverage. No new features. --- @@ -550,7 +550,7 @@ As a maintainer, I want error conditions covered by tests so regressions in vali --- -# M2 — Boomerang (v1.2.0) +# M2 — Boomerang (v1.2.0) ✅ **Theme:** Complete store→retrieve round trip + CLI. --- @@ -903,7 +903,7 @@ As a developer, I want `git cas restore --out ` so I can retrie --- -# M3 — Launchpad (v1.3.0) +# M3 — Launchpad (v1.3.0) ✅ **Theme:** Automated quality gates and release process. --- @@ -1014,12 +1014,12 @@ As a maintainer, I want releases published automatically on version tags so publ --- -# M4 — Compass (v1.4.0) +# M4 — Compass (v1.4.0) ✅ **Theme:** Read manifests from Git, manage stored assets, analyze storage. --- -## Task 4.1: Implement readManifest() on CasService +## Task 4.1: Implement readManifest() on CasService ✅ **User Story** As a developer, I want to reconstruct a Manifest from a Git tree OID so I can inspect and restore assets without holding manifests in memory. @@ -1073,7 +1073,7 @@ As a developer, I want to reconstruct a Manifest from a Git tree OID so I can in --- -## Task 4.2: Implement deleteAsset() (logical unlink info) +## Task 4.2: Implement deleteAsset() (logical unlink info) ✅ **User Story** As a developer, I want to "delete" an asset logically so I can manage lifecycle even though Git GC handles physical deletion. @@ -1124,7 +1124,7 @@ As a developer, I want to "delete" an asset logically so I can manage lifecycle --- -## Task 4.3: Implement orphaned chunk analysis +## Task 4.3: Implement orphaned chunk analysis ✅ **User Story** As an operator, I want to identify referenced chunks across many assets so I can assess storage waste. diff --git a/index.js b/index.js index 4170ec1..9a35d51 100644 --- a/index.js +++ b/index.js @@ -177,4 +177,28 @@ export default class ContentAddressableStore { const service = await this.#getService(); return await service.verifyIntegrity(manifest); } + + /** + * Reads a manifest from a Git tree OID. + */ + async readManifest(options) { + const service = await this.#getService(); + return await service.readManifest(options); + } + + /** + * Returns deletion metadata for an asset stored in a Git tree. + */ + async deleteAsset(options) { + const service = await this.#getService(); + return await service.deleteAsset(options); + } + + /** + * Aggregates referenced chunk blob OIDs across multiple stored assets. + */ + async findOrphanedChunks(options) { + const service = await this.#getService(); + return await service.findOrphanedChunks(options); + } } diff --git a/src/domain/services/CasService.js b/src/domain/services/CasService.js index fcda265..513c2fa 100644 --- a/src/domain/services/CasService.js +++ b/src/domain/services/CasService.js @@ -242,6 +242,96 @@ export default class CasService { return { buffer, bytesWritten: buffer.length }; } + /** + * Reads a manifest from a Git tree OID. + * + * @param {Object} options + * @param {string} options.treeOid - Git tree OID to read the manifest from + * @returns {Promise} + * @throws {CasError} MANIFEST_NOT_FOUND if no manifest entry exists in the tree + * @throws {CasError} GIT_ERROR if the underlying Git command fails + */ + async readManifest({ treeOid }) { + let entries; + try { + entries = await this.persistence.readTree(treeOid); + } catch (err) { + if (err instanceof CasError) { throw err; } + throw new CasError( + `Failed to read tree ${treeOid}: ${err.message}`, + 'GIT_ERROR', + { treeOid, originalError: err }, + ); + } + + const manifestName = `manifest.${this.codec.extension}`; + const manifestEntry = entries.find((e) => e.name === manifestName); + + if (!manifestEntry) { + throw new CasError( + `No manifest entry (${manifestName}) found in tree ${treeOid}`, + 'MANIFEST_NOT_FOUND', + { treeOid, expectedName: manifestName }, + ); + } + + let blob; + try { + blob = await this.persistence.readBlob(manifestEntry.oid); + } catch (err) { + if (err instanceof CasError) { throw err; } + throw new CasError( + `Failed to read manifest blob ${manifestEntry.oid}: ${err.message}`, + 'GIT_ERROR', + { treeOid, manifestOid: manifestEntry.oid, originalError: err }, + ); + } + + const decoded = this.codec.decode(blob); + return new Manifest(decoded); + } + + /** + * Returns deletion metadata for an asset stored in a Git tree. + * Does not perform any destructive Git operations. + * + * @param {Object} options + * @param {string} options.treeOid - Git tree OID of the asset + * @returns {Promise<{ chunksOrphaned: number, slug: string }>} + * @throws {CasError} MANIFEST_NOT_FOUND if the tree has no manifest + */ + async deleteAsset({ treeOid }) { + const manifest = await this.readManifest({ treeOid }); + return { + slug: manifest.slug, + chunksOrphaned: manifest.chunks.length, + }; + } + + /** + * Aggregates referenced chunk blob OIDs across multiple stored assets. + * Analysis only — does not delete or modify anything. + * + * @param {Object} options + * @param {string[]} options.treeOids - Git tree OIDs to analyze + * @returns {Promise<{ referenced: Set, total: number }>} + * @throws {CasError} MANIFEST_NOT_FOUND if any treeOid lacks a manifest + */ + async findOrphanedChunks({ treeOids }) { + const referenced = new Set(); + let total = 0; + + for (const treeOid of treeOids) { + const manifest = await this.readManifest({ treeOid }); + for (const chunk of manifest.chunks) { + referenced.add(chunk.blob); + total += 1; + } + } + + return { referenced, total }; + } + /** * Verifies the integrity of a stored file by re-hashing its chunks. * @param {import('../value-objects/Manifest.js').default} manifest diff --git a/test/unit/domain/services/CasService.deleteAsset.test.js b/test/unit/domain/services/CasService.deleteAsset.test.js new file mode 100644 index 0000000..1bc1f04 --- /dev/null +++ b/test/unit/domain/services/CasService.deleteAsset.test.js @@ -0,0 +1,384 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'node:crypto'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +/** + * Helper to create deterministic 64-char SHA-256 digests for test data. + */ +function sha256Digest(seed) { + return createHash('sha256').update(seed).digest('hex'); +} + +/** + * Shared factory: builds standard test fixtures. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn(), + writeTree: vi.fn(), + readBlob: vi.fn(), + readTree: vi.fn(), + }; + + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + + return { mockPersistence, service }; +} + +// --------------------------------------------------------------------------- +// Golden path – standard manifest +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – golden path', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('returns slug and chunksOrphaned count for a multi-chunk manifest', async () => { + const manifestData = { + slug: 'my-asset', + filename: 'photo.jpg', + size: 2048, + chunks: [ + { index: 0, size: 1024, digest: sha256Digest('chunk0'), blob: 'blob-oid-1' }, + { index: 1, size: 1024, digest: sha256Digest('chunk1'), blob: 'blob-oid-2' }, + ], + }; + + const manifestJson = JSON.stringify(manifestData); + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + { mode: '100644', type: 'blob', oid: 'blob-oid-1', name: sha256Digest('chunk0') }, + { mode: '100644', type: 'blob', oid: 'blob-oid-2', name: sha256Digest('chunk1') }, + ]); + + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-abc123' }); + + expect(result).toEqual({ + slug: 'my-asset', + chunksOrphaned: 2, + }); + + expect(mockPersistence.readTree).toHaveBeenCalledWith('tree-abc123'); + expect(mockPersistence.readBlob).toHaveBeenCalledWith('manifest-oid'); + }); + + it('returns slug and chunksOrphaned count for a single-chunk manifest', async () => { + const manifestData = { + slug: 'small-file', + filename: 'tiny.txt', + size: 512, + chunks: [ + { index: 0, size: 512, digest: sha256Digest('only-chunk'), blob: 'blob-single' }, + ], + }; + + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-2', name: 'manifest.json' }, + { mode: '100644', type: 'blob', oid: 'blob-single', name: sha256Digest('only-chunk') }, + ]); + + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-xyz789' }); + + expect(result).toEqual({ + slug: 'small-file', + chunksOrphaned: 1, + }); + }); + + it('returns slug and chunksOrphaned count for a large multi-chunk manifest', async () => { + const chunks = []; + for (let i = 0; i < 10; i++) { + chunks.push({ + index: i, + size: 1024, + digest: sha256Digest(`chunk${i}`), + blob: `blob-oid-${i}`, + }); + } + + const manifestData = { + slug: 'large-asset', + filename: 'video.mp4', + size: 10240, + chunks, + }; + + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + const treeEntries = [ + { mode: '100644', type: 'blob', oid: 'manifest-oid-3', name: 'manifest.json' }, + ...chunks.map((c) => ({ + mode: '100644', + type: 'blob', + oid: c.blob, + name: c.digest, + })), + ]; + + mockPersistence.readTree.mockResolvedValue(treeEntries); + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-large' }); + + expect(result).toEqual({ + slug: 'large-asset', + chunksOrphaned: 10, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Edge case – empty manifest +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – empty manifest', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('returns chunksOrphaned=0 for manifest with no chunks', async () => { + const manifestData = { + slug: 'empty-asset', + filename: 'empty.bin', + size: 0, + chunks: [], + }; + + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-empty', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-empty' }); + + expect(result).toEqual({ + slug: 'empty-asset', + chunksOrphaned: 0, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Failures – missing manifest +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – missing manifest', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('throws MANIFEST_NOT_FOUND when tree has no manifest.json entry', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'some-blob', name: 'not-manifest.txt' }, + ]); + + await expect( + service.deleteAsset({ treeOid: 'tree-no-manifest' }), + ).rejects.toThrow(CasError); + + try { + await service.deleteAsset({ treeOid: 'tree-no-manifest' }); + } catch (err) { + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + expect(err.message).toContain('No manifest entry'); + expect(err.meta.treeOid).toBe('tree-no-manifest'); + expect(err.meta.expectedName).toBe('manifest.json'); + } + }); + + it('throws MANIFEST_NOT_FOUND for empty tree', async () => { + mockPersistence.readTree.mockResolvedValue([]); + + await expect( + service.deleteAsset({ treeOid: 'tree-empty-tree' }), + ).rejects.toThrow(CasError); + + try { + await service.deleteAsset({ treeOid: 'tree-empty-tree' }); + } catch (err) { + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + expect(err.meta.treeOid).toBe('tree-empty-tree'); + } + }); + + it('propagates GIT_ERROR when readTree fails', async () => { + mockPersistence.readTree.mockRejectedValue( + new Error('fatal: not a valid object name tree-bad'), + ); + + await expect( + service.deleteAsset({ treeOid: 'tree-bad' }), + ).rejects.toThrow(CasError); + + try { + await service.deleteAsset({ treeOid: 'tree-bad' }); + } catch (err) { + expect(err.code).toBe('GIT_ERROR'); + expect(err.message).toContain('Failed to read tree'); + expect(err.meta.treeOid).toBe('tree-bad'); + } + }); + + it('propagates GIT_ERROR when readBlob fails', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'bad-manifest-oid', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob.mockRejectedValue( + new Error('fatal: not a valid object name bad-manifest-oid'), + ); + + await expect( + service.deleteAsset({ treeOid: 'tree-corrupt' }), + ).rejects.toThrow(CasError); + + try { + await service.deleteAsset({ treeOid: 'tree-corrupt' }); + } catch (err) { + expect(err.code).toBe('GIT_ERROR'); + expect(err.message).toContain('Failed to read manifest blob'); + expect(err.meta.treeOid).toBe('tree-corrupt'); + expect(err.meta.manifestOid).toBe('bad-manifest-oid'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Manifest with encryption metadata +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – encrypted manifest', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('returns chunksOrphaned count for encrypted manifest', async () => { + const manifestData = { + slug: 'encrypted-asset', + filename: 'secret.dat', + size: 1536, + chunks: [ + { index: 0, size: 1024, digest: sha256Digest('enc-chunk0'), blob: 'enc-blob-1' }, + { index: 1, size: 512, digest: sha256Digest('enc-chunk1'), blob: 'enc-blob-2' }, + ], + encryption: { + algorithm: 'aes-256-gcm', + nonce: 'abcd1234', + tag: 'efgh5678', + encrypted: true, + }, + }; + + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'enc-manifest-oid', name: 'manifest.json' }, + { mode: '100644', type: 'blob', oid: 'enc-blob-1', name: sha256Digest('enc-chunk0') }, + { mode: '100644', type: 'blob', oid: 'enc-blob-2', name: sha256Digest('enc-chunk1') }, + ]); + + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-encrypted' }); + + expect(result).toEqual({ + slug: 'encrypted-asset', + chunksOrphaned: 2, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Various slug formats +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – slug variations', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('handles slug with special characters', async () => { + const manifestData = { + slug: 'my-asset_v2.0', + filename: 'data.bin', + size: 1024, + chunks: [ + { index: 0, size: 1024, digest: sha256Digest('x'), blob: 'blob-x' }, + ], + }; + + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-special' }); + + expect(result.slug).toBe('my-asset_v2.0'); + }); + + it('handles very long slug', async () => { + const longSlug = 'a'.repeat(256); + const manifestData = { + slug: longSlug, + filename: 'long.txt', + size: 100, + chunks: [ + { index: 0, size: 100, digest: sha256Digest('long'), blob: 'blob-long' }, + ], + }; + + const codec = new JsonCodec(); + const manifestBlob = codec.encode(manifestData); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob.mockResolvedValue(manifestBlob); + + const result = await service.deleteAsset({ treeOid: 'tree-long-slug' }); + + expect(result.slug).toBe(longSlug); + expect(result.slug.length).toBe(256); + }); +}); diff --git a/test/unit/domain/services/CasService.findOrphanedChunks.test.js b/test/unit/domain/services/CasService.findOrphanedChunks.test.js new file mode 100644 index 0000000..e78933e --- /dev/null +++ b/test/unit/domain/services/CasService.findOrphanedChunks.test.js @@ -0,0 +1,523 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; +import { digestOf } from '../../../helpers/crypto.js'; + +/** + * Shared factory: builds the standard test fixtures. + */ +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn(), + readTree: vi.fn(), + }; + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec: new JsonCodec(), + chunkSize: 1024, + }); + return { mockPersistence, service }; +} + +/** + * Helper: creates a valid manifest JSON structure. + */ +function manifestJson({ slug, filename, size, chunks }) { + return { + slug, + filename, + size, + chunks, + }; +} + +/** + * Helper: creates a chunk object with all required fields. + */ +function chunk(index, seed, blobOid) { + return { + index, + size: 1024, + digest: digestOf(seed), + blob: blobOid, + }; +} + +// --------------------------------------------------------------------------- +// findOrphanedChunks – golden path +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – golden path', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('collects all unique blob OIDs from a single manifest', async () => { + const manifest = manifestJson({ + slug: 'asset-1', + filename: 'file.bin', + size: 2048, + chunks: [ + chunk(0, 'chunk-0', 'blob-oid-1'), + chunk(1, 'chunk-1', 'blob-oid-2'), + ], + }); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-1', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(JSON.stringify(manifest)), + ); + + const result = await service.findOrphanedChunks({ treeOids: ['tree-1'] }); + + expect(result.referenced.size).toBe(2); + expect(result.referenced.has('blob-oid-1')).toBe(true); + expect(result.referenced.has('blob-oid-2')).toBe(true); + expect(result.total).toBe(2); + }); + + it('deduplicates shared chunk OIDs across multiple manifests', async () => { + const manifest1 = manifestJson({ + slug: 'asset-1', + filename: 'file1.bin', + size: 2048, + chunks: [ + chunk(0, 'chunk-0', 'blob-shared'), + chunk(1, 'chunk-1', 'blob-unique-1'), + ], + }); + + const manifest2 = manifestJson({ + slug: 'asset-2', + filename: 'file2.bin', + size: 2048, + chunks: [ + chunk(0, 'chunk-0', 'blob-shared'), + chunk(1, 'chunk-2', 'blob-unique-2'), + ], + }); + + mockPersistence.readTree + .mockResolvedValueOnce([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-1', name: 'manifest.json' }, + ]) + .mockResolvedValueOnce([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-2', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob + .mockResolvedValueOnce(Buffer.from(JSON.stringify(manifest1))) + .mockResolvedValueOnce(Buffer.from(JSON.stringify(manifest2))); + + const result = await service.findOrphanedChunks({ + treeOids: ['tree-1', 'tree-2'], + }); + + // 3 unique blobs: blob-shared, blob-unique-1, blob-unique-2 + expect(result.referenced.size).toBe(3); + expect(result.referenced.has('blob-shared')).toBe(true); + expect(result.referenced.has('blob-unique-1')).toBe(true); + expect(result.referenced.has('blob-unique-2')).toBe(true); + // Total counts all chunks: 2 + 2 = 4 + expect(result.total).toBe(4); + }); + + it('counts total correctly even when all chunks are identical', async () => { + const manifest1 = manifestJson({ + slug: 'asset-1', + filename: 'file1.bin', + size: 1024, + chunks: [chunk(0, 'chunk-0', 'blob-same')], + }); + + const manifest2 = manifestJson({ + slug: 'asset-2', + filename: 'file2.bin', + size: 1024, + chunks: [chunk(0, 'chunk-0', 'blob-same')], + }); + + const manifest3 = manifestJson({ + slug: 'asset-3', + filename: 'file3.bin', + size: 1024, + chunks: [chunk(0, 'chunk-0', 'blob-same')], + }); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob + .mockResolvedValueOnce(Buffer.from(JSON.stringify(manifest1))) + .mockResolvedValueOnce(Buffer.from(JSON.stringify(manifest2))) + .mockResolvedValueOnce(Buffer.from(JSON.stringify(manifest3))); + + const result = await service.findOrphanedChunks({ + treeOids: ['tree-1', 'tree-2', 'tree-3'], + }); + + // Only 1 unique blob + expect(result.referenced.size).toBe(1); + expect(result.referenced.has('blob-same')).toBe(true); + // Total counts all instances: 3 + expect(result.total).toBe(3); + }); + + it('handles manifest with no chunks', async () => { + const manifest = manifestJson({ + slug: 'empty-asset', + filename: 'empty.bin', + size: 0, + chunks: [], + }); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(JSON.stringify(manifest)), + ); + + const result = await service.findOrphanedChunks({ treeOids: ['tree-1'] }); + + expect(result.referenced.size).toBe(0); + expect(result.total).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – edge cases +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – edge cases', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('returns empty set and zero total for empty treeOids array', async () => { + const result = await service.findOrphanedChunks({ treeOids: [] }); + + expect(result.referenced.size).toBe(0); + expect(result.total).toBe(0); + // Should never call readTree or readBlob + expect(mockPersistence.readTree).not.toHaveBeenCalled(); + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); + }); + + it('processes single treeOid with large manifest', async () => { + const chunks = []; + for (let i = 0; i < 100; i++) { + chunks.push(chunk(i, `chunk-${i}`, `blob-oid-${i}`)); + } + + const manifest = manifestJson({ + slug: 'large-asset', + filename: 'large.bin', + size: 102400, + chunks, + }); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(JSON.stringify(manifest)), + ); + + const result = await service.findOrphanedChunks({ treeOids: ['tree-large'] }); + + expect(result.referenced.size).toBe(100); + expect(result.total).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – stress test +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – stress test', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('handles 10 manifests with 10 chunks each, some shared', async () => { + const treeOids = []; + const manifests = []; + + // Create 10 manifests + for (let m = 0; m < 10; m++) { + const chunks = []; + for (let c = 0; c < 10; c++) { + // First 5 chunks are shared across all manifests + // Last 5 chunks are unique to each manifest + const blobOid = c < 5 ? `blob-shared-${c}` : `blob-m${m}-c${c}`; + chunks.push(chunk(c, `chunk-m${m}-c${c}`, blobOid)); + } + + manifests.push( + manifestJson({ + slug: `asset-${m}`, + filename: `file-${m}.bin`, + size: 10240, + chunks, + }), + ); + + treeOids.push(`tree-${m}`); + } + + // Mock readTree to always return a manifest entry + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + // Mock readBlob to return the appropriate manifest + manifests.forEach((manifest) => { + mockPersistence.readBlob.mockResolvedValueOnce( + Buffer.from(JSON.stringify(manifest)), + ); + }); + + const result = await service.findOrphanedChunks({ treeOids }); + + // Unique blobs: + // - 5 shared blobs (blob-shared-0 to blob-shared-4) + // - 50 unique blobs (5 per manifest × 10 manifests) + // Total: 55 unique blobs + expect(result.referenced.size).toBe(55); + + // Verify shared blobs are present + for (let c = 0; c < 5; c++) { + expect(result.referenced.has(`blob-shared-${c}`)).toBe(true); + } + + // Verify some unique blobs are present + expect(result.referenced.has('blob-m0-c5')).toBe(true); + expect(result.referenced.has('blob-m9-c9')).toBe(true); + + // Total chunks: 10 manifests × 10 chunks = 100 + expect(result.total).toBe(100); + + // Verify readTree was called 10 times + expect(mockPersistence.readTree).toHaveBeenCalledTimes(10); + // Verify readBlob was called 10 times (once per manifest) + expect(mockPersistence.readBlob).toHaveBeenCalledTimes(10); + }); + + it('handles many manifests with complete overlap', async () => { + const treeOids = []; + const manifest = manifestJson({ + slug: 'shared-asset', + filename: 'shared.bin', + size: 3072, + chunks: [ + chunk(0, 'chunk-0', 'blob-a'), + chunk(1, 'chunk-1', 'blob-b'), + chunk(2, 'chunk-2', 'blob-c'), + ], + }); + + // Create 20 identical tree references + for (let i = 0; i < 20; i++) { + treeOids.push(`tree-${i}`); + } + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + // Return the same manifest for all reads + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(JSON.stringify(manifest)), + ); + + const result = await service.findOrphanedChunks({ treeOids }); + + // Only 3 unique blobs despite 20 manifests + expect(result.referenced.size).toBe(3); + expect(result.referenced.has('blob-a')).toBe(true); + expect(result.referenced.has('blob-b')).toBe(true); + expect(result.referenced.has('blob-c')).toBe(true); + + // Total: 20 manifests × 3 chunks = 60 + expect(result.total).toBe(60); + }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – failures (fail closed) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – failures', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('throws MANIFEST_NOT_FOUND when first treeOid has no manifest', async () => { + // readTree returns empty array (no manifest entry) + mockPersistence.readTree.mockResolvedValue([]); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-missing'] }), + ).rejects.toThrow(CasError); + + try { + await service.findOrphanedChunks({ treeOids: ['tree-missing'] }); + } catch (err) { + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + expect(err.message).toContain('No manifest entry'); + expect(err.meta.treeOid).toBe('tree-missing'); + } + }); + + it('throws MANIFEST_NOT_FOUND when second treeOid has no manifest', async () => { + const manifest1 = manifestJson({ + slug: 'asset-1', + filename: 'file1.bin', + size: 1024, + chunks: [chunk(0, 'chunk-0', 'blob-1')], + }); + + // Mock returns valid tree first, then empty tree + mockPersistence.readTree.mockImplementation((oid) => { + if (oid === 'tree-1') { + return Promise.resolve([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-1', name: 'manifest.json' }, + ]); + } + return Promise.resolve([]); // tree-missing has no entries + }); + + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(JSON.stringify(manifest1)), + ); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-1', 'tree-missing'] }), + ).rejects.toThrow(CasError); + + try { + await service.findOrphanedChunks({ treeOids: ['tree-1', 'tree-missing'] }); + } catch (err) { + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + expect(err.meta.treeOid).toBe('tree-missing'); + } + }); + + it('throws GIT_ERROR when readTree fails', async () => { + mockPersistence.readTree.mockRejectedValue( + new Error('Git command failed: invalid object'), + ); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-bad'] }), + ).rejects.toThrow(CasError); + + try { + await service.findOrphanedChunks({ treeOids: ['tree-bad'] }); + } catch (err) { + expect(err.code).toBe('GIT_ERROR'); + expect(err.message).toContain('Failed to read tree'); + expect(err.meta.treeOid).toBe('tree-bad'); + } + }); + + it('throws GIT_ERROR when readBlob fails', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob.mockRejectedValue( + new Error('Git command failed: invalid blob'), + ); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-1'] }), + ).rejects.toThrow(CasError); + + try { + await service.findOrphanedChunks({ treeOids: ['tree-1'] }); + } catch (err) { + expect(err.code).toBe('GIT_ERROR'); + expect(err.message).toContain('Failed to read manifest blob'); + } + }); + + it('throws when manifest JSON is invalid', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + // Return invalid JSON + mockPersistence.readBlob.mockResolvedValue( + Buffer.from('{ invalid json }'), + ); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-1'] }), + ).rejects.toThrow(); + }); + + it('throws when manifest violates schema (missing required field)', async () => { + const invalidManifest = { + slug: 'asset-1', + // Missing filename + size: 1024, + chunks: [chunk(0, 'chunk-0', 'blob-1')], + }; + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(JSON.stringify(invalidManifest)), + ); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-1'] }), + ).rejects.toThrow(); + }); + + it('fails closed on any error by not continuing to next treeOid', async () => { + const manifest1 = manifestJson({ + slug: 'asset-1', + filename: 'file1.bin', + size: 1024, + chunks: [chunk(0, 'chunk-0', 'blob-1')], + }); + + mockPersistence.readTree + .mockResolvedValueOnce([ + { mode: '100644', type: 'blob', oid: 'manifest-oid-1', name: 'manifest.json' }, + ]) + .mockRejectedValueOnce(new Error('Git error on second tree')); + + mockPersistence.readBlob.mockResolvedValueOnce( + Buffer.from(JSON.stringify(manifest1)), + ); + + await expect( + service.findOrphanedChunks({ treeOids: ['tree-1', 'tree-2', 'tree-3'] }), + ).rejects.toThrow(CasError); + + // Verify that we stopped at the second tree and never called readTree for tree-3 + expect(mockPersistence.readTree).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/unit/domain/services/CasService.readManifest.test.js b/test/unit/domain/services/CasService.readManifest.test.js new file mode 100644 index 0000000..6c4a117 --- /dev/null +++ b/test/unit/domain/services/CasService.readManifest.test.js @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'node:crypto'; +import CasService from '../../../../src/domain/services/CasService.js'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +import JsonCodec from '../../../../src/infrastructure/codecs/JsonCodec.js'; +import Manifest from '../../../../src/domain/value-objects/Manifest.js'; +import CasError from '../../../../src/domain/errors/CasError.js'; + +function digestOf(seed) { + return createHash('sha256').update(seed).digest('hex'); +} + +function validManifestData(overrides = {}) { + return { + slug: 'test-asset', + filename: 'test.bin', + size: 2048, + chunks: [ + { index: 0, size: 1024, digest: digestOf('chunk-0'), blob: 'blob-oid-0' }, + { index: 1, size: 1024, digest: digestOf('chunk-1'), blob: 'blob-oid-1' }, + ], + ...overrides, + }; +} + +describe('CasService.readManifest', () => { + let service; + let mockPersistence; + let codec; + + beforeEach(() => { + mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn(), + readTree: vi.fn(), + }; + + codec = new JsonCodec(); + + service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec, + chunkSize: 1024, + }); + }); + + describe('golden path', () => { + it('reads and decodes manifest from tree', async () => { + const treeOid = 'tree-oid-123'; + const manifestOid = 'manifest-oid-456'; + const data = validManifestData(); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'other-oid', name: 'data.dat' }, + { mode: '100644', type: 'blob', oid: manifestOid, name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid }); + + expect(mockPersistence.readTree).toHaveBeenCalledWith(treeOid); + expect(mockPersistence.readBlob).toHaveBeenCalledWith(manifestOid); + expect(result).toBeInstanceOf(Manifest); + expect(result.slug).toBe('test-asset'); + expect(result.size).toBe(2048); + expect(result.chunks).toHaveLength(2); + }); + + it('finds manifest entry regardless of position in tree', async () => { + const manifestOid = 'manifest-oid-456'; + const data = validManifestData(); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: manifestOid, name: 'manifest.json' }, + { mode: '100644', type: 'blob', oid: 'chunk-oid-1', name: digestOf('chunk-0') }, + { mode: '100644', type: 'blob', oid: 'chunk-oid-2', name: digestOf('chunk-1') }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid: 'tree-oid' }); + + expect(result).toBeInstanceOf(Manifest); + expect(mockPersistence.readBlob).toHaveBeenCalledWith(manifestOid); + }); + }); + + describe('manifest not found', () => { + it('throws MANIFEST_NOT_FOUND when no manifest entry exists', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'chunk-oid', name: 'chunk1.dat' }, + ]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } + + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); + }); + + it('throws MANIFEST_NOT_FOUND when tree is empty', async () => { + mockPersistence.readTree.mockResolvedValue([]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } + }); + + it('throws MANIFEST_NOT_FOUND when manifest has wrong extension', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'oid', name: 'manifest.txt' }, + ]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } + }); + + it('throws MANIFEST_NOT_FOUND for bare "manifest" without extension', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'oid', name: 'manifest' }, + ]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } + }); + }); + + describe('corrupt data handling', () => { + it('throws when manifest JSON is unparseable', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from('{ invalid json }')); + + await expect(service.readManifest({ treeOid: 'tree-oid' })).rejects.toThrow(); + }); + + it('throws when manifest data fails Zod validation', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + // Valid JSON but missing required manifest fields + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(codec.encode({ foo: 'bar' })), + ); + + await expect(service.readManifest({ treeOid: 'tree-oid' })).rejects.toThrow( + /Invalid manifest data/, + ); + }); + }); + + describe('git error handling', () => { + it('wraps non-CasError from readTree as GIT_ERROR', async () => { + mockPersistence.readTree.mockRejectedValue(new Error('object not found')); + + try { + await service.readManifest({ treeOid: 'bad-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('GIT_ERROR'); + } + + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); + }); + + it('wraps non-CasError from readBlob as GIT_ERROR', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockRejectedValue(new Error('blob not found')); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('GIT_ERROR'); + } + }); + + it('re-throws CasError from readTree as-is', async () => { + const original = new CasError('Tree parse failed', 'TREE_PARSE_ERROR', {}); + mockPersistence.readTree.mockRejectedValue(original); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBe(original); + expect(err.code).toBe('TREE_PARSE_ERROR'); + } + }); + + it('re-throws CasError from readBlob as-is', async () => { + const original = new CasError('Blob read failed', 'SOME_CAS_ERROR', {}); + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockRejectedValue(original); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBe(original); + expect(err.code).toBe('SOME_CAS_ERROR'); + } + }); + }); + + describe('edge cases', () => { + it('handles manifest with empty chunks array', async () => { + const data = validManifestData({ size: 0, chunks: [] }); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid: 'tree-oid' }); + + expect(result).toBeInstanceOf(Manifest); + expect(result.chunks).toHaveLength(0); + expect(result.size).toBe(0); + }); + + it('handles tree with many entries and manifest at end', async () => { + const data = validManifestData(); + const entries = [ + ...Array.from({ length: 100 }, (_, i) => ({ + mode: '100644', + type: 'blob', + oid: `chunk-oid-${i}`, + name: `chunk-${i}`, + })), + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]; + + mockPersistence.readTree.mockResolvedValue(entries); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid: 'tree-oid' }); + + expect(result).toBeInstanceOf(Manifest); + expect(result.chunks).toHaveLength(2); + expect(mockPersistence.readBlob).toHaveBeenCalledWith('manifest-oid'); + }); + + it('includes treeOid in MANIFEST_NOT_FOUND error meta', async () => { + mockPersistence.readTree.mockResolvedValue([]); + + try { + await service.readManifest({ treeOid: 'specific-tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.meta.treeOid).toBe('specific-tree-oid'); + expect(err.meta.expectedName).toBe('manifest.json'); + } + }); + }); +}); From ad2dbcbefad2f67efc7451a1c056504a4b98e2ab Mon Sep 17 00:00:00 2001 From: "J. Kirby Ross" Date: Fri, 6 Feb 2026 14:04:49 -0800 Subject: [PATCH 2/2] style(test): fix lint errors in M4 Compass test suites Break large describe callbacks into smaller blocks to satisfy max-lines-per-function (50) and max-nested-callbacks (3) rules. Remove unused variable in deleteAsset tests. --- .../services/CasService.deleteAsset.test.js | 61 +- .../CasService.findOrphanedChunks.test.js | 216 +++++-- .../services/CasService.readManifest.test.js | 566 ++++++++++-------- 3 files changed, 544 insertions(+), 299 deletions(-) diff --git a/test/unit/domain/services/CasService.deleteAsset.test.js b/test/unit/domain/services/CasService.deleteAsset.test.js index 1bc1f04..4fb035c 100644 --- a/test/unit/domain/services/CasService.deleteAsset.test.js +++ b/test/unit/domain/services/CasService.deleteAsset.test.js @@ -34,9 +34,9 @@ function setup() { } // --------------------------------------------------------------------------- -// Golden path – standard manifest +// Golden path – multi-chunk manifest // --------------------------------------------------------------------------- -describe('CasService.deleteAsset() – golden path', () => { +describe('CasService.deleteAsset() – golden path (multi-chunk)', () => { let service; let mockPersistence; @@ -55,7 +55,6 @@ describe('CasService.deleteAsset() – golden path', () => { ], }; - const manifestJson = JSON.stringify(manifestData); const codec = new JsonCodec(); const manifestBlob = codec.encode(manifestData); @@ -77,6 +76,18 @@ describe('CasService.deleteAsset() – golden path', () => { expect(mockPersistence.readTree).toHaveBeenCalledWith('tree-abc123'); expect(mockPersistence.readBlob).toHaveBeenCalledWith('manifest-oid'); }); +}); + +// --------------------------------------------------------------------------- +// Golden path – single-chunk manifest +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – golden path (single-chunk)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('returns slug and chunksOrphaned count for a single-chunk manifest', async () => { const manifestData = { @@ -105,6 +116,18 @@ describe('CasService.deleteAsset() – golden path', () => { chunksOrphaned: 1, }); }); +}); + +// --------------------------------------------------------------------------- +// Golden path – large multi-chunk manifest +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – golden path (large manifest)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('returns slug and chunksOrphaned count for a large multi-chunk manifest', async () => { const chunks = []; @@ -187,9 +210,9 @@ describe('CasService.deleteAsset() – empty manifest', () => { }); // --------------------------------------------------------------------------- -// Failures – missing manifest +// Failures – MANIFEST_NOT_FOUND // --------------------------------------------------------------------------- -describe('CasService.deleteAsset() – missing manifest', () => { +describe('CasService.deleteAsset() – MANIFEST_NOT_FOUND errors', () => { let service; let mockPersistence; @@ -230,6 +253,18 @@ describe('CasService.deleteAsset() – missing manifest', () => { expect(err.meta.treeOid).toBe('tree-empty-tree'); } }); +}); + +// --------------------------------------------------------------------------- +// Failures – GIT_ERROR propagation +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – GIT_ERROR propagation', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('propagates GIT_ERROR when readTree fails', async () => { mockPersistence.readTree.mockRejectedValue( @@ -322,9 +357,9 @@ describe('CasService.deleteAsset() – encrypted manifest', () => { }); // --------------------------------------------------------------------------- -// Various slug formats +// Slug variations – special characters // --------------------------------------------------------------------------- -describe('CasService.deleteAsset() – slug variations', () => { +describe('CasService.deleteAsset() – slug with special characters', () => { let service; let mockPersistence; @@ -355,6 +390,18 @@ describe('CasService.deleteAsset() – slug variations', () => { expect(result.slug).toBe('my-asset_v2.0'); }); +}); + +// --------------------------------------------------------------------------- +// Slug variations – very long slug +// --------------------------------------------------------------------------- +describe('CasService.deleteAsset() – very long slug', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('handles very long slug', async () => { const longSlug = 'a'.repeat(256); diff --git a/test/unit/domain/services/CasService.findOrphanedChunks.test.js b/test/unit/domain/services/CasService.findOrphanedChunks.test.js index e78933e..138c533 100644 --- a/test/unit/domain/services/CasService.findOrphanedChunks.test.js +++ b/test/unit/domain/services/CasService.findOrphanedChunks.test.js @@ -48,10 +48,67 @@ function chunk(index, seed, blobOid) { }; } +/** + * Helper: builds two manifests that share one chunk OID. + */ +function buildDedupFixtures() { + const manifest1 = manifestJson({ + slug: 'asset-1', + filename: 'file1.bin', + size: 2048, + chunks: [ + chunk(0, 'chunk-0', 'blob-shared'), + chunk(1, 'chunk-1', 'blob-unique-1'), + ], + }); + + const manifest2 = manifestJson({ + slug: 'asset-2', + filename: 'file2.bin', + size: 2048, + chunks: [ + chunk(0, 'chunk-0', 'blob-shared'), + chunk(1, 'chunk-2', 'blob-unique-2'), + ], + }); + + return { manifest1, manifest2 }; +} + +/** + * Helper: builds 10 manifests with 10 chunks each (5 shared, 5 unique) + * and the corresponding treeOids array. + */ +function buildSharedChunkFixtures() { + const treeOids = []; + const manifests = []; + + for (let m = 0; m < 10; m++) { + const chunks = []; + for (let c = 0; c < 10; c++) { + const blobOid = c < 5 ? `blob-shared-${c}` : `blob-m${m}-c${c}`; + chunks.push(chunk(c, `chunk-m${m}-c${c}`, blobOid)); + } + + manifests.push( + manifestJson({ + slug: `asset-${m}`, + filename: `file-${m}.bin`, + size: 10240, + chunks, + }), + ); + + treeOids.push(`tree-${m}`); + } + + return { treeOids, manifests }; +} + // --------------------------------------------------------------------------- -// findOrphanedChunks – golden path +// findOrphanedChunks – golden path (single manifest) // --------------------------------------------------------------------------- -describe('CasService – findOrphanedChunks – golden path', () => { +describe('CasService – findOrphanedChunks – golden path (single manifest)', () => { let service; let mockPersistence; @@ -84,27 +141,21 @@ describe('CasService – findOrphanedChunks – golden path', () => { expect(result.referenced.has('blob-oid-2')).toBe(true); expect(result.total).toBe(2); }); +}); - it('deduplicates shared chunk OIDs across multiple manifests', async () => { - const manifest1 = manifestJson({ - slug: 'asset-1', - filename: 'file1.bin', - size: 2048, - chunks: [ - chunk(0, 'chunk-0', 'blob-shared'), - chunk(1, 'chunk-1', 'blob-unique-1'), - ], - }); +// --------------------------------------------------------------------------- +// findOrphanedChunks – golden path (dedup across manifests) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – golden path (dedup)', () => { + let service; + let mockPersistence; - const manifest2 = manifestJson({ - slug: 'asset-2', - filename: 'file2.bin', - size: 2048, - chunks: [ - chunk(0, 'chunk-0', 'blob-shared'), - chunk(1, 'chunk-2', 'blob-unique-2'), - ], - }); + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('deduplicates shared chunk OIDs across multiple manifests', async () => { + const { manifest1, manifest2 } = buildDedupFixtures(); mockPersistence.readTree .mockResolvedValueOnce([ @@ -130,6 +181,18 @@ describe('CasService – findOrphanedChunks – golden path', () => { // Total counts all chunks: 2 + 2 = 4 expect(result.total).toBe(4); }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – golden path (identical chunks) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – golden path (identical chunks)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('counts total correctly even when all chunks are identical', async () => { const manifest1 = manifestJson({ @@ -172,6 +235,18 @@ describe('CasService – findOrphanedChunks – golden path', () => { // Total counts all instances: 3 expect(result.total).toBe(3); }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – golden path (empty manifest) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – golden path (empty manifest)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('handles manifest with no chunks', async () => { const manifest = manifestJson({ @@ -244,9 +319,9 @@ describe('CasService – findOrphanedChunks – edge cases', () => { }); // --------------------------------------------------------------------------- -// findOrphanedChunks – stress test +// findOrphanedChunks – stress test (shared chunks) // --------------------------------------------------------------------------- -describe('CasService – findOrphanedChunks – stress test', () => { +describe('CasService – findOrphanedChunks – stress test (shared chunks)', () => { let service; let mockPersistence; @@ -255,30 +330,7 @@ describe('CasService – findOrphanedChunks – stress test', () => { }); it('handles 10 manifests with 10 chunks each, some shared', async () => { - const treeOids = []; - const manifests = []; - - // Create 10 manifests - for (let m = 0; m < 10; m++) { - const chunks = []; - for (let c = 0; c < 10; c++) { - // First 5 chunks are shared across all manifests - // Last 5 chunks are unique to each manifest - const blobOid = c < 5 ? `blob-shared-${c}` : `blob-m${m}-c${c}`; - chunks.push(chunk(c, `chunk-m${m}-c${c}`, blobOid)); - } - - manifests.push( - manifestJson({ - slug: `asset-${m}`, - filename: `file-${m}.bin`, - size: 10240, - chunks, - }), - ); - - treeOids.push(`tree-${m}`); - } + const { treeOids, manifests } = buildSharedChunkFixtures(); // Mock readTree to always return a manifest entry mockPersistence.readTree.mockResolvedValue([ @@ -296,7 +348,7 @@ describe('CasService – findOrphanedChunks – stress test', () => { // Unique blobs: // - 5 shared blobs (blob-shared-0 to blob-shared-4) - // - 50 unique blobs (5 per manifest × 10 manifests) + // - 50 unique blobs (5 per manifest x 10 manifests) // Total: 55 unique blobs expect(result.referenced.size).toBe(55); @@ -309,7 +361,7 @@ describe('CasService – findOrphanedChunks – stress test', () => { expect(result.referenced.has('blob-m0-c5')).toBe(true); expect(result.referenced.has('blob-m9-c9')).toBe(true); - // Total chunks: 10 manifests × 10 chunks = 100 + // Total chunks: 10 manifests x 10 chunks = 100 expect(result.total).toBe(100); // Verify readTree was called 10 times @@ -317,6 +369,18 @@ describe('CasService – findOrphanedChunks – stress test', () => { // Verify readBlob was called 10 times (once per manifest) expect(mockPersistence.readBlob).toHaveBeenCalledTimes(10); }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – stress test (complete overlap) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – stress test (complete overlap)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('handles many manifests with complete overlap', async () => { const treeOids = []; @@ -353,15 +417,15 @@ describe('CasService – findOrphanedChunks – stress test', () => { expect(result.referenced.has('blob-b')).toBe(true); expect(result.referenced.has('blob-c')).toBe(true); - // Total: 20 manifests × 3 chunks = 60 + // Total: 20 manifests x 3 chunks = 60 expect(result.total).toBe(60); }); }); // --------------------------------------------------------------------------- -// findOrphanedChunks – failures (fail closed) +// findOrphanedChunks – MANIFEST_NOT_FOUND (first tree) // --------------------------------------------------------------------------- -describe('CasService – findOrphanedChunks – failures', () => { +describe('CasService – findOrphanedChunks – MANIFEST_NOT_FOUND (first tree)', () => { let service; let mockPersistence; @@ -385,6 +449,18 @@ describe('CasService – findOrphanedChunks – failures', () => { expect(err.meta.treeOid).toBe('tree-missing'); } }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – MANIFEST_NOT_FOUND (second tree) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – MANIFEST_NOT_FOUND (second tree)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('throws MANIFEST_NOT_FOUND when second treeOid has no manifest', async () => { const manifest1 = manifestJson({ @@ -419,6 +495,18 @@ describe('CasService – findOrphanedChunks – failures', () => { expect(err.meta.treeOid).toBe('tree-missing'); } }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – failures (GIT_ERROR) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – GIT_ERROR failures', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('throws GIT_ERROR when readTree fails', async () => { mockPersistence.readTree.mockRejectedValue( @@ -458,6 +546,18 @@ describe('CasService – findOrphanedChunks – failures', () => { expect(err.message).toContain('Failed to read manifest blob'); } }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – failures (invalid manifest data) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – invalid manifest data', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('throws when manifest JSON is invalid', async () => { mockPersistence.readTree.mockResolvedValue([ @@ -494,6 +594,18 @@ describe('CasService – findOrphanedChunks – failures', () => { service.findOrphanedChunks({ treeOids: ['tree-1'] }), ).rejects.toThrow(); }); +}); + +// --------------------------------------------------------------------------- +// findOrphanedChunks – failures (fail-closed behavior) +// --------------------------------------------------------------------------- +describe('CasService – findOrphanedChunks – fail-closed behavior', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); it('fails closed on any error by not continuing to next treeOid', async () => { const manifest1 = manifestJson({ diff --git a/test/unit/domain/services/CasService.readManifest.test.js b/test/unit/domain/services/CasService.readManifest.test.js index 6c4a117..ec6d9b5 100644 --- a/test/unit/domain/services/CasService.readManifest.test.js +++ b/test/unit/domain/services/CasService.readManifest.test.js @@ -23,260 +23,346 @@ function validManifestData(overrides = {}) { }; } -describe('CasService.readManifest', () => { +function setup() { + const mockPersistence = { + writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), + writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), + readBlob: vi.fn(), + readTree: vi.fn(), + }; + + const codec = new JsonCodec(); + + const service = new CasService({ + persistence: mockPersistence, + crypto: new NodeCryptoAdapter(), + codec, + chunkSize: 1024, + }); + + return { service, mockPersistence, codec }; +} + +// --------------------------------------------------------------------------- +// Golden path +// --------------------------------------------------------------------------- +describe('CasService.readManifest – golden path', () => { let service; let mockPersistence; let codec; beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn().mockResolvedValue('mock-blob-oid'), - writeTree: vi.fn().mockResolvedValue('mock-tree-oid'), - readBlob: vi.fn(), - readTree: vi.fn(), - }; - - codec = new JsonCodec(); - - service = new CasService({ - persistence: mockPersistence, - crypto: new NodeCryptoAdapter(), - codec, - chunkSize: 1024, - }); + ({ service, mockPersistence, codec } = setup()); + }); + + it('reads and decodes manifest from tree', async () => { + const treeOid = 'tree-oid-123'; + const manifestOid = 'manifest-oid-456'; + const data = validManifestData(); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'other-oid', name: 'data.dat' }, + { mode: '100644', type: 'blob', oid: manifestOid, name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid }); + + expect(mockPersistence.readTree).toHaveBeenCalledWith(treeOid); + expect(mockPersistence.readBlob).toHaveBeenCalledWith(manifestOid); + expect(result).toBeInstanceOf(Manifest); + expect(result.slug).toBe('test-asset'); + expect(result.size).toBe(2048); + expect(result.chunks).toHaveLength(2); + }); + + it('finds manifest entry regardless of position in tree', async () => { + const manifestOid = 'manifest-oid-456'; + const data = validManifestData(); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: manifestOid, name: 'manifest.json' }, + { mode: '100644', type: 'blob', oid: 'chunk-oid-1', name: digestOf('chunk-0') }, + { mode: '100644', type: 'blob', oid: 'chunk-oid-2', name: digestOf('chunk-1') }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid: 'tree-oid' }); + + expect(result).toBeInstanceOf(Manifest); + expect(mockPersistence.readBlob).toHaveBeenCalledWith(manifestOid); + }); +}); + +// --------------------------------------------------------------------------- +// Manifest not found – missing entry and empty tree +// --------------------------------------------------------------------------- +describe('CasService.readManifest – manifest not found (missing entry)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('throws MANIFEST_NOT_FOUND when no manifest entry exists', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'chunk-oid', name: 'chunk1.dat' }, + ]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } + + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); + }); + + it('throws MANIFEST_NOT_FOUND when tree is empty', async () => { + mockPersistence.readTree.mockResolvedValue([]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Manifest not found – wrong name variations +// --------------------------------------------------------------------------- +describe('CasService.readManifest – manifest not found (wrong name)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); }); - describe('golden path', () => { - it('reads and decodes manifest from tree', async () => { - const treeOid = 'tree-oid-123'; - const manifestOid = 'manifest-oid-456'; - const data = validManifestData(); - - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'other-oid', name: 'data.dat' }, - { mode: '100644', type: 'blob', oid: manifestOid, name: 'manifest.json' }, - ]); - mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); - - const result = await service.readManifest({ treeOid }); - - expect(mockPersistence.readTree).toHaveBeenCalledWith(treeOid); - expect(mockPersistence.readBlob).toHaveBeenCalledWith(manifestOid); - expect(result).toBeInstanceOf(Manifest); - expect(result.slug).toBe('test-asset'); - expect(result.size).toBe(2048); - expect(result.chunks).toHaveLength(2); - }); - - it('finds manifest entry regardless of position in tree', async () => { - const manifestOid = 'manifest-oid-456'; - const data = validManifestData(); - - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: manifestOid, name: 'manifest.json' }, - { mode: '100644', type: 'blob', oid: 'chunk-oid-1', name: digestOf('chunk-0') }, - { mode: '100644', type: 'blob', oid: 'chunk-oid-2', name: digestOf('chunk-1') }, - ]); - mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); - - const result = await service.readManifest({ treeOid: 'tree-oid' }); - - expect(result).toBeInstanceOf(Manifest); - expect(mockPersistence.readBlob).toHaveBeenCalledWith(manifestOid); - }); + it('throws MANIFEST_NOT_FOUND when manifest has wrong extension', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'oid', name: 'manifest.txt' }, + ]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } }); - describe('manifest not found', () => { - it('throws MANIFEST_NOT_FOUND when no manifest entry exists', async () => { - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'chunk-oid', name: 'chunk1.dat' }, - ]); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(CasError); - expect(err.code).toBe('MANIFEST_NOT_FOUND'); - } - - expect(mockPersistence.readBlob).not.toHaveBeenCalled(); - }); - - it('throws MANIFEST_NOT_FOUND when tree is empty', async () => { - mockPersistence.readTree.mockResolvedValue([]); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(CasError); - expect(err.code).toBe('MANIFEST_NOT_FOUND'); - } - }); - - it('throws MANIFEST_NOT_FOUND when manifest has wrong extension', async () => { - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'oid', name: 'manifest.txt' }, - ]); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(CasError); - expect(err.code).toBe('MANIFEST_NOT_FOUND'); - } - }); - - it('throws MANIFEST_NOT_FOUND for bare "manifest" without extension', async () => { - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'oid', name: 'manifest' }, - ]); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(CasError); - expect(err.code).toBe('MANIFEST_NOT_FOUND'); - } - }); + it('throws MANIFEST_NOT_FOUND for bare "manifest" without extension', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'oid', name: 'manifest' }, + ]); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('MANIFEST_NOT_FOUND'); + } }); +}); + +// --------------------------------------------------------------------------- +// Corrupt data handling +// --------------------------------------------------------------------------- +describe('CasService.readManifest – corrupt data handling', () => { + let service; + let mockPersistence; + let codec; - describe('corrupt data handling', () => { - it('throws when manifest JSON is unparseable', async () => { - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, - ]); - mockPersistence.readBlob.mockResolvedValue(Buffer.from('{ invalid json }')); - - await expect(service.readManifest({ treeOid: 'tree-oid' })).rejects.toThrow(); - }); - - it('throws when manifest data fails Zod validation', async () => { - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, - ]); - // Valid JSON but missing required manifest fields - mockPersistence.readBlob.mockResolvedValue( - Buffer.from(codec.encode({ foo: 'bar' })), - ); - - await expect(service.readManifest({ treeOid: 'tree-oid' })).rejects.toThrow( - /Invalid manifest data/, - ); - }); + beforeEach(() => { + ({ service, mockPersistence, codec } = setup()); }); - describe('git error handling', () => { - it('wraps non-CasError from readTree as GIT_ERROR', async () => { - mockPersistence.readTree.mockRejectedValue(new Error('object not found')); - - try { - await service.readManifest({ treeOid: 'bad-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(CasError); - expect(err.code).toBe('GIT_ERROR'); - } - - expect(mockPersistence.readBlob).not.toHaveBeenCalled(); - }); - - it('wraps non-CasError from readBlob as GIT_ERROR', async () => { - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, - ]); - mockPersistence.readBlob.mockRejectedValue(new Error('blob not found')); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(CasError); - expect(err.code).toBe('GIT_ERROR'); - } - }); - - it('re-throws CasError from readTree as-is', async () => { - const original = new CasError('Tree parse failed', 'TREE_PARSE_ERROR', {}); - mockPersistence.readTree.mockRejectedValue(original); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBe(original); - expect(err.code).toBe('TREE_PARSE_ERROR'); - } - }); - - it('re-throws CasError from readBlob as-is', async () => { - const original = new CasError('Blob read failed', 'SOME_CAS_ERROR', {}); - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, - ]); - mockPersistence.readBlob.mockRejectedValue(original); - - try { - await service.readManifest({ treeOid: 'tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err).toBe(original); - expect(err.code).toBe('SOME_CAS_ERROR'); - } - }); + it('throws when manifest JSON is unparseable', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from('{ invalid json }')); + + await expect(service.readManifest({ treeOid: 'tree-oid' })).rejects.toThrow(); + }); + + it('throws when manifest data fails Zod validation', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + // Valid JSON but missing required manifest fields + mockPersistence.readBlob.mockResolvedValue( + Buffer.from(codec.encode({ foo: 'bar' })), + ); + + await expect(service.readManifest({ treeOid: 'tree-oid' })).rejects.toThrow( + /Invalid manifest data/, + ); }); +}); + +// --------------------------------------------------------------------------- +// Git error handling – wrapping non-CasError +// --------------------------------------------------------------------------- +describe('CasService.readManifest – git error wrapping', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('wraps non-CasError from readTree as GIT_ERROR', async () => { + mockPersistence.readTree.mockRejectedValue(new Error('object not found')); + + try { + await service.readManifest({ treeOid: 'bad-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('GIT_ERROR'); + } + + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); + }); + + it('wraps non-CasError from readBlob as GIT_ERROR', async () => { + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockRejectedValue(new Error('blob not found')); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(CasError); + expect(err.code).toBe('GIT_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Git error handling – re-throwing CasError +// --------------------------------------------------------------------------- +describe('CasService.readManifest – CasError passthrough', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('re-throws CasError from readTree as-is', async () => { + const original = new CasError('Tree parse failed', 'TREE_PARSE_ERROR', {}); + mockPersistence.readTree.mockRejectedValue(original); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBe(original); + expect(err.code).toBe('TREE_PARSE_ERROR'); + } + }); + + it('re-throws CasError from readBlob as-is', async () => { + const original = new CasError('Blob read failed', 'SOME_CAS_ERROR', {}); + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockRejectedValue(original); + + try { + await service.readManifest({ treeOid: 'tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err).toBe(original); + expect(err.code).toBe('SOME_CAS_ERROR'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases – empty chunks and large tree +// --------------------------------------------------------------------------- +describe('CasService.readManifest – edge cases (empty & large)', () => { + let service; + let mockPersistence; + let codec; + + beforeEach(() => { + ({ service, mockPersistence, codec } = setup()); + }); + + it('handles manifest with empty chunks array', async () => { + const data = validManifestData({ size: 0, chunks: [] }); + + mockPersistence.readTree.mockResolvedValue([ + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid: 'tree-oid' }); + + expect(result).toBeInstanceOf(Manifest); + expect(result.chunks).toHaveLength(0); + expect(result.size).toBe(0); + }); + + it('handles tree with many entries and manifest at end', async () => { + const data = validManifestData(); + const filler = Array.from({ length: 100 }, (_, i) => ({ + mode: '100644', + type: 'blob', + oid: `chunk-oid-${i}`, + name: `chunk-${i}`, + })); + const entries = [ + ...filler, + { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, + ]; + + mockPersistence.readTree.mockResolvedValue(entries); + mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); + + const result = await service.readManifest({ treeOid: 'tree-oid' }); + + expect(result).toBeInstanceOf(Manifest); + expect(result.chunks).toHaveLength(2); + expect(mockPersistence.readBlob).toHaveBeenCalledWith('manifest-oid'); + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases – error meta +// --------------------------------------------------------------------------- +describe('CasService.readManifest – edge cases (error meta)', () => { + let service; + let mockPersistence; + + beforeEach(() => { + ({ service, mockPersistence } = setup()); + }); + + it('includes treeOid in MANIFEST_NOT_FOUND error meta', async () => { + mockPersistence.readTree.mockResolvedValue([]); - describe('edge cases', () => { - it('handles manifest with empty chunks array', async () => { - const data = validManifestData({ size: 0, chunks: [] }); - - mockPersistence.readTree.mockResolvedValue([ - { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, - ]); - mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); - - const result = await service.readManifest({ treeOid: 'tree-oid' }); - - expect(result).toBeInstanceOf(Manifest); - expect(result.chunks).toHaveLength(0); - expect(result.size).toBe(0); - }); - - it('handles tree with many entries and manifest at end', async () => { - const data = validManifestData(); - const entries = [ - ...Array.from({ length: 100 }, (_, i) => ({ - mode: '100644', - type: 'blob', - oid: `chunk-oid-${i}`, - name: `chunk-${i}`, - })), - { mode: '100644', type: 'blob', oid: 'manifest-oid', name: 'manifest.json' }, - ]; - - mockPersistence.readTree.mockResolvedValue(entries); - mockPersistence.readBlob.mockResolvedValue(Buffer.from(codec.encode(data))); - - const result = await service.readManifest({ treeOid: 'tree-oid' }); - - expect(result).toBeInstanceOf(Manifest); - expect(result.chunks).toHaveLength(2); - expect(mockPersistence.readBlob).toHaveBeenCalledWith('manifest-oid'); - }); - - it('includes treeOid in MANIFEST_NOT_FOUND error meta', async () => { - mockPersistence.readTree.mockResolvedValue([]); - - try { - await service.readManifest({ treeOid: 'specific-tree-oid' }); - expect.unreachable('should have thrown'); - } catch (err) { - expect(err.meta.treeOid).toBe('specific-tree-oid'); - expect(err.meta.expectedName).toBe('manifest.json'); - } - }); + try { + await service.readManifest({ treeOid: 'specific-tree-oid' }); + expect.unreachable('should have thrown'); + } catch (err) { + expect(err.meta.treeOid).toBe('specific-tree-oid'); + expect(err.meta.expectedName).toBe('manifest.json'); + } }); });