store port: get and create documents; depositing a fact is a use case #3
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+31
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user