diff options
| -rw-r--r-- | README.md | 50 | ||||
| -rw-r--r-- | src/Cepis/CepNotFoundException.cs | 44 | ||||
| -rw-r--r-- | src/Cepis/Cepis.csproj | 2 | ||||
| -rw-r--r-- | src/Cepis/CepisClient.cs | 70 | ||||
| -rw-r--r-- | src/Cepis/Faixa.cs | 99 | ||||
| -rw-r--r-- | src/Cepis/FaixaReader.cs | 98 | ||||
| -rw-r--r-- | tests/Cepis.Tests/Fixtures.cs | 24 | ||||
| -rw-r--r-- | tests/Cepis.Tests/LookupTests.cs | 99 | ||||
| -rw-r--r-- | tests/Cepis.Tests/ModelTests.cs | 20 |
9 files changed, 496 insertions, 10 deletions
@@ -23,7 +23,7 @@ cd cepis-csharp make pack ``` -That writes `artifacts/Cepis.0.1.0.nupkg`; add it from a local feed, or +That writes `artifacts/Cepis.0.2.0.nupkg`; add it from a local feed, or reference `src/Cepis/Cepis.csproj` directly. ## Usage @@ -190,7 +190,7 @@ decision you have to make**, not by the HTTP status that caused it: | error | when | does retrying help? | | --- | --- | --- | | `InvalidCepException` | not 8 digits | no | -| `CepNotFoundException` | well-formed, not in the database | no | +| `CepNotFoundException` | well-formed, not in the database; may carry `Faixa` | no | | `RateLimitedException` | 429; carries `RetryAfter` | yes, later | | `ServerErrorException` | 5xx; carries `Status` | yes, later | | `NetworkException` | DNS, connection, timeout, TLS | yes, later | @@ -220,6 +220,52 @@ A cancellation you asked for is not a `CepisException` — it surfaces as is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. +### A CEP that does not exist + +A 404 is still a 404: `LookupAsync` throws `CepNotFoundException` and there is +no address. What the exception may add is `Faixa`, the municipality that CEP +*would* have belonged to: + +```csharp +try +{ + Address address = await client.LookupAsync("25845000"); +} +catch (CepNotFoundException e) when (e.Faixa is { } faixa) +{ + faixa.Localidade; // "Areal" + faixa.UF; // "RJ" + faixa.Estado; // "Rio de Janeiro" + faixa.Ibge; // "3300225" + faixa.CepIni; // "25845001" + faixa.CepFim; // "25849899" + faixa.Ceps; // 118 + // "we don't have that CEP — did you mean a street in Areal/RJ?" +} +``` + +Worth handling, because a CEP that fails is rarely random: it is usually +someone who knows the city and guessed the number. The classic guess is the +`-000`, which 957 municipalities never received — `25845000` above is Areal's. + +Three things not to get wrong: + +- **It is context for an error, not an address.** Nothing in it says the CEP + exists. Filling a form from these fields is inventing an address. +- **`CepIni` and `CepFim` are what exists, not what the Correios publish.** The + DNE's own range for Areal starts at `25845000` — the CEP that just failed. + `CepIni` is `25845001`, the smallest one actually in the database. +- **The range is sparse.** Areal's 118 CEPs are spread across the ~4,900 + numbers between its two ends. That is what `Ceps` is for; never walk the + interval, and never show the pair as a list of valid CEPs. + +`Faixa` is `null` whenever the API named no municipality: a CEP in a band +nobody owns, an older API, or a block this client could not read. The API +**omits** the field rather than sending a null, and an unreadable one is +dropped instead of failing the lookup — a courtesy on top of a 404 must not +turn a clean not-found into a parse error. Absence is the ordinary case; treat +it as one. + ## Development Everything runs through `make` and the .NET SDK: diff --git a/src/Cepis/CepNotFoundException.cs b/src/Cepis/CepNotFoundException.cs index ced7b16..19a4576 100644 --- a/src/Cepis/CepNotFoundException.cs +++ b/src/Cepis/CepNotFoundException.cs @@ -4,24 +4,60 @@ namespace Cepis; /// The CEP is well-formed but does not exist in the DNE database. /// </summary> /// <remarks> +/// <para> /// Retrying does not help: the answer is definitive until the next fortnightly /// database. A batch never throws this — a CEP with no address is /// <see langword="null"/> at its position instead. +/// </para> +/// <para> +/// <see cref="Faixa"/> carries the municipality the CEP would have belonged to, +/// when the API can name one. It is context for the failure and not a fallback +/// address: this is still a 404. +/// </para> /// </remarks> public sealed class CepNotFoundException : CepisException { - /// <summary>Create the exception with a message.</summary> + /// <summary>Create the exception with a message and the range, if any.</summary> /// <param name="message">what went wrong</param> - public CepNotFoundException(string message) + /// <param name="faixa"> + /// the municipality the CEP would belong to, or <see langword="null"/> when + /// the API named none + /// </param> + public CepNotFoundException(string message, Faixa? faixa = null) : base(message) { + Faixa = faixa; } - /// <summary>Create the exception with a message and an underlying cause.</summary> + /// <summary>Create the exception with a message, the range and a cause.</summary> /// <param name="message">what went wrong</param> + /// <param name="faixa">the municipality the CEP would belong to, if any</param> /// <param name="innerException">the failure underneath</param> - public CepNotFoundException(string message, Exception? innerException) + public CepNotFoundException(string message, Faixa? faixa, Exception? innerException) : base(message, innerException) { + Faixa = faixa; } + + /// <summary> + /// The municipality the CEP would have belonged to, or + /// <see langword="null"/> when there is none. + /// </summary> + /// <remarks> + /// <para> + /// <see langword="null"/> is the ordinary case for a CEP falling in a band + /// no municipality owns: the API <b>omits</b> the block rather than sending + /// a null, and an older API does not send it at all. Anything unreadable in + /// it lands here as <see langword="null"/> too — a courtesy field is not + /// worth turning a clean not-found into a parse error over. + /// </para> + /// <para> + /// It says which municipality the number <i>would</i> be in, not that any + /// address exists. Read <see cref="Cepis.Faixa"/> before using + /// <see cref="Cepis.Faixa.CepIni"/> and <see cref="Cepis.Faixa.CepFim"/>: + /// they bound the CEPs that exist there, and the range between them is + /// mostly empty. + /// </para> + /// </remarks> + public Faixa? Faixa { get; } } diff --git a/src/Cepis/Cepis.csproj b/src/Cepis/Cepis.csproj index 16ffb75..e0ba207 100644 --- a/src/Cepis/Cepis.csproj +++ b/src/Cepis/Cepis.csproj @@ -15,7 +15,7 @@ <PropertyGroup> <PackageId>Cepis</PackageId> - <Version>0.1.0</Version> + <Version>0.2.0</Version> <Authors>Murilo Ijanc</Authors> <Description>Client for the public CEP API at cep.is. Official Correios data (DNE), refreshed every two weeks.</Description> <PackageLicenseExpression>ISC</PackageLicenseExpression> diff --git a/src/Cepis/CepisClient.cs b/src/Cepis/CepisClient.cs index 72637c0..e32b78e 100644 --- a/src/Cepis/CepisClient.cs +++ b/src/Cepis/CepisClient.cs @@ -54,7 +54,7 @@ public sealed class CepisClient : IDisposable public const int MaxBatch = 500; /// <summary>The version of this library.</summary> - public const string Version = "0.1.0"; + public const string Version = "0.2.0"; private const string AcceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1"; @@ -175,9 +175,17 @@ public sealed class CepisClient : IDisposable /// Look up a single CEP. /// </summary> /// <remarks> + /// <para> /// This is the only call that carries a coordinate. About 57% of CEPs have /// one; when they do not, <see cref="Address.Geo"/> is /// <see langword="null"/>. + /// </para> + /// <para> + /// A CEP that does not exist throws, as it always has. What is new is that + /// the exception may name the municipality the number would belong to, in + /// <see cref="CepNotFoundException.Faixa"/> — context for the failure, not + /// an address, and never a reason to treat the lookup as having succeeded. + /// </para> /// </remarks> /// <param name="cep">the CEP, masked or not</param> /// <param name="cancellationToken">cancels the call, waits included</param> @@ -186,7 +194,10 @@ public sealed class CepisClient : IDisposable /// the CEP is not eight digits, in which case no request is made, or the API /// rejected it with a 400 /// </exception> - /// <exception cref="CepNotFoundException">well-formed, but not in the database</exception> + /// <exception cref="CepNotFoundException"> + /// well-formed, but not in the database; carries the municipality it would + /// belong to when the API names one + /// </exception> /// <exception cref="RateLimitedException">the API answered 429 on every attempt</exception> /// <exception cref="ServerErrorException">the API answered 5xx on every attempt</exception> /// <exception cref="NetworkException">the request never completed</exception> @@ -198,7 +209,8 @@ public sealed class CepisClient : IDisposable if (reply.Status == 404) { - throw new CepNotFoundException($"CEP {number} does not exist in the DNE database"); + var faixa = ReadFaixa(reply.Body); + throw new CepNotFoundException(NotFoundMessage(number, faixa), faixa); } if (reply.Status == 400) @@ -459,6 +471,58 @@ public sealed class CepisClient : IDisposable return fallback; } + /// <summary> + /// Read the <c>faixa</c> block out of a 404 body, when it has one. + /// </summary> + /// <remarks> + /// The block is optional and always will be: the API omits it — it does not + /// send a null — for a CEP in a band no municipality owns. Absent, + /// misshapen or unreadable, the answer here is <see langword="null"/> and + /// the caller still gets the plain not-found it was going to get anyway. + /// </remarks> + /// <param name="body">the 404 body</param> + /// <returns>the municipality the CEP would belong to, or null</returns> + private static Faixa? ReadFaixa(byte[] body) + { + try + { + using var document = JsonDocument.Parse(new ReadOnlyMemory<byte>(body)); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("faixa", out var faixa)) + { + return FaixaReader.Read(faixa); + } + } + catch (JsonException) + { + return null; + } + + return null; + } + + /// <summary> + /// What to tell the caller about a CEP that is not in the database. + /// </summary> + /// <remarks> + /// The municipality goes in the message when the API named one, because the + /// message is what ends up in a log, and "the range belongs to Areal/RJ" is + /// the difference between a dead end and a question worth asking the user. + /// The count is in it for the same reason the API sends it: so nobody reads + /// the two ends as a list of CEPs to walk. + /// </remarks> + /// <param name="cep">the eight digits</param> + /// <param name="faixa">the municipality it would belong to, if any</param> + /// <returns>the message</returns> + private static string NotFoundMessage(string cep, Faixa? faixa) + { + var message = $"CEP {cep} does not exist in the DNE database"; + return faixa is null + ? message + : message + $" — the range belongs to {faixa.Localidade}/{faixa.UF}, " + + $"which has {faixa.Ceps} CEP(s) between {faixa.CepIni} and {faixa.CepFim}"; + } + private static string Preview(byte[] body) => Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 120)); diff --git a/src/Cepis/Faixa.cs b/src/Cepis/Faixa.cs new file mode 100644 index 0000000..e2477b6 --- /dev/null +++ b/src/Cepis/Faixa.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Serialization; + +namespace Cepis; + +/// <summary> +/// The municipality a CEP that does not exist would have belonged to. +/// </summary> +/// <remarks> +/// <para> +/// This is <b>context for an error, never an address</b>. It rides on the 404 +/// of <see cref="CepisClient.LookupAsync(string, CancellationToken)"/>, hanging +/// off <see cref="CepNotFoundException.Faixa"/>. The CEP that was asked for +/// still does not exist, and nothing here says otherwise: filling a form from +/// these fields would be inventing an address. +/// </para> +/// <para> +/// It is worth having because a CEP that fails is rarely random — it is usually +/// someone who knows the city and guessed the number. The classic guess is the +/// <c>-000</c>, which 957 municipalities never received. Knowing where the +/// guess landed is what turns "CEP not found" into "did you mean a street in +/// Areal/RJ?". +/// </para> +/// <para> +/// <see cref="CepIni"/> and <see cref="CepFim"/> are the smallest and the +/// largest CEP that <b>actually exist</b> in that municipality, <i>not</i> the +/// bounds the Correios publish. For Areal the DNE's own range starts at +/// <c>25845000</c> — exactly the CEP that just failed — while +/// <see cref="CepIni"/> is <c>25845001</c>. +/// </para> +/// <para> +/// The range is <b>sparse</b>, and the pair is not a list of valid CEPs. Areal +/// has 118 of them (<see cref="Ceps"/>) spread across the ~4,900 numbers +/// between its two ends, so a number picked in between overwhelmingly does not +/// exist either. Never iterate the interval, and never show it as a set of +/// usable CEPs. +/// </para> +/// <para> +/// The property names are the API's own, in Portuguese, with only the casing +/// adapted — the same rule the rest of the data follows. On the wire they are +/// <c>localidade</c>, <c>uf</c>, <c>estado</c>, <c>regiao</c>, <c>ibge</c>, +/// <c>cep_ini</c>, <c>cep_fim</c> and <c>ceps</c>. +/// </para> +/// </remarks> +public sealed record Faixa +{ + /// <summary>The municipality. Wire name <c>localidade</c>.</summary> + [JsonPropertyName("localidade")] + public string Localidade { get; init; } = ""; + + /// <summary>The two-letter state code. Wire name <c>uf</c>.</summary> + [JsonPropertyName("uf")] + public string UF { get; init; } = ""; + + /// <summary>The state's name. Wire name <c>estado</c>.</summary> + [JsonPropertyName("estado")] + public string Estado { get; init; } = ""; + + /// <summary>The region of the country. Wire name <c>regiao</c>.</summary> + [JsonPropertyName("regiao")] + public string Regiao { get; init; } = ""; + + /// <summary>The IBGE municipality code. Wire name <c>ibge</c>.</summary> + [JsonPropertyName("ibge")] + public string Ibge { get; init; } = ""; + + /// <summary> + /// The smallest CEP that exists in that municipality, unmasked. Wire name + /// <c>cep_ini</c>. + /// </summary> + /// <remarks> + /// The smallest one <i>in the database</i>, not the start of the range the + /// Correios publish — the published start may itself be a CEP that was never + /// issued, which is often precisely the one the caller just tried. + /// </remarks> + [JsonPropertyName("cep_ini")] + public string CepIni { get; init; } = ""; + + /// <summary> + /// The largest CEP that exists in that municipality, unmasked. Wire name + /// <c>cep_fim</c>. + /// </summary> + /// <remarks> + /// Together with <see cref="CepIni"/> it bounds the CEPs of the + /// municipality; it does not enumerate them. See <see cref="Ceps"/>. + /// </remarks> + [JsonPropertyName("cep_fim")] + public string CepFim { get; init; } = ""; + + /// <summary> + /// How many CEPs the municipality actually has. Wire name <c>ceps</c>. + /// </summary> + /// <remarks> + /// This field exists to keep <see cref="CepIni"/> and <see cref="CepFim"/> + /// from being read as a contiguous interval. Areal's 118 CEPs sit inside a + /// span of about 4,900 numbers: the range is mostly empty. + /// </remarks> + [JsonPropertyName("ceps")] + public int Ceps { get; init; } +} diff --git a/src/Cepis/FaixaReader.cs b/src/Cepis/FaixaReader.cs new file mode 100644 index 0000000..da3c6a2 --- /dev/null +++ b/src/Cepis/FaixaReader.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text.Json; + +namespace Cepis; + +/// <summary> +/// Reads the <c>faixa</c> block of a 404 body, yielding <see langword="null"/> +/// for anything it cannot make sense of. +/// </summary> +/// <remarks> +/// <para> +/// A reader rather than a <see cref="System.Text.Json.Serialization.JsonConverter{T}"/> +/// because <c>faixa</c> is not a field of any model this library deserializes: +/// it hangs off an error body that is otherwise only read for its message. +/// </para> +/// <para> +/// Nothing in here throws. The block is a courtesy on top of a 404 — a missing, +/// misshapen or half-empty one must leave the caller with the same clean +/// <see cref="CepNotFoundException"/> it would have got before the API grew the +/// field, never with a parse failure on top of a not-found. +/// </para> +/// </remarks> +internal static class FaixaReader +{ + /// <summary>Turn the <c>faixa</c> element into a range, or null.</summary> + /// <param name="element">the block, as it came off the wire</param> + /// <returns>the range, or null when there is nothing usable in it</returns> + internal static Faixa? Read(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + var localidade = ReadString(element, "localidade"); + if (string.IsNullOrWhiteSpace(localidade)) + { + return null; + } + + return new Faixa + { + Localidade = localidade, + UF = ReadString(element, "uf"), + Estado = ReadString(element, "estado"), + Regiao = ReadString(element, "regiao"), + Ibge = ReadString(element, "ibge"), + CepIni = ReadString(element, "cep_ini"), + CepFim = ReadString(element, "cep_fim"), + Ceps = ReadInt(element, "ceps"), + }; + } + + /// <summary> + /// The property as text, or the empty string when it is absent or of a + /// shape that is not text. + /// </summary> + /// <remarks> + /// A number is accepted and taken verbatim, the way + /// <see cref="LenientStringConverter"/> does it for the address fields. + /// </remarks> + private static string ReadString(JsonElement owner, string name) + { + if (!owner.TryGetProperty(name, out var element)) + { + return ""; + } + + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? "", + JsonValueKind.Number => element.GetRawText(), + _ => "", + }; + } + + /// <summary>The property as a count, or zero when it cannot be read.</summary> + private static int ReadInt(JsonElement owner, string name) + { + if (!owner.TryGetProperty(name, out var element)) + { + return 0; + } + + return element.ValueKind switch + { + JsonValueKind.Number => element.TryGetInt32(out var number) ? number : 0, + JsonValueKind.String => int.TryParse( + element.GetString(), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var parsed) + ? parsed + : 0, + _ => 0, + }; + } +} diff --git a/tests/Cepis.Tests/Fixtures.cs b/tests/Cepis.Tests/Fixtures.cs index 648f881..a7e69b3 100644 --- a/tests/Cepis.Tests/Fixtures.cs +++ b/tests/Cepis.Tests/Fixtures.cs @@ -40,6 +40,30 @@ public static class Fixtures public const string NotFound = """{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"""; + /// <summary> + /// The 404 body for a CEP inside a municipality's range. The CEP that draws + /// it is 25845000 — the DNE's own start for Areal, and a CEP that was never + /// issued. + /// </summary> + public const string NotFoundWithFaixa = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro", + "regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001", + "cep_fim":"25849899","ceps":118}} + """; + + /// <summary>A 404 whose range block is not even an object.</summary> + public const string NotFoundBrokenFaixa = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + "faixa":"Areal"} + """; + + /// <summary>A 404 whose range block names no municipality.</summary> + public const string NotFoundNamelessFaixa = """ + {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + "faixa":{"uf":"RJ","cep_ini":"25845001","cep_fim":"25849899","ceps":118}} + """; + /// <summary>The 400 body.</summary> public const string InvalidCep = """{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}"""; diff --git a/tests/Cepis.Tests/LookupTests.cs b/tests/Cepis.Tests/LookupTests.cs index ad3e2bf..91f1c03 100644 --- a/tests/Cepis.Tests/LookupTests.cs +++ b/tests/Cepis.Tests/LookupTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Cepis.Tests; /// <summary>A single lookup: the happy path, the coordinate, and the refusals.</summary> @@ -120,6 +122,103 @@ public sealed class LookupTests : HttpTestBase } [Fact] + public async Task AMissingCepNamesTheMunicipalityItWouldBeIn() + { + Server.Respond("/25845000", Reply.Of(404, Fixtures.NotFoundWithFaixa)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("25845000")); + + var faixa = failure.Faixa; + Assert.NotNull(faixa); + Assert.Equal("Areal", faixa.Localidade); + Assert.Equal("RJ", faixa.UF); + Assert.Equal("Rio de Janeiro", faixa.Estado); + Assert.Equal("Sudeste", faixa.Regiao); + Assert.Equal("3300225", faixa.Ibge); + Assert.Equal("25845001", faixa.CepIni); + Assert.Equal("25849899", faixa.CepFim); + Assert.Equal(118, faixa.Ceps); + } + + /// <summary> + /// The CEP asked for, 25845000, is the DNE's own start for Areal. The range + /// reports what exists, so it starts one above — and holds 118 CEPs across + /// the ~4,900 numbers it spans, which is why the count travels with it. + /// </summary> + [Fact] + public async Task TheRangeIsWhatExistsAndIsSparse() + { + Server.Respond("/25845000", Reply.Of(404, Fixtures.NotFoundWithFaixa)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("25845000")); + + var faixa = failure.Faixa; + Assert.NotNull(faixa); + Assert.NotEqual("25845000", faixa.CepIni); + Assert.True(faixa.Ceps < int.Parse(faixa.CepFim, CultureInfo.InvariantCulture) + - int.Parse(faixa.CepIni, CultureInfo.InvariantCulture)); + } + + [Fact] + public async Task TheMessageMentionsTheMunicipality() + { + Server.Respond("/25845000", Reply.Of(404, Fixtures.NotFoundWithFaixa)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("25845000")); + + Assert.Contains("25845000", failure.Message, StringComparison.Ordinal); + Assert.Contains("Areal/RJ", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AMissingCepInNobodysRangeHasNoMunicipality() + { + Server.Respond("/99999999", Reply.Of(404, Fixtures.NotFound)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("99999999")); + + Assert.Null(failure.Faixa); + Assert.Equal("CEP 99999999 does not exist in the DNE database", failure.Message); + } + + [Fact] + public async Task ARangeOfTheWrongShapeIsDropped() + { + Server.Respond("/25845000", Reply.Of(404, Fixtures.NotFoundBrokenFaixa)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("25845000")); + + Assert.Null(failure.Faixa); + } + + [Fact] + public async Task ARangeNamingNoMunicipalityIsDropped() + { + Server.Respond("/25845000", Reply.Of(404, Fixtures.NotFoundNamelessFaixa)); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("25845000")); + + Assert.Null(failure.Faixa); + } + + [Fact] + public async Task AnUnreadable404BodyIsStillJustANotFound() + { + Server.Respond("/25845000", Reply.Of(404, "<html><body>gateway</body></html>")); + + var failure = await Assert.ThrowsAsync<CepNotFoundException>( + () => Client.LookupAsync("25845000")); + + Assert.Null(failure.Faixa); + } + + [Fact] public async Task A400CarriesTheApiMessage() { Server.Respond("/00000000", Reply.Of(400, Fixtures.InvalidCep)); diff --git a/tests/Cepis.Tests/ModelTests.cs b/tests/Cepis.Tests/ModelTests.cs index 7d9d0d6..f53ecb7 100644 --- a/tests/Cepis.Tests/ModelTests.cs +++ b/tests/Cepis.Tests/ModelTests.cs @@ -59,6 +59,26 @@ public sealed class ModelTests } [Fact] + public void ARangeDefaultsToTheEmptyStringToo() + { + var faixa = new Faixa(); + + Assert.Equal("", faixa.Localidade); + Assert.Equal("", faixa.CepIni); + Assert.Equal("", faixa.CepFim); + Assert.Equal(0, faixa.Ceps); + } + + [Fact] + public void ANotFoundCarriesNoRangeUnlessTheApiNamedOne() + { + Assert.Null(new CepNotFoundException("nope").Faixa); + Assert.Equal( + "Areal", + new CepNotFoundException("nope", new Faixa { Localidade = "Areal" }).Faixa?.Localidade); + } + + [Fact] public void HealthIsOkOnlyForTheDocumentedStatus() { Assert.True(new Health { Status = "ok" }.Ok); |