Skip to content

Commit fe3dd4d

Browse files
committed
fix(contracts,web): Add JsonPropertyName attributes for snake_case API Gateway payloads
API Gateway sends snake_case JSON (property_id, seller_name) but C# models used PascalCase properties, causing null deserialization. Added [JsonPropertyName] attributes to CreateContractRequest, UpdateContractRequest, and ApprovePublicationRequest. Added tests verifying snake_case JSON deserialization.
1 parent d8147e9 commit fe3dd4d

4 files changed

Lines changed: 131 additions & 5 deletions

File tree

Unicorn.Contracts/ContractsService.Test/ContractEventHandlerTest.cs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public async Task Create_contract_saves_message_with_new_status()
5757
var mockDynamoDbClient = Substitute.ForPartsOf<AmazonDynamoDBClient>();
5858
mockDynamoDbClient.PutItemAsync(Arg.Any<PutItemRequest>())
5959
.Returns(new PutItemResponse());
60-
60+
6161
var context = TestHelpers.NewLambdaContext();
6262

6363
// Act
@@ -66,7 +66,65 @@ public async Task Create_contract_saves_message_with_new_status()
6666

6767
// Assert
6868
await mockDynamoDbClient.Received(1).PutItemAsync(Arg.Any<PutItemRequest>());
69-
69+
70+
}
71+
72+
[Fact]
73+
public async Task Create_contract_with_snake_case_json_populates_all_dynamodb_attributes()
74+
{
75+
// Arrange - use snake_case JSON matching the actual API Gateway payload format
76+
var snakeCaseJson = """
77+
{
78+
"address": {
79+
"country": "USA",
80+
"city": "Anytown",
81+
"street": "Main Street",
82+
"number": 222
83+
},
84+
"seller_name": "John Doe",
85+
"property_id": "usa/anytown/main-street/222"
86+
}
87+
""";
88+
89+
var sqsEvent = new SQSEvent()
90+
{
91+
Records = new List<SQSEvent.SQSMessage>
92+
{
93+
new()
94+
{
95+
Body = snakeCaseJson,
96+
MessageAttributes = new Dictionary<string, SQSEvent.MessageAttribute>
97+
{
98+
{ "HttpMethod", new SQSEvent.MessageAttribute { StringValue = "POST" } }
99+
}
100+
}
101+
}
102+
};
103+
104+
PutItemRequest? capturedRequest = null;
105+
var mockDynamoDbClient = Substitute.ForPartsOf<AmazonDynamoDBClient>();
106+
mockDynamoDbClient.PutItemAsync(Arg.Do<PutItemRequest>(r => capturedRequest = r))
107+
.Returns(new PutItemResponse());
108+
109+
var context = TestHelpers.NewLambdaContext();
110+
111+
// Act
112+
var function = new ContractEventHandler(mockDynamoDbClient);
113+
await function.FunctionHandler(sqsEvent, context);
114+
115+
// Assert - verify DynamoDB item has non-empty attribute values
116+
Assert.NotNull(capturedRequest);
117+
Assert.False(string.IsNullOrEmpty(capturedRequest.Item["PropertyId"].S),
118+
"PropertyId should not be null or empty - snake_case 'property_id' must map to PascalCase 'PropertyId'");
119+
Assert.False(string.IsNullOrEmpty(capturedRequest.Item["SellerName"].S),
120+
"SellerName should not be null or empty - snake_case 'seller_name' must map to PascalCase 'SellerName'");
121+
Assert.NotNull(capturedRequest.Item["Address"].M);
122+
Assert.False(string.IsNullOrEmpty(capturedRequest.Item["Address"].M["City"].S),
123+
"Address.City should not be null or empty");
124+
Assert.False(string.IsNullOrEmpty(capturedRequest.Item["Address"].M["Street"].S),
125+
"Address.Street should not be null or empty");
126+
Assert.Equal("usa/anytown/main-street/222", capturedRequest.Item["PropertyId"].S);
127+
Assert.Equal("John Doe", capturedRequest.Item["SellerName"].S);
70128
}
71129

72130
[Fact]

Unicorn.Contracts/ContractsService/Contract.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Text.Json.Serialization;
67
using Amazon.DynamoDBv2.Model;
78

