Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
4 changes: 2 additions & 2 deletions infection.json.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"badge": "master"
}
},
"minMsi": 61.74,
"minCoveredMsi": 76.77
"minMsi": 90,
"minCoveredMsi": 90
}
144 changes: 124 additions & 20 deletions tests/Client/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -133,37 +194,80 @@ public function sendRequest(RequestInterface $request): ResponseInterface
}
}

final class TestLogger extends AbstractLogger
final class TestRequestFactory implements RequestFactoryInterface
{
/** @var list<non-empty-string> */
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<array-key, mixed> $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<array{class: \Closure(): HttpClientInterface}>
*/
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]];
}
}
44 changes: 26 additions & 18 deletions tests/Client/ErrorResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string}>
*/
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"}}'];
}
}
12 changes: 12 additions & 0 deletions tests/Event/ContentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
55 changes: 55 additions & 0 deletions tests/Event/CustomTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Loading
Loading