From 9f14ef8939013d52bff0014d3841dc690001f5b2 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Sun, 13 Sep 2026 13:02:36 -0400 Subject: [PATCH] fix: read price labels instead of casting them, and require currency (#21) `Price::fromArray()` put unchecked `(string)` casts on `currency`, `unit`, `name`, `source`, `type` and `formatted`. Confirmed on origin/main: currency: ["EUR"] -> "Array" (+ PHP "Array to string conversion") unit: true -> "1" name: 978 -> "978" A mislabelled unit - barrel where the payload said tonne - is the same harm class as a wrong number, and harder to spot, because the number beside it is right. Present-but-non-string values now raise `ApiException` naming the field and its actual type. Absent and explicitly null still mean "not provided". Also breaking: `currency` is required alongside `code` and `price`. It defaulted to 'USD', which labelled a euro-denominated carbon price as dollars. A $0.00 Brent quote is obviously broken and a human catches it; `78.40 USD` on an EUA contract is plausible, roughly 8% wrong, and flows into a model undetected. The catalogue is not USD-only - the repo's own fixtures carry EU_CARBON_EUR. #18 already broke BC on this method, so the strictness costs nothing extra. Surrounding whitespace on `currency` is normalized, the one value the DTO adjusts, so `$price->currency === 'EUR'` behaves. Nothing about the label changes. Existing fixtures that built price rows without a currency were updated; no assertion was weakened. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --- CHANGELOG.md | 17 ++ README.md | 8 + src/Price.php | 79 ++++++-- tests/ClientTest.php | 4 +- tests/MalformedPriceRowTest.php | 7 +- tests/PriceFieldCoercionTest.php | 300 +++++++++++++++++++++++++++++++ tests/RawPathOriginTest.php | 8 +- tests/RetryPolicyTest.php | 14 +- tests/fixtures/capture.php | 2 +- 9 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 tests/PriceFieldCoercionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 56147ab..adcba2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,25 @@ origin is compared against the configured base URL before the credential is attached. Explicit custom `$baseUrl` values are unaffected. +### Changed + +- **Breaking:** `currency` is required on a price row. `Price::fromArray()` + defaulted it to `USD`, which labelled a euro-denominated carbon price as + dollars. A `$0.00` Brent quote is obviously broken and a human catches it; + `78.40 USD` on an EUA contract is plausible, roughly 8% wrong, and flows into + a model undetected. The catalogue is not USD-only - the repo's own fixtures + carry `EU_CARBON_EUR`. + ### Fixed +- Read the descriptive fields instead of casting them. `currency`, `unit`, + `name`, `source`, `type` and `formatted` were unchecked `(string)` casts, so + `currency: ["EUR"]` became the literal `'Array'` plus a PHP + "Array to string conversion" warning, `true` became `'1'`, and the ISO + numeric currency `978` became `'978'`. A present-but-non-string value now + raises `ApiException` naming the field and its actual type; absent and null + still mean "not provided". Surrounding whitespace on `currency` is + normalized so `$price->currency === 'EUR'` behaves. - Reject malformed price rows instead of reporting them as `$0.00`. A row without a usable code or a numeric price, an unparseable timestamp, and a missing or non-array `prices` field now raise `ApiException` across diff --git a/README.md b/README.md index d78fd13..ed3c6eb 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ the caller requires a predictable single `Price` result. `source`, `change24h`, `name`, `unit`, `type`, and `formatted` when supplied by the API. +`code`, `price` and `currency` are required; a row missing any of them raises +`ApiException` rather than being defaulted. `currency` in particular is never +assumed to be USD — the catalogue is not USD-only, and a euro-denominated +carbon price labelled in dollars is plausible enough to pass unnoticed. The +descriptive fields are read as strings or refused, never cast, so a payload +that sends `unit` as something other than a string fails loudly instead of +arriving as a mislabelled quantity. + ## Several Prices In One Request `by_code` accepts up to **20 comma-separated commodity codes**, and the whole call diff --git a/src/Price.php b/src/Price.php index b1b1e79..9d65121 100644 --- a/src/Price.php +++ b/src/Price.php @@ -42,14 +42,27 @@ public function __construct( * `created_at`/`updated_at` for the timestamp and `change_24h`/ * `change_percent_24h` for the 24h change. * - * A row that does not carry a usable code and a numeric price is rejected - * rather than defaulted: a manufactured $0.00 is indistinguishable from a - * real quote once it leaves the SDK. Legitimate zero and negative prices - * are preserved. + * A row that does not carry a usable code, a numeric price and a currency + * is rejected rather than defaulted: a manufactured $0.00 is + * indistinguishable from a real quote once it leaves the SDK. Legitimate + * zero and negative prices are preserved. + * + * `currency` is required. It used to default to 'USD', which labelled a + * euro-denominated carbon price as dollars - and unlike a $0.00 Brent + * quote, `78.40 USD` on an EUA contract is plausible, roughly 8% wrong, + * and flows into a model undetected. The catalogue is not USD-only; the + * repo's own fixtures carry EU_CARBON_EUR. + * + * The descriptive fields are read, not coerced. `(string)` on an array + * produced the literal 'Array' plus a PHP warning, on `true` produced '1', + * and on the ISO numeric currency 978 produced '978'. A mislabelled unit - + * barrel where the payload said tonne - is the same harm class as a wrong + * number, and harder to spot because the number beside it is right. * * @param array $data * - * @throws ApiException when a required field is missing or unparseable + * @throws ApiException when a required field is missing, unparseable or of + * the wrong type */ public static function fromArray(array $data): self { @@ -68,6 +81,17 @@ public static function fromArray(array $data): self )); } + $currency = $data['currency'] ?? null; + if (!is_string($currency) || trim($currency) === '') { + throw new ApiException(sprintf( + 'Price row for %s is missing a usable currency (got %s); refusing to label ' + . 'it USD by default, because a mislabelled currency is a plausible wrong ' + . 'number rather than an obvious one.', + $data['code'], + get_debug_type($currency), + )); + } + $timestamp = $data['created_at'] ?? $data['updated_at'] ?? null; $updatedAt = null; if (is_string($timestamp) && $timestamp !== '') { @@ -93,17 +117,50 @@ public static function fromArray(array $data): self return new self( code: $data['code'], price: (float) $data['price'], - currency: (string) ($data['currency'] ?? 'USD'), + // The only value this DTO adjusts: insignificant surrounding + // whitespace, so `$price->currency === 'EUR'` behaves. Nothing + // about the label changes. + currency: trim($currency), updatedAt: $updatedAt, change24h: is_numeric($change) ? (float) $change : null, - name: isset($data['name']) ? (string) $data['name'] : null, - unit: isset($data['unit']) ? (string) $data['unit'] : null, - source: isset($data['source']) ? (string) $data['source'] : null, - type: isset($data['type']) ? (string) $data['type'] : null, - formatted: isset($data['formatted']) ? (string) $data['formatted'] : null, + name: self::optionalString($data, 'name', $data['code']), + unit: self::optionalString($data, 'unit', $data['code']), + source: self::optionalString($data, 'source', $data['code']), + type: self::optionalString($data, 'type', $data['code']), + formatted: self::optionalString($data, 'formatted', $data['code']), ); } + /** + * Read an optional descriptive field, or refuse it. + * + * Absent and explicitly null both mean "not provided". Anything that is + * present but not a string is a malformed row, not something to cast: the + * cast is what turned `['EUR']` into 'Array' and `978` into '978'. + * + * @param array $data + * + * @throws ApiException when the field is present with a non-string value + */ + private static function optionalString(array $data, string $key, string $code): ?string + { + if (!array_key_exists($key, $data) || $data[$key] === null) { + return null; + } + + if (!is_string($data[$key])) { + throw new ApiException(sprintf( + 'Price row for %s carries a non-string %s (%s); refusing to coerce it into a ' + . 'label, because a wrong label is as costly as a wrong number.', + $code, + $key, + get_debug_type($data[$key]), + )); + } + + return $data[$key]; + } + /** * @return array */ diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 7a54c46..d7a6a02 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -117,8 +117,8 @@ public function testHistoricalPeriodEndpoints(): void 'status' => 'success', 'data' => [ 'prices' => [ - ['price' => 70.00, 'created_at' => '2026-07-01T00:00:00Z', 'code' => 'BRENT_CRUDE_USD'], - ['price' => 71.00, 'created_at' => '2026-07-02T00:00:00Z', 'code' => 'BRENT_CRUDE_USD'], + ['price' => 70.00, 'created_at' => '2026-07-01T00:00:00Z', 'code' => 'BRENT_CRUDE_USD', 'currency' => 'USD'], + ['price' => 71.00, 'created_at' => '2026-07-02T00:00:00Z', 'code' => 'BRENT_CRUDE_USD', 'currency' => 'USD'], ], ], ]; diff --git a/tests/MalformedPriceRowTest.php b/tests/MalformedPriceRowTest.php index 9082474..e301174 100644 --- a/tests/MalformedPriceRowTest.php +++ b/tests/MalformedPriceRowTest.php @@ -151,7 +151,12 @@ public function testLegitimateNumericPricesArePreserved(float|int|string $price) { $this->transport->queue(200, [ 'status' => 'success', - 'data' => ['prices' => [['code' => 'WTI_USD', 'price' => $price, 'created_at' => '2026-04-20T00:00:00Z']]], + 'data' => ['prices' => [[ + 'code' => 'WTI_USD', + 'price' => $price, + 'currency' => 'USD', + 'created_at' => '2026-04-20T00:00:00Z', + ]]], ]); $prices = $this->client()->pastDay('WTI_USD'); diff --git a/tests/PriceFieldCoercionTest.php b/tests/PriceFieldCoercionTest.php new file mode 100644 index 0000000..d415e4f --- /dev/null +++ b/tests/PriceFieldCoercionTest.php @@ -0,0 +1,300 @@ + + */ + public static function nonStringFields(): array + { + $fields = ['currency', 'unit', 'name', 'source', 'type', 'formatted']; + $values = [ + 'array' => ['EUR'], + 'nested array' => ['value' => 'EUR'], + 'true' => true, + 'false' => false, + 'int' => 978, + 'float' => 1.5, + ]; + + $cases = []; + foreach ($fields as $field) { + foreach ($values as $label => $value) { + $cases[$field . ' as ' . $label] = [$field, $value]; + } + } + + return $cases; + } + + /** + * @param mixed $value + */ + #[DataProvider('nonStringFields')] + public function testNonStringFieldIsRejectedRatherThanCoerced(string $field, $value): void + { + $row = [ + 'code' => 'EU_CARBON_EUR', + 'price' => 78.40, + 'currency' => 'EUR', + $field => $value, + ]; + + try { + $price = Price::fromArray($row); + } catch (ApiException $e) { + $this->assertStringContainsString($field, $e->getMessage()); + $this->assertStringContainsString('EU_CARBON_EUR', $e->getMessage()); + + return; + } + + $this->fail(sprintf( + 'Field %s was coerced to %s instead of being rejected.', + $field, + var_export($price->{self::property($field)}, true), + )); + } + + /** + * The exact case from the report: `currency: ["EUR"]` became the literal + * string 'Array', so a euro-denominated carbon price claimed a currency + * that does not exist. + */ + public function testArrayCurrencyDoesNotBecomeTheLiteralStringArray(): void + { + try { + $price = Price::fromArray([ + 'code' => 'EU_CARBON_EUR', + 'price' => 78.40, + 'currency' => ['EUR'], + ]); + } catch (ApiException) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->fail('currency ["EUR"] became ' . var_export($price->currency, true)); + } + + /** + * `currency` is required. A row that does not say what the number is + * denominated in is not a usable price row. + * + * @return array}> + */ + public static function rowsWithoutAUsableCurrency(): array + { + return [ + 'absent' => [['code' => 'EU_CARBON_EUR', 'price' => 78.40]], + 'null' => [['code' => 'EU_CARBON_EUR', 'price' => 78.40, 'currency' => null]], + 'empty string' => [['code' => 'EU_CARBON_EUR', 'price' => 78.40, 'currency' => '']], + 'whitespace only' => [['code' => 'EU_CARBON_EUR', 'price' => 78.40, 'currency' => ' ']], + 'iso numeric code' => [['code' => 'EU_CARBON_EUR', 'price' => 78.40, 'currency' => 978]], + ]; + } + + /** + * @param array $row + */ + #[DataProvider('rowsWithoutAUsableCurrency')] + public function testCurrencyIsRequiredRatherThanDefaultedToUsd(array $row): void + { + try { + $price = Price::fromArray($row); + } catch (ApiException $e) { + $this->assertStringContainsString('currency', $e->getMessage()); + + return; + } + + $this->fail(sprintf( + 'A row with no usable currency was labelled %s.', + var_export($price->currency, true), + )); + } + + /** + * The specific harm, stated end to end: a euro carbon price must never + * reach a caller labelled USD. + */ + public function testEuroCarbonPriceIsNeverLabelledUsd(): void + { + $transport = new MockTransport(); + $transport->queue(200, [ + 'status' => 'success', + 'data' => ['code' => 'EU_CARBON_EUR', 'price' => 78.40], + ]); + $client = new Client(self::KEY, Client::DEFAULT_BASE_URL, 10.0, 0, $transport); + + try { + $price = $client->latest('EU_CARBON_EUR'); + } catch (ApiException) { + $this->expectNotToPerformAssertions(); + + return; + } + + $this->fail(sprintf( + 'EU_CARBON_EUR at %s came back as %s.', + is_array($price) ? 'list' : (string) $price->price, + is_array($price) ? 'list' : var_export($price->currency, true), + )); + } + + /** + * Well-formed rows keep working, and the values survive untouched. + */ + public function testWellFormedStringFieldsArePreserved(): void + { + $price = Price::fromArray([ + 'code' => 'EU_CARBON_EUR', + 'price' => 78.40, + 'currency' => 'EUR', + 'unit' => 'tonne', + 'name' => 'EU Carbon Permits', + 'source' => 'market_reporting', + 'type' => 'spot', + 'formatted' => '€78.40', + ]); + + $this->assertSame('EUR', $price->currency); + $this->assertSame('tonne', $price->unit); + $this->assertSame('EU Carbon Permits', $price->name); + $this->assertSame('market_reporting', $price->source); + $this->assertSame('spot', $price->type); + $this->assertSame('€78.40', $price->formatted); + } + + /** + * The optional fields stay optional: absent and explicitly null are both + * fine, and neither invents a value. + * + * @return array + */ + public static function optionalFields(): array + { + return [ + 'unit' => ['unit'], + 'name' => ['name'], + 'source' => ['source'], + 'type' => ['type'], + 'formatted' => ['formatted'], + ]; + } + + #[DataProvider('optionalFields')] + public function testOptionalFieldsMayBeAbsentOrNull(string $field): void + { + $base = ['code' => 'BRENT_CRUDE_USD', 'price' => 71.80, 'currency' => 'USD']; + + $absent = Price::fromArray($base); + $this->assertNull($absent->{self::property($field)}); + + $explicitNull = Price::fromArray($base + [$field => null]); + $this->assertNull($explicitNull->{self::property($field)}); + } + + /** + * Insignificant whitespace around a currency label is normalized rather + * than passed through, so `$price->currency === 'EUR'` does what a caller + * expects. This is the only value the DTO adjusts, and it changes no + * meaning. + */ + public function testCurrencyWhitespaceIsNormalized(): void + { + $price = Price::fromArray([ + 'code' => 'EU_CARBON_EUR', + 'price' => 78.40, + 'currency' => " EUR\n", + ]); + + $this->assertSame('EUR', $price->currency); + } + + /** + * A coerced field must never have reached a caller through the client + * either. + */ + public function testClientRefusesARowWithACoercedUnit(): void + { + $transport = new MockTransport(); + $transport->queue(200, [ + 'status' => 'success', + 'data' => [ + 'code' => 'EU_CARBON_EUR', + 'price' => 78.40, + 'currency' => 'EUR', + 'unit' => ['tonne'], + ], + ]); + $client = new Client(self::KEY, Client::DEFAULT_BASE_URL, 10.0, 0, $transport); + + $this->expectException(ApiException::class); + + $client->latest('EU_CARBON_EUR'); + } + + /** + * No PHP warning is emitted on the way: the old `(string)` cast on an + * array raised "Array to string conversion", which in a strict error + * handler is an exception from library internals and in a lax one is a + * line in a log nobody reads. + */ + public function testNoArrayToStringWarningIsEmitted(): void + { + $seen = []; + set_error_handler(static function (int $errno, string $message) use (&$seen): bool { + $seen[] = $message; + + return true; + }); + + try { + Price::fromArray([ + 'code' => 'EU_CARBON_EUR', + 'price' => 78.40, + 'currency' => ['EUR'], + ]); + } catch (ApiException) { + // expected + } finally { + restore_error_handler(); + } + + $this->assertSame([], $seen, 'PHP warnings were raised: ' . implode('; ', $seen)); + } + + private static function property(string $field): string + { + return $field; + } +} diff --git a/tests/RawPathOriginTest.php b/tests/RawPathOriginTest.php index 429a581..b350856 100644 --- a/tests/RawPathOriginTest.php +++ b/tests/RawPathOriginTest.php @@ -52,7 +52,7 @@ public static function offOriginPaths(): array public function testOffOriginRawPathIsRejectedBeforeAnyRequestIsMade(string $path): void { $transport = new MockTransport(); - $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'X', 'price' => 1.0]]); + $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'X', 'price' => 1.0, 'currency' => 'USD']]); $client = new Client(self::KEY, self::BASE, 10.0, 0, $transport); try { @@ -92,7 +92,7 @@ public static function sameOriginPaths(): array public function testSameOriginPathsKeepTheConfiguredHost(string $path): void { $transport = new MockTransport(); - $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'X', 'price' => 1.0]]); + $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'X', 'price' => 1.0, 'currency' => 'USD']]); $client = new Client(self::KEY, self::BASE, 10.0, 0, $transport); $client->raw()->get($path); @@ -112,7 +112,7 @@ public function testSameOriginPathsKeepTheConfiguredHost(string $path): void public function testExplicitCustomBaseUrlStillWorks(): void { $transport = new MockTransport(); - $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'X', 'price' => 1.0]]); + $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'X', 'price' => 1.0, 'currency' => 'USD']]); $client = new Client(self::KEY, 'http://127.0.0.1:8080/proxy', 10.0, 0, $transport); $client->raw()->get('/v1/prices/latest'); @@ -126,7 +126,7 @@ public function testExplicitCustomBaseUrlStillWorks(): void public function testModelledEndpointsStillResolve(): void { $transport = new MockTransport(); - $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $client = new Client(self::KEY, self::BASE, 10.0, 0, $transport); $client->latest('BRENT_CRUDE_USD'); diff --git a/tests/RetryPolicyTest.php b/tests/RetryPolicyTest.php index 11910ff..5b683f9 100644 --- a/tests/RetryPolicyTest.php +++ b/tests/RetryPolicyTest.php @@ -95,7 +95,7 @@ public function testExhaustedDurableQuotaIsNotRetried(array $body): void public function testRecoverableBurstLimitIsStillRetried(): void { $this->transport->queue(429, ['error_code' => 'RATE_LIMIT_EXCEEDED'], ['Retry-After' => '2']); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $price = $this->client()->latest('BRENT_CRUDE_USD'); @@ -107,7 +107,7 @@ public function testRecoverableBurstLimitIsStillRetried(): void public function testHourlyCircuitBreakerWithinBudgetIsRetried(): void { $this->transport->queue(429, ['error_code' => 'HOURLY_CIRCUIT_BREAKER_EXCEEDED'], ['Retry-After' => '5']); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $this->client()->latest('BRENT_CRUDE_USD'); @@ -136,7 +136,7 @@ public function testRetryAfterLongerThanTheBudgetIsNotShortened(): void public function testNegativeRetryAfterNeverProducesANegativeOrZeroHotRetry(): void { $this->transport->queue(429, ['error_code' => 'RATE_LIMIT_EXCEEDED'], ['Retry-After' => '-30']); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $this->client()->latest('BRENT_CRUDE_USD'); @@ -164,7 +164,7 @@ public function testHttpDateRetryAfterInThePastRetriesImmediately(): void ['error_code' => 'RATE_LIMIT_EXCEEDED'], ['Retry-After' => gmdate('D, d M Y H:i:s \G\M\T', time() - 600)], ); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $this->client()->latest('BRENT_CRUDE_USD'); @@ -195,7 +195,7 @@ public function testHttpDateRetryAfterBeyondTheBudgetIsNotShortened(): void public function testUnparseableRetryAfterFallsBackToBackoff(): void { $this->transport->queue(429, ['error_code' => 'RATE_LIMIT_EXCEEDED'], ['Retry-After' => 'soon']); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $this->client()->latest('BRENT_CRUDE_USD'); @@ -224,7 +224,7 @@ public function testServerErrorsAreStillRetried(): void { $this->transport->queue(500, ['error' => 'boom']); $this->transport->queue(503, ['error' => 'boom']); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $this->client()->latest('BRENT_CRUDE_USD'); @@ -237,7 +237,7 @@ public function testEveryRecordedSleepIsNonNegativeAndBounded(): void $this->transport->queue(429, ['error_code' => 'RATE_LIMIT_EXCEEDED'], ['Retry-After' => '-1']); $this->transport->queue(500, []); $this->transport->queue(429, ['error_code' => 'RATE_LIMIT_EXCEEDED'], ['Retry-After' => '0']); - $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8]]); + $this->transport->queue(200, ['status' => 'success', 'data' => ['code' => 'BRENT_CRUDE_USD', 'price' => 71.8, 'currency' => 'USD']]); $this->client()->latest('BRENT_CRUDE_USD'); diff --git a/tests/fixtures/capture.php b/tests/fixtures/capture.php index 2815439..262d699 100644 --- a/tests/fixtures/capture.php +++ b/tests/fixtures/capture.php @@ -16,4 +16,4 @@ } header('Content-Type: application/json'); -echo json_encode(['status' => 'success', 'data' => ['code' => 'CAPTURED', 'price' => 1.0]], JSON_THROW_ON_ERROR); +echo json_encode(['status' => 'success', 'data' => ['code' => 'CAPTURED', 'price' => 1.0, 'currency' => 'USD']], JSON_THROW_ON_ERROR);