From cf5597871327dbff2c7c743672d696e7ff340c42 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:45:04 -0300 Subject: Initial import --- tests/TestCepis.pas | 1161 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1161 insertions(+) create mode 100644 tests/TestCepis.pas (limited to 'tests') 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, 'captive portal'); + 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. -- cgit v1.3