From ba7591a3eede2221dfef248bedb6377755672b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 31 Aug 2026 13:50:47 +0200 Subject: [PATCH] Improve test coverage to 100% and raise Infection thresholds Line coverage goes from 90% to 100% and the mutation score (MSI) from 74% to 94%; the Infection thresholds are raised from 61.74/76.77 to 90/90 to lock the gains in. Tests only, no changes to src/: - ClientExceptionTest (new): all three factories with exact messages, including subcode and the user facing title/message variants - FbTest (new): DateTimeInterface creation time, founding date/future bounds, subdomain index validation and immutability - ClientTest: injected request/stream factories (one request per pixel) and HTTP client auto discovery through a test DiscoveryStrategy - ErrorResponseTest: data provider covering missing required fields and a wrong type for every field - FbqGeneratorTest: JSON encode failure logs an error and returns '' - EventTest/ContentTest/CustomTest: default event id/time, the standard event list, invalid action_source/delivery_category, full Custom payload, unsupported values - TestLogger moved to tests/TestLogger.php so it can be shared --- CLAUDE.md | 2 +- infection.json.dist | 4 +- tests/Client/ClientTest.php | 144 ++++++++++++++++++++---- tests/Client/ErrorResponseTest.php | 44 +++++--- tests/Event/ContentTest.php | 12 ++ tests/Event/CustomTest.php | 55 +++++++++ tests/Event/EventTest.php | 60 ++++++++++ tests/Exception/ClientExceptionTest.php | 105 +++++++++++++++++ tests/Generator/FbqGeneratorTest.php | 16 +++ tests/TestLogger.php | 42 +++++++ tests/ValueObject/FbTest.php | 134 ++++++++++++++++++++++ 11 files changed, 577 insertions(+), 41 deletions(-) create mode 100644 tests/Exception/ClientExceptionTest.php create mode 100644 tests/TestLogger.php create mode 100644 tests/ValueObject/FbTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 61d2c92..7d9a5c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ Composer scripts (defined in `composer.json`): - `composer phpunit` — run the test suite (PHPUnit 10) - `composer analyse` — PHPStan static analysis (`phpstan.dist.neon`: `level: max`, analysed against PHP 8.1) - `composer check-style` / `composer fix-style` — ECS coding-standard check / autofix -- `vendor/bin/infection` — mutation testing (thresholds: minMsi 61.74, minCoveredMsi 76.77). Needs a coverage driver (pcov or Xdebug); CI uses pcov. With neither installed locally you'll get "No code coverage driver available". +- `vendor/bin/infection` — mutation testing (thresholds: minMsi 90, minCoveredMsi 90). Needs a coverage driver (pcov or Xdebug); CI uses pcov. With neither installed locally you'll get "No code coverage driver available". - `vendor/bin/composer-dependency-analyser` — verify declared composer deps match actual usage The dev tooling (PHPStan + extensions, ECS via `sylius-labs/coding-standard`, PHPUnit, Infection, Rector, composer-normalize, composer-dependency-analyser) is listed directly in `require-dev` rather than pulled in through the `setono/code-quality-pack` meta-package. The pack's current major requires PHP >= 8.2; inlining the tools keeps the whole toolchain runnable on PHP 8.1. When bumping a tool, pick the latest version that still supports PHP 8.1 (e.g. PHPUnit stays on `^10.5`, Infection on `^0.29`). diff --git a/infection.json.dist b/infection.json.dist index 7ff7b38..b40c4ac 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -11,6 +11,6 @@ "badge": "master" } }, - "minMsi": 61.74, - "minCoveredMsi": 76.77 + "minMsi": 90, + "minCoveredMsi": 90 } diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index fe5b993..40699e9 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -4,16 +4,23 @@ namespace Setono\MetaConversionsApi\Client; +use FacebookAds\ApiConfig; +use Http\Discovery\Psr18ClientDiscovery; +use Http\Discovery\Strategy\DiscoveryStrategy; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface as HttpClientInterface; +use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Log\AbstractLogger; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApi\Exception\ClientException; use Setono\MetaConversionsApi\Pixel\Pixel; +use Setono\MetaConversionsApi\TestLogger; /** * @covers \Setono\MetaConversionsApi\Client\Client @@ -66,6 +73,60 @@ public function it_includes_access_token_and_test_event_code_in_the_request(): v self::assertStringContainsString('test_event_code=TEST123', $body); } + /** + * @test + */ + public function it_sends_one_request_per_pixel_using_the_injected_factories(): void + { + $httpClient = new TestHttpClient(); + $requestFactory = new TestRequestFactory(); + $streamFactory = new TestStreamFactory(); + + $client = new Client(); + $client->setHttpClient($httpClient); + $client->setRequestFactory($requestFactory); + $client->setStreamFactory($streamFactory); + + $event = new Event(Event::EVENT_PURCHASE); + $event->pixels[] = new Pixel('pixel_1', 'token_1'); + $event->pixels[] = new Pixel('pixel_2', 'token_2'); + $client->sendEvent($event); + + self::assertSame(2, $requestFactory->calls); + self::assertSame(2, $streamFactory->calls); + self::assertCount(2, $httpClient->requests); + + [$first, $second] = $httpClient->requests; + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_1/events', ApiConfig::APIVersion), (string) $first->getUri()); + self::assertStringContainsString('access_token=token_1', (string) $first->getBody()); + self::assertSame(sprintf('https://graph.facebook.com/v%s/pixel_2/events', ApiConfig::APIVersion), (string) $second->getUri()); + self::assertStringContainsString('access_token=token_2', (string) $second->getBody()); + } + + /** + * @test + */ + public function it_discovers_an_http_client_when_none_is_injected(): void + { + $httpClient = new TestHttpClient(); + + $strategies = [...Psr18ClientDiscovery::getStrategies()]; + TestDiscoveryStrategy::$httpClient = $httpClient; + Psr18ClientDiscovery::prependStrategy(TestDiscoveryStrategy::class); + + try { + $event = new Event(Event::EVENT_PURCHASE); + $event->pixels[] = new Pixel('pixel_id'); + + (new Client())->sendEvent($event); + } finally { + Psr18ClientDiscovery::setStrategies($strategies); + TestDiscoveryStrategy::$httpClient = null; + } + + self::assertCount(1, $httpClient->requests); + } + /** * @test */ @@ -133,37 +194,80 @@ public function sendRequest(RequestInterface $request): ResponseInterface } } -final class TestLogger extends AbstractLogger +final class TestRequestFactory implements RequestFactoryInterface { - /** @var list */ - public array $messages = []; + public int $calls = 0; + + private RequestFactoryInterface $decorated; + + public function __construct() + { + $this->decorated = new Psr17Factory(); + } /** - * @param mixed $level - * @param string|\Stringable $message - * @param array $context + * @param UriInterface|string $uri */ - public function log($level, $message, array $context = []): void + public function createRequest(string $method, $uri): RequestInterface { - $message = (string) $message; - if ('' === $message) { - return; - } + ++$this->calls; + + return $this->decorated->createRequest($method, $uri); + } +} + +final class TestStreamFactory implements StreamFactoryInterface +{ + public int $calls = 0; + + private StreamFactoryInterface $decorated; + + public function __construct() + { + $this->decorated = new Psr17Factory(); + } + + public function createStream(string $content = ''): StreamInterface + { + ++$this->calls; + + return $this->decorated->createStream($content); + } - $this->messages[] = $message; + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + return $this->decorated->createStreamFromFile($filename, $mode); } /** - * @param non-empty-string $regexp + * @param resource $resource */ - public function hasMessageMatching(string $regexp): bool + public function createStreamFromResource($resource): StreamInterface { - foreach ($this->messages as $message) { - if (preg_match($regexp, $message) === 1) { - return true; - } + return $this->decorated->createStreamFromResource($resource); + } +} + +/** + * Lets the tests control what php-http/discovery finds, so that auto discovery can be tested without a real HTTP client + */ +final class TestDiscoveryStrategy implements DiscoveryStrategy +{ + public static ?HttpClientInterface $httpClient = null; + + /** + * @param string $type + * + * @return list + */ + public static function getCandidates($type): array + { + if (HttpClientInterface::class !== $type || null === self::$httpClient) { + return []; } - return false; + $httpClient = self::$httpClient; + + return [['class' => static fn (): HttpClientInterface => $httpClient]]; } } diff --git a/tests/Client/ErrorResponseTest.php b/tests/Client/ErrorResponseTest.php index 12463c2..6dbd4d3 100644 --- a/tests/Client/ErrorResponseTest.php +++ b/tests/Client/ErrorResponseTest.php @@ -58,38 +58,46 @@ public function it_captures_the_optional_fields_when_present(): void public function it_throws_when_the_response_is_not_valid_json(): void { $this->expectException(ClientException::class); + $this->expectExceptionMessage('The response from Meta/Facebook was not valid JSON'); ErrorResponse::fromJson('this is not json'); } /** * @test + * + * @dataProvider responsesWithAnInvalidFormat */ - public function it_throws_when_the_response_is_not_an_array(): void + public function it_throws_when_the_response_does_not_have_the_expected_format(string $json): void { $this->expectException(ClientException::class); + $this->expectExceptionMessage('Expected a JSON response like'); - ErrorResponse::fromJson('100'); + ErrorResponse::fromJson($json); } /** - * @test + * @return \Generator */ - public function it_throws_when_the_error_key_is_missing(): void + public static function responsesWithAnInvalidFormat(): \Generator { - $this->expectException(ClientException::class); - - ErrorResponse::fromJson('{"foo":"bar"}'); - } - - /** - * @test - */ - public function it_throws_when_a_field_has_the_wrong_type(): void - { - $this->expectException(ClientException::class); - - // the code field must be an int - ErrorResponse::fromJson('{"error":{"message":"m","type":"t","code":"not-an-int","fbtrace_id":"x"}}'); + yield 'not an array' => ['100']; + yield 'missing error key' => ['{"foo":"bar"}']; + yield 'error is null' => ['{"error":null}']; + yield 'error is not an array' => ['{"error":"Invalid parameter"}']; + + yield 'missing message' => ['{"error":{"type":"OAuthException","code":100,"fbtrace_id":"trace123"}}']; + yield 'missing type' => ['{"error":{"message":"Invalid parameter","code":100,"fbtrace_id":"trace123"}}']; + yield 'missing code' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","fbtrace_id":"trace123"}}']; + yield 'missing fbtrace_id' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":100}}']; + + yield 'message is not a string' => ['{"error":{"message":123,"type":"OAuthException","code":100,"fbtrace_id":"trace123"}}']; + yield 'type is not a string' => ['{"error":{"message":"Invalid parameter","type":123,"code":100,"fbtrace_id":"trace123"}}']; + yield 'code is not an integer' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":"not-an-int","fbtrace_id":"trace123"}}']; + yield 'fbtrace_id is not a string' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"fbtrace_id":123}}']; + yield 'error_subcode is not an integer' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_subcode":"not-an-int","fbtrace_id":"trace123"}}']; + yield 'is_transient is not a boolean' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"is_transient":"yes","fbtrace_id":"trace123"}}']; + yield 'error_user_title is not a string' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_user_title":123,"fbtrace_id":"trace123"}}']; + yield 'error_user_msg is not a string' => ['{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_user_msg":123,"fbtrace_id":"trace123"}}']; } } diff --git a/tests/Event/ContentTest.php b/tests/Event/ContentTest.php index db706e0..e45a6a8 100644 --- a/tests/Event/ContentTest.php +++ b/tests/Event/ContentTest.php @@ -35,4 +35,16 @@ public function it_filters_empty_values_from_the_payload(): void self::assertSame(['id' => 'product_id'], $content->getPayload()); } + + /** + * @test + */ + public function it_rejects_an_invalid_delivery_category(): void + { + $content = new Content('product_id', 1, 9.95, 'teleportation'); + + $this->expectException(\InvalidArgumentException::class); + + $content->getPayload(); + } } diff --git a/tests/Event/CustomTest.php b/tests/Event/CustomTest.php index 7f62e68..90b09aa 100644 --- a/tests/Event/CustomTest.php +++ b/tests/Event/CustomTest.php @@ -25,4 +25,59 @@ public function it_generates_payload(): void 'my_custom_property' => 'test', ], $user->getPayload()); } + + /** + * @test + */ + public function it_generates_the_full_payload(): void + { + $custom = new Custom(); + $custom->contentCategory = 'Shoes'; + $custom->contentIds = ['PROD_1', 'PROD_2']; + $custom->contentName = 'Sneakers'; + $custom->contentType = 'product'; + $custom->contents[] = new Content('PROD_1', 1, 99.95); + $custom->currency = 'DKK'; + $custom->deliveryCategory = 'home_delivery'; + $custom->numItems = 2; + $custom->orderId = 'ORDER_1'; + $custom->predictedLtv = 500.0; + $custom->searchString = 'sneakers'; + $custom->status = 'completed'; + $custom->value = 199.9; + $custom->customProperties['my_custom_property'] = 'test'; + $custom->customProperties['value'] = 'overridden by the standard property'; + + self::assertEquals([ + 'my_custom_property' => 'test', + 'content_category' => 'Shoes', + 'content_ids' => ['PROD_1', 'PROD_2'], + 'content_name' => 'Sneakers', + 'content_type' => 'product', + 'contents' => [ + ['id' => 'PROD_1', 'quantity' => 1, 'item_price' => 99.95], + ], + 'currency' => 'dkk', + 'delivery_category' => 'home_delivery', + 'num_items' => 2, + 'order_id' => 'ORDER_1', + 'predicted_ltv' => 500.0, + 'search_string' => 'sneakers', + 'status' => 'completed', + 'value' => 199.9, + ], $custom->getPayload()); + } + + /** + * @test + */ + public function it_rejects_values_it_cannot_normalize(): void + { + $custom = new Custom(); + $custom->customProperties['unsupported'] = new \stdClass(); + + $this->expectException(\InvalidArgumentException::class); + + $custom->getPayload(); + } } diff --git a/tests/Event/EventTest.php b/tests/Event/EventTest.php index a267814..27eeb16 100644 --- a/tests/Event/EventTest.php +++ b/tests/Event/EventTest.php @@ -163,4 +163,64 @@ public function it_tells_if_it_has_pixels(): void $event->pixels[] = new Pixel('pixel_id'); self::assertTrue($event->hasPixels()); } + + /** + * @test + */ + public function it_generates_an_event_id_and_event_time_by_default(): void + { + $before = time(); + $event = new Event(Event::EVENT_PURCHASE); + $after = time(); + + self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $event->eventId); + self::assertNotSame($event->eventId, (new Event(Event::EVENT_PURCHASE))->eventId); + self::assertGreaterThanOrEqual($before, $event->eventTime); + self::assertLessThanOrEqual($after, $event->eventTime); + self::assertSame(Event::ACTION_SOURCE_WEBSITE, $event->actionSource); + } + + /** + * @test + */ + public function it_lists_the_standard_events(): void + { + $events = Event::getEvents(); + + self::assertSame([ + 'AddToCart', + 'AddPaymentInfo', + 'AddToWishlist', + 'CompleteRegistration', + 'Contact', + 'CustomizeProduct', + 'Donate', + 'FindLocation', + 'InitiateCheckout', + 'Lead', + 'Purchase', + 'Schedule', + 'Search', + 'StartTrial', + 'SubmitApplication', + 'Subscribe', + 'ViewContent', + ], $events); + + foreach ($events as $eventName) { + self::assertFalse((new Event($eventName))->isCustom(), sprintf('%s should be a standard event', $eventName)); + } + } + + /** + * @test + */ + public function it_rejects_an_invalid_action_source(): void + { + $event = new Event(Event::EVENT_PURCHASE, 'not_a_valid_action_source'); + + $this->expectException(\InvalidArgumentException::class); + + $event->getPayload(); + } } diff --git a/tests/Exception/ClientExceptionTest.php b/tests/Exception/ClientExceptionTest.php new file mode 100644 index 0000000..506f01f --- /dev/null +++ b/tests/Exception/ClientExceptionTest.php @@ -0,0 +1,105 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + self::assertSame($jsonException, $exception->getPrevious()); + } + + /** + * @test + */ + public function it_is_created_from_an_invalid_response_format(): void + { + $exception = ClientException::invalidResponseFormat('{"foo":"bar"}'); + + self::assertSame( + 'Expected a JSON response like {"error":{"message":"string","type":"string","code":int,"fbtrace_id":"string"}}, but got {"foo":"bar"}', + $exception->getMessage(), + ); + } + + /** + * @test + */ + public function it_is_created_from_an_error_response(): void + { + $json = '{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"fbtrace_id":"trace123"}}'; + + $exception = ClientException::fromErrorResponse(ErrorResponse::fromJson($json)); + + self::assertSame( + "An error occurred sending an event to Meta/Facebook: Invalid parameter (code: 100, type: OAuthException, trace id: trace123)\n\nRaw JSON response:\n\n" . $json, + $exception->getMessage(), + ); + } + + /** + * @test + */ + public function it_includes_the_subcode_and_the_user_facing_message_when_present(): void + { + $json = '{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_subcode":2804050,"is_transient":false,"error_user_title":"Customer information parameters","error_user_msg":"This event has insufficient customer information.","fbtrace_id":"trace123"}}'; + + $exception = ClientException::fromErrorResponse(ErrorResponse::fromJson($json)); + + self::assertSame( + "An error occurred sending an event to Meta/Facebook: Invalid parameter (code: 100, subcode: 2804050, type: OAuthException, trace id: trace123)\n\nCustomer information parameters This event has insufficient customer information.\n\nRaw JSON response:\n\n" . $json, + $exception->getMessage(), + ); + } + + /** + * @test + * + * @dataProvider partialUserFacingMessages + */ + public function it_includes_the_user_facing_message_when_only_the_title_or_the_message_is_present(string $json, string $expectedUserFacingMessage): void + { + $exception = ClientException::fromErrorResponse(ErrorResponse::fromJson($json)); + + self::assertSame( + "An error occurred sending an event to Meta/Facebook: Invalid parameter (code: 100, type: OAuthException, trace id: trace123)\n\n" . $expectedUserFacingMessage . "\n\nRaw JSON response:\n\n" . $json, + $exception->getMessage(), + ); + } + + /** + * @return \Generator + */ + public static function partialUserFacingMessages(): \Generator + { + yield 'only the title' => [ + '{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_user_title":"Customer information parameters","fbtrace_id":"trace123"}}', + 'Customer information parameters', + ]; + + yield 'only the message' => [ + '{"error":{"message":"Invalid parameter","type":"OAuthException","code":100,"error_user_msg":"This event has insufficient customer information.","fbtrace_id":"trace123"}}', + 'This event has insufficient customer information.', + ]; + } +} diff --git a/tests/Generator/FbqGeneratorTest.php b/tests/Generator/FbqGeneratorTest.php index 7fcce24..c2bc7a7 100644 --- a/tests/Generator/FbqGeneratorTest.php +++ b/tests/Generator/FbqGeneratorTest.php @@ -8,6 +8,7 @@ use Setono\MetaConversionsApi\Event\Event; use Setono\MetaConversionsApi\Event\Parameters; use Setono\MetaConversionsApi\Pixel\Pixel; +use Setono\MetaConversionsApi\TestLogger; final class FbqGeneratorTest extends TestCase { @@ -102,4 +103,19 @@ public function it_generates_track_for_a_custom_event(): void $generator->generateTrack($event, false), ); } + + /** + * @test + */ + public function it_logs_an_error_and_returns_an_empty_string_when_the_user_data_cannot_be_encoded(): void + { + $logger = new TestLogger(); + + $generator = new FbqGenerator(); + $generator->setLogger($logger); + + // malformed UTF-8 cannot be JSON encoded + self::assertSame('', $generator->generateInit([new Pixel('111')], ['em' => "\xB1\x31"])); + self::assertTrue($logger->hasMessageMatching('/Malformed UTF-8 characters/')); + } } diff --git a/tests/TestLogger.php b/tests/TestLogger.php new file mode 100644 index 0000000..418189e --- /dev/null +++ b/tests/TestLogger.php @@ -0,0 +1,42 @@ + */ + public array $messages = []; + + /** + * @param mixed $level + * @param string|\Stringable $message + * @param array $context + */ + public function log($level, $message, array $context = []): void + { + $message = (string) $message; + if ('' === $message) { + return; + } + + $this->messages[] = $message; + } + + /** + * @param non-empty-string $regexp + */ + public function hasMessageMatching(string $regexp): bool + { + foreach ($this->messages as $message) { + if (preg_match($regexp, $message) === 1) { + return true; + } + } + + return false; + } +} diff --git a/tests/ValueObject/FbTest.php b/tests/ValueObject/FbTest.php new file mode 100644 index 0000000..ea4bac6 --- /dev/null +++ b/tests/ValueObject/FbTest.php @@ -0,0 +1,134 @@ +getSubdomainIndex()); + self::assertGreaterThanOrEqual($before, $fb->getCreationTime()); + self::assertLessThanOrEqual($after, $fb->getCreationTime()); + } + + /** + * @test + * + * @dataProvider subdomainIndexes + */ + public function it_has_an_immutable_subdomain_index_setter(int $subdomainIndex): void + { + $fb = new Fbp(); + $newFb = $fb->withSubdomainIndex($subdomainIndex); + + self::assertNotSame($fb, $newFb); + self::assertSame(Fb::SUBDOMAIN_INDEX_FACEBOOK_COM, $fb->getSubdomainIndex()); + self::assertSame($subdomainIndex, $newFb->getSubdomainIndex()); + self::assertStringStartsWith(sprintf('fb.%d.', $subdomainIndex), $newFb->value()); + } + + /** + * @return \Generator + */ + public static function subdomainIndexes(): \Generator + { + yield 'com' => [Fb::SUBDOMAIN_INDEX_COM]; + yield 'facebook.com' => [Fb::SUBDOMAIN_INDEX_FACEBOOK_COM]; + yield 'www.facebook.com' => [Fb::SUBDOMAIN_INDEX_WWW_FACEBOOK_COM]; + } + + /** + * @test + * + * @dataProvider invalidSubdomainIndexes + */ + public function it_rejects_an_invalid_subdomain_index(int $subdomainIndex): void + { + $this->expectException(\InvalidArgumentException::class); + + (new Fbp())->withSubdomainIndex($subdomainIndex); + } + + /** + * @return \Generator + */ + public static function invalidSubdomainIndexes(): \Generator + { + yield [-1]; + yield [3]; + } + + /** + * @test + */ + public function it_accepts_a_datetime_as_creation_time(): void + { + $fb = (new Fbp())->withCreationTime(new \DateTimeImmutable('2022-07-03 19:00:32.584', new \DateTimeZone('UTC'))); + + self::assertSame(1656874832584, $fb->getCreationTime()); + self::assertSame(1656874832, $fb->getCreationTimeAsSeconds()); + self::assertSame('2022-07-03 19:00:32.584', $fb->getCreationTimeAsDateTime()->format('Y-m-d H:i:s.v')); + } + + /** + * @test + */ + public function it_accepts_creation_times_between_the_founding_of_facebook_and_now(): void + { + $fb = new Fbp(); + + self::assertSame(1_075_590_000_000, $fb->withCreationTime(1_075_590_000_000)->getCreationTime()); + + $now = time() * 1000; + self::assertSame($now, $fb->withCreationTime($now)->getCreationTime()); + + // the upper bound is one second into the future + $oneSecondFromNow = (time() + 1) * 1000; + self::assertSame($oneSecondFromNow, $fb->withCreationTime($oneSecondFromNow)->getCreationTime()); + } + + /** + * @test + * + * @dataProvider creationTimesOutOfRange + */ + public function it_rejects_a_creation_time_out_of_range(int $creationTime): void + { + $this->expectException(\InvalidArgumentException::class); + + (new Fbp())->withCreationTime($creationTime); + } + + /** + * @return \Generator + */ + public static function creationTimesOutOfRange(): \Generator + { + yield 'before Facebook was founded' => [1_075_589_999_999]; + yield 'in the future' => [(time() + 60) * 1000]; + } + + /** + * @test + */ + public function it_rejects_a_creation_time_that_is_neither_an_integer_nor_a_datetime(): void + { + $this->expectException(\InvalidArgumentException::class); + + (new Fbp())->withCreationTime('1656874832584'); // @phpstan-ignore argument.type + } +}