fix: read price labels instead of casting them, and require currency (#21) - #27
Conversation
…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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
#25 (timestamp rejection) and #26 (redirect refusal) landed while this was open. Both conflicts were co-located additions, not competing edits, and both sides survive: - src/Price.php: #25 added the private parseTimestamp() helper at the same offset this branch added optionalString(). Both methods are kept, and fromArray() calls both - the currency guard runs before the timestamp is parsed, then the label fields are read. Verified by direct probe: a fabricated timestamp, a relative timestamp, a leap second, an array currency, a missing currency, an array unit and a row carrying two defects at once are each rejected, while a well-formed row parses. - CHANGELOG.md: #25's timestamp bullet and this branch's label bullet are separate fixes; both stay under "### Fixed". Nothing from #26 conflicted. No assertion on either side was weakened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
Rebased onto
|
| Suite | Result with src/Price.php reverted |
|---|---|
FabricatedTimestampTest (#25) |
OK (41 tests, 56 assertions) |
RedirectOriginTest (#26) |
OK (11 tests, 59 assertions) |
MalformedPriceRowTest |
OK (41 tests, 46 assertions) |
RetryPolicyTest |
OK (17 tests, 52 assertions) |
RawPathOriginTest |
OK (24 tests, 57 assertions) |
ClientTest |
OK (18 tests, 85 assertions) |
PublicClaimsTest |
OK (8 tests, 238 assertions) |
PriceFieldCoercionTest |
Failures: 46 |
The red is this defect's own. It also shows the fixture edits on this branch (adding 'currency' => 'USD' to rows that omitted it) weakened nothing: those tests pass against the old lenient Price.php too.
Baseline
| Tests | Assertions | |
|---|---|---|
clean main @ 056669c, pristine clone |
160 | 593 |
| this branch | 212 | 689 |
(The earlier 108/478 figure was main @ 21339d4; #25 and #26 moved it.)
Green: OK (52 tests, 96 assertions) for PriceFieldCoercionTest, OK (212 tests, 689 assertions) for the suite.
The two judgement calls flagged for Karl are untouched by the rebase: leap-second rejection, and naive timestamps read as UTC. Both live in #25's parseTimestamp(), which this merge did not modify.
🤖 Generated with Claude Code
) Adds phpstan/phpstan as a dev dependency, a phpstan.neon at level 9 over src/ with no baseline, a "phpstan" composer script, and a static-analysis job in the test workflow. The 17 errors quoted in #24 are stale: PR #27 removed six Price.php casts and #26 rewrote the transport. Re-measured on 0672df0, level 9 on src/ reports 14. All 14 are fixed here, so the level ships clean: - dataOrFail()/priceOrFail() rebuild decoded JSON objects with string keys, making the array<string, mixed> the SDK declares everywhere actually true (json_decode turns a numeric JSON key into an int array key). - originOf() in Client and CurlTransport reads parse_url() parts through is_string()/is_int() instead of casting mixed, so an unnameable part cannot compare equal to a real origin. - errorMessage() narrows $body['error'] and $body['data'] to arrays before reaching into them. - CurlTransport rejects an empty method or URL, which is also what curl_setopt_array's non-empty-string contract requires. - Removed two provably dead checks: the api-key null test the guard above it already proved, and the assert()/null seed on a loop that always runs. No baseline file and no @PHPStan-Ignore comments. phpstan.neon is export-ignored and excluded from the composer archive - it is dev tooling, not package content. The PHP constraint stays >=8.1: it is pinned by an existing assertion in PublicClaimsTest and narrowing it to ^8.1 is a packaging decision with its own release note, not a CI change. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #21.
The defect
src/Price.phpcast six fields with an unchecked(string). Reproduced onorigin/main@21339d43e: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. PHPStan level 9 flags all six casts.
The fix
Present-but-non-string values raise
ApiExceptionnaming the field and itsactual type (
get_debug_type). Absent and explicitly null still mean "notprovided" and still give
null. No PHP warning is emitted on any path — thereis a test asserting that, because a warning from library internals is either an
exception under a strict handler or a log line nobody reads.
currencyis now requiredThe
?? 'USD'default labelled a euro-denominated carbon price as dollars. A$0.00Brent quote is obviously broken and a human catches it;78.40 USDonan EUA contract is plausible, roughly 8% wrong, and flows into a model
undetected. The repo's own fixtures carry
EU_CARBON_EUR, so the catalogue isnot USD-only. #18 already broke BC on this method, so the strictness costs
nothing extra.
The ISO numeric spelling
978is rejected rather than accepted as'978'—it is a currency identifier, but not the one
$price->currencyis documentedto hold, and silently accepting it would put a number where callers compare a
code.
One value the DTO adjusts: surrounding whitespace on
currencyis trimmed, so$price->currency === 'EUR'behaves for" EUR\n". Nothing about the labelchanges; it is the alternative to shipping a value that fails every comparison
a caller writes.
TDD evidence
Test written first, against unmodified
origin/mainsource.RED (
./vendor/bin/phpunit --filter PriceFieldCoercionTest, source atorigin/main):(All 36 field × type combinations are covered; the list above is trimmed.)
Re-proved red-capable after the fix by
git checkout origin/main -- src/Price.php:Tests: 52, Assertions: 62, Failures: 46, Warnings: 6.— then restored.GREEN (this branch):
Full suite, PHP 8.5.8 / PHPUnit 13.3.3:
main@21339d43e:OK (108 tests, 478 assertions)OK (160 tests, 574 assertions)14 existing tests failed on the required-currency change because their fixture
rows omitted it. Those rows gained
'currency' => 'USD'; no assertion wasweakened or removed — the diff on those files is additive only
(
RetryPolicyTest,RawPathOriginTest,ClientTest,MalformedPriceRowTest,tests/fixtures/capture.php).Compatibility
Price::fromArray()throws for two input shapes it previously accepted: a rowwith no
currency, and a row with a non-string label field. The version bumpand the written-out BC note are handled separately in the versioning PR rather
than bundled here. README now states the required fields.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo