summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md50
-rw-r--r--src/Cepis/CepNotFoundException.cs44
-rw-r--r--src/Cepis/Cepis.csproj2
-rw-r--r--src/Cepis/CepisClient.cs70
-rw-r--r--src/Cepis/Faixa.cs99
-rw-r--r--src/Cepis/FaixaReader.cs98
-rw-r--r--tests/Cepis.Tests/Fixtures.cs24
-rw-r--r--tests/Cepis.Tests/LookupTests.cs99
-rw-r--r--tests/Cepis.Tests/ModelTests.cs20
9 files changed, 496 insertions, 10 deletions
diff --git a/README.md b/README.md
index 8d25149..d1df236 100644
--- a/README.md
+++ b/README.md
@@ -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);