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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ See [UPGRADE-4.0.md](UPGRADE-4.0.md) for a step-by-step migration guide.
- `BillomatConfig::$language` (and `BillomatClient::create(language: …)`) sets the `Accept-Language` header, e.g. `'en-gb'`; default stays `'de-de'`, `null` omits the header.
- Exception messages include the method, the path and Billomat's error text, e.g. `Billomat responded with HTTP 422 to POST /clients. Name is missing`.
- `OffersApi::uncancel()`/`unclear()`, `ConfirmationsApi::uncancel()`/`unclear()` and `RemindersApi::uncancel()` for the `/uncancel` and `/unclear` endpoints (checked in the sandbox).
- **Contract fixtures:** anonymized sandbox responses under `tests/Fixtures/Contract/` and `ContractFixturesTest` catch drift between the Billomat API and the models in CI without credentials; `composer fixtures:record` re-records them (read-only GETs, see `docs/testing.md`).
- `Client::$propertyValues`, `Supplier::$propertyValues` (the `client-property-values`/`supplier-property-values` Billomat returns with every client and supplier) and `Role::$created`/`$updated`, found by the contract fixtures.

### Changed
- Development: PHPStan runs with the bleeding-edge ruleset and without `ignoreErrors` for the tests; the tests capture the outgoing `MockHttpClient` request as a typed `CapturedRequest` (method, URL, query, headers, decoded JSON body) instead of nested `mixed` arrays. No change to the public API.
Expand Down
9 changes: 7 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@
"extract:spec": "php tools/extract-billomat-spec.php",
"audit:spec": "php tools/audit-spec-vs-code.php",
"generate": "php tools/generate.php",
"generate:check": "php tools/generate.php --check"
"generate:check": "php tools/generate.php --check",
"fixtures:record": [
"Composer\\Config::disableProcessTimeout",
"php tools/record-fixtures.php"
]
},
"scripts-descriptions": {
"test": "PHPUnit-Tests (Unit only, schnell)",
Expand All @@ -135,6 +139,7 @@
"extract:spec": "Billomat-API-Doku (HTML-Spiegel) in docs/spec/billomat.json überführen",
"audit:spec": "Vergleicht docs/spec/billomat.json gegen src/ und schreibt docs/audit.md",
"generate": "Erzeugt Models und Options aus docs/spec/billomat.json + tools/codegen/definitions/",
"generate:check": "Prüft, ob die generierten Dateien unter src/ aktuell sind"
"generate:check": "Prüft, ob die generierten Dateien unter src/ aktuell sind",
"fixtures:record": "Nimmt anonymisierte Contract-Fixtures per GET gegen die Billomat-Sandbox auf (benötigt .env.test.local)"
}
}
38 changes: 38 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,44 @@ final class MyIntegrationTest extends AbstractBillomatIntegrationTestCase
- **No production keys.** Use a separate sandbox account only; see [SECURITY.md](../SECURITY.md).
- **Keep the `markTestSkipped()` branch working.** A test that is meaningless without credentials must skip gracefully, otherwise the suite breaks elsewhere.

## Contract fixtures

`tests/Contract/ContractFixturesTest.php` (part of the unit suite, no network) checks the models against recorded, anonymized responses from the Billomat sandbox in `tests/Fixtures/Contract/<resource>/<list|get>.json`. Every fixture goes through the real `*Api` method (`MockHttpClient` + `Psr18Client`), and the test fails when

- hydration fails or returns fewer records than the response contains,
- the response has a field the model doesn't know (a non-empty `$extra` on generated models, also in nested models such as positions; for handwritten models, a field `toArray()` doesn't export),
- a non-empty value gets lost while hydrating, e.g. an enum value the SDK doesn't know, an unparsable date or an unknown entry in `payment_types`.

The failure names the resource and the field, e.g. `invoices/get: 'Invoice.new_field' => 'unbekanntes Feld'`. Fix real drift in the codegen definition (`tools/codegen/definitions/`) and run `composer generate`. Differences that are accepted on purpose go into `KNOWN_DRIFT` in the test with a reason; an entry that no longer occurs fails the test, so the list doesn't go stale.

### Re-recording

```bash
composer fixtures:record # needs BILLOMAT_ID and BILLOMAT_API_KEY in .env.test.local
composer fixtures:record -- --cache=/tmp/billomat # keep raw responses outside the repo and reuse them on the next run
```

`tools/record-fixtures.php` only sends GET requests: one list per resource (`per_page=3`) and, if the sandbox has data, one single-object GET of the first entry, plus the sub-resources (items, comments, tags, email receivers, contacts) of that entry. A full run takes about 75 requests and stops at 150, well below the sandbox limit of 1000 requests per 15 minutes. It never creates, updates or deletes anything and skips `/clients/myself` and the account endpoint. Resources without data in the sandbox get only a `list.json`; the test picks up new fixtures automatically, a new resource needs an entry in `ContractFixturesTest::calls()`.

With `--cache`, a cached response is used instead of a request, so you can tune the anonymization without hitting the API again. The cache holds unanonymized data: keep it outside the repository and delete it afterwards.

### Anonymization

