diff --git a/src/Wiki.Adapters.Gitea/GiteaDtos.cs b/src/Wiki.Adapters.Gitea/GiteaDtos.cs new file mode 100644 index 0000000..417b413 --- /dev/null +++ b/src/Wiki.Adapters.Gitea/GiteaDtos.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Wiki.Adapters.Gitea; + +// Shapes of the Gitea API responses actually used. Verified against a live instance +// (GET /repos/{o}/{r}/branches/{b}, GET /repos/{o}/{r}/git/trees/{sha}) rather than the swagger doc. + +internal sealed class BranchResponse +{ + [JsonPropertyName("commit")] + public BranchCommit Commit { get; set; } = new(); +} + +internal sealed class BranchCommit +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("timestamp")] + public DateTimeOffset? Timestamp { get; set; } +} + +internal sealed class TreeResponse +{ + [JsonPropertyName("tree")] + public List? Tree { get; set; } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("truncated")] + public bool Truncated { get; set; } +} + +internal sealed class TreeEntry +{ + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("sha")] + public string Sha { get; set; } = ""; +} + +/// The subset of Gitea's "create file" response (POST .../contents/{path}) that a fact sink needs. +internal sealed class ContentsCreateResponse +{ + [JsonPropertyName("commit")] + public ContentsCommit Commit { get; set; } = new(); +} + +internal sealed class ContentsCommit +{ + [JsonPropertyName("sha")] + public string Sha { get; set; } = ""; +} diff --git a/src/Wiki.Adapters.Gitea/GiteaFactSink.cs b/src/Wiki.Adapters.Gitea/GiteaFactSink.cs new file mode 100644 index 0000000..a5ff3eb --- /dev/null +++ b/src/Wiki.Adapters.Gitea/GiteaFactSink.cs @@ -0,0 +1,157 @@ +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 new file mode 100644 index 0000000..bf43add --- /dev/null +++ b/src/Wiki.Adapters.Gitea/GiteaHttp.cs @@ -0,0 +1,42 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Wiki.Adapters.Gitea; + +/// HTTP plumbing shared by and : +/// client setup, JSON decoding and turning a failed response into a message worth reading. +internal static class GiteaHttp +{ + public static readonly JsonSerializerOptions Json = new(); + + private const int ErrorBodyPreviewLength = 300; + + /// Points the client at the configured Gitea and carries the token, unless the caller + /// (a test, typically) has already set these up on the HttpClient it handed in. + public static void Configure(HttpClient client, GiteaOptions options) + { + client.BaseAddress ??= new Uri(options.BaseUrl, UriKind.Absolute); + if (client.DefaultRequestHeaders.Authorization is null && !string.IsNullOrEmpty(options.Token)) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.Token); + } + + public static async Task GetJsonAsync(HttpClient client, string url, CancellationToken ct) + { + using var response = await client.GetAsync(url, ct).ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(stream, Json, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Gitea returned an empty body for {url}."); + } + + public static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) + { + if (response.IsSuccessStatusCode) return; + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (body.Length > ErrorBodyPreviewLength) body = body[..ErrorBodyPreviewLength]; + throw new HttpRequestException( + $"Gitea {(int)response.StatusCode} {response.ReasonPhrase} for {response.RequestMessage?.RequestUri}: {body}", + null, + response.StatusCode); + } +} diff --git a/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs b/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs new file mode 100644 index 0000000..7e83748 --- /dev/null +++ b/src/Wiki.Adapters.Gitea/GiteaNoteStore.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; +using Microsoft.Extensions.Options; +using Wiki.Core.Model; +using Wiki.Core.Parsing; +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 +{ + // 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. + private const int PageSize = 1000; + private const int MaxPages = 50; + + private readonly HttpClient _http; + private readonly GiteaOptions _options; + + public string Name => "gitea"; + + public GiteaNoteStore(HttpClient httpClient, IOptions options) + { + _http = httpClient; + _options = options.Value; + GiteaHttp.Configure(_http, _options); + } + + public async Task GetHeadAsync(CancellationToken ct) + { + var branch = await FetchBranchAsync(ct).ConfigureAwait(false); + return new StoreHead(branch.Commit.Id, branch.Commit.Timestamp); + } + + public async Task> ListAsync(string commit, IReadOnlySet kinds, CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var refs = new List(); + var page = 1; + var seen = 0; + while (true) + { + var url = $"api/v1/repos/{repo}/git/trees/{Uri.EscapeDataString(commit)}?recursive=true&per_page={PageSize}&page={page}"; + var tree = await GiteaHttp.GetJsonAsync(_http, url, ct).ConfigureAwait(false); + var entries = tree.Tree ?? []; + foreach (var entry in entries) + { + if (entry.Type != "blob") continue; + if (DocumentParser.Classify(entry.Path) is not { } classified) continue; + if (!kinds.Contains(classified.Kind)) continue; + refs.Add(new DocumentRef(classified.Kind, classified.Slug, entry.Path, entry.Sha)); + } + 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. + var done = entries.Count == 0 + || (tree.TotalCount > 0 ? seen >= tree.TotalCount : entries.Count < PageSize); + if (done) break; + page++; + if (page > MaxPages) throw new InvalidOperationException("Gitea tree paging exceeded the safety cap."); + } + return refs; + } + + public async Task ReadAsync(DocumentRef reference, string commit, CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var path = GiteaUrls.EncodeSegments(reference.Path); + var url = $"api/v1/repos/{repo}/raw/{path}?ref={Uri.EscapeDataString(commit)}"; + using var response = await _http.GetAsync(url, ct).ConfigureAwait(false); + await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + + public async Task CheckAsync(CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + try + { + var branch = await FetchBranchAsync(ct).ConfigureAwait(false); + return new AdapterHealth(Name, true, $"HEAD {branch.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow); + } + catch (Exception ex) + { + return new AdapterHealth(Name, false, ex.Message, sw.Elapsed, DateTimeOffset.UtcNow); + } + } + + private Task FetchBranchAsync(CancellationToken ct) + { + var repo = GiteaUrls.EncodeSegments(_options.Repository); + var branch = Uri.EscapeDataString(_options.Branch); + return GiteaHttp.GetJsonAsync(_http, $"api/v1/repos/{repo}/branches/{branch}", ct); + } +} diff --git a/src/Wiki.Adapters.Gitea/GiteaOptions.cs b/src/Wiki.Adapters.Gitea/GiteaOptions.cs new file mode 100644 index 0000000..9365cb0 --- /dev/null +++ b/src/Wiki.Adapters.Gitea/GiteaOptions.cs @@ -0,0 +1,10 @@ +namespace Wiki.Adapters.Gitea; + +/// How to reach the notes repository on Gitea. +public sealed class GiteaOptions +{ + public string BaseUrl { get; set; } = "https://gitea.renner.uno"; + public string Repository { get; set; } = "k.renner/notes"; + public string Branch { get; set; } = "main"; + public string Token { get; set; } = ""; +} diff --git a/src/Wiki.Adapters.Gitea/GiteaUrls.cs b/src/Wiki.Adapters.Gitea/GiteaUrls.cs new file mode 100644 index 0000000..1b00757 --- /dev/null +++ b/src/Wiki.Adapters.Gitea/GiteaUrls.cs @@ -0,0 +1,9 @@ +namespace Wiki.Adapters.Gitea; + +/// Path building shared by the store and the sink: repo/branch/path segments are each +/// percent-encoded on their own so a literal '/' between segments survives, matching Gitea's routing. +internal static class GiteaUrls +{ + public static string EncodeSegments(string value) => + string.Join('/', value.Split('/').Select(Uri.EscapeDataString)); +} diff --git a/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6d31631 --- /dev/null +++ b/src/Wiki.Adapters.Gitea/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; +using Wiki.Core.Ports; + +namespace Wiki.Adapters.Gitea; + +public static class ServiceCollectionExtensions +{ + /// Registers the Gitea-backed and , each + /// with its own managed HttpClient from . + public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action configure) + { + services.Configure(configure); + services.AddHttpClient(); + services.AddHttpClient(); + services.AddTransient(sp => sp.GetRequiredService()); + services.AddTransient(sp => sp.GetRequiredService()); + return services; + } +} diff --git a/src/Wiki.Adapters.Gitea/Wiki.Adapters.Gitea.csproj b/src/Wiki.Adapters.Gitea/Wiki.Adapters.Gitea.csproj index f5a3032..9440b48 100644 --- a/src/Wiki.Adapters.Gitea/Wiki.Adapters.Gitea.csproj +++ b/src/Wiki.Adapters.Gitea/Wiki.Adapters.Gitea.csproj @@ -4,6 +4,11 @@ + + + + + net10.0 enable diff --git a/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs b/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs new file mode 100644 index 0000000..0437e8d --- /dev/null +++ b/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Wiki.Contracts.Gitea; + +/// +/// A minimal Gitea API double, in front of an in-memory copy of . +/// Reproduces the quirks the real Gitea note-store adapter has to tolerate: the tree listing pages +/// with "tree": null past the last page, and may report truncated=true even on a page that turns +/// out to be the last one with data. Blob shas are a SHA-1 hex of the file's content, so they are +/// stable across repeated calls and distinct per distinct content, exactly what the note-store +/// contract requires. +/// +public sealed class FakeGiteaHandler : HttpMessageHandler +{ + private readonly Dictionary _files; + private readonly string _repo; + private readonly string _branch; + private readonly string _commit; + private readonly int _pageSize; + + /// Every file created via POST .../contents/{path}, path -> decoded text, in call order. + public List<(string Path, string Content, string Branch, string Message)> Created { get; } = []; + + public FakeGiteaHandler(string repository = "k.renner/notes", string branch = "main", int pageSize = 3) + { + _repo = repository; + _branch = branch; + _commit = VaultFixture.Commit; + _pageSize = pageSize; + _files = new Dictionary(VaultFixture.Files); + } + + /// Adds a file to the fake as if it already existed at HEAD, e.g. to force the + /// fact sink into its -2 suffix path. + public void SeedFile(string path, string content) => _files[path] = content; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var path = request.RequestUri!.AbsolutePath; + var prefix = $"/api/v1/repos/{_repo}/"; + if (!path.StartsWith(prefix, StringComparison.Ordinal)) + return Task.FromResult(NotFound()); + + var rest = path[prefix.Length..]; + + if (request.Method == HttpMethod.Get && rest == $"branches/{_branch}") + return Task.FromResult(Json(new JsonObject + { + ["commit"] = new JsonObject + { + ["id"] = _commit, + ["timestamp"] = "2026-09-20T12:00:00Z", + }, + })); + + if (request.Method == HttpMethod.Get && rest.StartsWith($"git/trees/{_commit}", StringComparison.Ordinal)) + return Task.FromResult(HandleTree(request.RequestUri)); + + if (request.Method == HttpMethod.Get && rest.StartsWith("raw/", StringComparison.Ordinal)) + return Task.FromResult(HandleRaw(rest["raw/".Length..])); + + if (request.Method == HttpMethod.Get && rest.StartsWith("contents/", StringComparison.Ordinal)) + return Task.FromResult(HandleContentsExists(rest["contents/".Length..])); + + if (request.Method == HttpMethod.Post && rest.StartsWith("contents/", StringComparison.Ordinal)) + return HandleContentsCreateAsync(rest["contents/".Length..], request, ct); + + return Task.FromResult(NotFound()); + } + + private HttpResponseMessage HandleTree(Uri requestUri) + { + var page = QueryInt(requestUri, "page", 1); + var entries = BuildEntries(); + var start = (page - 1) * _pageSize; + + var body = new JsonObject + { + ["sha"] = _commit, + ["total_count"] = entries.Count, + }; + + if (start >= entries.Count) + { + // Gitea answers "tree": null once you page past the end. + body["tree"] = null; + return Json(body); + } + + var slice = entries.Skip(start).Take(_pageSize).ToList(); + var tree = new JsonArray(); + foreach (var e in slice) + { + tree.Add(new JsonObject + { + ["path"] = e.Path, + ["type"] = e.Type, + ["sha"] = e.Sha, + }); + } + body["tree"] = tree; + // Gitea sets this even when this page happens to be the last one with data; callers must + // not use it to decide whether to stop. + body["truncated"] = true; + return Json(body); + } + + private HttpResponseMessage HandleRaw(string encodedPathAndQuery) + { + var filePath = StripQuery(encodedPathAndQuery); + return _files.TryGetValue(filePath, out var content) + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(content, Encoding.UTF8, "text/plain") } + : NotFound(); + } + + private HttpResponseMessage HandleContentsExists(string encodedPathAndQuery) + { + var filePath = StripQuery(encodedPathAndQuery); + return _files.ContainsKey(filePath) + ? Json(new JsonObject { ["path"] = filePath }) + : NotFound(); + } + + private async Task HandleContentsCreateAsync(string encodedPathAndQuery, HttpRequestMessage request, CancellationToken ct) + { + var filePath = StripQuery(encodedPathAndQuery); + var payload = await request.Content!.ReadFromJsonAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("empty create-file request body"); + var base64 = payload["content"]!.GetValue(); + var branch = payload["branch"]?.GetValue() ?? _branch; + var message = payload["message"]?.GetValue() ?? ""; + var text = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + + if (_files.ContainsKey(filePath)) + return NotFound(HttpStatusCode.UnprocessableEntity, $"{filePath} already exists"); + + _files[filePath] = text; + Created.Add((filePath, text, branch, message)); + + return Json(new JsonObject + { + ["commit"] = new JsonObject { ["sha"] = Sha1Hex(text) }, + }); + } + + private static string StripQuery(string encodedPathAndQuery) + { + var raw = encodedPathAndQuery.Contains('?') ? encodedPathAndQuery[..encodedPathAndQuery.IndexOf('?')] : encodedPathAndQuery; + return Uri.UnescapeDataString(raw); + } + + private static int QueryInt(Uri uri, string key, int fallback) + { + var query = uri.Query.TrimStart('?'); + foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = pair.Split('=', 2); + if (kv.Length == 2 && kv[0] == key && int.TryParse(Uri.UnescapeDataString(kv[1]), out var value)) + return value; + } + return fallback; + } + + private List<(string Path, string Type, string Sha)> BuildEntries() + { + var dirs = new SortedSet(StringComparer.Ordinal); + foreach (var filePath in _files.Keys) + { + var slash = filePath.LastIndexOf('/'); + while (slash > 0) + { + dirs.Add(filePath[..slash]); + slash = filePath.LastIndexOf('/', slash - 1); + } + } + + var entries = new List<(string Path, string Type, string Sha)>(); + foreach (var dir in dirs) + entries.Add((dir, "tree", Sha1Hex($"dir:{dir}"))); + foreach (var (filePath, content) in _files) + entries.Add((filePath, "blob", Sha1Hex(content))); + + return entries.OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + } + + private static string Sha1Hex(string content) + { + var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(content)); + return Convert.ToHexStringLower(bytes); + } + + private static HttpResponseMessage Json(JsonNode? body) => + new(HttpStatusCode.OK) { Content = new StringContent(body?.ToJsonString() ?? "null", Encoding.UTF8, "application/json") }; + + private static HttpResponseMessage NotFound(HttpStatusCode status = HttpStatusCode.NotFound, string message = "not found") => + new(status) { Content = new StringContent(JsonSerializer.Serialize(new { message }), Encoding.UTF8, "application/json") }; +} diff --git a/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs new file mode 100644 index 0000000..96d2042 --- /dev/null +++ b/test/Wiki.Contracts/Gitea/GiteaFactSinkTests.cs @@ -0,0 +1,121 @@ +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 new file mode 100644 index 0000000..59853af --- /dev/null +++ b/test/Wiki.Contracts/Gitea/GiteaNoteStoreContractTests.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Options; +using Wiki.Adapters.Gitea; +using Wiki.Core.Ports; + +namespace Wiki.Contracts.Gitea; + +public sealed class GiteaNoteStoreContractTests : NoteStoreContract +{ + private const string Repository = "k.renner/notes"; + private const string Branch = "main"; + + protected override Task CreateStoreAsync() + { + 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 Task.FromResult(new GiteaNoteStore(httpClient, options)); + } +}