89
namespace Unicorn.Contracts.ContractService;
@@ -61,8 +62,10 @@ public Dictionary<string, AttributeValue> ToMap()
6162
/// </summary>
6263
public class CreateContractRequest
6364
{
64-
public string? PropertyId { get; set; }
65+
[JsonPropertyName("property_id")]
66+
public string? PropertyId { get; set; }
6567
public Address? Address { get; set; }
68+
[JsonPropertyName("seller_name")]
6669
public string? SellerName { get; set; }
6770
}
6871

@@ -71,5 +74,6 @@ public class CreateContractRequest
7174
/// </summary>
7275
public class UpdateContractRequest
7376
{
77+
[JsonPropertyName("property_id")]
7478
public string? PropertyId { get; set; }
7579
}

Unicorn.Web/PublicationManagerService.Tests/RequestApprovalFunctionTest.cs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,67 @@ await eventBindingClient.Received(1)
9090
Arg.Any<CancellationToken>());
9191
}
9292

93+
[Fact]
94+
public async Task Request_approval_with_snake_case_json_deserializes_property_id()
95+
{
96+
// Arrange - use snake_case JSON matching the actual API Gateway payload format
97+
var snakeCaseJson = """
98+
{
99+
"property_id": "usa/anytown/main-street/777"
100+
}
101+
""";
102+
103+
var context = TestHelpers.NewLambdaContext();
104+
var dynamoDbContext = Substitute.For<IDynamoDBContext>();
105+
var eventBindingClient = Substitute.For<IAmazonEventBridge>();
106+
107+
var sqsEvent = new SQSEvent()
108+
{
109+
Records = new List<SQSEvent.SQSMessage>
110+
{
111+
new()
112+
{
113+
Body = snakeCaseJson
114+
}
115+
}
116+
};
117+
118+
var searchResult = new List<PropertyRecord>
119+
{
120+
new()
121+
{
122+
Country = "USA",
123+
City = "Anytown",
124+
Street = "Main Street",
125+
PropertyNumber = "777",
126+
ListPrice = 2000000.00M,
127+
Images = new() { "image1.jpg" },
128+
Status = PropertyStatus.Pending
129+
}
130+
};
131+
132+
dynamoDbContext
133+
.FromQueryAsync<PropertyRecord>(Arg.Any<Amazon.DynamoDBv2.DocumentModel.QueryOperationConfig>())
134+
.Returns(TestHelpers.NewDynamoDBSearchResult(searchResult));
135+
136+
eventBindingClient.PutEventsAsync(Arg.Any<PutEventsRequest>(), Arg.Any<CancellationToken>())
137+
.Returns(new PutEventsResponse { FailedEntryCount = 0 });
138+
139+
// Act
140+
var function = new RequestApprovalFunction(dynamoDbContext, eventBindingClient);
141+
await function.FunctionHandler(sqsEvent, context);
142+
143+
// Assert - verify the property_id was deserialized correctly and DynamoDB was queried
144+
dynamoDbContext.Received(1)
145+
.FromQueryAsync<PropertyRecord>(Arg.Any<Amazon.DynamoDBv2.DocumentModel.QueryOperationConfig>());
146+
147+
await eventBindingClient.Received(1)
148+
.PutEventsAsync(
149+
Arg.Is<PutEventsRequest>(r =>
150+
r.Entries.First().Resources.Contains("usa/anytown/main-street/777")),
151+
Arg.Any<CancellationToken>());
152+
}
153+
93154
[Fact]
94155
public async Task Do_not_publish_event_when_property_status_is_approved()
95156
{
@@ -152,6 +213,6 @@ await eventBindingClient.Received(0)
152213

153214
public class ApiGwSqsPayload
154215
{
216+
[System.Text.Json.Serialization.JsonPropertyName("property_id")]
155217
public required string PropertyId { get; set; }
156-
157218
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: MIT-0
33

4+
using System.Text.Json.Serialization;
5+
46
namespace Unicorn.Web.PublicationManagerService;
57

68
/// <summary>
79
/// Represents an event when the publication is approved.
810
/// </summary>
911
[Serializable]
1012
public class ApprovePublicationRequest
11-
{
13+
{
14+
[JsonPropertyName("property_id")]
1215
public string PropertyId { get; set; } = null!;
1316
}
1417

0 commit comments

Comments
 (0)