Before writing, the recorder replaces personal data with deterministic placeholders in the same format (the same original value always gets the same placeholder, so re-recording gives stable diffs):

- names, first/last names, company names, account owners → `Name 1`, `Vorname1`, `Nachname1`, `Firma 1 GmbH`
- e-mail addresses (also inside other strings and comma-separated lists) → `kontakt1@example.com`, senders → `Firma 1 GmbH <absender1@example.com>`
- phone, fax and mobile numbers → `+49 30 0000001`
- street, zip, city, state and the composed `address` → `Musterstraße 1`, `10001`, `Musterstadt 1`
- IBAN (also inside other strings), BIC, bank name, account and bank numbers, creditor ID → `DE00000000000000000001`, `TESTDEFFXXX`, `Testbank 1`
- tax and VAT numbers → `000/000/00001`, `DE000000001`
- URLs → `https://www.example.com/1`
- free texts (`note`, `intro`, `description`, `comment`, `title`, `label`, `subject`, `text`, settings like `invoice_intro`, …) → `Text 1`
- file names and Base64 file contents → `dokument-1.pdf` and a tiny placeholder PDF
- the Billomat ID anywhere in a string → `mycompany`

Types, formats, enum values, amounts, dates, IDs, numbering and the presence of fields stay unchanged; empty strings stay empty. Before committing new fixtures, check them for anything that still looks personal, e.g. `grep -rn '@' tests/Fixtures/Contract | grep -v example.com`, a grep for your Billomat ID, and a look at phone-like numbers. Add new field names to `FIELD_KINDS`/`FIELD_SUFFIX_KINDS` in the recorder when Billomat adds personal fields.

## Coverage

