# app/modules/loyalty/tests/integration/test_terminal_devices.py """Integration tests for terminal device pairing & revocation. Covers both the merchant API (`/api/v1/merchants/loyalty/devices/...`) and the admin-on-behalf API (`/api/v1/admin/loyalty/merchants/{id}/devices/...`), plus the bearer-auth path on the store API that accepts a paired-device JWT. """ import pytest from jose import jwt from app.modules.loyalty.models import TerminalDevice from middleware.auth import AuthManager MERCHANT_BASE = "/api/v1/merchants/loyalty" STORE_BASE = "/api/v1/store/loyalty" @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantPairing: """POST /merchants/loyalty/devices — creates row and returns one-time QR.""" def test_pair_device_returns_setup_token_and_qr( self, client, loyalty_merchant_headers, loyalty_store_setup, db ): store = loyalty_store_setup["store"] response = client.post( f"{MERCHANT_BASE}/devices", json={"store_id": store.id, "label": "Counter 1"}, headers=loyalty_merchant_headers, ) assert response.status_code == 201, response.text data = response.json() assert data["label"] == "Counter 1" assert data["store_id"] == store.id assert data["status"] == "active" assert data["setup_token"] assert data["qr_png_base64"].startswith("data:image/png;base64,") assert data["setup_payload"]["store_code"] == store.store_code assert data["setup_payload"]["auth_token"] == data["setup_token"] # Token is signed by AuthManager and carries the device_setup claim # plus the same jti as the row. auth = AuthManager() decoded = jwt.decode( data["setup_token"], auth.secret_key, algorithms=[auth.algorithm] ) assert decoded["device_setup"] is True assert decoded["store_id"] == store.id device = ( db.query(TerminalDevice) .filter(TerminalDevice.id == data["id"]) .first() ) assert device is not None assert device.jti == decoded["jti"] def test_pair_device_rejects_foreign_store( self, client, loyalty_merchant_headers, db ): """A merchant cannot pair against a store they don't own.""" # store_id=99999 doesn't belong to this merchant response = client.post( f"{MERCHANT_BASE}/devices", json={"store_id": 99999, "label": "Hijack"}, headers=loyalty_merchant_headers, ) assert response.status_code == 404 @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestMerchantListAndRevoke: """GET / POST revoke — listing and lifecycle.""" def test_list_excludes_revoked_by_default( self, client, loyalty_merchant_headers, loyalty_store_setup ): store = loyalty_store_setup["store"] # Pair two devices for label in ("A", "B"): r = client.post( f"{MERCHANT_BASE}/devices", json={"store_id": store.id, "label": label}, headers=loyalty_merchant_headers, ) assert r.status_code == 201 # Revoke first one listing = client.get( f"{MERCHANT_BASE}/devices", headers=loyalty_merchant_headers ).json() first_id = listing["devices"][-1]["id"] # deterministic order: created_at desc r = client.post( f"{MERCHANT_BASE}/devices/{first_id}/revoke", json={"reason": "lost"}, headers=loyalty_merchant_headers, ) assert r.status_code == 200 assert r.json()["status"] == "revoked" # Default list excludes the revoked one active = client.get( f"{MERCHANT_BASE}/devices", headers=loyalty_merchant_headers ).json() assert active["total"] == 1 # include_revoked=true brings it back full = client.get( f"{MERCHANT_BASE}/devices?include_revoked=true", headers=loyalty_merchant_headers, ).json() assert full["total"] == 2 def test_revoke_is_idempotent( self, client, loyalty_merchant_headers, loyalty_store_setup ): store = loyalty_store_setup["store"] r = client.post( f"{MERCHANT_BASE}/devices", json={"store_id": store.id, "label": "Idem"}, headers=loyalty_merchant_headers, ).json() first = client.post( f"{MERCHANT_BASE}/devices/{r['id']}/revoke", json={}, headers=loyalty_merchant_headers, ).json() second = client.post( f"{MERCHANT_BASE}/devices/{r['id']}/revoke", json={}, headers=loyalty_merchant_headers, ).json() assert first["revoked_at"] == second["revoked_at"] @pytest.mark.integration @pytest.mark.api @pytest.mark.loyalty class TestStoreAPIBearerAuth: """Paired-device JWT is accepted on the store API.""" def test_device_token_authenticates_store_api( self, client, loyalty_merchant_headers, loyalty_store_setup ): store = loyalty_store_setup["store"] paired = client.post( f"{MERCHANT_BASE}/devices", json={"store_id": store.id, "label": "Counter 1"}, headers=loyalty_merchant_headers, ).json() token = paired["setup_token"] response = client.get( f"{STORE_BASE}/program", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200, response.text body = response.json() assert body["merchant_id"] == loyalty_store_setup["merchant"].id def test_revoked_device_token_is_rejected( self, client, loyalty_merchant_headers, loyalty_store_setup ): store = loyalty_store_setup["store"] paired = client.post( f"{MERCHANT_BASE}/devices", json={"store_id": store.id, "label": "Burn"}, headers=loyalty_merchant_headers, ).json() token = paired["setup_token"] client.post( f"{MERCHANT_BASE}/devices/{paired['id']}/revoke", json={"reason": "test"}, headers=loyalty_merchant_headers, ) response = client.get( f"{STORE_BASE}/program", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code in (400, 401)