'https://bnfexpress.test', 'client_id' => 'ev_admin', 'client_secret' => 'shared-secret', ]; function assertSignedCorrectly($request, string $method, string $path, string $rawBody, string $secret): bool { $timestamp = $request->header('X-Timestamp')[0] ?? null; $expected = hash_hmac('sha256', strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody, $secret); return $request->hasHeader('X-Client-Id', 'ev_admin') && $timestamp !== null && abs(time() - (int) $timestamp) < 5 && $request->header('X-Signature')[0] === $expected; } test('listFaqs signs a GET request and excludes the query string from the signed path', function () use ($config) { Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]); (new BnfexpressAdminClient($config))->listFaqs(q: 'range', search: 'semantic', limit: 10, offset: 0); Http::assertSent(function ($request) use ($config) { return $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0' && assertSignedCorrectly($request, 'GET', '/admin/faqs', '', $config['client_secret']); }); }); test('listFaqs omits search when q is empty', function () use ($config) { Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]); (new BnfexpressAdminClient($config))->listFaqs(); Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev'); }); test('createFaq signs the exact raw JSON body being sent', function () use ($config) { Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]); (new BnfexpressAdminClient($config))->createFaq('How do I charge?', ['source' => 'admin']); $rawBody = json_encode([ 'content' => 'How do I charge?', 'agent' => 'ev', 'metadata' => ['source' => 'admin'], ]); Http::assertSent(function ($request) use ($config, $rawBody) { return $request->url() === 'https://bnfexpress.test/admin/faqs' && $request->method() === 'POST' && $request->body() === $rawBody && assertSignedCorrectly($request, 'POST', '/admin/faqs', $rawBody, $config['client_secret']); }); }); test('getActiveInstruction requests the active instruction for the ev agent', function () use ($config) { Http::fake(['bnfexpress.test/*' => Http::response(['content' => 'You are the EV assistant.'])]); $result = (new BnfexpressAdminClient($config))->getActiveInstruction(); expect($result)->toBe(['content' => 'You are the EV assistant.']); Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev'); }); test('a non-2xx response surfaces the gateway detail message', function () use ($config) { Http::fake(['bnfexpress.test/*' => Http::response(['detail' => 'FAQ not found.'], 404)]); (new BnfexpressAdminClient($config))->getFaq(999); })->throws(BnfexpressApiException::class, 'FAQ not found.'); test('deleteFaq signs a bodyless DELETE request', function () use ($config) { Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]); (new BnfexpressAdminClient($config))->deleteFaq(5); Http::assertSent(function ($request) use ($config) { return $request->url() === 'https://bnfexpress.test/admin/faqs/5' && $request->method() === 'DELETE' && assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']); }); });