214 lines
10 KiB
C#
214 lines
10 KiB
C#
using Wiki.Core.Model;
|
|
using Wiki.Core.Ports;
|
|
|
|
namespace Wiki.Contracts;
|
|
|
|
/// <summary>Every <see cref="INoteStore"/> adapter derives a test class from this and supplies a store over <see cref="VaultFixture"/>.</summary>
|
|
public abstract class NoteStoreContract
|
|
{
|
|
/// <summary>A store whose current head exposes exactly <see cref="VaultFixture.Files"/>.</summary>
|
|
protected abstract Task<INoteStore> 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> { 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<DocumentExistsException>(() =>
|
|
store.CreateAsync("raw/2026-09-24-new.md", "other", "raw: New", CancellationToken.None));
|
|
await Assert.ThrowsAsync<DocumentExistsException>(() =>
|
|
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<DocumentExistsException>(() =>
|
|
store.CreateAsync("raw/2026-09-24-new-1.jpg", bytes, "x", CancellationToken.None));
|
|
}
|
|
|
|
private static readonly IReadOnlySet<DocumentKind> ArticleKinds = new HashSet<DocumentKind> { 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<StoreConflictException>(() => 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<FileNotFoundException>(() => 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<StoreConflictException>(() => 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);
|
|
}
|
|
}
|