NEO-29: cover hotbar edge paths and add Bruno requests

Add server/client tests for duplicate-slot, schema, missing-player, normalization, and busy/error handling paths, and include Bruno requests for the new hotbar routes with happy and representative failure checks.
pull/54/head
VinPropane 2026-04-25 22:36:23 -04:00
parent acde9e8145
commit b92b86a407
6 changed files with 274 additions and 0 deletions

View File

@ -0,0 +1,17 @@
meta {
name: hotbar-loadout get missing player
type: http
seq: 2
}
get {
url: {{baseUrl}}/game/players/missing-player/hotbar-loadout
body: none
auth: none
}
tests {
test("status 404", function () {
expect(res.getStatus()).to.equal(404);
});
}

View File

@ -0,0 +1,24 @@
meta {
name: hotbar-loadout get
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: none
auth: none
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("schema and slots present", function () {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.slotCount).to.equal(8);
expect(Array.isArray(body.slots)).to.equal(true);
});
}

View File

@ -0,0 +1,29 @@
meta {
name: hotbar-loadout post bad schema
type: http
seq: 4
}
post {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: json
auth: none
}
body:json {
{
"schemaVersion": 999,
"slots": [
{
"slotIndex": 0,
"abilityId": "prototype_pulse"
}
]
}
}
tests {
test("status 400", function () {
expect(res.getStatus()).to.equal(400);
});
}

View File

@ -0,0 +1,40 @@
meta {
name: hotbar-loadout post bind
type: http
seq: 3
}
post {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slots": [
{
"slotIndex": 0,
"abilityId": "prototype_pulse"
},
{
"slotIndex": 3,
"abilityId": "prototype_burst"
}
]
}
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("updated true and payload shape", function () {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.updated).to.equal(true);
expect(body.loadout.slotCount).to.equal(8);
});
}

View File

@ -35,6 +35,35 @@ class MockHttpTransport:
return OK return OK
class HoldFirstThenAutoTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var request_count: int = 0
var last_body: String = ""
var _first: bool = true
func request(
_url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
request_data: String = ""
) -> Error:
request_count += 1
last_body = request_data
var body_bytes: PackedByteArray = '{"schemaVersion":1,"updated":true,"loadout":{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'.to_utf8_buffer()
if _first:
_first = false
call_deferred("_emit", body_bytes)
else:
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
return OK
func _emit(body_bytes: PackedByteArray) -> void:
request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes)
func _make_client(transport: Node) -> Node: func _make_client(transport: Node) -> Node:
var c: Node = HotbarClient.new() var c: Node = HotbarClient.new()
c.set("injected_http", transport) c.set("injected_http", transport)
@ -107,3 +136,35 @@ func test_denied_update_emits_reason_signal() -> void:
monitor_signals(c) monitor_signals(c)
c.call("request_bind_slot", 1, "not_real") c.call("request_bind_slot", 1, "not_real")
assert_signal(c).is_emitted("loadout_update_denied") assert_signal(c).is_emitted("loadout_update_denied")
func test_bind_slot_while_busy_is_ignored() -> void:
var transport := HoldFirstThenAutoTransport.new()
var c := _make_client(transport)
c.call("request_bind_slot", 1, "prototype_pulse")
c.call("request_bind_slot", 2, "prototype_guard")
await get_tree().process_frame
await get_tree().process_frame
assert_that(transport.request_count).is_equal(1)
var parsed: Variant = JSON.parse_string(transport.last_body)
assert_that(parsed is Dictionary).is_true()
var slots: Array = (parsed as Dictionary).get("slots", [])
assert_that(int((slots[0] as Dictionary).get("slotIndex", -1))).is_equal(1)
func test_http_failure_does_not_mutate_cached_loadout() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_CANT_CONNECT, 0, "")
var c := _make_client(transport)
c.call("request_sync_from_server")
var cached: Dictionary = c.call("cached_loadout")
assert_that(cached.is_empty()).is_true()
func test_non_json_error_response_keeps_cached_loadout_empty() -> void:
var transport := MockHttpTransport.new()
transport.enqueue(HTTPRequest.RESULT_SUCCESS, 500, "oops")
var c := _make_client(transport)
c.call("request_sync_from_server")
var cached: Dictionary = c.call("cached_loadout")
assert_that(cached.is_empty()).is_true()

View File

@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using Xunit; using Xunit;
@ -97,4 +98,106 @@ public sealed class HotbarLoadoutApiTests
Assert.False(body!.Updated); Assert.False(body!.Updated);
Assert.Equal(HotbarLoadoutApi.ReasonUnknownAbility, body.ReasonCode); Assert.Equal(HotbarLoadoutApi.ReasonUnknownAbility, body.ReasonCode);
} }
[Fact]
public async Task PostHotbarLoadout_ShouldDenyDuplicateSlots_WithReasonCode()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots =
[
new HotbarSlotBindingJson { SlotIndex = 1, AbilityId = PrototypeAbilityRegistry.PrototypePulse },
new HotbarSlotBindingJson { SlotIndex = 1, AbilityId = PrototypeAbilityRegistry.PrototypeGuard },
],
});
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
Assert.NotNull(body);
Assert.False(body!.Updated);
Assert.Equal(HotbarLoadoutApi.ReasonDuplicateSlot, body.ReasonCode);
}
[Fact]
public async Task PostHotbarLoadout_ShouldNormalizeAbilityIdCaseAndWhitespace_WhenKnownAbility()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots = [new HotbarSlotBindingJson { SlotIndex = 5, AbilityId = " PROTOTYPE_DASH " }],
});
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var body = await post.Content.ReadFromJsonAsync<HotbarLoadoutUpdateResponse>();
Assert.NotNull(body);
Assert.True(body!.Updated);
Assert.Equal(PrototypeAbilityRegistry.PrototypeDash, body.Loadout.Slots[5].AbilityId);
}
[Fact]
public async Task PostHotbarLoadout_ShouldReturnBadRequest_WhenSchemaVersionWrong()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = 999,
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
});
Assert.Equal(HttpStatusCode.BadRequest, post.StatusCode);
}
[Fact]
public async Task PostHotbarLoadout_ShouldReturnBadRequest_WhenBodyMalformed()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.PostAsync(
"/game/players/dev-local-1/hotbar-loadout",
new StringContent("{\"schemaVersion\":1,\"slots\":[", Encoding.UTF8, "application/json"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetHotbarLoadout_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/game/players/missing-player/hotbar-loadout");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostHotbarLoadout_ShouldReturnNotFound_WhenPlayerUnknown()
{
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var post = await client.PostAsJsonAsync(
"/game/players/missing-player/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
});
Assert.Equal(HttpStatusCode.NotFound, post.StatusCode);
}
} }