using Wiki.Core.Model; using Wiki.Core.Ports; namespace Wiki.Contracts; /// Every adapter derives a test class from this and supplies a store over . public abstract class NoteStoreContract { /// A store whose current head exposes exactly . protected abstract Task CreateStoreAsync(); [Fact] public async Task HeadHasACommit() { var store = await CreateStoreAsync(); var head = await store.GetHeadAsync(CancellationToken.None); Assert.False(string.IsNullOrWhiteSpace(head.Commit)); } [Fact] public async Task ListsOnlyVaultDocumentsOfRequestedKinds() { var store = await CreateStoreAsync(); var head = await store.GetHeadAsync(CancellationToken.None); var all = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); Assert.Equal( VaultFixture.Expected.OrderBy(e => e.Path), all.Select(r => (r.Kind, r.Slug, r.Path)).OrderBy(e => e.Path)); var wiki = await store.ListAsync(head.Commit, new HashSet { DocumentKind.Wiki }, CancellationToken.None); Assert.All(wiki, r => Assert.Equal(DocumentKind.Wiki, r.Kind)); Assert.Equal(2, wiki.Count); } [Fact] public async Task ContentHashIsStableAndDistinct() { var store = await CreateStoreAsync(); var head = await store.GetHeadAsync(CancellationToken.None); var a = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); var b = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); Assert.Equal(a.OrderBy(r => r.Path).Select(r => r.ContentHash), b.OrderBy(r => r.Path).Select(r => r.ContentHash)); Assert.All(a, r => Assert.False(string.IsNullOrWhiteSpace(r.ContentHash))); Assert.Equal(a.Count, a.Select(r => r.ContentHash).Distinct().Count()); } [Fact] public async Task ReadsExactText() { var store = await CreateStoreAsync(); var head = await store.GetHeadAsync(CancellationToken.None); var refs = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); foreach (var r in refs) { var text = await store.ReadAsync(r, head.Commit, CancellationToken.None); Assert.Equal(VaultFixture.Files[r.Path], text); } } [Fact] public async Task CreatesANewDocumentAndRefusesAnExistingPath() { var store = await CreateStoreAsync(); var written = await store.CreateAsync("raw/2026-09-24-new.md", "---\ntitle: New\n---\n\nText.\n", "raw: New", CancellationToken.None); Assert.Equal("raw/2026-09-24-new.md", written.Path); Assert.False(string.IsNullOrWhiteSpace(written.Commit)); var head = await store.GetHeadAsync(CancellationToken.None); var refs = await store.ListAsync(head.Commit, VaultFixture.AllKinds, CancellationToken.None); var created = Assert.Single(refs, r => r.Path == "raw/2026-09-24-new.md"); Assert.Equal("---\ntitle: New\n---\n\nText.\n", await store.ReadAsync(created, head.Commit, CancellationToken.None)); await Assert.ThrowsAsync(() => store.CreateAsync("raw/2026-09-24-new.md", "other", "raw: New", CancellationToken.None)); await Assert.ThrowsAsync(() => store.CreateAsync("wiki/udm-dns.md", "other", "x", CancellationToken.None)); } [Fact] public async Task CreatesABinaryAndRefusesAnExistingPath() { var store = await CreateStoreAsync(); var bytes = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 1, 2, 3 }; var written = await store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "raw: New (attachment 1)", CancellationToken.None); Assert.Equal("raw/2026-09-24-new-1.jpg", written.Path); Assert.False(string.IsNullOrWhiteSpace(written.Commit)); await Assert.ThrowsAsync(() => store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "x", CancellationToken.None)); } private static readonly IReadOnlySet ArticleKinds = new HashSet { DocumentKind.Article }; [Fact] public async Task Commit_CreatesUpdatesDeletesInOneCommit() { var store = await CreateStoreAsync(); var seed = await store.CommitAsync( [ new FileChange(FileChangeOp.Create, "articles/de/alt.md", "alt", null), new FileChange(FileChangeOp.Create, "articles/de/weg.md", "weg", null), ], "seed", CancellationToken.None); var before = await store.GetHeadAsync(CancellationToken.None); Assert.Equal(seed, before.Commit); var refs = await store.ListAsync(before.Commit, ArticleKinds, CancellationToken.None); var alt = Assert.Single(refs, r => r.Path == "articles/de/alt.md"); var weg = Assert.Single(refs, r => r.Path == "articles/de/weg.md"); var sha = await store.CommitAsync( [ new FileChange(FileChangeOp.Create, "articles/de/neu.md", "neu", null), new FileChange(FileChangeOp.Update, "articles/de/alt.md", "alt v2", alt.ContentHash), new FileChange(FileChangeOp.Delete, "articles/de/weg.md", null, weg.ContentHash), ], "reconcile", CancellationToken.None); var after = await store.GetHeadAsync(CancellationToken.None); Assert.NotEqual(before.Commit, after.Commit); Assert.Equal(sha, after.Commit); var listed = await store.ListAsync(after.Commit, ArticleKinds, CancellationToken.None); Assert.Equal(["articles/de/alt.md", "articles/de/neu.md"], listed.Select(r => r.Path).Order().ToArray()); var updated = Assert.Single(listed, r => r.Path == "articles/de/alt.md"); Assert.Equal("alt v2", await store.ReadAsync(updated, after.Commit, CancellationToken.None)); } [Fact] public async Task Commit_StaleSha_ThrowsConflict() { var store = await CreateStoreAsync(); await store.CommitAsync([new FileChange(FileChangeOp.Create, "articles/de/a.md", "a", null)], "seed", CancellationToken.None); await Assert.ThrowsAsync(() => store.CommitAsync( [new FileChange(FileChangeOp.Update, "articles/de/a.md", "b", "0000000000000000000000000000000000000000")], "stale", CancellationToken.None)); } [Fact] public async Task ReadText_ReturnsTextAndUsableSha() { var store = await CreateStoreAsync(); await store.CommitAsync([new FileChange(FileChangeOp.Create, "articles/_plan.json", "{\"v\":1}", null)], "plan", CancellationToken.None); var head = await store.GetHeadAsync(CancellationToken.None); var stored = await store.ReadTextAsync("articles/_plan.json", head.Commit, CancellationToken.None); Assert.NotNull(stored); Assert.Equal("{\"v\":1}", stored.Text); await store.CommitAsync([new FileChange(FileChangeOp.Update, "articles/_plan.json", "{\"v\":2}", stored.BlobSha)], "plan v2", CancellationToken.None); var next = await store.GetHeadAsync(CancellationToken.None); Assert.Equal("{\"v\":2}", (await store.ReadTextAsync("articles/_plan.json", next.Commit, CancellationToken.None))!.Text); } [Fact] public async Task ReadText_MissingFile_IsNull() { var store = await CreateStoreAsync(); var head = await store.GetHeadAsync(CancellationToken.None); Assert.Null(await store.ReadTextAsync("articles/_missing.json", head.Commit, CancellationToken.None)); } [Fact] public async Task ReadBytes_ReturnsExactBytes() { var store = await CreateStoreAsync(); var bytes = new byte[] { 0x89, 0x50, 0x4E }; await store.CreateAsync("articles/img/a.png", bytes, "img", CancellationToken.None); var head = await store.GetHeadAsync(CancellationToken.None); Assert.Equal(bytes, await store.ReadBytesAsync("articles/img/a.png", head.Commit, CancellationToken.None)); } [Fact] public async Task ReadBytes_MissingFile_ThrowsFileNotFound() { var store = await CreateStoreAsync(); var head = await store.GetHeadAsync(CancellationToken.None); await Assert.ThrowsAsync(() => store.ReadBytesAsync("articles/img/missing.png", head.Commit, CancellationToken.None)); } [Fact] public async Task ReadBytes_PathWithSpace_RoundTrips() { var store = await CreateStoreAsync(); var bytes = new byte[] { 0x89, 0x50, 0x4E }; await store.CreateAsync("raw/a b.png", bytes, "img", CancellationToken.None); var head = await store.GetHeadAsync(CancellationToken.None); Assert.Equal(bytes, await store.ReadBytesAsync("raw/a b.png", head.Commit, CancellationToken.None)); } [Fact] public async Task Commit_FailedBatch_AppliesNothing() { var store = await CreateStoreAsync(); await store.CommitAsync([new FileChange(FileChangeOp.Create, "articles/de/a.md", "a", null)], "seed", CancellationToken.None); var before = await store.GetHeadAsync(CancellationToken.None); await Assert.ThrowsAsync(() => store.CommitAsync( [ new FileChange(FileChangeOp.Create, "articles/de/b.md", "b", null), new FileChange(FileChangeOp.Update, "articles/de/a.md", "a2", "0000000000000000000000000000000000000000"), ], "mixed", CancellationToken.None)); var after = await store.GetHeadAsync(CancellationToken.None); Assert.Equal(before.Commit, after.Commit); var listed = await store.ListAsync(after.Commit, ArticleKinds, CancellationToken.None); Assert.DoesNotContain(listed, r => r.Path == "articles/de/b.md"); } [Fact] public async Task HealthReportsOk() { var store = await CreateStoreAsync(); var health = await store.CheckAsync(CancellationToken.None); Assert.True(health.Ok, health.Detail); Assert.Equal(store.Name, health.Adapter); } }