diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:38 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 13:10:34 -0300 |
| commit | a2b10fd494fb389e556e40134031eb86e56e11e7 (patch) | |
| tree | 08b6df8a06f2c7c15488b90d57a6d8286cd57a22 /tests | |
| download | csharp-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/Cepis.Tests/BatchTests.cs | 120 | ||||
| -rw-r--r-- | tests/Cepis.Tests/Cepis.Tests.csproj | 26 | ||||
| -rw-r--r-- | tests/Cepis.Tests/ConfigurationTests.cs | 80 | ||||
| -rw-r--r-- | tests/Cepis.Tests/CsvTests.cs | 79 | ||||
| -rw-r--r-- | tests/Cepis.Tests/FakeServer.cs | 168 | ||||
| -rw-r--r-- | tests/Cepis.Tests/Fixtures.cs | 69 | ||||
| -rw-r--r-- | tests/Cepis.Tests/HealthTests.cs | 46 | ||||
| -rw-r--r-- | tests/Cepis.Tests/HttpTestBase.cs | 33 | ||||
| -rw-r--r-- | tests/Cepis.Tests/LookupTests.cs | 168 | ||||
| -rw-r--r-- | tests/Cepis.Tests/ModelTests.cs | 68 | ||||
| -rw-r--r-- | tests/Cepis.Tests/NormalizeTests.cs | 35 | ||||
| -rw-r--r-- | tests/Cepis.Tests/RetryTests.cs | 249 |
12 files changed, 1141 insertions, 0 deletions
diff --git a/tests/Cepis.Tests/BatchTests.cs b/tests/Cepis.Tests/BatchTests.cs new file mode 100644 index 0000000..8e075f1 --- /dev/null +++ b/tests/Cepis.Tests/BatchTests.cs @@ -0,0 +1,120 @@ +using System.Text; + +namespace Cepis.Tests; + +/// <summary>The batch: order, absences, chunking, and the missing coordinate.</summary> +public sealed class BatchTests : HttpTestBase +{ + [Fact] + public async Task KeepsTheRequestedOrderAndLength() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvBatch)); + + var result = await Client.LookupManyAsync(["01001000", "99999999", "20040020"]); + + Assert.Equal(3, result.Count); + Assert.Equal("São Paulo", result[0]?.Localidade); + Assert.Null(result[1]); + Assert.Equal("Rio de Janeiro", result[2]?.Localidade); + } + + [Fact] + public async Task CarriesNoCoordinate() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvBatch)); + + var result = await Client.LookupManyAsync(["01001000"]); + + Assert.NotNull(result[0]); + Assert.Null(result[0]!.Geo); + } + + [Fact] + public async Task AMalformedCepIsNullRatherThanAnError() + { + Server.Handle(call => Reply.Of(200, Csv(call))); + + var result = await Client.LookupManyAsync(["01001000", "oops", "20040020"]); + + Assert.Equal(3, result.Count); + Assert.NotNull(result[0]); + Assert.Null(result[1]); + Assert.NotNull(result[2]); + } + + [Fact] + public async Task ChunksAboveFiveHundred() + { + Server.Handle(call => Reply.Of(200, Csv(call))); + var ceps = Enumerable.Range(0, 600).Select(i => $"{i:D8}").ToList(); + + var result = await Client.LookupManyAsync(ceps); + + Assert.Equal(600, result.Count); + Assert.Equal(2, Server.Calls.Count); + Assert.Equal(CepisClient.MaxBatch, CountCeps(Server.Calls[0])); + Assert.Equal(100, CountCeps(Server.Calls[1])); + Assert.All(result, address => Assert.NotNull(address)); + } + + [Fact] + public async Task ExactlyFiveHundredIsOneRequest() + { + Server.Handle(call => Reply.Of(200, Csv(call))); + var ceps = Enumerable.Range(0, CepisClient.MaxBatch).Select(i => $"{i:D8}").ToList(); + + var result = await Client.LookupManyAsync(ceps); + + Assert.Equal(CepisClient.MaxBatch, result.Count); + Assert.Single(Server.Calls); + } + + [Fact] + public async Task AShortResponseIsPaddedRatherThanShifted() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvHeader + "\n" + Fixtures.CsvRow("01001000") + "\n")); + + var result = await Client.LookupManyAsync(["01001000", "20040020", "30140071"]); + + Assert.Equal(3, result.Count); + Assert.Equal("01001000", result[0]?.Cep); + Assert.Null(result[1]); + Assert.Null(result[2]); + } + + [Fact] + public async Task AnEmptyInputMakesNoRequest() + { + var result = await Client.LookupManyAsync([]); + + Assert.Empty(result); + Assert.Empty(Server.Calls); + } + + [Fact] + public async Task ValuesWithCommasSurviveTheParse() + { + Server.Handle(_ => Reply.Of(200, Fixtures.CsvHeader + "\n" + + "\"01001000\",\"Praça da Sé, lado ímpar\",,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107\n")); + + var result = await Client.LookupManyAsync(["01001000"]); + + Assert.Equal("Praça da Sé, lado ímpar", result[0]?.Logradouro); + } + + private static string Csv(Call call) + { + var body = new StringBuilder(Fixtures.CsvHeader); + foreach (var cep in Requested(call)) + { + body.Append('\n').Append(cep.Length == 8 ? Fixtures.CsvRow(cep) : cep + ",,,,,,,,,,,,"); + } + + return body.Append('\n').ToString(); + } + + private static string[] Requested(Call call) => + (call.Ceps ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries); + + private static int CountCeps(Call call) => Requested(call).Length; +} diff --git a/tests/Cepis.Tests/Cepis.Tests.csproj b/tests/Cepis.Tests/Cepis.Tests.csproj new file mode 100644 index 0000000..40faa78 --- /dev/null +++ b/tests/Cepis.Tests/Cepis.Tests.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <LangVersion>12.0</LangVersion> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="xunit" Version="2.5.3" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> + </ItemGroup> + + <ItemGroup> + <Using Include="Xunit" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\Cepis\Cepis.csproj" /> + </ItemGroup> + +</Project> diff --git a/tests/Cepis.Tests/ConfigurationTests.cs b/tests/Cepis.Tests/ConfigurationTests.cs new file mode 100644 index 0000000..2f6fab9 --- /dev/null +++ b/tests/Cepis.Tests/ConfigurationTests.cs @@ -0,0 +1,80 @@ +namespace Cepis.Tests; + +/// <summary>What the client accepts as configuration, and what it refuses.</summary> +public sealed class ConfigurationTests : HttpTestBase +{ + [Fact] + public void TheDefaultsAreTheDocumentedOnes() + { + var options = new CepisClientOptions(); + + Assert.Equal(CepisClient.DefaultUrl, options.BaseUrl); + Assert.Equal(TimeSpan.FromSeconds(10), options.Timeout); + Assert.Equal(3, options.MaxAttempts); + Assert.Equal(TimeSpan.FromMilliseconds(500), options.BackoffBase); + Assert.Equal(TimeSpan.FromSeconds(8), options.BackoffMax); + Assert.Null(options.TotalTimeout); + Assert.Null(options.UserAgent); + } + + [Fact] + public void TheDefaultUserAgentIdentifiesTheLibrary() + { + using var client = new CepisClient(); + + Assert.Equal($"cepis-csharp/{CepisClient.Version} (+https://cep.is)", client.UserAgent); + } + + [Fact] + public async Task ATrailingSlashCannotDoubleTheSeparator() + { + using var client = new CepisClient(new CepisClientOptions { BaseUrl = Server.Url + "///" }); + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + await client.LookupAsync("01001000"); + + Assert.Equal(Server.Url, client.BaseUrl); + Assert.Equal("/01001000", Assert.Single(Server.Calls).Path); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MaxAttemptsBelowOneIsRefused(int attempts) => + Assert.Throws<ArgumentException>( + () => new CepisClient(new CepisClientOptions { MaxAttempts = attempts })); + + [Fact] + public void ATimeoutOfZeroIsRefused() => + Assert.Throws<ArgumentException>( + () => new CepisClient(new CepisClientOptions { Timeout = TimeSpan.Zero })); + + [Fact] + public void AnEmptyBaseUrlIsRefused() => + Assert.Throws<ArgumentException>( + () => new CepisClient(new CepisClientOptions { BaseUrl = " " })); + + [Fact] + public void ATotalTimeoutOfZeroIsRefused() => + Assert.Throws<ArgumentException>( + () => new CepisClient(new CepisClientOptions { TotalTimeout = TimeSpan.Zero })); + + [Fact] + public async Task AnInjectedHttpClientIsUsedAndNotDisposed() + { + using var http = new HttpClient(); + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + using (var client = new CepisClient(http, new CepisClientOptions { BaseUrl = Server.Url })) + { + await client.LookupAsync("01001000"); + } + + Assert.Equal(1, Server.Count("/01001000")); + await http.GetAsync(new Uri(Server.Url + "/01001000")); + } + + [Fact] + public void TheSharedDefaultPointsAtTheRealApi() => + Assert.Equal(CepisClient.DefaultUrl, CepisClient.Default.BaseUrl); +} diff --git a/tests/Cepis.Tests/CsvTests.cs b/tests/Cepis.Tests/CsvTests.cs new file mode 100644 index 0000000..97a353f --- /dev/null +++ b/tests/Cepis.Tests/CsvTests.cs @@ -0,0 +1,79 @@ +namespace Cepis.Tests; + +/// <summary>The CSV reader behind the batch endpoint.</summary> +public sealed class CsvTests +{ + [Fact] + public void ReadsTheHeaderAndOneRecord() + { + var rows = Csv.Parse("a,b,c\n1,2,3\n"); + + var row = Assert.Single(rows); + Assert.Equal("1", row["a"]); + Assert.Equal("2", row["b"]); + Assert.Equal("3", row["c"]); + } + + [Fact] + public void AQuotedValueMayContainACommaAndANewline() + { + var rows = Csv.Parse("a,b\n\"Praça da Sé, lado ímpar\",\"two\nlines\"\n"); + + var row = Assert.Single(rows); + Assert.Equal("Praça da Sé, lado ímpar", row["a"]); + Assert.Equal("two\nlines", row["b"]); + } + + [Fact] + public void DoubledQuotesAreOneQuote() + { + var rows = Csv.Parse("a\n\"say \"\"hi\"\"\"\n"); + + Assert.Equal("say \"hi\"", Assert.Single(rows)["a"]); + } + + [Theory] + [InlineData("a,b\n1,2\n")] + [InlineData("a,b\r\n1,2\r\n")] + [InlineData("a,b\r1,2\r")] + [InlineData("a,b\n1,2")] + public void EveryLineEndingWorks(string text) + { + var rows = Csv.Parse(text); + + Assert.Equal("1", Assert.Single(rows)["a"]); + } + + [Fact] + public void ALeadingByteOrderMarkIsDropped() + { + var rows = Csv.Parse("\uFEFFa,b\n1,2\n"); + + Assert.Equal("1", Assert.Single(rows)["a"]); + } + + [Fact] + public void AnEmptyValueStaysEmpty() + { + var rows = Csv.Parse("a,b,c\n1,,3\n"); + + Assert.Equal("", Assert.Single(rows)["b"]); + } + + [Fact] + public void AShortRecordIsFilledOut() + { + var rows = Csv.Parse("a,b,c\n1\n"); + + var row = Assert.Single(rows); + Assert.Equal("1", row["a"]); + Assert.Equal("", row["b"]); + Assert.Equal("", row["c"]); + } + + [Fact] + public void AHeaderAloneYieldsNoRecords() => Assert.Empty(Csv.Parse("a,b,c\n")); + + [Fact] + public void AnEmptyDocumentYieldsNoRecords() => Assert.Empty(Csv.Parse("")); +} diff --git a/tests/Cepis.Tests/FakeServer.cs b/tests/Cepis.Tests/FakeServer.cs new file mode 100644 index 0000000..1bd4d60 --- /dev/null +++ b/tests/Cepis.Tests/FakeServer.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace Cepis.Tests; + +/// <summary>One canned response.</summary> +public sealed record Reply(int Status, string Body, IReadOnlyDictionary<string, string>? Headers = null) +{ + /// <summary>A reply with no extra headers.</summary> + public static Reply Of(int status, string body) => new(status, body); + + /// <summary>A reply carrying one extra header.</summary> + public static Reply Of(int status, string body, string header, string value) => + new(status, body, new Dictionary<string, string>(StringComparer.Ordinal) { [header] = value }); +} + +/// <summary>One request the server saw.</summary> +public sealed record Call(string Path, string? Ceps); + +/// <summary> +/// A throwaway HTTP server on a free loopback port, with programmable routes and +/// a request log. +/// </summary> +/// <remarks> +/// The tests point a real <see cref="CepisClient"/> at it, so the code path they +/// exercise is the one that runs in production, request construction and error +/// body reading included. It doubles as the test that <c>BaseUrl</c> works: if +/// it did not, none of the suite would run. +/// </remarks> +public sealed class FakeServer : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly Dictionary<string, Queue<Reply>> _routes = new(StringComparer.Ordinal); + private readonly List<Call> _calls = []; + private readonly object _gate = new(); + private Func<Call, Reply>? _handler; + + /// <summary>Start the server on a free port.</summary> + public FakeServer() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + + Url = $"http://127.0.0.1:{port}"; + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + _listener.Start(); + _ = Task.Run(LoopAsync); + } + + /// <summary>Where the server listens, without a trailing slash.</summary> + public string Url { get; } + + /// <summary>Every request seen so far, in order.</summary> + public IReadOnlyList<Call> Calls + { + get + { + lock (_gate) + { + return [.. _calls]; + } + } + } + + /// <summary> + /// Register the replies for a path. Each request consumes the next one; the + /// last repeats once the list runs out, which is how retrying is tested. + /// </summary> + public void Respond(string path, params Reply[] replies) + { + lock (_gate) + { + _routes[path] = new Queue<Reply>(replies); + } + } + + /// <summary>Answer every request with this function instead of the routes.</summary> + public void Handle(Func<Call, Reply> handler) + { + lock (_gate) + { + _handler = handler; + } + } + + /// <summary>How many requests so far went to that path.</summary> + public int Count(string path) + { + lock (_gate) + { + return _calls.Count(call => call.Path == path); + } + } + + /// <summary>Stop the server.</summary> + public void Dispose() => _listener.Close(); + + private async Task LoopAsync() + { + while (_listener.IsListening) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync().ConfigureAwait(false); + } + catch (Exception stopped) when (stopped is HttpListenerException + or ObjectDisposedException + or InvalidOperationException) + { + return; + } + + _ = Task.Run(() => Serve(context)); + } + } + + private void Serve(HttpListenerContext context) + { + var call = new Call(context.Request.Url!.AbsolutePath, context.Request.QueryString["ceps"]); + var reply = Next(call); + var body = Encoding.UTF8.GetBytes(reply.Body); + + try + { + context.Response.StatusCode = reply.Status; + if (reply.Headers is not null) + { + foreach (var (name, value) in reply.Headers) + { + context.Response.Headers[name] = value; + } + } + + context.Response.ContentLength64 = body.Length; + context.Response.OutputStream.Write(body); + context.Response.OutputStream.Close(); + } + catch (Exception gone) when (gone is HttpListenerException or ObjectDisposedException or IOException) + { + } + } + + private Reply Next(Call call) + { + Func<Call, Reply> handler; + lock (_gate) + { + _calls.Add(call); + if (_handler is null) + { + if (!_routes.TryGetValue(call.Path, out var queue) || queue.Count == 0) + { + return Reply.Of(404, Fixtures.NotFound); + } + + return queue.Count > 1 ? queue.Dequeue() : queue.Peek(); + } + + handler = _handler; + } + + return handler(call); + } +} diff --git a/tests/Cepis.Tests/Fixtures.cs b/tests/Cepis.Tests/Fixtures.cs new file mode 100644 index 0000000..648f881 --- /dev/null +++ b/tests/Cepis.Tests/Fixtures.cs @@ -0,0 +1,69 @@ +namespace Cepis.Tests; + +/// <summary>Bodies the API really returns, copied from it.</summary> +public static class Fixtures +{ + /// <summary>A CEP with a street-precision coordinate.</summary> + public const string SeAddress = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar", + "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP", + "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004", + "ddd":"11","siafi":"7107", + "geo":{"lat":-23.567202,"lon":-46.649659,"raio_m":1084,"precisao":"logradouro"}} + """; + + /// <summary>A CEP the database has no coordinate for.</summary> + public const string NoGeoAddress = """ + {"cep":"20040020","logradouro":"Avenida Rio Branco","complemento":"", + "unidade":"","bairro":"Centro","localidade":"Rio de Janeiro","uf":"RJ", + "estado":"Rio de Janeiro","regiao":"Sudeste","ibge":"3304557","gia":"", + "ddd":"21","siafi":"6001"} + """; + + /// <summary>A coordinate whose numbers cannot be read.</summary> + public const string BrokenGeoAddress = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"","unidade":"", + "bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo", + "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107", + "geo":{"lat":"nowhere","precisao":"logradouro"}} + """; + + /// <summary>A response carrying a field this library does not model.</summary> + public const string FutureAddress = """ + {"cep":"01001000","logradouro":"Praça da Sé","complemento":"","unidade":"", + "bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo", + "regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107", + "cep_pai":"01000000"} + """; + + /// <summary>The 404 body.</summary> + public const string NotFound = + """{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"""; + + /// <summary>The 400 body.</summary> + public const string InvalidCep = + """{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}"""; + + /// <summary>The 500 body.</summary> + public const string InternalError = """{"erro":"interno","mensagem":"falha interna"}"""; + + /// <summary>A healthy API.</summary> + public const string Healthy = """ + {"status":"ok","dne":"26071","ceps":1596168,"geo":894489, + "base_gerada_em":"2026-07-24T03:48:28Z"} + """; + + /// <summary>The batch header row.</summary> + public const string CsvHeader = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; + + /// <summary>Three CEPs, the middle one absent from the database.</summary> + public const string CsvBatch = CsvHeader + "\n" + + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107\n" + + "99999999,,,,,,,,,,,,\n" + + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001\n"; + + /// <summary>One data row for a CEP, in the batch's column order.</summary> + public static string CsvRow(string cep) => + $"{cep},Rua Teste,,,Centro,Cidade,SP,São Paulo,Sudeste,3550308,,11,7107"; +} diff --git a/tests/Cepis.Tests/HealthTests.cs b/tests/Cepis.Tests/HealthTests.cs new file mode 100644 index 0000000..2855d72 --- /dev/null +++ b/tests/Cepis.Tests/HealthTests.cs @@ -0,0 +1,46 @@ +namespace Cepis.Tests; + +/// <summary>The health endpoint, which reports rather than throws.</summary> +public sealed class HealthTests : HttpTestBase +{ + [Fact] + public async Task ReadsAHealthyApi() + { + Server.Respond("/health", Reply.Of(200, Fixtures.Healthy)); + + var health = await Client.HealthAsync(); + + Assert.True(health.Ok); + Assert.Equal("ok", health.Status); + Assert.Equal("26071", health.Dne); + Assert.Equal(1596168, health.Ceps); + Assert.Equal(894489, health.Geo); + Assert.Equal("2026-07-24T03:48:28Z", health.BaseGeradaEm); + } + + [Fact] + public async Task ADegradedApiReportsRatherThanThrows() + { + Server.Respond("/health", Reply.Of(503, + """{"status":"degradado","dne":"26071","ceps":0,"geo":0,"base_gerada_em":""}""")); + + var health = await Client.HealthAsync(); + + Assert.False(health.Ok); + Assert.Equal("degradado", health.Status); + Assert.Equal(1, Server.Count("/health")); + } + + [Fact] + public async Task AnUnreachableApiStillThrows() + { + using var unreachable = new CepisClient(new CepisClientOptions + { + BaseUrl = "http://127.0.0.1:1", + MaxAttempts = 1, + Timeout = TimeSpan.FromSeconds(30), + }); + + await Assert.ThrowsAsync<NetworkException>(() => unreachable.HealthAsync()); + } +} diff --git a/tests/Cepis.Tests/HttpTestBase.cs b/tests/Cepis.Tests/HttpTestBase.cs new file mode 100644 index 0000000..115ceef --- /dev/null +++ b/tests/Cepis.Tests/HttpTestBase.cs @@ -0,0 +1,33 @@ +namespace Cepis.Tests; + +/// <summary>A fake API and a real client pointed at it, torn down per test.</summary> +public abstract class HttpTestBase : IDisposable +{ + /// <summary>The fake API.</summary> + protected FakeServer Server { get; } = new(); + + /// <summary> + /// A client whose backoff is shortened so the suite stays fast, and whose + /// per-attempt timeout is deliberately generous: a loaded machine must not + /// be able to turn a passing test into a spurious network failure. + /// </summary> + protected CepisClient Client { get; } + + /// <summary>Start the server and point a client at it.</summary> + protected HttpTestBase() => + Client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromSeconds(30), + BackoffBase = TimeSpan.FromMilliseconds(1), + BackoffMax = TimeSpan.FromMilliseconds(20), + }); + + /// <summary>Stop the server and release the client.</summary> + public void Dispose() + { + Client.Dispose(); + Server.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/tests/Cepis.Tests/LookupTests.cs b/tests/Cepis.Tests/LookupTests.cs new file mode 100644 index 0000000..ad3e2bf --- /dev/null +++ b/tests/Cepis.Tests/LookupTests.cs @@ -0,0 +1,168 @@ +namespace Cepis.Tests; + +/// <summary>A single lookup: the happy path, the coordinate, and the refusals.</summary> +public sealed class LookupTests : HttpTestBase +{ + [Fact] + public async Task ReadsEveryField() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001-000"); + + Assert.Equal("01001000", address.Cep); + Assert.Equal("01001-000", address.FormattedCep); + Assert.Equal("Praça da Sé", address.Logradouro); + Assert.Equal("lado ímpar", address.Complemento); + Assert.Equal("", address.Unidade); + Assert.Equal("Sé", address.Bairro); + Assert.Equal("São Paulo", address.Localidade); + Assert.Equal("SP", address.UF); + Assert.Equal("São Paulo", address.Estado); + Assert.Equal("Sudeste", address.Regiao); + Assert.Equal("3550308", address.Ibge); + Assert.Equal("1004", address.Gia); + Assert.Equal("11", address.Ddd); + Assert.Equal("7107", address.Siafi); + } + + [Fact] + public async Task TheMaskIsStrippedBeforeTheRequest() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + await Client.LookupAsync("01001-000"); + + Assert.Equal("/01001000", Assert.Single(Server.Calls).Path); + } + + [Fact] + public async Task ReadsTheCoordinate() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001000"); + + var geo = address.Geo; + Assert.NotNull(geo); + Assert.Equal(-23.567202, geo.Lat, 6); + Assert.Equal(-46.649659, geo.Lon, 6); + Assert.Equal(1084, geo.RaioM); + Assert.Equal("logradouro", geo.Precisao); + Assert.True(geo.IsPrecise); + } + + [Fact] + public async Task AnAbsentCoordinateIsNull() + { + Server.Respond("/20040020", Reply.Of(200, Fixtures.NoGeoAddress)); + + var address = await Client.LookupAsync("20040020"); + + Assert.Null(address.Geo); + Assert.Equal("Rio de Janeiro", address.Localidade); + } + + [Fact] + public async Task AMalformedCoordinateDoesNotLoseTheAddress() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.BrokenGeoAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Null(address.Geo); + Assert.Equal("São Paulo", address.Localidade); + } + + [Fact] + public async Task ACoordinateOfTheWrongShapeIsDropped() + { + Server.Respond("/01001000", Reply.Of(200, """{"cep":"01001000","localidade":"X","geo":[]}""")); + + var address = await Client.LookupAsync("01001000"); + + Assert.Null(address.Geo); + Assert.Equal("X", address.Localidade); + } + + [Fact] + public async Task AFieldThisLibraryDoesNotKnowIsKept() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.FutureAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Equal("São Paulo", address.Localidade); + var extra = address.Extra; + Assert.NotNull(extra); + Assert.Equal("01000000", extra["cep_pai"].GetString()); + } + + [Fact] + public async Task AMissingCepThrows() + { + Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("99999999")); + + Assert.Contains("99999999", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A404IsNeverRetried() + { + Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound)); + + await Assert.ThrowsAsync<CepNotFoundException>(() => Client.LookupAsync("99999999")); + + Assert.Equal(1, Server.Count("/99999999")); + } + + [Fact] + public async Task A400CarriesTheApiMessage() + { + Server.Respond("/00000000", Reply.Of(400, Fixtures.InvalidCep)); + + var failure = await Assert.ThrowsAsync<InvalidCepException>( + () => Client.LookupAsync("00000000")); + + Assert.Equal("CEP deve ter 8 dígitos", failure.Message); + Assert.Equal(1, Server.Count("/00000000")); + } + + [Fact] + public async Task AMalformedCepCostsNoRequest() + { + await Assert.ThrowsAsync<InvalidCepException>(() => Client.LookupAsync("1234567")); + + Assert.Empty(Server.Calls); + } + + [Fact] + public async Task AnUnintelligibleBodyIsDiagnosed() + { + Server.Respond("/01001000", Reply.Of(200, "<html><body>captive portal</body></html>")); + + await Assert.ThrowsAsync<InvalidResponseException>(() => Client.LookupAsync("01001000")); + } + + [Fact] + public async Task AJsonBodyThatIsNotAnObjectIsDiagnosed() + { + Server.Respond("/01001000", Reply.Of(200, "null")); + + await Assert.ThrowsAsync<InvalidResponseException>(() => Client.LookupAsync("01001000")); + } + + [Fact] + public async Task ACancelledCallDoesNotBecomeANetworkError() + { + Server.Respond("/01001000", Reply.Of(200, Fixtures.SeAddress)); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync<OperationCanceledException>( + () => Client.LookupAsync("01001000", cancellation.Token)); + } +} diff --git a/tests/Cepis.Tests/ModelTests.cs b/tests/Cepis.Tests/ModelTests.cs new file mode 100644 index 0000000..7d9d0d6 --- /dev/null +++ b/tests/Cepis.Tests/ModelTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json; + +namespace Cepis.Tests; + +/// <summary>The data objects on their own, with no server involved.</summary> +public sealed class ModelTests +{ + [Fact] + public void EveryFieldDefaultsToTheEmptyString() + { + var address = new Address(); + + Assert.Equal("", address.Cep); + Assert.Equal("", address.Logradouro); + Assert.Equal("", address.Siafi); + Assert.Null(address.Geo); + } + + [Fact] + public void FormattedCepAddsTheMask() => + Assert.Equal("01001-000", new Address { Cep = "01001000" }.FormattedCep); + + [Fact] + public void FormattedCepLeavesAnythingElseAlone() => + Assert.Equal("123", new Address { Cep = "123" }.FormattedCep); + + [Fact] + public void TwoAddressesForTheSamePlaceAreEqual() + { + var one = new Address { Cep = "01001000", Localidade = "São Paulo" }; + var other = new Address { Cep = "01001000", Localidade = "São Paulo" }; + + Assert.Equal(one, other); + Assert.Equal(one.GetHashCode(), other.GetHashCode()); + } + + [Fact] + public void EqualityIgnoresFieldsThisLibraryDoesNotModel() + { + var plain = new Address { Cep = "01001000" }; + var enriched = plain with + { + Extra = new Dictionary<string, JsonElement>(StringComparer.Ordinal) + { + ["cep_pai"] = JsonDocument.Parse("\"01000000\"").RootElement, + }, + }; + + Assert.Equal(plain, enriched); + Assert.DoesNotContain("cep_pai", enriched.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void TheCoordinateIsPreciseOnlyForAStreet() + { + Assert.True(new Geo { Precisao = "logradouro" }.IsPrecise); + Assert.False(new Geo { Precisao = "localidade" }.IsPrecise); + Assert.False(new Geo().IsPrecise); + } + + [Fact] + public void HealthIsOkOnlyForTheDocumentedStatus() + { + Assert.True(new Health { Status = "ok" }.Ok); + Assert.False(new Health { Status = "degradado" }.Ok); + Assert.False(new Health().Ok); + } +} diff --git a/tests/Cepis.Tests/NormalizeTests.cs b/tests/Cepis.Tests/NormalizeTests.cs new file mode 100644 index 0000000..3ff60cb --- /dev/null +++ b/tests/Cepis.Tests/NormalizeTests.cs @@ -0,0 +1,35 @@ +namespace Cepis.Tests; + +/// <summary>Local validation, which happens before any request.</summary> +public sealed class NormalizeTests +{ + [Theory] + [InlineData("01001000", "01001000")] + [InlineData("01001-000", "01001000")] + [InlineData(" 01001-000 ", "01001000")] + [InlineData("01.001-000", "01001000")] + [InlineData("01 001 000", "01001000")] + public void AcceptsTheMask(string input, string expected) => + Assert.Equal(expected, CepisClient.NormalizeCep(input)); + + [Theory] + [InlineData("")] + [InlineData("0100100")] + [InlineData("010010000")] + [InlineData("0100100a")] + [InlineData("abcdefgh")] + [InlineData("01001-00")] + public void RejectsAnythingElse(string input) => + Assert.Throws<InvalidCepException>(() => CepisClient.NormalizeCep(input)); + + [Fact] + public void RejectsNull() => + Assert.Throws<InvalidCepException>(() => CepisClient.NormalizeCep(null!)); + + [Fact] + public void TheMessageCarriesWhatWasGiven() + { + var failure = Assert.Throws<InvalidCepException>(() => CepisClient.NormalizeCep("123")); + Assert.Contains("123", failure.Message, StringComparison.Ordinal); + } +} diff --git a/tests/Cepis.Tests/RetryTests.cs b/tests/Cepis.Tests/RetryTests.cs new file mode 100644 index 0000000..2114a12 --- /dev/null +++ b/tests/Cepis.Tests/RetryTests.cs @@ -0,0 +1,249 @@ +namespace Cepis.Tests; + +/// <summary>The retry policy: what is retried, how long it waits, what it never retries.</summary> +public sealed class RetryTests : HttpTestBase +{ + [Fact] + public async Task ATransientFiveHundredIsRetried() + { + Server.Respond("/01001000", + Reply.Of(500, Fixtures.InternalError), + Reply.Of(200, Fixtures.SeAddress)); + + var address = await Client.LookupAsync("01001000"); + + Assert.Equal("São Paulo", address.Localidade); + Assert.Equal(2, Server.Count("/01001000")); + } + + [Theory] + [InlineData(500)] + [InlineData(502)] + [InlineData(503)] + [InlineData(504)] + public async Task EveryTransientStatusIsRetried(int status) + { + Server.Respond("/01001000", Reply.Of(status, Fixtures.InternalError), Reply.Of(200, Fixtures.SeAddress)); + + await Client.LookupAsync("01001000"); + + Assert.Equal(2, Server.Count("/01001000")); + } + + [Fact] + public async Task ThreeAttemptsIsTheDefault() + { + Server.Respond("/01001000", Reply.Of(503, Fixtures.InternalError)); + + var failure = await Assert.ThrowsAsync<ServerErrorException>( + () => Client.LookupAsync("01001000")); + + Assert.Equal(503, failure.Status); + Assert.Equal(3, Server.Count("/01001000")); + } + + [Fact] + public async Task OneAttemptDisablesRetrying() + { + using var single = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + MaxAttempts = 1, + }); + Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError)); + + await Assert.ThrowsAsync<ServerErrorException>(() => single.LookupAsync("01001000")); + + Assert.Equal(1, Server.Count("/01001000")); + } + + [Fact] + public async Task A429BecomesRateLimitedAndCarriesRetryAfter() + { + Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "7")); + + var failure = await Assert.ThrowsAsync<RateLimitedException>( + () => Client.LookupAsync("01001000")); + + Assert.Equal(TimeSpan.FromSeconds(7), failure.RetryAfter); + } + + [Fact] + public async Task A429WithoutRetryAfterLeavesNothingToRescheduleWith() + { + Server.Respond("/01001000", Reply.Of(429, "{}")); + + var failure = await Assert.ThrowsAsync<RateLimitedException>( + () => Client.LookupAsync("01001000")); + + Assert.Null(failure.RetryAfter); + } + + [Fact] + public void AnAbsurdRetryAfterIsCappedAtBackoffMax() + { + using var capped = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromMilliseconds(100), + }); + + Assert.Equal(TimeSpan.FromMilliseconds(100), capped.Backoff(1, TimeSpan.FromHours(1))); + } + + [Fact] + public void ARetryAfterUnderTheCapIsHonouredAsAsked() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromSeconds(8), + }); + + Assert.Equal(TimeSpan.FromSeconds(2), client.Backoff(1, TimeSpan.FromSeconds(2))); + } + + [Fact] + public void TheBackoffGrowsExponentiallyAndNeverPassesTheCap() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffBase = TimeSpan.FromMilliseconds(100), + BackoffMax = TimeSpan.FromMilliseconds(800), + }); + + for (var attempt = 1; attempt <= 8; attempt++) + { + var ceiling = TimeSpan.FromMilliseconds(Math.Min(100 * Math.Pow(2, attempt - 1), 800)); + for (var draw = 0; draw < 200; draw++) + { + Assert.InRange(client.Backoff(attempt, null), TimeSpan.Zero, ceiling); + } + } + } + + [Fact] + public void TheBackoffIsJitteredRatherThanTheCeiling() + { + using var client = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffBase = TimeSpan.FromSeconds(1), + BackoffMax = TimeSpan.FromSeconds(8), + }); + + var draws = Enumerable.Range(0, 50).Select(_ => client.Backoff(3, null)).Distinct().Count(); + + Assert.True(draws > 1, $"full jitter draws a random wait, got {draws} distinct value(s)"); + } + + [Fact] + public async Task AnAbsurdRetryAfterDoesNotParkTheCaller() + { + using var capped = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + BackoffMax = TimeSpan.FromMilliseconds(50), + }); + Server.Respond("/01001000", Reply.Of(429, "{}", "Retry-After", "3600")); + + var failure = await Assert.ThrowsAsync<RateLimitedException>( + () => capped.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); + + Assert.Equal(TimeSpan.FromSeconds(3600), failure.RetryAfter); + Assert.Equal(3, Server.Count("/01001000")); + } + + [Theory] + [InlineData("120", 120)] + [InlineData(" 120 ", 120)] + [InlineData("0", 0)] + public void RetryAfterInSeconds(string header, int expected) => + Assert.Equal(TimeSpan.FromSeconds(expected), CepisClient.ParseRetryAfter(header)); + + [Fact] + public void RetryAfterAsAnHttpDate() + { + var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 2099 07:28:00 GMT"); + + Assert.NotNull(parsed); + Assert.True(parsed > TimeSpan.Zero); + } + + [Fact] + public void AnHttpDateInThePastIsZeroRatherThanNegative() + { + var parsed = CepisClient.ParseRetryAfter("Wed, 21 Oct 1998 07:28:00 GMT"); + + Assert.Equal(TimeSpan.Zero, parsed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a date at all")] + public void NonsenseRetryAfterIsIgnored(string? header) => + Assert.Null(CepisClient.ParseRetryAfter(header)); + + [Fact] + public async Task NothingListeningIsANetworkError() + { + using var unreachable = new CepisClient(new CepisClientOptions + { + BaseUrl = "http://127.0.0.1:1", + MaxAttempts = 2, + BackoffBase = TimeSpan.FromMilliseconds(1), + BackoffMax = TimeSpan.FromMilliseconds(5), + Timeout = TimeSpan.FromSeconds(30), + }); + + await Assert.ThrowsAsync<NetworkException>(() => unreachable.LookupAsync("01001000")); + } + + [Fact] + public async Task TheTotalTimeoutStopsTheCallShortOfItsAttempts() + { + const int budgetedAttempts = 200; + using var bounded = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromSeconds(30), + MaxAttempts = budgetedAttempts, + BackoffBase = TimeSpan.FromMilliseconds(100), + BackoffMax = TimeSpan.FromMilliseconds(100), + TotalTimeout = TimeSpan.FromSeconds(3), + }); + Server.Respond("/01001000", Reply.Of(500, Fixtures.InternalError)); + + var failure = await Assert.ThrowsAsync<ServerErrorException>( + () => bounded.LookupAsync("01001000")); + + var attempts = Server.Count("/01001000"); + Assert.Equal(500, failure.Status); + Assert.InRange(attempts, 1, budgetedAttempts - 1); + } + + [Fact] + public async Task TheTotalTimeoutEndsACallThatNeverGetsAnAnswer() + { + using var bounded = new CepisClient(new CepisClientOptions + { + BaseUrl = Server.Url, + Timeout = TimeSpan.FromMinutes(1), + MaxAttempts = 3, + TotalTimeout = TimeSpan.FromMilliseconds(500), + }); + Server.Handle(_ => + { + Thread.Sleep(TimeSpan.FromSeconds(10)); + return Reply.Of(200, Fixtures.SeAddress); + }); + + await Assert.ThrowsAsync<NetworkException>( + () => bounded.LookupAsync("01001000").WaitAsync(TimeSpan.FromSeconds(60))); + + Assert.Equal(1, Server.Count("/01001000")); + } +} |