Files
Wiki/test/Wiki.Contracts/Gitea/FakeGiteaHandler.cs
T
claude-botandClaude Sonnet 5 f67cc7b871 feat(gitea): implement the Gitea note-store and fact-sink adapters
INoteStore reads the vault straight from Gitea (branch HEAD, a paged
recursive tree listing, raw file reads); IFactSink appends a new raw/
file via the contents API, numbering around any path collision. Both
verified against the live gitea.renner.uno API for response shape
rather than the swagger doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:40:57 +02:00

203 lines
8.0 KiB
C#

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;
/// <summary>
/// A minimal Gitea API double, in front of an in-memory copy of <see cref="VaultFixture.Files"/>.
/// 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.
/// </summary>
public sealed class FakeGiteaHandler : HttpMessageHandler
{
private readonly Dictionary<string, string> _files;
private readonly string _repo;
private readonly string _branch;
private readonly string _commit;
private readonly int _pageSize;
/// <summary>Every file created via POST .../contents/{path}, path -> decoded text, in call order.</summary>
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<string, string>(VaultFixture.Files);
}
/// <summary>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.</summary>
public void SeedFile(string path, string content) => _files[path] = content;
protected override Task<HttpResponseMessage> 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<HttpResponseMessage> HandleContentsCreateAsync(string encodedPathAndQuery, HttpRequestMessage request, CancellationToken ct)
{
var filePath = StripQuery(encodedPathAndQuery);
var payload = await request.Content!.ReadFromJsonAsync<JsonObject>(ct).ConfigureAwait(false)
?? throw new InvalidOperationException("empty create-file request body");
var base64 = payload["content"]!.GetValue<string>();
var branch = payload["branch"]?.GetValue<string>() ?? _branch;
var message = payload["message"]?.GetValue<string>() ?? "";
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<string>(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") };
}