summaryrefslogtreecommitdiffstats
path: root/tests/TestCepis.pas
diff options
context:
space:
mode:
Diffstat (limited to 'tests/TestCepis.pas')
-rw-r--r--tests/TestCepis.pas1161
1 files changed, 1161 insertions, 0 deletions
diff --git a/tests/TestCepis.pas b/tests/TestCepis.pas
new file mode 100644
index 0000000..75a57ee
--- /dev/null
+++ b/tests/TestCepis.pas
@@ -0,0 +1,1161 @@
+{
+ Test suite for the Cepis unit.
+
+ Every test brings up a real HTTP server on loopback and points BaseURL at it.
+ No test touches the network, and nothing about the HTTP layer is mocked: the
+ code path exercised is the one that runs in production, request construction
+ and error body reading included.
+
+ It doubles as the test that BaseURL works - if it did not, none of this would
+ run.
+
+ The fixture field names stay Portuguese because they mirror the API's JSON.
+}
+program TestCepis;
+
+{$MODE OBJFPC}
+{$H+}
+
+uses
+{$IFDEF UNIX}
+ cthreads,
+{$ENDIF}
+ Classes, SysUtils, fpcunit, testregistry, consoletestrunner,
+ fphttpserver, httpdefs, Cepis;
+
+const
+ SeAddress =
+ '{"cep":"01001000","logradouro":"Pra' + #$C3#$A7 + 'a da S' + #$C3#$A9 +
+ '","complemento":"lado ' + #$C3#$AD + 'mpar","unidade":"","bairro":"S' +
+ #$C3#$A9 + '","localidade":"S' + #$C3#$A3 + 'o Paulo","uf":"SP",' +
+ '"estado":"S' + #$C3#$A3 + 'o Paulo","regiao":"Sudeste","ibge":"3550308",' +
+ '"gia":"1004","ddd":"11","siafi":"7107"';
+
+ SaoPaulo = 'S' + #$C3#$A3 + 'o Paulo';
+ PracaDaSe = 'Pra' + #$C3#$A7 + 'a da S' + #$C3#$A9;
+
+ CsvHeader = 'cep,logradouro,complemento,unidade,bairro,localidade,uf,' +
+ 'estado,regiao,ibge,gia,ddd,siafi';
+
+type
+ TFakeReply = record
+ Status: Integer;
+ Body: string;
+ HeaderName: string;
+ HeaderValue: string;
+ end;
+ TFakeReplies = array of TFakeReply;
+
+ TFakeHandler = procedure(const APath: string; ACallIndex: Integer;
+ var AReply: TFakeReply) of object;
+
+ TFakeRoute = class
+ public
+ Path: string;
+ Replies: TFakeReplies;
+ Cursor: Integer;
+ end;
+
+ TLocalHttpServer = class(TFPHttpServer)
+ public
+ property Address;
+ end;
+
+ TAcceptThread = class(TThread)
+ private
+ FServer: TLocalHttpServer;
+ protected
+ procedure Execute; override;
+ public
+ constructor Create(AServer: TLocalHttpServer);
+ end;
+
+ { A throwaway HTTP server on loopback with per-test programmable routes. }
+ TFakeServer = class
+ private
+ FServer: TLocalHttpServer;
+ FThread: TAcceptThread;
+ FRoutes: TStringList;
+ FCalls: TStringList;
+ FLock: TRTLCriticalSection;
+ FHandler: TFakeHandler;
+ FPort: Word;
+ procedure DoRequest(Sender: TObject;
+ var ARequest: TFPHTTPConnectionRequest;
+ var AResponse: TFPHTTPConnectionResponse);
+ function GetBaseURL: string;
+ function TryStart(APort: Word): Boolean;
+ procedure Stop;
+ function RouteFor(const APath: string): TFakeRoute;
+ public
+ constructor Create;
+ destructor Destroy; override;
+ procedure Respond(const APath: string; AStatus: Integer;
+ const ABody: string);
+ procedure RespondWithHeader(const APath: string; AStatus: Integer;
+ const ABody, AHeaderName, AHeaderValue: string);
+ procedure RespondSequence(const APath: string;
+ const AReplies: array of TFakeReply);
+ function CallCount(const APrefix: string): Integer;
+ function TotalCalls: Integer;
+ property Handler: TFakeHandler read FHandler write FHandler;
+ property BaseURL: string read GetBaseURL;
+ end;
+
+ { Fixture: a fake server plus a client whose backoff is near zero. }
+ TCepisTestCase = class(TTestCase)
+ protected
+ FServer: TFakeServer;
+ FClient: TCepisClient;
+ procedure SetUp; override;
+ procedure TearDown; override;
+ end;
+
+function Reply(AStatus: Integer; const ABody: string): TFakeReply;
+begin
+ Result.Status := AStatus;
+ Result.Body := ABody;
+ Result.HeaderName := '';
+ Result.HeaderValue := '';
+end;
+
+constructor TAcceptThread.Create(AServer: TLocalHttpServer);
+begin
+ FServer := AServer;
+ FreeOnTerminate := False;
+ inherited Create(False);
+end;
+
+procedure TAcceptThread.Execute;
+begin
+ try
+ FServer.Active := True;
+ except
+ on E: Exception do
+ ;
+ end;
+end;
+
+constructor TFakeServer.Create;
+var
+ Attempt: Integer;
+begin
+ inherited Create;
+ InitCriticalSection(FLock);
+ FRoutes := TStringList.Create;
+ FRoutes.OwnsObjects := True;
+ FCalls := TStringList.Create;
+ for Attempt := 1 to 40 do
+ if TryStart(20000 + Random(30000)) then
+ begin
+ FCalls.Clear;
+ Exit;
+ end;
+ raise Exception.Create('could not bind a loopback port for the test server');
+end;
+
+function TFakeServer.TryStart(APort: Word): Boolean;
+var
+ Probe: TCepisClient;
+ Waited: Integer;
+ Health: TCepisHealth;
+begin
+ FPort := APort;
+ FServer := TLocalHttpServer.Create(nil);
+ FServer.Address := '127.0.0.1';
+ FServer.Port := APort;
+ FServer.Threaded := True;
+ FServer.AcceptIdleTimeout := 50;
+ FServer.OnRequest := @DoRequest;
+ FThread := TAcceptThread.Create(FServer);
+
+ Probe := TCepisClient.Create;
+ try
+ Probe.BaseURL := GetBaseURL;
+ Probe.MaxAttempts := 1;
+ Probe.Timeout := 1.0;
+ Waited := 0;
+ while Waited < 3000 do
+ begin
+ if FThread.Finished then
+ Break;
+ try
+ Health := Probe.Health;
+ Health.Free;
+ Exit(True);
+ except
+ on E: ECepisNetworkError do
+ ;
+ on E: ECepis do
+ Exit(True);
+ end;
+ Sleep(20);
+ Inc(Waited, 20);
+ end;
+ finally
+ Probe.Free;
+ end;
+
+ Stop;
+ Result := False;
+end;
+
+procedure TFakeServer.Stop;
+begin
+ if FServer <> nil then
+ try
+ FServer.Active := False;
+ except
+ on E: Exception do
+ ;
+ end;
+ if FThread <> nil then
+ begin
+ FThread.WaitFor;
+ FreeAndNil(FThread);
+ end;
+ FreeAndNil(FServer);
+end;
+
+destructor TFakeServer.Destroy;
+begin
+ Stop;
+ FreeAndNil(FRoutes);
+ FreeAndNil(FCalls);
+ DoneCriticalSection(FLock);
+ inherited Destroy;
+end;
+
+function TFakeServer.GetBaseURL: string;
+begin
+ Result := Format('http://127.0.0.1:%d', [FPort]);
+end;
+
+function TFakeServer.RouteFor(const APath: string): TFakeRoute;
+var
+ Index: Integer;
+begin
+ Index := FRoutes.IndexOf(APath);
+ if Index >= 0 then
+ Exit(TFakeRoute(FRoutes.Objects[Index]));
+ Result := TFakeRoute.Create;
+ Result.Path := APath;
+ Result.Cursor := 0;
+ FRoutes.AddObject(APath, Result);
+end;
+
+procedure TFakeServer.Respond(const APath: string; AStatus: Integer;
+ const ABody: string);
+begin
+ RespondWithHeader(APath, AStatus, ABody, '', '');
+end;
+
+procedure TFakeServer.RespondWithHeader(const APath: string; AStatus: Integer;
+ const ABody, AHeaderName, AHeaderValue: string);
+var
+ Route: TFakeRoute;
+begin
+ EnterCriticalSection(FLock);
+ try
+ Route := RouteFor(APath);
+ SetLength(Route.Replies, 1);
+ Route.Replies[0].Status := AStatus;
+ Route.Replies[0].Body := ABody;
+ Route.Replies[0].HeaderName := AHeaderName;
+ Route.Replies[0].HeaderValue := AHeaderValue;
+ Route.Cursor := 0;
+ finally
+ LeaveCriticalSection(FLock);
+ end;
+end;
+
+procedure TFakeServer.RespondSequence(const APath: string;
+ const AReplies: array of TFakeReply);
+var
+ Route: TFakeRoute;
+ I: Integer;
+begin
+ EnterCriticalSection(FLock);
+ try
+ Route := RouteFor(APath);
+ SetLength(Route.Replies, Length(AReplies));
+ for I := 0 to High(AReplies) do
+ Route.Replies[I] := AReplies[I];
+ Route.Cursor := 0;
+ finally
+ LeaveCriticalSection(FLock);
+ end;
+end;
+
+function TFakeServer.CallCount(const APrefix: string): Integer;
+var
+ I: Integer;
+begin
+ Result := 0;
+ EnterCriticalSection(FLock);
+ try
+ for I := 0 to FCalls.Count - 1 do
+ if Copy(FCalls[I], 1, Length(APrefix)) = APrefix then
+ Inc(Result);
+ finally
+ LeaveCriticalSection(FLock);
+ end;
+end;
+
+function TFakeServer.TotalCalls: Integer;
+begin
+ EnterCriticalSection(FLock);
+ try
+ Result := FCalls.Count;
+ finally
+ LeaveCriticalSection(FLock);
+ end;
+end;
+
+procedure TFakeServer.DoRequest(Sender: TObject;
+ var ARequest: TFPHTTPConnectionRequest;
+ var AResponse: TFPHTTPConnectionResponse);
+var
+ Path: string;
+ Current: TFakeReply;
+ Route: TFakeRoute;
+ Index, CallIndex: Integer;
+begin
+ Path := ARequest.URL;
+ Current := Reply(404, '{"erro":"cep_nao_encontrado"}');
+
+ EnterCriticalSection(FLock);
+ try
+ FCalls.Add(Path);
+ CallIndex := FCalls.Count;
+ Index := FRoutes.IndexOf(Path);
+ if Index >= 0 then
+ begin
+ Route := TFakeRoute(FRoutes.Objects[Index]);
+ if Length(Route.Replies) > 0 then
+ begin
+ Current := Route.Replies[Route.Cursor];
+ if Route.Cursor < High(Route.Replies) then
+ Inc(Route.Cursor);
+ end;
+ end
+ else if Assigned(FHandler) then
+ begin
+ Current := Reply(200, '');
+ FHandler(Path, CallIndex, Current);
+ end;
+ finally
+ LeaveCriticalSection(FLock);
+ end;
+
+ AResponse.Code := Current.Status;
+ AResponse.CodeText := 'Test';
+ AResponse.ContentType := 'application/json; charset=utf-8';
+ if Current.HeaderName <> '' then
+ AResponse.SetCustomHeader(Current.HeaderName, Current.HeaderValue);
+ AResponse.Content := Current.Body;
+end;
+
+procedure TCepisTestCase.SetUp;
+begin
+ inherited SetUp;
+ FServer := TFakeServer.Create;
+ FClient := TCepisClient.Create;
+ FClient.BaseURL := FServer.BaseURL;
+ FClient.Timeout := 5.0;
+ FClient.BackoffBase := 0.001;
+ FClient.BackoffMax := 0.01;
+end;
+
+procedure TCepisTestCase.TearDown;
+begin
+ FreeAndNil(FClient);
+ FreeAndNil(FServer);
+ inherited TearDown;
+end;
+
+type
+ TTestNormalisation = class(TTestCase)
+ published
+ procedure AcceptsMaskedAndUnmasked;
+ procedure RejectsBadFormat;
+ procedure FormatsBack;
+ end;
+
+procedure TTestNormalisation.AcceptsMaskedAndUnmasked;
+begin
+ AssertEquals('01001000', CepisNormalizeCEP('01001000'));
+ AssertEquals('01001000', CepisNormalizeCEP('01001-000'));
+ AssertEquals('01001000', CepisNormalizeCEP(' 01001-000 '));
+ AssertEquals('01001000', CepisNormalizeCEP('01001.000'));
+end;
+
+procedure TTestNormalisation.RejectsBadFormat;
+const
+ Bad: array[0..4] of string = ('', '123', '0100100a', '010010000', 'abcdefgh');
+var
+ I: Integer;
+ Raised: Boolean;
+begin
+ for I := Low(Bad) to High(Bad) do
+ begin
+ Raised := False;
+ try
+ CepisNormalizeCEP(Bad[I]);
+ except
+ on E: ECepisInvalidCEP do
+ Raised := True;
+ end;
+ AssertTrue('expected ECepisInvalidCEP for "' + Bad[I] + '"', Raised);
+ end;
+end;
+
+procedure TTestNormalisation.FormatsBack;
+var
+ Address: TCepisAddress;
+begin
+ Address := TCepisAddress.Create;
+ try
+ AssertEquals('', Address.FormattedCEP);
+ finally
+ Address.Free;
+ end;
+end;
+
+type
+ TTestLookup = class(TCepisTestCase)
+ published
+ procedure SuccessfulLookup;
+ procedure ReadsTheCoordinateWhenPresent;
+ procedure MunicipalityCoordinateIsNotPrecise;
+ procedure MalformedGeoDoesNotLoseTheAddress;
+ procedure NewApiFieldDoesNotBreak;
+ procedure NotFound;
+ procedure NotFoundIsNotRetried;
+ procedure InvalidCepNeverLeavesTheMachine;
+ procedure Api400BecomesInvalidCep;
+ procedure ResponseThatIsNotJson;
+ end;
+
+procedure TTestLookup.SuccessfulLookup;
+var
+ Address: TCepisAddress;
+begin
+ FServer.Respond('/01001000', 200, SeAddress + '}');
+ Address := FClient.Lookup('01001-000');
+ try
+ AssertEquals(SaoPaulo, Address.Localidade);
+ AssertEquals('SP', Address.UF);
+ AssertEquals(PracaDaSe, Address.Logradouro);
+ AssertEquals('01001-000', Address.FormattedCEP);
+ AssertTrue('geo must be nil when the API omits it', Address.Geo = nil);
+ finally
+ Address.Free;
+ end;
+end;
+
+procedure TTestLookup.ReadsTheCoordinateWhenPresent;
+var
+ Address: TCepisAddress;
+begin
+ FServer.Respond('/01310100', 200, SeAddress +
+ ',"geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}}');
+ Address := FClient.Lookup('01310100');
+ try
+ AssertTrue('geo must be present', Address.Geo <> nil);
+ AssertEquals(-23.5, Address.Geo.Lat, 0.0001);
+ AssertEquals(-46.6, Address.Geo.Lon, 0.0001);
+ AssertEquals(120, Address.Geo.RaioM);
+ AssertTrue('street-level geo is precise', Address.Geo.IsPrecise);
+ finally
+ Address.Free;
+ end;
+end;
+
+procedure TTestLookup.MunicipalityCoordinateIsNotPrecise;
+var
+ Address: TCepisAddress;
+begin
+ FServer.Respond('/01001000', 200, SeAddress +
+ ',"geo":{"lat":-23.5,"lon":-46.6,"raio_m":22000,"precisao":"localidade"}}');
+ Address := FClient.Lookup('01001000');
+ try
+ AssertTrue('geo must be present', Address.Geo <> nil);
+ AssertFalse('municipality centroid is not precise', Address.Geo.IsPrecise);
+ finally
+ Address.Free;
+ end;
+end;
+
+procedure TTestLookup.MalformedGeoDoesNotLoseTheAddress;
+var
+ Address: TCepisAddress;
+begin
+ FServer.Respond('/01001000', 200, SeAddress + ',"geo":{"lat":"not a number"}}');
+ Address := FClient.Lookup('01001000');
+ try
+ AssertTrue('malformed geo is dropped', Address.Geo = nil);
+ AssertEquals(SaoPaulo, Address.Localidade);
+ finally
+ Address.Free;
+ end;
+end;
+
+procedure TTestLookup.NewApiFieldDoesNotBreak;
+var
+ Address: TCepisAddress;
+begin
+ FServer.Respond('/01001000', 200, SeAddress + ',"campo_do_futuro":"value"}');
+ Address := FClient.Lookup('01001000');
+ try
+ AssertEquals(SaoPaulo, Address.Localidade);
+ AssertEquals('value', Address.RawField('campo_do_futuro'));
+ finally
+ Address.Free;
+ end;
+end;
+
+procedure TTestLookup.NotFound;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ Message: string;
+begin
+ FServer.Respond('/99999999', 404,
+ '{"erro":"cep_nao_encontrado","mensagem":"gone"}');
+ Raised := False;
+ Message := '';
+ Address := nil;
+ try
+ Address := FClient.Lookup('99999999');
+ except
+ on E: ECepisNotFound do
+ begin
+ Raised := True;
+ Message := E.Message;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisNotFound', Raised);
+ AssertTrue('message must name the CEP', Pos('99999999', Message) > 0);
+end;
+
+procedure TTestLookup.NotFoundIsNotRetried;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+begin
+ FServer.Respond('/99999999', 404, '{"erro":"cep_nao_encontrado"}');
+ Raised := False;
+ Address := nil;
+ try
+ Address := FClient.Lookup('99999999');
+ except
+ on E: ECepisNotFound do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisNotFound', Raised);
+ AssertEquals(1, FServer.CallCount('/99999999'));
+end;
+
+procedure TTestLookup.InvalidCepNeverLeavesTheMachine;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+begin
+ Raised := False;
+ Address := nil;
+ try
+ Address := FClient.Lookup('123');
+ except
+ on E: ECepisInvalidCEP do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisInvalidCEP', Raised);
+ AssertEquals(0, FServer.TotalCalls);
+end;
+
+procedure TTestLookup.Api400BecomesInvalidCep;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ Message: string;
+begin
+ FServer.Respond('/00000000', 400,
+ '{"erro":"cep_invalido","mensagem":"rejected by the API"}');
+ Raised := False;
+ Message := '';
+ Address := nil;
+ try
+ Address := FClient.Lookup('00000000');
+ except
+ on E: ECepisInvalidCEP do
+ begin
+ Raised := True;
+ Message := E.Message;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisInvalidCEP', Raised);
+ AssertEquals('rejected by the API', Message);
+ AssertEquals(1, FServer.CallCount('/00000000'));
+end;
+
+procedure TTestLookup.ResponseThatIsNotJson;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+begin
+ FServer.Respond('/01001000', 200, '<html>captive portal</html>');
+ Raised := False;
+ Address := nil;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisInvalidResponse do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisInvalidResponse', Raised);
+end;
+
+type
+ TTestRetry = class(TCepisTestCase)
+ published
+ procedure RetriesOn500ThenSucceeds;
+ procedure ExhaustsAttemptsThenRaises;
+ procedure MaxAttemptsOneDisablesRetry;
+ procedure RateLimitedCarriesRetryAfter;
+ procedure RetryAfterHonoursTheCap;
+ procedure RetryAfterAcceptsAnHttpDate;
+ procedure NetworkErrorWhenNothingListens;
+ procedure TotalTimeoutBoundsTheWholeCall;
+ end;
+
+procedure TTestRetry.RetriesOn500ThenSucceeds;
+var
+ Address: TCepisAddress;
+begin
+ FServer.RespondSequence('/01001000',
+ [Reply(500, '{"erro":"interno"}'), Reply(200, SeAddress + '}')]);
+ Address := FClient.Lookup('01001000');
+ try
+ AssertEquals(SaoPaulo, Address.Localidade);
+ AssertEquals(2, FServer.CallCount('/01001000'));
+ finally
+ Address.Free;
+ end;
+end;
+
+procedure TTestRetry.ExhaustsAttemptsThenRaises;
+var
+ Address: TCepisAddress;
+ Status: Integer;
+ Raised: Boolean;
+begin
+ FServer.Respond('/01001000', 503, '{"erro":"interno"}');
+ Raised := False;
+ Status := 0;
+ Address := nil;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisServerError do
+ begin
+ Raised := True;
+ Status := E.Status;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisServerError', Raised);
+ AssertEquals(503, Status);
+ AssertEquals(3, FServer.CallCount('/01001000'));
+end;
+
+procedure TTestRetry.MaxAttemptsOneDisablesRetry;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+begin
+ FClient.MaxAttempts := 1;
+ FServer.Respond('/01001000', 500, '{}');
+ Raised := False;
+ Address := nil;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisServerError do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisServerError', Raised);
+ AssertEquals(1, FServer.CallCount('/01001000'));
+end;
+
+procedure TTestRetry.RateLimitedCarriesRetryAfter;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ RetryAfter: Double;
+begin
+ FServer.RespondWithHeader('/01001000', 429, '{}', 'Retry-After', '7');
+ Raised := False;
+ RetryAfter := -1;
+ Address := nil;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisRateLimited do
+ begin
+ Raised := True;
+ RetryAfter := E.RetryAfter;
+ end;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisRateLimited', Raised);
+ AssertEquals(7.0, RetryAfter, 0.0001);
+end;
+
+procedure TTestRetry.RetryAfterHonoursTheCap;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ Started: QWord;
+begin
+ FServer.RespondWithHeader('/01001000', 429, '{}', 'Retry-After', '3600');
+ Raised := False;
+ Address := nil;
+ Started := GetTickCount64;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisRateLimited do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisRateLimited', Raised);
+ AssertTrue('an absurd Retry-After must not park the thread',
+ GetTickCount64 - Started < 5000);
+end;
+
+procedure TTestRetry.RetryAfterAcceptsAnHttpDate;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ Started: QWord;
+begin
+ FServer.RespondWithHeader('/01001000', 429, '{}', 'Retry-After',
+ 'Wed, 21 Oct 2026 07:28:00 GMT');
+ Raised := False;
+ Address := nil;
+ Started := GetTickCount64;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisRateLimited do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisRateLimited', Raised);
+ AssertTrue('a date far in the future is still capped',
+ GetTickCount64 - Started < 5000);
+end;
+
+procedure TTestRetry.NetworkErrorWhenNothingListens;
+var
+ Client: TCepisClient;
+ Address: TCepisAddress;
+ Raised: Boolean;
+begin
+ Client := TCepisClient.Create;
+ try
+ Client.BaseURL := 'http://127.0.0.1:1';
+ Client.MaxAttempts := 2;
+ Client.BackoffBase := 0.001;
+ Client.BackoffMax := 0.01;
+ Client.Timeout := 1.0;
+ Raised := False;
+ Address := nil;
+ try
+ Address := Client.Lookup('01001000');
+ except
+ on E: ECepisNetworkError do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisNetworkError', Raised);
+ finally
+ Client.Free;
+ end;
+end;
+
+procedure TTestRetry.TotalTimeoutBoundsTheWholeCall;
+var
+ Address: TCepisAddress;
+ Raised: Boolean;
+ Started: QWord;
+begin
+ FClient.MaxAttempts := 50;
+ FClient.BackoffBase := 0.05;
+ FClient.BackoffMax := 0.05;
+ FClient.TotalTimeout := 0.3;
+ FServer.Respond('/01001000', 500, '{}');
+ Raised := False;
+ Address := nil;
+ Started := GetTickCount64;
+ try
+ Address := FClient.Lookup('01001000');
+ except
+ on E: ECepisServerError do
+ Raised := True;
+ end;
+ Address.Free;
+ AssertTrue('expected ECepisServerError', Raised);
+ AssertTrue('the deadline must cut the run short',
+ GetTickCount64 - Started < 3000);
+ AssertTrue('fewer than the 50 configured attempts',
+ FServer.CallCount('/01001000') < 50);
+end;
+
+type
+ TTestBatch = class(TCepisTestCase)
+ private
+ FChunkCalls: Integer;
+ procedure ChunkHandler(const APath: string; ACallIndex: Integer;
+ var AReply: TFakeReply);
+ published
+ procedure PreservesOrderAndMarksMissing;
+ procedure FieldWithCommaIsParsed;
+ procedure EmptyListMakesNoRequest;
+ procedure BatchCarriesNoGeo;
+ procedure ChunksAboveTheApiLimit;
+ procedure TruncatedResponseDoesNotShift;
+ end;
+
+procedure TTestBatch.PreservesOrderAndMarksMissing;
+var
+ Result: TCepisAddressList;
+begin
+ FServer.Respond('/csv?ceps=01001000%2C99999999%2C20040020', 200,
+ CsvHeader + #10 +
+ '01001000,' + PracaDaSe + ',lado ' + #$C3#$AD + 'mpar,,S' + #$C3#$A9 +
+ ',' + SaoPaulo + ',SP,' + SaoPaulo + ',Sudeste,3550308,1004,11,7107' + #10 +
+ '99999999,,,,,,,,,,,,' + #10 +
+ '20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,' +
+ 'Sudeste,3304557,,21,6001' + #10);
+ Result := FClient.LookupMany(['01001000', '99999999', '20040020']);
+ try
+ AssertEquals(3, Result.Count);
+ AssertTrue('first must be present', Result[0] <> nil);
+ AssertEquals(SaoPaulo, Result[0].Localidade);
+ AssertTrue('missing CEP becomes nil at its position', Result[1] = nil);
+ AssertTrue('third must be present', Result[2] <> nil);
+ AssertEquals('RJ', Result[2].UF);
+ finally
+ Result.Free;
+ end;
+end;
+
+procedure TTestBatch.FieldWithCommaIsParsed;
+var
+ Result: TCepisAddressList;
+begin
+ FServer.Respond('/csv?ceps=01001000', 200,
+ CsvHeader + #10 +
+ '01001000,"Rua Um, Dois",,,Centro,' + SaoPaulo + ',SP,' + SaoPaulo +
+ ',Sudeste,1,,11,1' + #10);
+ Result := FClient.LookupMany(['01001000']);
+ try
+ AssertEquals(1, Result.Count);
+ AssertTrue('must be present', Result[0] <> nil);
+ AssertEquals('Rua Um, Dois', Result[0].Logradouro);
+ finally
+ Result.Free;
+ end;
+end;
+
+procedure TTestBatch.EmptyListMakesNoRequest;
+var
+ Result: TCepisAddressList;
+ Empty: array of string;
+begin
+ SetLength(Empty, 0);
+ Result := FClient.LookupMany(Empty);
+ try
+ AssertEquals(0, Result.Count);
+ AssertEquals(0, FServer.TotalCalls);
+ finally
+ Result.Free;
+ end;
+end;
+
+procedure TTestBatch.BatchCarriesNoGeo;
+var
+ Result: TCepisAddressList;
+begin
+ FServer.Respond('/csv?ceps=01001000', 200,
+ CsvHeader + #10 +
+ '01001000,' + PracaDaSe + ',,,Centro,' + SaoPaulo + ',SP,' + SaoPaulo +
+ ',Sudeste,1,,11,1' + #10);
+ Result := FClient.LookupMany(['01001000']);
+ try
+ AssertTrue('must be present', Result[0] <> nil);
+ AssertTrue('a batch never carries a coordinate', Result[0].Geo = nil);
+ finally
+ Result.Free;
+ end;
+end;
+
+procedure TTestBatch.ChunkHandler(const APath: string; ACallIndex: Integer;
+ var AReply: TFakeReply);
+var
+ Body: string;
+ I, First, Last: Integer;
+begin
+ Inc(FChunkCalls);
+ if FChunkCalls = 1 then
+ begin
+ First := 0;
+ Last := 499;
+ end
+ else
+ begin
+ First := 500;
+ Last := 500;
+ end;
+ Body := CsvHeader + #10;
+ for I := First to Last do
+ Body := Body + Format('%.8d,,,,,X,SP,%s,Sudeste,1,,11,1', [I, SaoPaulo]) + #10;
+ AReply.Status := 200;
+ AReply.Body := Body;
+end;
+
+procedure TTestBatch.ChunksAboveTheApiLimit;
+var
+ Ceps: array of string;
+ Result: TCepisAddressList;
+ I: Integer;
+begin
+ FChunkCalls := 0;
+ FServer.Handler := @ChunkHandler;
+ SetLength(Ceps, 501);
+ for I := 0 to 500 do
+ Ceps[I] := Format('%.8d', [I]);
+ Result := FClient.LookupMany(Ceps);
+ try
+ AssertEquals(501, Result.Count);
+ AssertEquals(2, FChunkCalls);
+ AssertTrue('last entry present', Result[500] <> nil);
+ finally
+ Result.Free;
+ end;
+end;
+
+procedure TTestBatch.TruncatedResponseDoesNotShift;
+var
+ Result: TCepisAddressList;
+begin
+ FServer.Respond('/csv?ceps=01001000%2C20040020', 200,
+ CsvHeader + #10 +
+ '01001000,,,,,' + SaoPaulo + ',SP,' + SaoPaulo + ',Sudeste,1,,11,1' + #10);
+ Result := FClient.LookupMany(['01001000', '20040020']);
+ try
+ AssertEquals(2, Result.Count);
+ AssertTrue('first must be present', Result[0] <> nil);
+ AssertTrue('padding beats shifting', Result[1] = nil);
+ finally
+ Result.Free;
+ end;
+end;
+
+type
+ TTestHealth = class(TCepisTestCase)
+ published
+ procedure ReadsHealth;
+ procedure DegradedDoesNotRaise;
+ end;
+
+procedure TTestHealth.ReadsHealth;
+var
+ Health: TCepisHealth;
+begin
+ FServer.Respond('/health', 200,
+ '{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,' +
+ '"base_gerada_em":"2026-07-24T03:48:28Z"}');
+ Health := FClient.Health;
+ try
+ AssertTrue('must report healthy', Health.Ok);
+ AssertEquals('26071', Health.Dne);
+ AssertEquals(1596168, Health.Ceps);
+ AssertEquals(894489, Health.Geo);
+ AssertEquals('2026-07-24T03:48:28Z', Health.BaseGeradaEm);
+ finally
+ Health.Free;
+ end;
+end;
+
+procedure TTestHealth.DegradedDoesNotRaise;
+var
+ Health: TCepisHealth;
+begin
+ FServer.Respond('/health', 503,
+ '{"status":"degradado","dne":"","ceps":0,"geo":0}');
+ Health := FClient.Health;
+ try
+ AssertFalse('degraded is reported, not raised', Health.Ok);
+ AssertEquals('degradado', Health.Status);
+ AssertEquals(1, FServer.CallCount('/health'));
+ finally
+ Health.Free;
+ end;
+end;
+
+type
+ TTestConfiguration = class(TTestCase)
+ published
+ procedure TrailingSlashIsNotDuplicated;
+ procedure InvalidParameters;
+ procedure UserAgentIdentifiesTheLibrary;
+ procedure DefaultsAreTheDocumentedOnes;
+ end;
+
+procedure TTestConfiguration.TrailingSlashIsNotDuplicated;
+var
+ Client: TCepisClient;
+begin
+ Client := TCepisClient.Create;
+ try
+ Client.BaseURL := 'http://x/';
+ AssertEquals('http://x', Client.BaseURL);
+ finally
+ Client.Free;
+ end;
+end;
+
+procedure TTestConfiguration.InvalidParameters;
+var
+ Client: TCepisClient;
+ Raised: Boolean;
+begin
+ Client := TCepisClient.Create;
+ try
+ Raised := False;
+ try
+ Client.MaxAttempts := 0;
+ except
+ on E: ECepis do
+ Raised := True;
+ end;
+ AssertTrue('MaxAttempts = 0 must be rejected', Raised);
+
+ Raised := False;
+ try
+ Client.Timeout := 0;
+ except
+ on E: ECepis do
+ Raised := True;
+ end;
+ AssertTrue('Timeout = 0 must be rejected', Raised);
+ finally
+ Client.Free;
+ end;
+end;
+
+procedure TTestConfiguration.UserAgentIdentifiesTheLibrary;
+var
+ Client: TCepisClient;
+begin
+ Client := TCepisClient.Create;
+ try
+ AssertTrue('User-Agent must name the library',
+ Pos('cepis-pascal/', Client.UserAgent) = 1);
+ finally
+ Client.Free;
+ end;
+end;
+
+procedure TTestConfiguration.DefaultsAreTheDocumentedOnes;
+var
+ Client: TCepisClient;
+begin
+ Client := TCepisClient.Create;
+ try
+ AssertEquals(CepisDefaultURL, Client.BaseURL);
+ AssertEquals(3, Client.MaxAttempts);
+ AssertEquals(0.5, Client.BackoffBase, 0.0001);
+ AssertEquals(8.0, Client.BackoffMax, 0.0001);
+ AssertEquals(500, CepisMaxBatch);
+ finally
+ Client.Free;
+ end;
+end;
+
+type
+ TTestOwnership = class(TCepisTestCase)
+ published
+ procedure ExtractKeepsAnAddressAlive;
+ procedure ExtractGeoTransfersTheCoordinate;
+ end;
+
+procedure TTestOwnership.ExtractKeepsAnAddressAlive;
+var
+ List: TCepisAddressList;
+ Kept: TCepisAddress;
+begin
+ FServer.Respond('/csv?ceps=01001000', 200,
+ CsvHeader + #10 +
+ '01001000,' + PracaDaSe + ',,,Centro,' + SaoPaulo + ',SP,' + SaoPaulo +
+ ',Sudeste,1,,11,1' + #10);
+ List := FClient.LookupMany(['01001000']);
+ Kept := List.Extract(0);
+ try
+ AssertTrue('list no longer owns it', List[0] = nil);
+ List.Free;
+ AssertEquals(SaoPaulo, Kept.Localidade);
+ finally
+ Kept.Free;
+ end;
+end;
+
+procedure TTestOwnership.ExtractGeoTransfersTheCoordinate;
+var
+ Address: TCepisAddress;
+ Geo: TCepisGeo;
+begin
+ FServer.Respond('/01310100', 200, SeAddress +
+ ',"geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}}');
+ Address := FClient.Lookup('01310100');
+ Geo := Address.ExtractGeo;
+ try
+ AssertTrue('address no longer owns it', Address.Geo = nil);
+ Address.Free;
+ AssertEquals(120, Geo.RaioM);
+ finally
+ Geo.Free;
+ end;
+end;
+
+var
+ Runner: TTestRunner;
+
+begin
+ Randomize;
+ RegisterTest(TTestNormalisation);
+ RegisterTest(TTestLookup);
+ RegisterTest(TTestRetry);
+ RegisterTest(TTestBatch);
+ RegisterTest(TTestHealth);
+ RegisterTest(TTestConfiguration);
+ RegisterTest(TTestOwnership);
+
+ Runner := TTestRunner.Create(nil);
+ try
+ Runner.Initialize;
+ Runner.Title := 'cepis test suite';
+ Runner.Run;
+ finally
+ Runner.Free;
+ end;
+end.