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>
203 lines
8.0 KiB
C#
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") };
|
|
}
|