store port: get and create documents; depositing a fact is a use case #3

Merged
k.renner merged 3 commits from fix/adapter-names into main 2026-09-24 11:39:39 +00:00
16 changed files with 272 additions and 319 deletions
-157
View File
@@ -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;
/// <summary>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.</summary>
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<GiteaOptions> options)
{
_http = httpClient;
_options = options.Value;
GiteaHttp.Configure(_http, _options);
}
public async Task<FactReceipt> 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<ContentsCreateResponse>(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<AdapterHealth> 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<BranchResponse>(_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);
}
}
/// <summary>The candidate path if free at HEAD, else the same with -2, -3, ... appended until one is free.</summary>
private async Task<string> 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<bool> 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();
}
/// <summary>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.</summary>
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;
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ using System.Text.Json;
namespace Wiki.Adapters.Gitea;
/// <summary>HTTP plumbing shared by <see cref="GiteaNoteStore"/> and <see cref="GiteaFactSink"/>:
/// <summary>HTTP plumbing shared by <see cref="GiteaRepository"/>:
/// client setup, JSON decoding and turning a failed response into a message worth reading.</summary>
internal static class GiteaHttp
{
@@ -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;
/// <summary>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.</summary>
public sealed class GiteaNoteStore : INoteStore
/// <summary>
/// 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.
/// </summary>
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<GiteaOptions> options)
public GiteaRepository(HttpClient httpClient, IOptions<GiteaOptions> 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);
}
/// <summary>POST contents: Gitea answers 422 when the path already exists on the branch.</summary>
public async Task<StoreWrite> 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<ContentsCreateResponse>(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<AdapterHealth> CheckAsync(CancellationToken ct)
{
var sw = Stopwatch.StartNew();
@@ -1,24 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Wiki.Core.Ports;
namespace Wiki.Adapters.Gitea;
public static class ServiceCollectionExtensions
{
/// <summary>Registers the Gitea-backed <see cref="INoteStore"/> and <see cref="IFactSink"/>, each
/// with its own managed HttpClient from <see cref="IHttpClientFactory"/>. Reads retry transient
/// failures; pooled connections are recycled so a stale keep-alive is not reused for long.</summary>
public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action<GiteaOptions> configure)
private const string ClientName = "gitea";
/// <summary>Registers <see cref="GiteaRepository"/> as the <see cref="INoteStore"/>. Reads retry
/// transient failures; pooled connections are recycled so a stale keep-alive is not reused for long.</summary>
public static IServiceCollection AddGiteaNoteStore(this IServiceCollection services, Action<GiteaOptions> configure)
{
services.Configure(configure);
services.AddHttpClient<GiteaNoteStore>()
services.AddHttpClient(ClientName)
.AddHttpMessageHandler(() => new TransientRetryHandler())
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
services.AddHttpClient<GiteaFactSink>()
.AddHttpMessageHandler(() => new TransientRetryHandler())
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
services.AddTransient<INoteStore>(sp => sp.GetRequiredService<GiteaNoteStore>());
services.AddTransient<IFactSink>(sp => sp.GetRequiredService<GiteaFactSink>());
services.AddSingleton<INoteStore>(sp => new GiteaRepository(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(ClientName),
sp.GetRequiredService<IOptions<GiteaOptions>>()));
return services;
}
}
@@ -0,0 +1,94 @@
using System.Globalization;
using System.Text;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Application.Facts;
/// <summary>
/// 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.
/// </summary>
public sealed class DepositFactUseCase(INoteStore store, TimeProvider clock)
{
private const int MaxSlugLength = 80;
private const int MaxSuffixAttempts = 1000;
public async Task<FactReceipt> 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();
}
/// <summary>Quotes a scalar that would otherwise be ambiguous in flow-less YAML.</summary>
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;
}
}
+9
View File
@@ -14,3 +14,12 @@ public sealed record IndexState(string ModelId, IReadOnlyDictionary<string, stri
/// <summary>Head of the note store: the commit the listing is taken from.</summary>
public sealed record StoreHead(string Commit, DateTimeOffset? CommittedAt);
/// <summary>Result of creating a document in the store.</summary>
public sealed record StoreWrite(string Path, string Commit);
/// <summary>The path is already taken; the caller picks another.</summary>
public sealed class DocumentExistsException(string path) : Exception($"document already exists: {path}")
{
public string Path { get; } = path;
}
+6 -6
View File
@@ -9,7 +9,10 @@ public interface IAdapterHealth
Task<AdapterHealth> CheckAsync(CancellationToken ct);
}
/// <summary>Where the vault's documents are read from.</summary>
/// <summary>
/// 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.
/// </summary>
public interface INoteStore : IAdapterHealth
{
/// <summary>The current head; a listing is always taken at a head.</summary>
@@ -20,12 +23,9 @@ public interface INoteStore : IAdapterHealth
/// <summary>The document's text at that head.</summary>
Task<string> ReadAsync(DocumentRef reference, string commit, CancellationToken ct);
}
/// <summary>Where a deposited fact goes: one new raw source, never an edit of an existing file.</summary>
public interface IFactSink : IAdapterHealth
{
Task<FactReceipt> AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct);
/// <summary>Creates a new document at the path on the current head. Throws <see cref="DocumentExistsException"/> when the path is taken.</summary>
Task<StoreWrite> CreateAsync(string path, string text, string message, CancellationToken ct);
}
/// <summary>Text to vector. One instance per configured model.</summary>
+12 -8
View File
@@ -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<IReadOnlyList<IEmbeddingModel>>(sp =>
wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService<IEmbeddingModel>).ToList());
builder.Services.AddSingleton<IEmbeddingModel>(sp => sp.GetRequiredKeyedService<IEmbeddingModel>(wiki.Embeddings.Active));
// One adapter may serve several ports (Gitea is note store and fact sink); list it once.
builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
[
sp.GetRequiredService<INoteStore>(),
sp.GetRequiredService<IFactSink>(),
sp.GetRequiredService<IVectorIndex>(),
sp.GetRequiredService<IDocumentCatalog>(),
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
]);
{
List<IAdapterHealth> all =
[
sp.GetRequiredService<INoteStore>(),
sp.GetRequiredService<IVectorIndex>(),
sp.GetRequiredService<IDocumentCatalog>(),
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
];
return all.Distinct().ToList();
});
// ---- use cases ---------------------------------------------------------------------------
builder.Services.AddSingleton(new SyncOptions());
@@ -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<ArgumentException>(() => 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));
}
+10
View File
@@ -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<StoreWrite> 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<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -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<ArgumentException>(() => 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);
}
}
@@ -20,6 +20,6 @@ public sealed class GiteaNoteStoreContractTests : NoteStoreContract
Branch = Branch,
Token = "test-token",
});
return Task.FromResult<INoteStore>(new GiteaNoteStore(httpClient, options));
return Task.FromResult<INoteStore>(new GiteaRepository(httpClient, options));
}
}
+19
View File
@@ -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<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 HealthReportsOk()
{
-2
View File
@@ -32,8 +32,6 @@ public sealed class WikiHostFactory : WebApplicationFactory<Program>
{
services.RemoveAll<INoteStore>();
services.AddSingleton<INoteStore>(Store);
services.RemoveAll<IFactSink>();
services.AddSingleton<IFactSink>(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");
+7 -7
View File
@@ -33,14 +33,14 @@ public sealed class FakeStore : INoteStore
}
public Task<string> ReadAsync(DocumentRef reference, string commit, CancellationToken ct) => Task.FromResult(Files[reference.Path]);
public Task<AdapterHealth> 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<FactReceipt> AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct) =>
Task.FromResult(new FactReceipt("raw/fake.md", "c", now));
public Task<StoreWrite> 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<AdapterHealth> CheckAsync(CancellationToken ct) => Task.FromResult(new AdapterHealth(Name, true, null, TimeSpan.Zero, DateTimeOffset.UnixEpoch));
}