diff --git a/README.md b/README.md index fe908fd..15066a5 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,17 @@ var history = await client.Insights.GetMessageHistoryAsync(new GetMessageHistory }); ``` +Fetch message analytics: + +```csharp +var analytics = await client.Insights.GetMessageAnalyticsAsync(new GetMessageAnalyticsRequest +{ + PhoneNumber = "2348012345678", + DateFrom = "2026-06-01", + DateTo = "2026-06-13" +}); +``` + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -247,7 +258,7 @@ Implemented in the current SDK: - Sender IDs: list and request sender IDs. - Number API: send message through a dedicated Termii number. - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. -- Insights: balance, DND status, number intelligence, and message history. +- Insights: balance, DND status, number intelligence, message history, and message analytics. Deferred or not yet implemented: @@ -255,7 +266,6 @@ Deferred or not yet implemented: - Campaign phonebook APIs. - Product notification email APIs. - Webhook event models. -- `/api/sms/history/analytics`, pending additional verification. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 00fb79a..755e848 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -57,7 +57,7 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Insights | Search DND/number status | GET | `/api/check/dnd` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | -| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Needs verification | #9 | Planned unit + optional integration | +| Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | In progress | #34 | Unit tests added | ## Deferred Coverage @@ -82,7 +82,7 @@ The provided Postman collection currently lists these groups: | --- | --- | --- | | Switch | SenderId, Numbers, Send | Covered by #3, #5, and #7. | | Token | Send Token, Verify Token, In-App Token | Covered by #4. | -| Insight | history analytics, Search, Balance, Inbox API | Covered by #9, with `/api/sms/history/analytics` marked for verification against live behavior/docs. | +| Insight | history analytics, Search, Balance, Inbox API | Covered by #9 and #34. | ## Implementation Rules diff --git a/src/Termii/Insights/GetMessageAnalyticsRequest.cs b/src/Termii/Insights/GetMessageAnalyticsRequest.cs new file mode 100644 index 0000000..df43302 --- /dev/null +++ b/src/Termii/Insights/GetMessageAnalyticsRequest.cs @@ -0,0 +1,23 @@ +namespace Termii; + +public sealed class GetMessageAnalyticsRequest +{ + public string? MessageId { get; set; } + + public string? DateFrom { get; set; } + + public string? DateTo { get; set; } + + public string? PhoneNumber { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiInsightsQueryString.AddIfPresent(query, "message_id", MessageId); + TermiiInsightsQueryString.AddIfPresent(query, "date_from", DateFrom); + TermiiInsightsQueryString.AddIfPresent(query, "date_to", DateTo); + TermiiInsightsQueryString.AddIfPresent(query, "phone_number", PhoneNumber); + + return TermiiInsightsQueryString.Append("/api/sms/history/analytics", query); + } +} diff --git a/src/Termii/Insights/ITermiiInsightsClient.cs b/src/Termii/Insights/ITermiiInsightsClient.cs index fe99310..52121e8 100644 --- a/src/Termii/Insights/ITermiiInsightsClient.cs +++ b/src/Termii/Insights/ITermiiInsightsClient.cs @@ -15,4 +15,8 @@ Task QueryNumberAsync( Task GetMessageHistoryAsync( GetMessageHistoryRequest? request = null, CancellationToken cancellationToken = default); + + Task GetMessageAnalyticsAsync( + GetMessageAnalyticsRequest? request = null, + CancellationToken cancellationToken = default); } diff --git a/src/Termii/Insights/MessageAnalyticsResponse.cs b/src/Termii/Insights/MessageAnalyticsResponse.cs new file mode 100644 index 0000000..baf92f8 --- /dev/null +++ b/src/Termii/Insights/MessageAnalyticsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class MessageAnalyticsResponse +{ + [JsonPropertyName("sent")] + public int? Sent { get; set; } + + [JsonPropertyName("delivered")] + public int? Delivered { get; set; } + + [JsonPropertyName("failed")] + public int? Failed { get; set; } + + [JsonPropertyName("pending")] + public int? Pending { get; set; } + + [JsonPropertyName("rejected")] + public int? Rejected { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Insights/TermiiInsightsClient.cs b/src/Termii/Insights/TermiiInsightsClient.cs index 024190b..1359550 100644 --- a/src/Termii/Insights/TermiiInsightsClient.cs +++ b/src/Termii/Insights/TermiiInsightsClient.cs @@ -64,4 +64,16 @@ public Task GetMessageHistoryAsync( TermiiAuthenticationLocation.Query, cancellationToken); } + + public Task GetMessageAnalyticsAsync( + GetMessageAnalyticsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetMessageAnalyticsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } } diff --git a/tests/Termii.Tests/TermiiInsightsClientTests.cs b/tests/Termii.Tests/TermiiInsightsClientTests.cs index 041b450..825d69a 100644 --- a/tests/Termii.Tests/TermiiInsightsClientTests.cs +++ b/tests/Termii.Tests/TermiiInsightsClientTests.cs @@ -142,6 +142,38 @@ public async Task GetMessageHistoryAsyncAddsFiltersAndDeserializesContent() Assert.Equal("sent", message.Status); } + [Fact] + public async Task GetMessageAnalyticsAsyncAddsFiltersAndDeserializesFlexibleResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"sent":12,"delivered":10,"failed":1,"pending":1,"unknown_metric":5}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Insights.GetMessageAnalyticsAsync( + new GetMessageAnalyticsRequest + { + MessageId = "msg-123", + DateFrom = "2026-06-01", + DateTo = "2026-06-13", + PhoneNumber = "2348012345678", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal( + "https://example.test/api/sms/history/analytics?message_id=msg-123&date_from=2026-06-01&date_to=2026-06-13&phone_number=2348012345678&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Equal(12, response.Sent); + Assert.Equal(10, response.Delivered); + Assert.Equal(1, response.Failed); + Assert.Equal(1, response.Pending); + Assert.NotNull(response.AdditionalData); + Assert.Equal(5, response.AdditionalData["unknown_metric"].GetInt32()); + } + [Fact] public async Task CheckDndAsyncRejectsMissingPhoneNumber() {