diff --git a/bruno/neon-sprawl-server/hotbar-loadout-get-missing-player.bru b/bruno/neon-sprawl-server/hotbar-loadout-get-missing-player.bru new file mode 100644 index 0000000..9b97978 --- /dev/null +++ b/bruno/neon-sprawl-server/hotbar-loadout-get-missing-player.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/hotbar-loadout-get.bru b/bruno/neon-sprawl-server/hotbar-loadout-get.bru new file mode 100644 index 0000000..52e7617 --- /dev/null +++ b/bruno/neon-sprawl-server/hotbar-loadout-get.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/hotbar-loadout-post-bad-schema.bru b/bruno/neon-sprawl-server/hotbar-loadout-post-bad-schema.bru new file mode 100644 index 0000000..5421180 --- /dev/null +++ b/bruno/neon-sprawl-server/hotbar-loadout-post-bad-schema.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/hotbar-loadout-post.bru b/bruno/neon-sprawl-server/hotbar-loadout-post.bru new file mode 100644 index 0000000..b7bc2ac --- /dev/null +++ b/bruno/neon-sprawl-server/hotbar-loadout-post.bru @@ -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); + }); +} diff --git a/client/test/hotbar_loadout_client_test.gd b/client/test/hotbar_loadout_client_test.gd index bcb8680..91a894c 100644 --- a/client/test/hotbar_loadout_client_test.gd +++ b/client/test/hotbar_loadout_client_test.gd @@ -35,6 +35,35 @@ class MockHttpTransport: 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: var c: Node = HotbarClient.new() c.set("injected_http", transport) @@ -107,3 +136,35 @@ func test_denied_update_emits_reason_signal() -> void: monitor_signals(c) c.call("request_bind_slot", 1, "not_real") 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() diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs index e89df5a..5377ea9 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/HotbarLoadoutApiTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using System.Text; using NeonSprawl.Server.Game.AbilityInput; using Xunit; @@ -97,4 +98,106 @@ public sealed class HotbarLoadoutApiTests Assert.False(body!.Updated); 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(); + 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(); + 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); + } }