diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..48d3a8d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include contracts *.json *.md +include tests/conftest.py diff --git a/Makefile b/Makefile index f0cde7f..56b0671 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PYTHON ?= python3.14 SOURCES = energy_tracker_api/ tests/ scripts/ example.py -.PHONY: help install install-dev clean test coverage lint format type-check build check-dist upload upload-test venv all +.PHONY: help install install-dev clean test test-contracts coverage lint format type-check build check-dist upload upload-test venv all help: @echo "Available commands:" @@ -10,6 +10,7 @@ help: @echo " make install-dev - Install package with development dependencies" @echo " make clean - Remove build artifacts and cache files" @echo " make test - Run tests" + @echo " make test-contracts - Run shared API contract cases" @echo " make coverage - Run tests with coverage report" @echo " make lint - Run code linting (black check + isort check)" @echo " make format - Format code with black and isort" @@ -54,6 +55,9 @@ clean: test: .install-dev-stamp venv/bin/python -m pytest tests/ -v +test-contracts: .install-dev-stamp + venv/bin/python -m pytest tests/test_contracts.py -v + coverage: .install-dev-stamp venv/bin/python -m pytest tests/ --cov=energy_tracker_api --cov-report=html --cov-report=term --cov-report=xml diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..963626d --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,57 @@ +# Shared SDK contract cases + +Language-independent examples for the Energy Tracker Python and TypeScript clients. +Run the Python adapter with `make test-contracts`; the cases also run in the normal +test and release checks. No API token, live backend or sibling checkout is needed. + +## Format and execution + +`schema.json` defines fixture format version 1. Each file in `cases/` contains a +`schemaVersion` and an array of cases with globally unique IDs. + +- `operation` and `input` describe a public SDK call. Bind these to the target + language's API; they do not prescribe method names or classes. Reading values + are decimal strings, environment values are JSON numbers, dates are offset ISO + timestamps. Missing optional inputs mean omitted arguments. +- `request` specifies the method, API-relative path, decoded query parameters, + required headers and body. Preserve a base URL's `/public-api/` prefix. Compare + header names case-insensitively; additional transport headers are allowed. + Query key order and JSON object key order are irrelevant; extra query/body + fields and duplicate query keys are not allowed. +- `response` is the local server's synthetic HTTP response. Bodies use exactly + one of `json`, UTF-8 `text`, or `base64`; `{}` means no body. A `json: null` + body is distinct from no body. Base64 preserves exact CSV bytes, including BOM + and line endings. +- `expected` contains either a normalized `result` or an `error`. Results use + camelCase DTO fields, decimal strings, and `{ "base64": "..." }` for bytes. + Void results and absent optional DTO fields are normalized to `null`. + Errors specify a semantic category, HTTP status, API message list and, when + present, `retryAfter` in seconds. Exception class names and local wording are + language-specific. Assert exactly one request, including on errors/redirects. + +Compare `timestamp`, `date`, `lastUpdatedAt`, `from`, `to`, `updatedAfter` and +`updatedBefore` as offset-aware instants. `Z` and equivalent offsets/fractional +spellings are interchangeable. Do not round to calendar boundaries. Compare +all other values directly, preserving decimal precision and list order. + +The wire examples were checked against the Public API controllers and DTOs in +[energy-tracker-core at fcc269a06184426caf64c882291d4209be57a723](https://github.com/StefaniOSApps/energy-tracker-core/tree/fcc269a06184426caf64c882291d4209be57a723/backend/src/app/public-api). +Error/redirect/malformed-response cases additionally specify SDK behavior; their +messages are illustrative, not fixed backend wording. These mocked responses +do not verify server calculations, validation rules or authorization scopes. +Timeouts, session lifecycle and language-specific input validation remain in +each SDK's own tests. + +## Reuse and versioning + +This directory is the fixture source. Pin an immutable Git commit of this +repository when consuming it; do not fetch a moving `main` in SDK CI. Export +with `git archive LICENSE contracts`, retain the repository's MIT license, and +record the source commit alongside the imported files. A TypeScript repository +can vendor that snapshot and run its own HTTP test adapter entirely offline. +Update the source fixtures here and import updates explicitly into consumers. + +`schemaVersion` versions the fixture format, independently of Python releases. +The pinned Git commit identifies the exact case revision. Unknown schema +versions and operations must fail rather than be skipped. Fixtures are included +in the source distribution, but are not a runtime dependency or part of the wheel. diff --git a/contracts/cases/calculations.json b/contracts/cases/calculations.json new file mode 100644 index 0000000..704db3a --- /dev/null +++ b/contracts/cases/calculations.json @@ -0,0 +1,313 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "calculations-dailyvalues-empty", + "operation": "calculations.dailyValues", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/daily-values", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "calculations-dailyvalues-filters", + "operation": "calculations.dailyValues", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "from": "2026-03-29T00:15:12+01:00", + "to": "2026-03-30T00:45:23+02:00", + "timeZone": "Europe/Berlin" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/daily-values", + "query": { + "from": "2026-03-29T00:15:12+01:00", + "to": "2026-03-30T00:45:23+02:00", + "timeZone": "Europe/Berlin" + }, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "date": "2026-03-28T23:00:00.000Z", + "actualValue": 4.2, + "actualDuration": 82800, + "expectedValue": 8.4, + "expectedDuration": 86400 + }, + { + "date": "2026-03-29T22:00:00.000Z", + "actualValue": 0, + "actualDuration": 0, + "expectedValue": 0, + "expectedDuration": 0 + } + ] + } + }, + "expected": { + "result": [ + { + "date": "2026-03-28T23:00:00.000Z", + "actualValue": 4.2, + "actualDuration": 82800, + "expectedValue": 8.4, + "expectedDuration": 86400 + }, + { + "date": "2026-03-29T22:00:00.000Z", + "actualValue": 0, + "actualDuration": 0, + "expectedValue": 0, + "expectedDuration": 0 + } + ] + } + }, + { + "id": "calculations-extrapolations-empty", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "month" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/month", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "calculations-extrapolations-filters", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "month", + "from": "2026-03-29T00:15:12+01:00", + "to": "2026-03-30T00:45:23+02:00", + "timeZone": "Europe/Berlin" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/month", + "query": { + "from": "2026-03-29T00:15:12+01:00", + "to": "2026-03-30T00:45:23+02:00", + "timeZone": "Europe/Berlin" + }, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "date": "2026-03-28T23:00:00.000Z", + "actualValue": 4.2, + "actualDuration": 82800, + "expectedValue": 8.4, + "expectedDuration": 86400 + }, + { + "date": "2026-03-29T22:00:00.000Z", + "actualValue": 0, + "actualDuration": 0, + "expectedValue": 0, + "expectedDuration": 0 + } + ] + } + }, + "expected": { + "result": [ + { + "date": "2026-03-28T23:00:00.000Z", + "actualValue": 4.2, + "actualDuration": 82800, + "expectedValue": 8.4, + "expectedDuration": 86400 + }, + { + "date": "2026-03-29T22:00:00.000Z", + "actualValue": 0, + "actualDuration": 0, + "expectedValue": 0, + "expectedDuration": 0 + } + ] + } + }, + { + "id": "calculations-interval-day", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "day", + "method": "standard" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/day", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "calculations-interval-week", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "week", + "method": "standard" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/week", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "calculations-interval-quarter", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "quarter", + "method": "standard" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/quarter", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "calculations-interval-year", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "year", + "method": "standard" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/year", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + } + ] +} diff --git a/contracts/cases/devices.json b/contracts/cases/devices.json new file mode 100644 index 0000000..afedfb6 --- /dev/null +++ b/contracts/cases/devices.json @@ -0,0 +1,259 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "devices-standard-empty", + "operation": "devices.listStandard", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "devices-standard-filters", + "operation": "devices.listStandard", + "input": { + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller + Süd", + "updatedAfter": "2026-09-01T12:34:56.123+02:00", + "updatedBefore": "2026-09-29T00:00:00Z" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": { + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller + Süd", + "updatedAfter": "2026-09-01T12:34:56.123+02:00", + "updatedBefore": "2026-09-29T00:00:00Z" + }, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller", + "lastUpdatedAt": "2026-09-01T10:34:56.123Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "name": "Wasser", + "folderPath": "/", + "lastUpdatedAt": null + } + ] + } + }, + "expected": { + "result": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller", + "lastUpdatedAt": "2026-09-01T10:34:56.123Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "name": "Wasser", + "folderPath": "/", + "lastUpdatedAt": null + } + ] + } + }, + { + "id": "devices-standard-optional-date", + "operation": "devices.listStandard", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas", + "folderPath": "/" + } + ] + } + }, + "expected": { + "result": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas", + "folderPath": "/", + "lastUpdatedAt": null + } + ] + } + }, + { + "id": "devices-virtual-empty", + "operation": "devices.listVirtual", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/virtual", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "devices-virtual-filters", + "operation": "devices.listVirtual", + "input": { + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller + Süd", + "updatedAfter": "2026-09-01T12:34:56.123+02:00", + "updatedBefore": "2026-09-29T00:00:00Z" + }, + "request": { + "method": "GET", + "path": "/v1/devices/virtual", + "query": { + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller + Süd", + "updatedAfter": "2026-09-01T12:34:56.123+02:00", + "updatedBefore": "2026-09-29T00:00:00Z" + }, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller", + "lastUpdatedAt": "2026-09-01T10:34:56.123Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "name": "Wasser", + "folderPath": "/", + "lastUpdatedAt": null + } + ] + } + }, + "expected": { + "result": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas & Wärme", + "folderPath": "/Haus/Keller", + "lastUpdatedAt": "2026-09-01T10:34:56.123Z" + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "name": "Wasser", + "folderPath": "/", + "lastUpdatedAt": null + } + ] + } + }, + { + "id": "devices-virtual-optional-date", + "operation": "devices.listVirtual", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/virtual", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas", + "folderPath": "/" + } + ] + } + }, + "expected": { + "result": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "name": "Gas", + "folderPath": "/", + "lastUpdatedAt": null + } + ] + } + } + ] +} diff --git a/contracts/cases/environments.json b/contracts/cases/environments.json new file mode 100644 index 0000000..de99b9a --- /dev/null +++ b/contracts/cases/environments.json @@ -0,0 +1,367 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "environments-list-empty", + "operation": "environments.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "environments-list", + "operation": "environments.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "title": "Temperatur", + "unit": "°C", + "entries": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": -2.5 + }, + { + "timestamp": "2026-09-02T00:00:00Z", + "value": 0 + } + ] + } + ] + } + }, + "expected": { + "result": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "title": "Temperatur", + "unit": "°C", + "entries": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": -2.5 + }, + { + "timestamp": "2026-09-02T00:00:00Z", + "value": 0 + } + ] + } + ] + } + }, + { + "id": "environments-get", + "operation": "environments.get", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "id": "22222222-2222-4222-8222-222222222222", + "title": "Temperatur", + "unit": "°C", + "entries": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": -2.5 + }, + { + "timestamp": "2026-09-02T00:00:00Z", + "value": 0 + } + ] + } + } + }, + "expected": { + "result": { + "id": "22222222-2222-4222-8222-222222222222", + "title": "Temperatur", + "unit": "°C", + "entries": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": -2.5 + }, + { + "timestamp": "2026-09-02T00:00:00Z", + "value": 0 + } + ] + } + } + }, + { + "id": "environments-create-minimal", + "operation": "environments.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "record": { + "title": "Temperatur" + } + }, + "request": { + "method": "POST", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "title": "Temperatur" + } + } + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "id": "22222222-2222-4222-8222-222222222222", + "entries": [], + "title": "Temperatur" + } + } + }, + "expected": { + "result": { + "unit": null, + "id": "22222222-2222-4222-8222-222222222222", + "entries": [], + "title": "Temperatur" + } + } + }, + { + "id": "environments-create-unit", + "operation": "environments.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "record": { + "title": "Temperatur", + "unit": "°C" + } + }, + "request": { + "method": "POST", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "title": "Temperatur", + "unit": "°C" + } + } + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "id": "22222222-2222-4222-8222-222222222222", + "entries": [], + "title": "Temperatur", + "unit": "°C" + } + } + }, + "expected": { + "result": { + "unit": "°C", + "id": "22222222-2222-4222-8222-222222222222", + "entries": [], + "title": "Temperatur" + } + } + }, + { + "id": "environments-delete", + "operation": "environments.delete", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222" + }, + "request": { + "method": "DELETE", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "environments-create-entry-minimal", + "operation": "environments.createEntry", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222", + "entry": { + "value": 0 + } + }, + "request": { + "method": "POST", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": 0 + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "environments-create-entry-timestamp", + "operation": "environments.createEntry", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222", + "entry": { + "value": -2.5, + "timestamp": "2026-09-01T12:34:56.123+02:00" + } + }, + "request": { + "method": "POST", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": -2.5, + "timestamp": "2026-09-01T12:34:56.123+02:00" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "environments-delete-entry", + "operation": "environments.deleteEntry", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222", + "timestamp": "2026-09-01T12:34:56.123+02:00" + }, + "request": { + "method": "DELETE", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222/entries", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "timestamp": "2026-09-01T12:34:56.123+02:00" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + } + ] +} diff --git a/contracts/cases/errors.json b/contracts/cases/errors.json new file mode 100644 index 0000000..3eaaf8c --- /dev/null +++ b/contracts/cases/errors.json @@ -0,0 +1,1470 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "devices-standard-empty-wrong-status", + "operation": "devices.listStandard", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "devices-virtual-empty-wrong-status", + "operation": "devices.listVirtual", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/virtual", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "readings-list-empty-wrong-status", + "operation": "meterReadings.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "readings-create-minimal-wrong-status", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": {} + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "readings-delete-wrong-status", + "operation": "meterReadings.delete", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "timestamp": "2026-09-01T12:34:56.123+02:00" + }, + "request": { + "method": "DELETE", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "timestamp": "2026-09-01T12:34:56.123+02:00" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": {} + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "readings-export-defaults-wrong-status", + "operation": "meterReadings.export", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "config": { + "columns": [ + "date", + "value" + ] + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings/export", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "includeHeader": true, + "delimiter": "comma", + "dateFormat": "iso", + "columns": [ + "date", + "value" + ] + } + } + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "text/csv; charset=utf-8" + }, + "body": { + "base64": "77u/ZGF0ZSx2YWx1ZQ0KMjAyNi0wOS0wMVQxMDozNDo1Ni4xMjNaLDEyMy40NQ0K" + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "environments-list-empty-wrong-status", + "operation": "environments.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "environments-get-wrong-status", + "operation": "environments.get", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "id": "22222222-2222-4222-8222-222222222222", + "title": "Temperatur", + "unit": "°C", + "entries": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": -2.5 + }, + { + "timestamp": "2026-09-02T00:00:00Z", + "value": 0 + } + ] + } + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "environments-create-minimal-wrong-status", + "operation": "environments.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "record": { + "title": "Temperatur" + } + }, + "request": { + "method": "POST", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "title": "Temperatur" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "id": "22222222-2222-4222-8222-222222222222", + "entries": [], + "title": "Temperatur" + } + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "environments-delete-wrong-status", + "operation": "environments.delete", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222" + }, + "request": { + "method": "DELETE", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": {} + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "environments-create-entry-minimal-wrong-status", + "operation": "environments.createEntry", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222", + "entry": { + "value": 0 + } + }, + "request": { + "method": "POST", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": 0 + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": {} + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "environments-delete-entry-wrong-status", + "operation": "environments.deleteEntry", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "environmentId": "22222222-2222-4222-8222-222222222222", + "timestamp": "2026-09-01T12:34:56.123+02:00" + }, + "request": { + "method": "DELETE", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/environments/22222222-2222-4222-8222-222222222222/entries", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "timestamp": "2026-09-01T12:34:56.123+02:00" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": {} + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "calculations-dailyvalues-empty-wrong-status", + "operation": "calculations.dailyValues", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/daily-values", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "calculations-extrapolations-empty-wrong-status", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "month" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/month", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "error": { + "kind": "api", + "status": 201, + "messages": [] + } + } + }, + { + "id": "readings-create-error-400", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 400, + "message": [ + "Request rejected", + "Check request parameters" + ] + } + } + }, + "expected": { + "error": { + "kind": "validation", + "status": 400, + "messages": [ + "Request rejected", + "Check request parameters" + ] + } + } + }, + { + "id": "readings-create-error-401", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 401, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 401, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "authentication", + "status": 401, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "readings-create-error-403", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 403, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 403, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "forbidden", + "status": 403, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "readings-create-error-404", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 404, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 404, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "notFound", + "status": 404, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "readings-create-error-409", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 409, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 409, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "conflict", + "status": 409, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "readings-create-error-429", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 429, + "headers": { + "Content-Type": "application/json", + "Retry-After": "60" + }, + "body": { + "json": { + "statusCode": 429, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "rateLimit", + "status": 429, + "messages": [ + "Request rejected" + ], + "retryAfter": 60 + } + } + }, + { + "id": "readings-create-error-500", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 500, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 500, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "api", + "status": 500, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "readings-create-error-503", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 503, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 503, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "unavailable", + "status": 503, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "readings-create-error-418", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 418, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 418, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "api", + "status": 418, + "messages": [ + "Request rejected" + ] + } + } + }, + { + "id": "rate-limit-retry-after-missing", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 429, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": { + "statusCode": 429, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "rateLimit", + "status": 429, + "messages": [ + "Request rejected" + ], + "retryAfter": null + } + } + }, + { + "id": "rate-limit-retry-after-invalid", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 429, + "headers": { + "Content-Type": "application/json", + "Retry-After": "tomorrow" + }, + "body": { + "json": { + "statusCode": 429, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "rateLimit", + "status": 429, + "messages": [ + "Request rejected" + ], + "retryAfter": null + } + } + }, + { + "id": "rate-limit-retry-after-zero", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 429, + "headers": { + "Content-Type": "application/json", + "Retry-After": "0" + }, + "body": { + "json": { + "statusCode": 429, + "message": "Request rejected" + } + } + }, + "expected": { + "error": { + "kind": "rateLimit", + "status": 429, + "messages": [ + "Request rejected" + ], + "retryAfter": 0 + } + } + }, + { + "id": "readings-create-redirect-301", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 301, + "headers": { + "Location": "/redirect-target" + }, + "body": {} + }, + "expected": { + "error": { + "kind": "api", + "status": 301, + "messages": [] + } + } + }, + { + "id": "readings-create-redirect-302", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 302, + "headers": { + "Location": "/redirect-target" + }, + "body": {} + }, + "expected": { + "error": { + "kind": "api", + "status": 302, + "messages": [] + } + } + }, + { + "id": "readings-create-redirect-303", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 303, + "headers": { + "Location": "/redirect-target" + }, + "body": {} + }, + "expected": { + "error": { + "kind": "api", + "status": 303, + "messages": [] + } + } + }, + { + "id": "readings-create-redirect-307", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 307, + "headers": { + "Location": "/redirect-target" + }, + "body": {} + }, + "expected": { + "error": { + "kind": "api", + "status": 307, + "messages": [] + } + } + }, + { + "id": "readings-create-redirect-308", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 308, + "headers": { + "Location": "/redirect-target" + }, + "body": {} + }, + "expected": { + "error": { + "kind": "api", + "status": 308, + "messages": [] + } + } + }, + { + "id": "readings-export-defaults-unavailable", + "operation": "meterReadings.export", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "config": { + "columns": [ + "date", + "value" + ] + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings/export", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "includeHeader": true, + "delimiter": "comma", + "dateFormat": "iso", + "columns": [ + "date", + "value" + ] + } + } + }, + "response": { + "status": 503, + "headers": { + "Content-Type": "text/html" + }, + "body": { + "text": "Service unavailable" + } + }, + "expected": { + "error": { + "kind": "unavailable", + "status": 503, + "messages": [] + } + } + }, + { + "id": "calculations-dailyvalues-empty-unavailable", + "operation": "calculations.dailyValues", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/daily-values", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 503, + "headers": { + "Content-Type": "text/html" + }, + "body": { + "text": "Service unavailable" + } + }, + "expected": { + "error": { + "kind": "unavailable", + "status": 503, + "messages": [] + } + } + }, + { + "id": "calculations-extrapolations-empty-unavailable", + "operation": "calculations.extrapolations", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "interval": "month" + }, + "request": { + "method": "GET", + "path": "/v1/devices/standard/11111111-1111-4111-8111-111111111111/extrapolations/standard/month", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 503, + "headers": { + "Content-Type": "text/html" + }, + "body": { + "text": "Service unavailable" + } + }, + "expected": { + "error": { + "kind": "unavailable", + "status": 503, + "messages": [] + } + } + }, + { + "id": "response-json-broken", + "operation": "devices.listStandard", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "text": "{" + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "response-json-null", + "operation": "devices.listStandard", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": null + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + }, + { + "id": "response-json-wrong-content-type", + "operation": "devices.listStandard", + "input": {}, + "request": { + "method": "GET", + "path": "/v1/devices/standard", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/html" + }, + "body": { + "text": "[]" + } + }, + "expected": { + "error": { + "kind": "api", + "status": 200, + "messages": [] + } + } + } + ] +} diff --git a/contracts/cases/meter-readings.json b/contracts/cases/meter-readings.json new file mode 100644 index 0000000..274a4e3 --- /dev/null +++ b/contracts/cases/meter-readings.json @@ -0,0 +1,489 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "readings-list-empty", + "operation": "meterReadings.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111" + }, + "request": { + "method": "GET", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [] + } + }, + "expected": { + "result": [] + } + }, + { + "id": "readings-list-filters", + "operation": "meterReadings.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "meterId": "33333333-3333-4333-8333-333333333333", + "from": "2026-09-01T12:34:56.123+02:00", + "to": "2026-09-29T00:00:00Z", + "sort": "asc" + }, + "request": { + "method": "GET", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": { + "meterId": "33333333-3333-4333-8333-333333333333", + "from": "2026-09-01T12:34:56.123+02:00", + "to": "2026-09-29T00:00:00Z", + "sort": "asc" + }, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": "9999999999.999999", + "rolloverOffset": 1000, + "meterId": "33333333-3333-4333-8333-333333333333", + "note": "Ablesung äöü", + "meterNumber": "00123" + } + ] + } + }, + "expected": { + "result": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": "9999999999.999999", + "rolloverOffset": 1000, + "meterId": "33333333-3333-4333-8333-333333333333", + "note": "Ablesung äöü", + "meterNumber": "00123" + } + ] + } + }, + { + "id": "readings-list-optional-fields", + "operation": "meterReadings.list", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "sort": "desc" + }, + "request": { + "method": "GET", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token" + }, + "body": {} + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "json": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": "9999999999.999999", + "rolloverOffset": 1000, + "meterId": "33333333-3333-4333-8333-333333333333" + } + ] + } + }, + "expected": { + "result": [ + { + "timestamp": "2026-09-01T10:34:56.123Z", + "value": "9999999999.999999", + "rolloverOffset": 1000, + "meterId": "33333333-3333-4333-8333-333333333333", + "note": null, + "meterNumber": null + } + ] + } + }, + { + "id": "readings-create-minimal", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "123.45" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "123.45" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "readings-create-zero", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "0" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "0" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "readings-create-precision", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "9999999999.999999", + "timestamp": "2026-09-01T12:34:56.123+02:00", + "note": "Ablesung äöü" + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "9999999999.999999", + "timestamp": "2026-09-01T12:34:56.123+02:00", + "note": "Ablesung äöü" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "readings-create-rounding-enabled", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "12.345678" + }, + "allowRounding": true + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": { + "allowRounding": "true" + }, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "12.345678" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "readings-create-rounding-disabled", + "operation": "meterReadings.create", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "reading": { + "value": "12.34" + }, + "allowRounding": false + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": { + "allowRounding": "false" + }, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "value": "12.34" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "readings-delete", + "operation": "meterReadings.delete", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "timestamp": "2026-09-01T12:34:56.123+02:00" + }, + "request": { + "method": "DELETE", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "timestamp": "2026-09-01T12:34:56.123+02:00" + } + } + }, + "response": { + "status": 204, + "headers": {}, + "body": {} + }, + "expected": { + "result": null + } + }, + { + "id": "readings-export-defaults", + "operation": "meterReadings.export", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "config": { + "columns": [ + "date", + "value" + ] + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings/export", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "includeHeader": true, + "delimiter": "comma", + "dateFormat": "iso", + "columns": [ + "date", + "value" + ] + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/csv; charset=utf-8" + }, + "body": { + "base64": "77u/ZGF0ZSx2YWx1ZQ0KMjAyNi0wOS0wMVQxMDozNDo1Ni4xMjNaLDEyMy40NQ0K" + } + }, + "expected": { + "result": { + "base64": "77u/ZGF0ZSx2YWx1ZQ0KMjAyNi0wOS0wMVQxMDozNDo1Ni4xMjNaLDEyMy40NQ0K" + } + } + }, + { + "id": "readings-export-configured", + "operation": "meterReadings.export", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "config": { + "columns": [ + "value", + "note", + "meter_id", + "meter_number" + ], + "includeHeader": false, + "delimiter": "semicolon", + "dateFormat": "unix_ms" + }, + "meterId": "33333333-3333-4333-8333-333333333333", + "from": "2026-09-01T12:34:56.123+02:00", + "to": "2026-09-29T00:00:00Z", + "sort": "asc" + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings/export", + "query": { + "meterId": "33333333-3333-4333-8333-333333333333", + "from": "2026-09-01T12:34:56.123+02:00", + "to": "2026-09-29T00:00:00Z", + "sort": "asc" + }, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "includeHeader": false, + "delimiter": "semicolon", + "dateFormat": "unix_ms", + "columns": [ + "value", + "note", + "meter_id", + "meter_number" + ] + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/csv; charset=utf-8" + }, + "body": { + "base64": "MTIzLjQ1O0FibGVzdW5nIMOkw7bDvDszMzMzMzMzMy0zMzMzLTQzMzMtODMzMy0zMzMzMzMzMzMzMzM7MDAxMjMNCg==" + } + }, + "expected": { + "result": { + "base64": "MTIzLjQ1O0FibGVzdW5nIMOkw7bDvDszMzMzMzMzMy0zMzMzLTQzMzMtODMzMy0zMzMzMzMzMzMzMzM7MDAxMjMNCg==" + } + } + }, + { + "id": "readings-export-json-looking", + "operation": "meterReadings.export", + "input": { + "deviceId": "11111111-1111-4111-8111-111111111111", + "config": { + "columns": [ + "value" + ], + "includeHeader": false + } + }, + "request": { + "method": "POST", + "path": "/v3/devices/standard/11111111-1111-4111-8111-111111111111/meter-readings/export", + "query": {}, + "headers": { + "Authorization": "Bearer contract-test-token", + "Content-Type": "application/json" + }, + "body": { + "json": { + "includeHeader": false, + "delimiter": "comma", + "dateFormat": "iso", + "columns": [ + "value" + ] + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/csv; charset=utf-8" + }, + "body": { + "base64": "MTIz" + } + }, + "expected": { + "result": { + "base64": "MTIz" + } + } + } + ] +} diff --git a/contracts/schema.json b/contracts/schema.json new file mode 100644 index 0000000..9856bba --- /dev/null +++ b/contracts/schema.json @@ -0,0 +1,787 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Energy Tracker SDK contract cases", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/case" + } + } + }, + "required": [ + "schemaVersion", + "cases" + ], + "additionalProperties": false, + "$defs": { + "case": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]+$" + }, + "operation": { + "enum": [ + "devices.listStandard", + "devices.listVirtual", + "meterReadings.list", + "meterReadings.create", + "meterReadings.delete", + "meterReadings.export", + "environments.list", + "environments.get", + "environments.create", + "environments.delete", + "environments.createEntry", + "environments.deleteEntry", + "calculations.dailyValues", + "calculations.extrapolations" + ] + }, + "input": { + "type": "object" + }, + "request": { + "type": "object", + "properties": { + "method": { + "enum": [ + "GET", + "POST", + "DELETE" + ] + }, + "path": { + "type": "string", + "pattern": "^/v[13]/" + }, + "query": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "$ref": "#/$defs/body" + } + }, + "required": [ + "method", + "path", + "query", + "headers", + "body" + ], + "additionalProperties": false + }, + "response": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "minimum": 200, + "maximum": 599 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "$ref": "#/$defs/body" + } + }, + "required": [ + "status", + "headers", + "body" + ], + "additionalProperties": false + }, + "expected": { + "oneOf": [ + { + "type": "object", + "properties": { + "result": {} + }, + "required": [ + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "kind": { + "enum": [ + "validation", + "authentication", + "forbidden", + "notFound", + "conflict", + "rateLimit", + "unavailable", + "api" + ] + }, + "status": { + "type": [ + "integer", + "null" + ] + }, + "messages": { + "type": "array", + "items": { + "type": "string" + } + }, + "retryAfter": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "kind", + "status", + "messages" + ], + "additionalProperties": false + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "id", + "operation", + "input", + "request", + "response", + "expected" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "operation": { + "const": "devices.listStandard" + } + } + }, + "then": { + "properties": { + "input": { + "$ref": "#/$defs/deviceFilters" + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "meterReadings.list" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "meterId": { + "type": "string" + }, + "sort": { + "enum": [ + "asc", + "desc" + ] + } + }, + "required": [ + "deviceId" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "meterReadings.create" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "reading": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + }, + "allowRounding": { + "type": "boolean" + } + }, + "required": [ + "deviceId", + "reading" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "meterReadings.delete" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "deviceId", + "timestamp" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "meterReadings.export" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "meterId": { + "type": "string" + }, + "sort": { + "enum": [ + "asc", + "desc" + ] + }, + "config": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "enum": [ + "date", + "value", + "note", + "meter_id", + "meter_number" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "includeHeader": { + "type": "boolean" + }, + "delimiter": { + "enum": [ + "comma", + "semicolon", + "tab" + ] + }, + "dateFormat": { + "enum": [ + "iso", + "date_time", + "unix", + "unix_ms" + ] + } + }, + "required": [ + "columns" + ], + "additionalProperties": false + } + }, + "required": [ + "deviceId", + "config" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "environments.list" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + } + }, + "required": [ + "deviceId" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "environments.get" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "deviceId", + "environmentId" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "environments.create" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "record": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "unit": { + "type": "string" + } + }, + "required": [ + "title" + ], + "additionalProperties": false + } + }, + "required": [ + "deviceId", + "record" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "environments.createEntry" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "environmentId": { + "type": "string" + }, + "entry": { + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "value" + ], + "additionalProperties": false + } + }, + "required": [ + "deviceId", + "environmentId", + "entry" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "environments.deleteEntry" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "environmentId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "deviceId", + "environmentId", + "timestamp" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "calculations.dailyValues" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "timeZone": { + "type": "string" + } + }, + "required": [ + "deviceId" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "calculations.extrapolations" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + }, + "timeZone": { + "type": "string" + }, + "method": { + "enum": [ + "standard" + ] + }, + "interval": { + "enum": [ + "day", + "week", + "month", + "quarter", + "year" + ] + } + }, + "required": [ + "deviceId", + "interval" + ], + "additionalProperties": false + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "devices.listVirtual" + } + } + }, + "then": { + "properties": { + "input": { + "$ref": "#/$defs/deviceFilters" + } + } + } + }, + { + "if": { + "properties": { + "operation": { + "const": "environments.delete" + } + } + }, + "then": { + "properties": { + "input": { + "type": "object", + "properties": { + "deviceId": { + "type": "string" + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "deviceId", + "environmentId" + ], + "additionalProperties": false + } + } + } + } + ] + }, + "body": { + "oneOf": [ + { + "type": "object", + "properties": { + "json": {} + }, + "required": [ + "json" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "base64": { + "type": "string", + "contentEncoding": "base64" + } + }, + "required": [ + "base64" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + ] + }, + "deviceFilters": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "folderPath": { + "type": "string" + }, + "updatedAfter": { + "type": "string", + "format": "date-time" + }, + "updatedBefore": { + "type": "string", + "format": "date-time" + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 94c02eb..d9f8c96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "isort>=7.0.0", "mypy>=1.14.0", "packaging>=24.0", + "jsonschema>=4.23.0", ] [project.urls] diff --git a/scripts/check_distribution.py b/scripts/check_distribution.py index 47249cb..bd00e28 100644 --- a/scripts/check_distribution.py +++ b/scripts/check_distribution.py @@ -24,8 +24,18 @@ def main() -> None: ): raise SystemExit("Wheel contains files outside the package and its metadata") with tarfile.open(sdists[0]) as sdist: - if not any(name.endswith("/energy_tracker_api/py.typed") for name in sdist.getnames()): + source_files = {name.partition("/")[2] for name in sdist.getnames()} + if "energy_tracker_api/py.typed" not in source_files: raise SystemExit("Source distribution is missing py.typed") + contract_files = { + str(path.relative_to(root)) + for path in (root / "contracts").rglob("*") + if path.is_file() + } + if "contracts/schema.json" not in contract_files or not contract_files <= source_files: + raise SystemExit("Source distribution is missing shared API contracts") + if not {"tests/test_contracts.py", "tests/conftest.py"} <= source_files: + raise SystemExit("Source distribution is missing the API contract test runner") with tempfile.TemporaryDirectory(prefix="energy-tracker-wheel-") as directory: environment = venv.EnvBuilder(with_pip=True) diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..056acb5 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,224 @@ +"""Run the shared SDK contracts through the public Python API and a local HTTP server.""" + +import base64 +import json +from dataclasses import fields, is_dataclass +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path + +import pytest +from aiohttp import web +from jsonschema import Draft202012Validator + +from energy_tracker_api import ( + AuthenticationError, + CalculationInterval, + ConflictError, + CreateEnvironmentEntryDto, + CreateEnvironmentRecordDto, + CreateMeterReadingDto, + CsvDelimiter, + DateFormat, + EnergyTrackerAPIError, + EnergyTrackerClient, + ExportColumn, + ExportMeterReadingsDto, + ExtrapolationMethod, + ForbiddenError, + RateLimitError, + ResourceNotFoundError, + ServiceUnavailableError, + SortDirection, + ValidationError, +) + +CONTRACTS = Path(__file__).resolve().parents[1] / "contracts" +ERRORS = { + "validation": ValidationError, + "authentication": AuthenticationError, + "forbidden": ForbiddenError, + "notFound": ResourceNotFoundError, + "conflict": ConflictError, + "rateLimit": RateLimitError, + "unavailable": ServiceUnavailableError, + "api": EnergyTrackerAPIError, +} +OPERATIONS = { + "devices.listStandard": ("devices", "list_standard"), + "devices.listVirtual": ("devices", "list_virtual"), + "meterReadings.list": ("meter_readings", "list"), + "meterReadings.create": ("meter_readings", "create"), + "meterReadings.delete": ("meter_readings", "delete"), + "meterReadings.export": ("meter_readings", "export"), + "environments.list": ("environments", "list"), + "environments.get": ("environments", "get"), + "environments.create": ("environments", "create"), + "environments.delete": ("environments", "delete"), + "environments.createEntry": ("environments", "create_entry"), + "environments.deleteEntry": ("environments", "delete_entry"), + "calculations.dailyValues": ("calculations", "daily_values"), + "calculations.extrapolations": ("calculations", "extrapolations"), +} +TIMESTAMPS = {"timestamp", "date", "lastUpdatedAt", "from", "to", "updatedAfter", "updatedBefore"} + + +def load_cases(): + schema = json.loads((CONTRACTS / "schema.json").read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + cases = [] + for path in sorted((CONTRACTS / "cases").glob("*.json")): + suite = json.loads(path.read_text(encoding="utf-8")) + validator.validate(suite) + cases.extend(suite["cases"]) + assert cases, "No contract cases found" + assert len({case["id"] for case in cases}) == len(cases), "Duplicate contract case ID" + assert {case["operation"] for case in cases} == set(OPERATIONS) + return cases + + +CASES = load_cases() + + +def snake_case(name): + return "".join("_" + char.lower() if char.isupper() else char for char in name) + + +def camel_case(name): + first, *rest = name.split("_") + return first + "".join(part.capitalize() for part in rest) + + +def arguments(data): + """Adapt neutral fixture inputs to Python's public parameter and DTO types.""" + result = {} + for key, value in data.items(): + name = snake_case(key) + if key in TIMESTAMPS: + value = datetime.fromisoformat(value) + name = {"from": "from_timestamp", "to": "to_timestamp"}.get(key, name) + elif key == "reading": + value = CreateMeterReadingDto(**arguments(value)) + name = "meter_reading" + elif key == "record": + value = CreateEnvironmentRecordDto(**arguments(value)) + name = "environment_record" + elif key == "entry": + value = CreateEnvironmentEntryDto(**arguments(value)) + elif key == "config": + value = ExportMeterReadingsDto(**arguments(value)) + name = "export_config" + elif key == "value" and isinstance(value, str): + value = Decimal(value) + elif key == "columns": + value = [ExportColumn(column) for column in value] + elif key in {"sort", "interval", "method", "delimiter", "dateFormat"}: + enum = { + "sort": SortDirection, + "interval": CalculationInterval, + "method": ExtrapolationMethod, + "delimiter": CsvDelimiter, + "dateFormat": DateFormat, + }[key] + value = enum(value) + result[name] = value + return result + + +def normalize(value, key=None): + """Expose native DTO results as neutral values; compare timestamps as instants.""" + if is_dataclass(value): + return normalize( + {camel_case(field.name): getattr(value, field.name) for field in fields(value)} + ) + if isinstance(value, dict): + return {name: normalize(item, name) for name, item in value.items()} + if isinstance(value, list): + return [normalize(item) for item in value] + if isinstance(value, bytes): + return {"base64": base64.b64encode(value).decode("ascii")} + if isinstance(value, Decimal): + return str(value) + if isinstance(value, bool): + # Python otherwise considers False == 0 and True == 1, unlike JSON. + return ("boolean", value) + if key in TIMESTAMPS and isinstance(value, str): + value = datetime.fromisoformat(value) + if isinstance(value, datetime): + assert value.utcoffset() is not None, "Contract timestamp must have a UTC offset" + return value.astimezone(UTC).isoformat() + return value + + +def body_bytes(body): + if "json" in body: + return json.dumps(body["json"], ensure_ascii=False, allow_nan=False).encode("utf-8") + if "base64" in body: + return base64.b64decode(body["base64"], validate=True) + return body.get("text", "").encode("utf-8") + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case["id"]) +async def test_api_contract(serve, case): + requests = [] + + async def handler(request): + requests.append((request, await request.read())) + response = case["response"] + return web.Response( + status=response["status"], + headers=response["headers"], + body=body_bytes(response["body"]), + ) + + # A base URL with a path also verifies that SDKs preserve reverse-proxy prefixes. + async with EnergyTrackerClient( + "contract-test-token", base_url=await serve(handler) + "public-api/" + ) as client: + resource, method = OPERATIONS[case["operation"]] + operation = getattr(getattr(client, resource), method) + kwargs = arguments(case["input"]) + expected = case["expected"] + if "error" in expected: + error = expected["error"] + with pytest.raises(ERRORS[error["kind"]]) as caught: + await operation(**kwargs) + assert type(caught.value) is ERRORS[error["kind"]] + assert caught.value.status_code == error["status"] + assert caught.value.api_message == error["messages"] + if "retryAfter" in error: + assert caught.value.retry_after == error["retryAfter"] + else: + result = await operation(**kwargs) + assert normalize(result) == normalize(expected["result"]) + + # Assert outside the handler so a request mismatch cannot masquerade as an HTTP 500. + assert len(requests) == 1, "Contract calls must not retry or follow redirects" + request, raw_body = requests[0] + expected_request = case["request"] + assert request.method == expected_request["method"] + assert request.path == "/public-api" + expected_request["path"] + assert len(request.query) == len(expected_request["query"]) + assert normalize(dict(request.query)) == normalize(expected_request["query"]) + for name, value in expected_request["headers"].items(): + assert request.headers[name] == value + if "json" in expected_request["body"]: + assert normalize(json.loads(raw_body)) == normalize(expected_request["body"]["json"]) + else: + assert raw_body == body_bytes(expected_request["body"]) + + +def test_each_operation_has_success_and_wrong_status_cases(): + for operation in OPERATIONS: + cases = [case for case in CASES if case["operation"] == operation] + success_statuses = { + case["response"]["status"] for case in cases if "result" in case["expected"] + } + assert len(success_statuses) == 1, operation + assert any( + "error" in case["expected"] + and 200 <= case["response"]["status"] < 300 + and case["response"]["status"] not in success_statuses + for case in cases + ), operation