```bash
Expand Down
23 changes: 20 additions & 3 deletions src/Model/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@
'facturx_identifier' => true,
'seller_identifier' => true,
'dig_exclude' => true,
'client-property-values' => true,
];

/**
* @param list<PaymentType> $defaultPaymentTypes
* @param array<string, mixed> $extra
* @param list<PaymentType> $defaultPaymentTypes
* @param list<ClientPropertyValue> $propertyValues
* @param array<string, mixed> $extra
*/
public function __construct(
/** Interne Billomat-ID des Kunden. */
Expand Down Expand Up @@ -290,6 +292,12 @@ public function __construct(
public ?string $sellerIdentifier = null,
/** Von Billomat geliefert, aber nicht dokumentiert. */
public ?bool $digExclude = null,
/**
* Attributwerte des Kunden.
*
* Von Billomat geliefert, aber nicht dokumentiert (leer: "").
*/
public array $propertyValues = [],
/**
* Felder, die Billomat liefert, das SDK aber (noch) nicht abbildet – roh, wie in der Response.
*
Expand Down Expand Up @@ -367,6 +375,7 @@ public static function fromArray(array $data): self
facturxIdentifier: ScalarCaster::toStringOrNull($data['facturx_identifier'] ?? null),
sellerIdentifier: ScalarCaster::toStringOrNull($data['seller_identifier'] ?? null),
digExclude: ScalarCaster::toBoolOrNull($data['dig_exclude'] ?? null),
propertyValues: ListCaster::toNestedList($data['client-property-values'] ?? null, 'client-property-value', 'id', ClientPropertyValue::fromArray(...)),
extra: array_diff_key($data, self::FIELDS),
);
}
Expand All @@ -380,7 +389,7 @@ public static function fromArray(array $data): self
*/
public function toArray(): array
{
return [
$data = [
...$this->extra,
'id' => $this->id,
'name' => $this->name,
Expand Down Expand Up @@ -443,5 +452,13 @@ public function toArray(): array
'seller_identifier' => $this->sellerIdentifier,
'dig_exclude' => $this->digExclude,
];

if ([] !== $this->propertyValues) {
$data['client-property-values'] = [
'client-property-value' => array_map(static fn (ClientPropertyValue $item): array => $item->toArray(), $this->propertyValues),
];
}

return $data;
}
}
11 changes: 11 additions & 0 deletions src/Model/Role.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

namespace Justpilot\Billomat\Model;

use DateTimeImmutable;
use Justpilot\Billomat\Internal\ScalarCaster;

use const DATE_ATOM;

/**
* Mitarbeiter-Rolle samt zugeordneter Zugriffsrechte.
*
Expand All @@ -26,6 +29,10 @@ public function __construct(
public ?int $id,
public ?string $name = null,
public array $permissions = [],
/** Erstellungszeitpunkt der Rolle. */
public ?DateTimeImmutable $created = null,
/** Zeitpunkt der letzten Änderung. */
public ?DateTimeImmutable $updated = null,
) {
}

Expand Down Expand Up @@ -59,6 +66,8 @@ public static function fromArray(array $data): self
id: ScalarCaster::toIntOrNull($data['id'] ?? null),
name: ScalarCaster::toStringOrNull($data['name'] ?? null),
permissions: $permissions,
created: ScalarCaster::toDateTimeOrNull($data['created'] ?? null),
updated: ScalarCaster::toDateTimeOrNull($data['updated'] ?? null),
);
}

Expand All @@ -71,6 +80,8 @@ public function toArray(): array
'id' => $this->id,
'name' => $this->name,
'permissions' => $this->permissions,
'created' => $this->created?->format(DATE_ATOM),
'updated' => $this->updated?->format(DATE_ATOM),
];
}
}
24 changes: 22 additions & 2 deletions src/Model/Supplier.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Justpilot\Billomat\Model;

use DateTimeImmutable;
use Justpilot\Billomat\Internal\ListCaster;
use Justpilot\Billomat\Internal\ScalarCaster;

use const DATE_ATOM;
Expand Down Expand Up @@ -62,9 +63,13 @@
'creditor_identifier' => true,
'costs_gross' => true,
'costs_net' => true,
'supplier-property-values' => true,
];

/** @param array<string, mixed> $extra */
/**
* @param list<SupplierPropertyValue> $propertyValues
* @param array<string, mixed> $extra
*/
public function __construct(
/** Interne Billomat-ID des Lieferanten. */
public ?int $id,
Expand Down Expand Up @@ -158,6 +163,12 @@ public function __construct(
public ?Money $costsGross = null,
/** Netto-Kosten (read-only Auswertungsfeld). */
public ?Money $costsNet = null,
/**
* Attributwerte des Lieferanten.
*
* Von Billomat geliefert, aber nicht dokumentiert (leer: "").
*/
public array $propertyValues = [],
/**
* Felder, die Billomat liefert, das SDK aber (noch) nicht abbildet – roh, wie in der Response.
*
Expand Down Expand Up @@ -211,6 +222,7 @@ public static function fromArray(array $data): self
creditorIdentifier: ScalarCaster::toStringOrNull($data['creditor_identifier'] ?? null),
costsGross: ScalarCaster::toMoneyOrNull($data['costs_gross'] ?? null),
costsNet: ScalarCaster::toMoneyOrNull($data['costs_net'] ?? null),
propertyValues: ListCaster::toNestedList($data['supplier-property-values'] ?? null, 'supplier-property-value', 'id', SupplierPropertyValue::fromArray(...)),
extra: array_diff_key($data, self::FIELDS),
);
}
Expand All @@ -224,7 +236,7 @@ public static function fromArray(array $data): self
*/
public function toArray(): array
{
return [
$data = [
...$this->extra,
'id' => $this->id,
'created' => $this->created?->format(DATE_ATOM),
Expand Down Expand Up @@ -263,5 +275,13 @@ public function toArray(): array
'costs_gross' => $this->costsGross?->toString(),
'costs_net' => $this->costsNet?->toString(),
];

if ([] !== $this->propertyValues) {
$data['supplier-property-values'] = [
'supplier-property-value' => array_map(static fn (SupplierPropertyValue $item): array => $item->toArray(), $this->propertyValues),
];
}

return $data;
}
}
13 changes: 13 additions & 0 deletions tests/Api/LookupTrioApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Symfony\Component\HttpClient\Psr18Client;
use Symfony\Component\HttpClient\Response\MockResponse;

use const DATE_ATOM;
use const JSON_THROW_ON_ERROR;

#[CoversClass(IncomingCategoriesApi::class)]
Expand Down Expand Up @@ -166,6 +167,8 @@ public function itGetsSingleRole(): void
'role' => [
'id' => 42,
'name' => 'Azubi',
'created' => '2013-09-27T10:49:44+02:00',
'updated' => '2014-01-02T08:00:00+01:00',
'articles' => 'READ',
'offers' => '',
],
Expand All @@ -179,6 +182,16 @@ public function itGetsSingleRole(): void
self::assertSame(42, $role->id);
self::assertSame('READ', $role->permissions['articles']);
self::assertNull($role->permissions['offers']);
self::assertArrayNotHasKey('created', $role->permissions);
self::assertSame('2013-09-27T10:49:44+02:00', $role->created?->format(DATE_ATOM));
self::assertSame('2014-01-02T08:00:00+01:00', $role->updated?->format(DATE_ATOM));
self::assertSame([
'id' => 42,
'name' => 'Azubi',
'permissions' => ['articles' => 'READ', 'offers' => null],
'created' => '2013-09-27T10:49:44+02:00',
'updated' => '2014-01-02T08:00:00+01:00',
], $role->toArray());
}

#[Test]
Expand Down
2 changes: 2 additions & 0 deletions tests/Api/SuppliersApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Justpilot\Billomat\Config\BillomatConfig;
use Justpilot\Billomat\Config\RetryPolicy;
use Justpilot\Billomat\Http\BillomatHttpClient;
use Justpilot\Billomat\Internal\ListCaster;
use Justpilot\Billomat\Internal\ScalarCaster;
use Justpilot\Billomat\Model\Supplier;
use Justpilot\Billomat\Tests\Support\CapturedRequest;
Expand All @@ -30,6 +31,7 @@
#[UsesClass(BillomatConfig::class)]
#[UsesClass(RetryPolicy::class)]
#[UsesClass(BillomatHttpClient::class)]
#[UsesClass(ListCaster::class)]
#[UsesClass(ScalarCaster::class)]
final class SuppliersApiTest extends TestCase
{
Expand Down
Loading
Loading