Desk: pin vectors to CultLib f6c9bf5f #43
7 changed files with 2952 additions and 899 deletions
|
|
@ -4,7 +4,7 @@ using System.Text.Json.Serialization;
|
|||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
|
||||
// (commit 89755b08) spells them: snake_case keys, every key written (nil where absent), declared in the
|
||||
// (commit f6c9bf5f) spells them: snake_case keys, every key written (nil where absent), declared in the
|
||||
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
|
||||
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
|
||||
|
||||
|
|
@ -151,22 +151,20 @@ public static class DeskRefusals
|
|||
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
|
||||
};
|
||||
|
||||
// fork_too_large (Libby's ruling) names its fork, so it is built here, not read from the table.
|
||||
// Not a literal const: the pinned vectors' doorRefusals do not name it yet, and the schema test pins literals to them.
|
||||
public static string ForkTooLarge => "fork_too_large";
|
||||
// fork_too_large names its fork, so it is built here, not read from the table; a bare door code of that name is
|
||||
// never a refusal. The texts are the pinned vectors' (alone is past the response budget).
|
||||
public const string ForkTooLarge = "fork_too_large";
|
||||
|
||||
public static int ForkTooLargeStatus => 422;
|
||||
|
||||
// A list page whose next fork alone is past the budget: the client continues with cursor c<order>; at order 0
|
||||
// it has reached the end.
|
||||
public static DeskRefusal ForkTooLargeDocument(string forkId, int order) => new(
|
||||
DeskSchemas.Refusal,
|
||||
ForkTooLarge,
|
||||
order > 0
|
||||
? $"fork_too_large: fork {forkId} (order {order}) is too large for a page; continue with cursor c{order}"
|
||||
: $"fork_too_large: fork {forkId} (order {order}) is too large for a page; the list has reached its end");
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} (order {order}) alone is past the response budget");
|
||||
|
||||
// A read of an oversize fork: the same permanent refusal, naming the fork and nothing to page.
|
||||
// A read of such a fork: nothing to page, so no order.
|
||||
public static DeskRefusal ForkTooLargeDocument(string forkId) => new(
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} is too large to return");
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} alone is past the response budget");
|
||||
|
||||
public static bool IsKnown(string code) => Table.ContainsKey(code);
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,22 @@ public sealed class DeskAnswerTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_cards_read_that_is_fork_too_large_stays_unavailable_until_the_contract_says_otherwise()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
|
||||
Assert.Equal(503, reply.Status);
|
||||
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Card_read_failure_is_unavailable()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,10 +13,18 @@ public sealed class DeskDocumentTests
|
|||
public void Refusal_and_unavailable_documents_match_vectors()
|
||||
{
|
||||
var rows = DeskVectors.Rows("doorRefusals").ToList();
|
||||
Assert.Equal(9, rows.Count);
|
||||
Assert.Equal(10, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var code = (string)row["message"]!["code"]!;
|
||||
if (code == DeskRefusals.ForkTooLarge)
|
||||
{
|
||||
// It names its fork, so it is built, not looked up; the row is the list form at order 3.
|
||||
Assert.True(DeskVectors.SameRefusal(Write(DeskRefusals.ForkTooLargeDocument("gamecult-chat:question:q3", 3)), row["message"], "gamecult-chat:question:q3", 3), $"refusal {code}");
|
||||
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.ForkTooLargeStatus);
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}");
|
||||
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ public sealed class DeskReadTests
|
|||
}
|
||||
}
|
||||
|
||||
Assert.Equal(7, steps);
|
||||
Assert.Equal(9, steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -123,7 +123,7 @@ public sealed class DeskReadTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_the_cursor_to_continue_with()
|
||||
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_its_order()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
|
@ -136,17 +136,14 @@ public sealed class DeskReadTests
|
|||
var error = (string)doc["error"]!;
|
||||
Assert.Contains("nibu:question:huge", error);
|
||||
Assert.Contains("order 7", error);
|
||||
Assert.Contains("c7", error);
|
||||
|
||||
// At order 0 there is no cursor to continue with: the list has reached its end.
|
||||
// At order 0 the text still names the fork and its order: the client reads "below 0" as the end.
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:first", 0);
|
||||
var end = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
|
||||
var endError = (string)JsonNode.Parse(end.Body)!["error"]!;
|
||||
Assert.Equal(422, end.Status);
|
||||
Assert.Contains("nibu:question:first", endError);
|
||||
Assert.Contains("order 0", endError);
|
||||
Assert.DoesNotContain("cursor c0", endError);
|
||||
Assert.Contains("end", endError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -182,6 +179,105 @@ public sealed class DeskReadTests
|
|||
Assert.DoesNotContain("cursor", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_short_pages_and_oversize_forks_follow_the_vectors()
|
||||
{
|
||||
var steps = 0;
|
||||
foreach (var scenario in DeskVectors.Rows("transport", "listShortPages"))
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
|
||||
{
|
||||
var request = step["request"]!;
|
||||
var name = (string)scenario["name"]!;
|
||||
if (step["refusal"] is { } refusal)
|
||||
{
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge((string)step["fork_id"]!, (int)step["order"]!);
|
||||
}
|
||||
else
|
||||
{
|
||||
var list = step["response"]!.Deserialize<ForkList>(DeskJson.Options)!;
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
|
||||
}
|
||||
|
||||
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
|
||||
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
|
||||
|
||||
Assert.Equal(step["refusal"] is null ? 200 : (int)step["httpStatus"]!, reply.Status);
|
||||
Assert.True(
|
||||
step["refusal"] is null
|
||||
? DeskVectors.SameShape(JsonNode.Parse(reply.Body), step["response"])
|
||||
: DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), step["refusal"], (string)step["fork_id"]!, (int)step["order"]!),
|
||||
name);
|
||||
steps++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(6, steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_past_the_cap_follows_the_vectors()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var rows = DeskVectors.Rows("transport", "readTooLarge").ToList();
|
||||
Assert.Single(rows);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var forkId = (string)row["fork_id"]!;
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", (string)row["request"]!["mind"]!, forkId), null, ReadToken(factory));
|
||||
|
||||
Assert.Equal((int)row["httpStatus"]!, reply.Status);
|
||||
Assert.True(DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), row["refusal"], forkId, null), (string)row["name"]!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Door_faults_follow_the_vectors_per_route()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var faults = DeskVectors.Rows("transport", "doorFaults").ToList();
|
||||
Assert.Equal(3, faults.Count);
|
||||
foreach (var row in faults)
|
||||
{
|
||||
var code = (string)row["doorCode"]!;
|
||||
string method = "GET", target;
|
||||
string? bearer = null;
|
||||
byte[]? body = null;
|
||||
switch ((string)row["route"]!)
|
||||
{
|
||||
case "read":
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
|
||||
target = DeskReplay.Target("read", "nibu", "nibu:question:glow-order");
|
||||
bearer = ReadToken(factory);
|
||||
break;
|
||||
case "list":
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
|
||||
target = "/api/desk/nibu/forks";
|
||||
bearer = ReadToken(factory);
|
||||
break;
|
||||
default:
|
||||
method = "POST";
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
|
||||
target = DeskReplay.Target("answer", "nibu", null);
|
||||
body = DeskReplay.AnswerBody("nibu:question:glow-order");
|
||||
bearer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
|
||||
break;
|
||||
}
|
||||
|
||||
var reply = await factory.SendAsync(method, target, body, bearer);
|
||||
|
||||
Assert.Equal((int)row["httpStatus"]!, reply.Status);
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), row["response"]), (string)row["name"]!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ namespace Bifrost.Web.Tests;
|
|||
public sealed class DeskVectorPinTests
|
||||
{
|
||||
[Fact]
|
||||
public void Vectors_are_cultlib_89755b08()
|
||||
public void Vectors_are_cultlib_f6c9bf5f()
|
||||
{
|
||||
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,12 +3,12 @@ using System.Text.Json.Nodes;
|
|||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
// CultLib's fork-card vectors, vendored at commit 89755b08b5dd4bf72ed8e5907b5ff4717e51da12.
|
||||
// CultLib's fork-card vectors, vendored at commit f6c9bf5f975177d2033578e9b51b4d21930b728a.
|
||||
public static class DeskVectors
|
||||
{
|
||||
public const string SourceCommit = "89755b08b5dd4bf72ed8e5907b5ff4717e51da12";
|
||||
public const string SourceCommit = "f6c9bf5f975177d2033578e9b51b4d21930b728a";
|
||||
|
||||
public const string FileSha256 = "3387debd16059621202b706761519231e7ad52d0d4bd071a32b80c0d916e42fa";
|
||||
public const string FileSha256 = "cc15fc9e36e9c1f7d77b42279dc0f9a72c392adb251cc6ae3a59b79b7a004619";
|
||||
|
||||
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");
|
||||
|
||||
|
|
@ -46,5 +46,21 @@ public static class DeskVectors
|
|||
}
|
||||
}
|
||||
|
||||
// The vectors' refusal `error` text is illustrative (Libby): the contract pins the code, the key set, the status
|
||||
// (asserted by the caller) and that the text names the fork id, and its order on a list.
|
||||
public static bool SameRefusal(JsonNode? actual, JsonNode? vector, string forkId, int? order)
|
||||
{
|
||||
if (actual is not JsonObject a || vector is not JsonObject v ||
|
||||
!a.Select(p => p.Key).SequenceEqual(v.Select(p => p.Key)) ||
|
||||
(string?)a["schemaVersion"] != (string?)v["schemaVersion"] || (string?)a["code"] != (string?)v["code"])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var error = (string?)a["error"] ?? string.Empty;
|
||||
return error.Contains(forkId, StringComparison.Ordinal) &&
|
||||
(order is null || error.Contains($"order {order}", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static byte[] Bytes(JsonNode node) => System.Text.Encoding.UTF8.GetBytes(node.ToJsonString());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue