summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:38 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 13:10:34 -0300
commita2b10fd494fb389e556e40134031eb86e56e11e7 (patch)
tree08b6df8a06f2c7c15488b90d57a6d8286cd57a22 /tests
downloadcsharp-c6b417fa15f1f27beb4b77bfda58c738f6b4b861.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests')
-rw-r--r--tests/Cepis.Tests/BatchTests.cs120
-rw-r--r--tests/Cepis.Tests/Cepis.Tests.csproj26
-rw-r--r--tests/Cepis.Tests/ConfigurationTests.cs80
-rw-r--r--tests/Cepis.Tests/CsvTests.cs79
-rw-r--r--tests/Cepis.Tests/FakeServer.cs168
-rw-r--r--tests/Cepis.Tests/Fixtures.cs69
-rw-r--r--tests/Cepis.Tests/HealthTests.cs46
-rw-r--r--tests/Cepis.Tests/HttpTestBase.cs33
-rw-r--r--tests/Cepis.Tests/LookupTests.cs168
-rw-r--r--tests/Cepis.Tests/ModelTests.cs68
-rw-r--r--tests/Cepis.Tests/NormalizeTests.cs35
-rw-r--r--tests/Cepis.Tests/RetryTests.cs249
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"));
+ }
+}