diff --git a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs b/src/Wiki.Adapters.Gitea/GiteaFactSink.cs deleted file mode 100644 index a5ff3eb..0000000 --- a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Net; -using System.Net.Http.Json; -using System.Text; -using Microsoft.Extensions.Options; -using Wiki.Core.Model; -using Wiki.Core.Ports; - -namespace Wiki.Adapters.Gitea; - -/// Deposits a fact as a new file under raw/ in Gitea. Never edits an existing file: a -/// title that would collide with an existing path at HEAD gets a numbered suffix instead. -public sealed class GiteaFactSink : IFactSink -{ - private const int MaxSlugLength = 80; - private const int MaxSuffixAttempts = 1000; - - private readonly HttpClient _http; - private readonly GiteaOptions _options; - - public string Name => "gitea"; - - public GiteaFactSink(HttpClient httpClient, IOptions options) - { - _http = httpClient; - _options = options.Value; - GiteaHttp.Configure(_http, _options); - } - - public async Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) - { - var errors = fact.Validate(); - if (errors.Count > 0) - throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact)); - - var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - var slug = Slugify(fact.Title); - var path = await ReservePathAsync(datePrefix, slug, ct).ConfigureAwait(false); - var text = BuildFile(fact, by, now, datePrefix); - - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var encodedPath = GiteaUrls.EncodeSegments(path); - var request = new - { - content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), - message = $"raw: {fact.Title}", - branch = _options.Branch, - }; - using var response = await _http.PostAsJsonAsync($"api/v1/repos/{repo}/contents/{encodedPath}", request, GiteaHttp.Json, ct) - .ConfigureAwait(false); - await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); - var created = await response.Content.ReadFromJsonAsync(GiteaHttp.Json, ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("Gitea returned an empty body creating a file."); - return new FactReceipt(path, created.Commit.Sha, now); - } - - public async Task CheckAsync(CancellationToken ct) - { - var sw = Stopwatch.StartNew(); - try - { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var branch = Uri.EscapeDataString(_options.Branch); - var head = await GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct) - .ConfigureAwait(false); - return new AdapterHealth(Name, true, $"HEAD {head.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow); - } - catch (Exception ex) - { - return new AdapterHealth(Name, false, ex.Message, sw.Elapsed, DateTimeOffset.UtcNow); - } - } - - /// The candidate path if free at HEAD, else the same with -2, -3, ... appended until one is free. - private async Task ReservePathAsync(string datePrefix, string slug, CancellationToken ct) - { - var candidate = $"raw/{datePrefix}-{slug}.md"; - for (var attempt = 2; await ExistsAtHeadAsync(candidate, ct).ConfigureAwait(false); attempt++) - { - if (attempt > MaxSuffixAttempts) - throw new InvalidOperationException($"Gitea has {MaxSuffixAttempts}+ raw files named like {datePrefix}-{slug}; giving up."); - candidate = $"raw/{datePrefix}-{slug}-{attempt}.md"; - } - return candidate; - } - - private async Task ExistsAtHeadAsync(string path, CancellationToken ct) - { - var repo = GiteaUrls.EncodeSegments(_options.Repository); - var encodedPath = GiteaUrls.EncodeSegments(path); - var branch = Uri.EscapeDataString(_options.Branch); - using var response = await _http.GetAsync($"api/v1/repos/{repo}/contents/{encodedPath}?ref={branch}", ct).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.NotFound) return false; - await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); - return true; - } - - private static string BuildFile(RawSource fact, Identity by, DateTimeOffset now, string capturedDate) - { - var body = fact.Content; - if (!body.EndsWith('\n')) body += "\n"; - - var sb = new StringBuilder(); - sb.Append("---\n"); - sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n'); - sb.Append("type: raw\n"); - sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n'); - sb.Append("captured: ").Append(capturedDate).Append('\n'); - sb.Append("captured_by: ").Append(by.Name).Append('\n'); - sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n'); - sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n"); - sb.Append("---\n"); - sb.Append('\n'); - sb.Append(body); - return sb.ToString(); - } - - /// Quotes a scalar that would otherwise be ambiguous or wrong in flow-less YAML: a colon - /// or a '#' can start a mapping value or a comment mid-line, and a leading quote needs escaping. - private static string YamlScalar(string value) - { - if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\'')) - return value; - return $"\"{value.Replace("\"", "\\\"")}\""; - } - - private static string Slugify(string title) - { - var sb = new StringBuilder(title.Length); - foreach (var ch in title.ToLowerInvariant()) - { - if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) sb.Append(ch); - else sb.Append('-'); - } - - var collapsed = new StringBuilder(sb.Length); - var lastWasDash = false; - foreach (var ch in sb.ToString()) - { - if (ch == '-') - { - if (lastWasDash) continue; - lastWasDash = true; - } - else - { - lastWasDash = false; - } - collapsed.Append(ch); - } - - var slug = collapsed.ToString().Trim('-'); - if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-'); - return slug.Length == 0 ? "untitled" : slug; - } -} diff --git a/src/Wiki.Adapters.Gitea/GiteaHttp.cs b/src/Wiki.Adapters.Gitea/GiteaHttp.cs index bf43add..fd7fa50 100644 --- a/src/Wiki.Adapters.Gitea/GiteaHttp.cs +++ b/src/Wiki.Adapters.Gitea/GiteaHttp.cs @@ -3,7 +3,7 @@ using System.Text.Json; namespace Wiki.Adapters.Gitea; -/// HTTP plumbing shared by and : +/// HTTP plumbing shared by : /// client setup, JSON decoding and turning a failed response into a message worth reading. internal static class GiteaHttp { diff --git a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs b/src/Wiki.Adapters.Gitea/GiteaRepository.cs similarity index 69% rename from src/Wiki.Adapters.Gitea/GiteaNoteStore.cs rename to src/Wiki.Adapters.Gitea/GiteaRepository.cs index 7e83748..48676de 100644 --- a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs +++ b/src/Wiki.Adapters.Gitea/GiteaRepository.cs @@ -1,4 +1,7 @@ using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Text; using Microsoft.Extensions.Options; using Wiki.Core.Model; using Wiki.Core.Parsing; @@ -6,9 +9,11 @@ using Wiki.Core.Ports; namespace Wiki.Adapters.Gitea; -/// Reads the vault straight from a Gitea repository: HEAD, a recursive tree listing and raw -/// file contents, all at the commit the listing was taken from. -public sealed class GiteaNoteStore : INoteStore +/// +/// One Gitea repository as the vault's storage: HEAD, a recursive tree listing, raw file contents +/// at a commit, and creating a new file on the branch. Knows paths and text, nothing about notes. +/// +public sealed class GiteaRepository : INoteStore { // Gitea answers "tree": null past the last page, and may mark the last page truncated=true even // though nothing follows; stop on a short/empty page or once total_count is reached either way. @@ -20,7 +25,7 @@ public sealed class GiteaNoteStore : INoteStore public string Name => "gitea"; - public GiteaNoteStore(HttpClient httpClient, IOptions options) + public GiteaRepository(HttpClient httpClient, IOptions options) { _http = httpClient; _options = options.Value; @@ -53,8 +58,7 @@ public sealed class GiteaNoteStore : INoteStore } seen += entries.Count; // total_count is the reliable signal (confirmed live: Gitea reports it regardless of the - // per_page requested); a short page only means "done" when total_count isn't available, - // since a server may cap the effective page size below what was asked for. + // per_page requested); a short page only means "done" when total_count isn't available. var done = entries.Count == 0 || (tree.TotalCount > 0 ? seen >= tree.TotalCount : entries.Count < PageSize); if (done) break; @@ -74,6 +78,27 @@ public sealed class GiteaNoteStore : INoteStore return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } + /// POST contents: Gitea answers 422 when the path already exists on the branch. + public async Task CreateAsync(string path, string text, string message, CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var encodedPath = GiteaUrls.EncodeSegments(path); + var request = new + { + content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)), + message, + branch = _options.Branch, + }; + using var response = await _http.PostAsJsonAsync($"api/v1/repos/{repo}/contents/{encodedPath}", request, GiteaHttp.Json, ct) + .ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.UnprocessableEntity) + throw new DocumentExistsException(path); + await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); + var created = await response.Content.ReadFromJsonAsync(GiteaHttp.Json, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Gitea returned an empty body creating a file."); + return new StoreWrite(path, created.Commit.Sha); + } + public async Task CheckAsync(CancellationToken ct) { var sw = Stopwatch.StartNew(); diff --git a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs index c2a8ff6..f9afdb2 100644 --- a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs +++ b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs @@ -1,24 +1,24 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Wiki.Core.Ports; namespace Wiki.Adapters.Gitea; public static class ServiceCollectionExtensions { - /// Registers the Gitea-backed and , each - /// with its own managed HttpClient from . Reads retry transient - /// failures; pooled connections are recycled so a stale keep-alive is not reused for long. - public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action configure) + private const string ClientName = "gitea"; + + /// Registers as the . Reads retry + /// transient failures; pooled connections are recycled so a stale keep-alive is not reused for long. + public static IServiceCollection AddGiteaNoteStore(this IServiceCollection services, Action configure) { services.Configure(configure); - services.AddHttpClient() + services.AddHttpClient(ClientName) .AddHttpMessageHandler(() => new TransientRetryHandler()) .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); - services.AddHttpClient() - .AddHttpMessageHandler(() => new TransientRetryHandler()) - .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); - services.AddTransient(sp => sp.GetRequiredService()); - services.AddTransient(sp => sp.GetRequiredService()); + services.AddSingleton(sp => new GiteaRepository( + sp.GetRequiredService().CreateClient(ClientName), + sp.GetRequiredService>())); return services; } } diff --git a/src/Wiki.Application/Facts/DepositFactUseCase.cs b/src/Wiki.Application/Facts/DepositFactUseCase.cs new file mode 100644 index 0000000..7a7f52d --- /dev/null +++ b/src/Wiki.Application/Facts/DepositFactUseCase.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using System.Text; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Application.Facts; + +/// +/// Turns a fact into a raw source note and stores it: provenance frontmatter in the vault's +/// shape, a dated slug path under raw/, a numbered suffix when the path is taken. The store only +/// ever sees a path and text. +/// +public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock) +{ + private const int MaxSlugLength = 80; + private const int MaxSuffixAttempts = 1000; + + public async Task DepositAsync(RawSource fact, Identity by, CancellationToken ct) + { + var errors = fact.Validate(); + if (errors.Count > 0) + throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact)); + + var now = clock.GetUtcNow(); + var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var slug = Slugify(fact.Title); + var text = BuildNote(fact, by, datePrefix); + var message = $"raw: {fact.Title}"; + + var path = $"raw/{datePrefix}-{slug}.md"; + for (var attempt = 2; ; attempt++) + { + try + { + var written = await store.CreateAsync(path, text, message, ct); + return new FactReceipt(written.Path, written.Commit, now); + } + catch (DocumentExistsException) when (attempt <= MaxSuffixAttempts) + { + path = $"raw/{datePrefix}-{slug}-{attempt}.md"; + } + } + } + + public static string BuildNote(RawSource fact, Identity by, string capturedDate) + { + var body = fact.Content; + if (!body.EndsWith('\n')) body += "\n"; + + var sb = new StringBuilder(); + sb.Append("---\n"); + sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n'); + sb.Append("type: raw\n"); + sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n'); + sb.Append("captured: ").Append(capturedDate).Append('\n'); + sb.Append("captured_by: ").Append(by.Name).Append('\n'); + sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n'); + sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n"); + sb.Append("---\n"); + sb.Append('\n'); + sb.Append(body); + return sb.ToString(); + } + + /// Quotes a scalar that would otherwise be ambiguous in flow-less YAML. + private static string YamlScalar(string value) + { + if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\'')) + return value; + return $"\"{value.Replace("\"", "\\\"")}\""; + } + + public static string Slugify(string title) + { + var sb = new StringBuilder(title.Length); + var lastWasDash = false; + foreach (var ch in title.ToLowerInvariant()) + { + if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) + { + sb.Append(ch); + lastWasDash = false; + } + else if (!lastWasDash) + { + sb.Append('-'); + lastWasDash = true; + } + } + var slug = sb.ToString().Trim('-'); + if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-'); + return slug.Length == 0 ? "untitled" : slug; + } +} diff --git a/src/Wiki.Core/Model/Retrieval.cs b/src/Wiki.Core/Model/Retrieval.cs index ff61934..f3cdc70 100644 --- a/src/Wiki.Core/Model/Retrieval.cs +++ b/src/Wiki.Core/Model/Retrieval.cs @@ -14,3 +14,12 @@ public sealed record IndexState(string ModelId, IReadOnlyDictionaryHead of the note store: the commit the listing is taken from. public sealed record StoreHead(string Commit, DateTimeOffset? CommittedAt); + +/// Result of creating a document in the store. +public sealed record StoreWrite(string Path, string Commit); + +/// The path is already taken; the caller picks another. +public sealed class DocumentExistsException(string path) : Exception($"document already exists: {path}") +{ + public string Path { get; } = path; +} diff --git a/src/Wiki.Core/Ports/Ports.cs b/src/Wiki.Core/Ports/Ports.cs index 1c0a039..9378bcd 100644 --- a/src/Wiki.Core/Ports/Ports.cs +++ b/src/Wiki.Core/Ports/Ports.cs @@ -9,7 +9,10 @@ public interface IAdapterHealth Task CheckAsync(CancellationToken ct); } -/// Where the vault's documents are read from. +/// +/// Where the vault's documents live: read at a head, and create a new document. What a document +/// means (a note, a raw source, a report) is the application's business; the store moves text. +/// public interface INoteStore : IAdapterHealth { /// The current head; a listing is always taken at a head. @@ -20,12 +23,9 @@ public interface INoteStore : IAdapterHealth /// The document's text at that head. Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct); -} -/// Where a deposited fact goes: one new raw source, never an edit of an existing file. -public interface IFactSink : IAdapterHealth -{ - Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct); + /// Creates a new document at the path on the current head. Throws when the path is taken. + Task CreateAsync(string path, string text, string message, CancellationToken ct); } /// Text to vector. One instance per configured model. diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 17fc409..ecf0d9e 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -18,7 +18,7 @@ builder.Services.AddSingleton(wiki); builder.Services.AddSingleton(TimeProvider.System); // ---- adapters, selected by configuration ------------------------------------------------ -builder.Services.AddGiteaAdapters(o => +builder.Services.AddGiteaNoteStore(o => { o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl; o.Repository = wiki.NoteStore.Gitea.Repository; @@ -47,14 +47,18 @@ foreach (var (name, model) in wiki.Embeddings.Models) builder.Services.AddSingleton>(sp => wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService).ToList()); builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.Embeddings.Active)); +// One adapter may serve several ports (Gitea is note store and fact sink); list it once. builder.Services.AddSingleton>(sp => -[ - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - .. sp.GetRequiredService>(), -]); +{ + List all = + [ + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + .. sp.GetRequiredService>(), + ]; + return all.Distinct().ToList(); +}); // ---- use cases --------------------------------------------------------------------------- builder.Services.AddSingleton(new SyncOptions()); diff --git a/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs b/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs new file mode 100644 index 0000000..4ba53be --- /dev/null +++ b/test/Wiki.Application.Tests/DepositFactUseCaseTests.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Time.Testing; +using Wiki.Application.Facts; +using Wiki.Core.Model; + +namespace Wiki.Application.Tests; + +public class DepositFactUseCaseTests +{ + private static readonly RawSource Fact = new( + Title: "UDM intercepts DNS: a follow-up", + Source: "https://example.com/thread#5", + Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.", + ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero), + Tags: ["dns", "udm"]); + + private static readonly Identity By = new("agent-x", IdentityKind.Machine); + private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero); + private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md"; + + private const string ExpectedText = + "---\n" + + "title: \"UDM intercepts DNS: a follow-up\"\n" + + "type: raw\n" + + "source: \"https://example.com/thread#5\"\n" + + "captured: 2026-09-23\n" + + "captured_by: agent-x\n" + + "observed: 2026-09-20T14:30:00+00:00\n" + + "tags: [dns, udm]\n" + + "---\n" + + "\n" + + "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n"; + + private readonly FakeStore _store = new(); + private DepositFactUseCase UseCase => new(_store, new FakeTimeProvider(Now)); + + [Fact] + public async Task WritesTheRawNoteAtTheDatedSlugPath() + { + var receipt = await UseCase.DepositAsync(Fact, By, CancellationToken.None); + Assert.Equal(ExpectedPath, receipt.Path); + Assert.Equal(Now, receipt.StoredAt); + Assert.Equal(ExpectedText, _store.Files[ExpectedPath]); + Assert.Equal(("raw: UDM intercepts DNS: a follow-up", ExpectedPath), _store.Created.Single()); + } + + [Fact] + public async Task NumbersThePathWhileItCollides() + { + _store.Files[ExpectedPath] = "already here"; + _store.Files["raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md"] = "also here"; + var receipt = await UseCase.DepositAsync(Fact, By, CancellationToken.None); + Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path); + } + + [Theory] + [InlineData("", "source", "content long enough to pass the minimum length check, easily.")] + [InlineData("title", "", "content long enough to pass the minimum length check, easily.")] + [InlineData("title", "source", "too short")] + public async Task RejectsAnInvalidFactBeforeTouchingTheStore(string title, string source, string content) + { + var invalid = new RawSource(title, source, content, Now, []); + await Assert.ThrowsAsync(() => UseCase.DepositAsync(invalid, By, CancellationToken.None)); + Assert.Empty(_store.Created); + } + + [Theory] + [InlineData("Talos v1.13.10: aes256k / kernel 6.18!", "talos-v1-13-10-aes256k-kernel-6-18")] + [InlineData(" ", "untitled")] + [InlineData("Ünïcödé näme", "n-c-d-n-me")] + public void SlugifiesTitles(string title, string slug) => Assert.Equal(slug, DepositFactUseCase.Slugify(title)); +} diff --git a/test/Wiki.Application.Tests/Fakes.cs b/test/Wiki.Application.Tests/Fakes.cs index 2d591a5..2c24003 100644 --- a/test/Wiki.Application.Tests/Fakes.cs +++ b/test/Wiki.Application.Tests/Fakes.cs @@ -34,6 +34,16 @@ public sealed class FakeStore : INoteStore return Task.FromResult(Files[reference.Path]); } + public List<(string Message, string Path)> Created { get; } = []; + + public Task CreateAsync(string path, string text, string message, CancellationToken ct) + { + if (Files.ContainsKey(path)) throw new DocumentExistsException(path); + Files[path] = text; + Created.Add((message, path)); + return Task.FromResult(new StoreWrite(path, Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(text))))); + } + public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); } diff --git a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj index 9d03af8..e190d11 100644 --- a/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj +++ b/test/Wiki.Application.Tests/Wiki.Application.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs deleted file mode 100644 index 96d2042..0000000 --- a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Microsoft.Extensions.Options; -using Wiki.Adapters.Gitea; -using Wiki.Core.Model; - -namespace Wiki.Contracts.Gitea; - -public sealed class GiteaFactSinkTests -{ - private const string Repository = "k.renner/notes"; - private const string Branch = "main"; - - private static readonly RawSource Fact = new( - Title: "UDM intercepts DNS: a follow-up", - Source: "https://example.com/thread#5", - Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.", - ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero), - Tags: ["dns", "udm"]); - - private static readonly Identity By = new("agent-x", IdentityKind.Machine); - private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero); - - private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md"; - - private static readonly string ExpectedText = - "---\n" + - "title: \"UDM intercepts DNS: a follow-up\"\n" + - "type: raw\n" + - "source: \"https://example.com/thread#5\"\n" + - "captured: 2026-09-23\n" + - "captured_by: agent-x\n" + - "observed: 2026-09-20T14:30:00+00:00\n" + - "tags: [dns, udm]\n" + - "---\n" + - "\n" + - "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n"; - - private static (FakeGiteaHandler Handler, GiteaFactSink Sink) CreateSink() - { - var handler = new FakeGiteaHandler(Repository, Branch, pageSize: 3); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://gitea.test") }; - var options = Options.Create(new GiteaOptions - { - BaseUrl = "https://gitea.test", - Repository = Repository, - Branch = Branch, - Token = "test-token", - }); - return (handler, new GiteaFactSink(httpClient, options)); - } - - [Fact] - public async Task WritesTheExpectedPathAndFrontmatter() - { - var (handler, sink) = CreateSink(); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal(ExpectedPath, receipt.Path); - Assert.Equal(Now, receipt.StoredAt); - - var created = Assert.Single(handler.Created); - Assert.Equal(ExpectedPath, created.Path); - Assert.Equal(ExpectedText, created.Content); - Assert.Equal(Branch, created.Branch); - } - - [Fact] - public async Task ReturnsTheCommitShaFromTheResponse() - { - var (_, sink) = CreateSink(); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal(Sha1Hex(ExpectedText), receipt.Commit); - } - - [Fact] - public async Task AppendsANumberedSuffixWhenThePathAlreadyExists() - { - var (handler, sink) = CreateSink(); - handler.SeedFile(ExpectedPath, "already here"); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", receipt.Path); - } - - [Fact] - public async Task KeepsNumberingWhileCandidatesCollide() - { - var (handler, sink) = CreateSink(); - handler.SeedFile(ExpectedPath, "already here"); - handler.SeedFile("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", "also here"); - - var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None); - - Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path); - } - - [Theory] - [InlineData("", "source", "content long enough to pass the minimum length check, easily.")] - [InlineData("title", "", "content long enough to pass the minimum length check, easily.")] - [InlineData("title", "source", "too short")] - public async Task RejectsAnInvalidRawSourceBeforeCallingGitea(string title, string source, string content) - { - var (handler, sink) = CreateSink(); - var invalid = new RawSource(title, source, content, Now, []); - - await Assert.ThrowsAsync(() => sink.AppendAsync(invalid, By, Now, CancellationToken.None)); - - Assert.Empty(handler.Created); - } - - private static string Sha1Hex(string content) - { - var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(content)); - return Convert.ToHexStringLower(bytes); - } -} diff --git a/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs index 59853af..71966ff 100644 --- a/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs +++ b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs @@ -20,6 +20,6 @@ public sealed class GiteaNoteStoreContractTests : NoteStoreContract Branch = Branch, Token = "test-token", }); - return Task.FromResult(new GiteaNoteStore(httpClient, options)); + return Task.FromResult(new GiteaRepository(httpClient, options)); } } diff --git a/test/Wiki.Contracts/NoteStoreContract.cs b/test/Wiki.Contracts/NoteStoreContract.cs index 86fc37d..3fed37f 100644 --- a/test/Wiki.Contracts/NoteStoreContract.cs +++ b/test/Wiki.Contracts/NoteStoreContract.cs @@ -57,6 +57,25 @@ public abstract class NoteStoreContract } } + [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 HealthReportsOk() { diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index 1a74703..d6b9827 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -32,8 +32,6 @@ public sealed class WikiHostFactory : WebApplicationFactory { services.RemoveAll(); services.AddSingleton(Store); - services.RemoveAll(); - services.AddSingleton(new FakeFactSink()); // The embedding model registered under the active name is replaced by a deterministic fake. services.RemoveAll(typeof(IEmbeddingModel)); var fake = new FakeEmbeddings("bge-m3"); diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs index 764cba8..cf29fcf 100644 --- a/test/Wiki.Host.Tests/Fakes.cs +++ b/test/Wiki.Host.Tests/Fakes.cs @@ -33,14 +33,14 @@ public sealed class FakeStore : INoteStore } public Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct) => Task.FromResult(Files[reference.Path]); - public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); -} -public sealed class FakeFactSink : IFactSink -{ - public string Name => "fake-facts"; - public Task AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) => - Task.FromResult(new FactReceipt("raw/fake.md", "c", now)); + public Task CreateAsync(string path, string text, string message, CancellationToken ct) + { + if (Files.ContainsKey(path)) throw new DocumentExistsException(path); + Files[path] = text; + return Task.FromResult(new StoreWrite(path, "c")); + } + public Task CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch)); }