anikeen_id = '01JUSER'; $owner->anikeen_id_access_token = 'user-token'; return $owner; } private function sentCall(int $index): string { $request = $this->sent[$index]['request']; return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'); } private function list(array $items = []): array { return ['data' => $items, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => count($items)], 'links' => ['next' => null]]; } public function test_charge_uses_the_versioned_endpoint_without_stripe_options(): void { $this->respond(201, ['data' => ['id' => 'tx1', 'status' => 'succeeded']]); $this->owner()->charge(10, 'pm_1', ['transfer_data' => ['destination' => 'acct_x']]); $this->assertSame('POST /api/v1/billing/charge Bearer user-token', $this->sentCall(0)); $this->assertSame(['amount' => 10, 'payment_method_id' => 'pm_1'], $this->sentJson(0)); } public function test_billing_overview(): void { $this->respond(200, ['data' => ['current_charges' => 11.9, 'has_default_payment_method' => true]]); $this->assertSame(11.9, $this->owner()->billingOverview()->current_charges); $this->assertSame('GET /api/v1/billing Bearer user-token', $this->sentCall(0)); } public function test_billing_overview_carries_the_billing_limit(): void { $this->respond(200, ['data' => ['current_charges' => 11.9, 'billing_limit' => ['object' => 'billing_limit', 'limit' => '50.00', 'used' => '11.90', 'remaining' => '38.10', 'reached' => false, 'version' => 3]]]); $limit = new \Anikeen\Id\Resources\BillingLimit($this->owner()->billingOverview()->billing_limit); $this->assertSame('38.10', $limit->remaining()); $this->assertTrue($limit->allows('38.10')); $this->assertNull($limit->usedByApp()); } public function test_charges_and_billing_limits_of_users_use_the_app_token(): void { $charge = ['data' => ['object' => 'charge', 'id' => '01JCHARGE', 'status' => 'open']]; $this->respond(201, $charge); $this->respond(200, $charge); $this->respond(200, $charge); $this->respond(200, ['data' => ['object' => 'billing_limit', 'limit' => null]]); app(AnikeenId::class)->createChargeForUser('01JUSER', ['idempotency_key' => 'k', 'name' => 'Card', 'quantity' => 1, 'unit_price' => '4.00']); app(AnikeenId::class)->findChargeForUser('01JUSER', '01JCHARGE'); app(AnikeenId::class)->voidChargeForUser('01JUSER', '01JCHARGE'); app(AnikeenId::class)->billingLimitForUser('01JUSER'); $this->assertSame('POST /api/v1/client/users/01JUSER/charges Bearer app-token', $this->sentCall(0)); $this->assertSame('GET /api/v1/client/users/01JUSER/charges/01JCHARGE Bearer app-token', $this->sentCall(1)); $this->assertSame('POST /api/v1/client/users/01JUSER/charges/01JCHARGE/void Bearer app-token', $this->sentCall(2)); $this->assertSame('GET /api/v1/client/users/01JUSER/billing-limit Bearer app-token', $this->sentCall(3)); } public function test_invoice_download_url_is_read_not_updated(): void { $this->respond(200, $this->list([['id' => 'inv1']])); $this->respond(200, ['data' => ['id' => 'inv1', 'temporary_url' => 'https://s3/inv1.pdf']]); $this->respond(200, ['data' => ['id' => 'inv1', 'temporary_url' => 'https://s3/inv1.pdf']]); $url = $this->owner()->invoices()->find('inv1')->getInvoiceTemporaryUrl(); $this->assertSame('https://s3/inv1.pdf', $url); $this->assertSame('GET /api/v1/invoices/inv1 Bearer user-token', $this->sentCall(2)); } public function test_order_items_are_created_on_the_items_endpoint(): void { $this->respond(200, $this->list()); // GET v1/orders $this->respond(200, ['data' => ['id' => 'order1', 'status' => 'draft']]); // GET v1/orders/order1 $this->respond(200, $this->list()); // GET v1/orders/order1/items $this->respond(201, ['data' => [['id' => 'item1', 'name' => 'Domain']]]); // POST v1/orders/order1/items $created = $this->owner()->orders()->find('order1')->orderItems() ->create('order1', ['items' => [['type' => 'digital', 'name' => 'Domain', 'price' => 10, 'unit' => 'year', 'units' => 1]]]); $this->assertSame('item1', $created->id); $this->assertSame('POST /api/v1/orders/order1/items Bearer user-token', $this->sentCall(3)); } public function test_create_user_uses_the_app_token_and_admin_endpoint(): void { $this->respond(200, ['data' => ['id' => '01JNEW']]); app(AnikeenId::class)->createUser(['first_name' => 'Erika', 'last_name' => 'Mustermann', 'email' => 'erika@example.com', 'password' => 'secret-123']); $this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0)); $this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0)); } public function test_create_user_can_send_an_invitation(): void { $this->respond(201, ['data' => ['id' => '01JNEW', 'email' => 'erika@example.com', 'created' => true, 'invitation_sent' => true]]); $result = app(AnikeenId::class)->createUser(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true]); $this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0)); $this->assertSame(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true], $this->sentJson(0)); $this->assertTrue($result->data->created); $this->assertTrue($result->data->invitation_sent); } public function test_find_user_by_email_uses_the_lookup_endpoint(): void { $this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Erika', 'email' => 'erika@example.com', 'email_verified' => true, 'has_billing_address' => false]]); $result = app(AnikeenId::class)->findUserByEmail('Erika@Example.com'); $this->assertSame('POST /api/v1/admin/users/lookup Bearer app-token', $this->sentCall(0)); $this->assertSame(['email' => 'Erika@Example.com'], $this->sentJson(0)); $this->assertSame('01JUSER', $result->data->id); $this->assertFalse($result->data->has_billing_address); } public function test_find_user_by_email_returns_null_without_an_account(): void { $this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']); $this->assertNull(app(AnikeenId::class)->findUserByEmail('nobody@example.com')); } public function test_find_user_by_email_throws_other_failures(): void { $this->respond(404, ['message' => 'Not found.']); // e.g. an Anikeen ID without the lookup $this->respond(500, ['message' => 'Server Error']); foreach ([404, 500] as $status) { try { app(AnikeenId::class)->findUserByEmail('erika@example.com'); $this->fail('Expected an ApiException.'); } catch (ApiException $e) { $this->assertSame($status, $e->status); } } } public function test_email_check_and_socialite_users(): void { $this->respond(200, ['data' => ['exists' => true]]); $this->respond(200, ['data' => ['id' => '01JUSER']]); $this->assertTrue(app(AnikeenId::class)->isEmailExisting('erika@example.com')->data->exists); app(AnikeenId::class)->findOrCreateSocialiteUser('twitch', '123', 'streamer@example.com'); $this->assertSame('POST /api/v1/admin/users/check Bearer app-token', $this->sentCall(0)); $this->assertSame('POST /api/v1/admin/users/socialite Bearer app-token', $this->sentCall(1)); $this->assertSame(['provider' => 'twitch', 'provider_id' => '123', 'email' => 'streamer@example.com'], $this->sentJson(1)); } public function test_ssh_keys_of_a_user_for_apps_are_not_cached_across_users(): void { $this->respond(200, ['data' => [['id' => 'k1', 'name' => 'A']]]); $this->respond(200, ['data' => [['id' => 'k2', 'name' => 'B']]]); $first = app(AnikeenId::class)->sshKeysByUserId('01JA'); $second = app(AnikeenId::class)->sshKeysByUserId('01JB'); $this->assertSame('k1', $first->toArray()[0]->id); $this->assertSame('k2', $second->toArray()[0]->id); $this->assertSame('GET /api/v1/client/users/01JA/ssh-keys Bearer app-token', $this->sentCall(0)); $this->assertSame('GET /api/v1/client/users/01JB/ssh-keys Bearer app-token', $this->sentCall(1)); } public function test_users_manage_their_own_ssh_keys(): void { $this->respond(200, $this->list()); $this->respond(201, ['data' => ['id' => 'k1', 'name' => 'Laptop']]); $this->respond(200, ['data' => ['id' => 'k1', 'name' => 'Work']]); $this->respond(204); $keys = $this->owner()->sshKeys(); $key = $keys->create('ssh-ed25519 AAAA… erika@laptop', 'Laptop'); $renamed = $key->update('Work'); $this->assertSame('Work', $renamed->name); $this->assertTrue($renamed->delete()); $this->assertSame([ 'GET /api/v1/ssh-keys Bearer user-token', 'POST /api/v1/ssh-keys Bearer user-token', 'PUT /api/v1/ssh-keys/k1 Bearer user-token', 'DELETE /api/v1/ssh-keys/k1 Bearer user-token', ], array_map(fn ($i) => $this->sentCall($i), range(0, 3))); } public function test_payment_methods_can_be_deleted(): void { $this->respond(200, ['data' => [['id' => 'pm_1']]]); $this->respond(200, ['data' => ['id' => 'pm_1', 'type' => 'card']]); $this->respond(204); $this->assertTrue($this->owner()->paymentMethods()->find('pm_1')->delete()); $this->assertSame('DELETE /api/v1/payment-methods/pm_1 Bearer user-token', $this->sentCall(2)); } public function test_products_and_boxes(): void { $this->respond(200, ['data' => [['id' => 'p1']]]); $this->respond(200, [['id' => 'b1']]); app(AnikeenId::class)->products(); app(AnikeenId::class)->boxes(); $this->assertStringStartsWith('GET /api/v1/products', $this->sentCall(0)); $this->assertStringStartsWith('GET /api/v1/boxes', $this->sentCall(1)); } public function test_shipping_labels_use_the_app_token(): void { foreach (range(1, 5) as $i) { $this->respond(200, ['data' => []]); } $this->http->append(new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/pdf'], '%PDF-1.7')); $id = app(AnikeenId::class); $id->shippingLabels(); $id->shippingLabel('l1'); $id->issueShippingLabel(['box' => ['id' => 'b1'], 'weight' => 20, 'address' => [], 'billed_user_id' => '01JUSER', 'test' => true]); $id->shipShippingLabel(['0A', 'FF']); $id->shippingPriceList(); $pdf = $id->shippingLabelPdf('l1'); $this->assertSame('%PDF-1.7', $pdf); $this->assertSame([ 'GET /api/v1/admin/shipping-labels Bearer app-token', 'GET /api/v1/admin/shipping-labels/l1 Bearer app-token', 'POST /api/v1/admin/shipping-labels/issue Bearer app-token', 'POST /api/v1/admin/shipping-labels/ship Bearer app-token', 'GET /api/v1/admin/shipping-labels/price-list Bearer app-token', 'GET /api/v1/admin/shipping-labels/l1/pdf Bearer app-token', ], array_map(fn ($i) => $this->sentCall($i), range(0, 5))); } public function test_orders_and_transactions_for_users(): void { $this->respond(201, ['data' => ['id' => 'o1']]); $this->respond(201, ['data' => ['id' => 'tx1']]); app(AnikeenId::class)->createOrderForUser('01JUSER', ['items' => [], 'checkout' => true]); app(AnikeenId::class)->createTransactionForUser('01JUSER', ['status' => 'succeeded', 'type' => 'deposit', 'amount' => '9.99', 'currency_iso' => 'EUR', 'created_at' => '2026-10-26']); $this->assertSame('POST /api/v1/admin/orders Bearer app-token', $this->sentCall(0)); $this->assertSame('01JUSER', $this->sentJson(0)['user_id']); $this->assertSame('POST /api/v1/admin/transactions Bearer app-token', $this->sentCall(1)); } public function test_billing_portal_links_for_users_use_the_app_token(): void { $link = ['object' => 'billing_portal_link', 'url' => 'https://id.test/billing/portal/01JUSER?signature=x', 'user_id' => '01JUSER', 'flow' => 'invoices', 'group' => 'team-1', 'return_url' => 'https://console.test/billing', 'expires_at' => '2026-11-04T10:15:00+00:00']; $this->respond(201, ['data' => $link]); $this->respond(201, ['data' => $link]); $created = app(AnikeenId::class)->createBillingPortalLinkForUser('01JUSER', ['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices']); $url = $this->owner()->appBillingPortalUrl(['flow' => 'payment_method_update']); $this->assertSame('billing_portal_link', $created->object); $this->assertSame($link['url'], $url); $this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(0)); $this->assertSame(['return_url' => 'https://console.test/billing', 'group' => 'team-1', 'flow' => 'invoices'], $this->sentJson(0)); $this->assertSame('POST /api/v1/client/users/01JUSER/billing-portal Bearer app-token', $this->sentCall(1)); $this->assertSame(['flow' => 'payment_method_update'], $this->sentJson(1)); } public function test_a_refused_billing_portal_link_is_an_api_exception(): void { $this->respond(422, ['message' => 'The return URL has to point to one of the app\'s redirect URIs.', 'errors' => ['return_url' => ['…']]]); try { $this->owner()->appBillingPortalUrl(['return_url' => 'https://elsewhere.test']); $this->fail('Expected an ApiException.'); } catch (ApiException $e) { $this->assertSame(422, $e->status); } } public function test_order_preview_maps_the_country_into_the_billing_address(): void { $this->respond(200, ['data' => ['gross_total' => 11.9]]); app(AnikeenId::class)->createOrderPreview(['country_iso' => 'DE', 'items' => []]); $this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0)); } private function preference(string $namespace, string $key, mixed $value, int $version = 7): array { return ['namespace' => $namespace, 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'app', 'client_id' => 'app-client']]; } private function conditions(int $index): array { $request = $this->sent[$index]['request']; return [$request->getHeaderLine('If-Match'), $request->getHeaderLine('If-None-Match')]; } public function test_global_preferences_of_the_user(): void { $this->respond(200, ['data' => [$this->preference('global', 'appearance', 'dark')]]); $preferences = $this->owner()->globalPreferences(); $this->assertSame('dark', $preferences->get('appearance')); $this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0)); } public function test_global_preference_with_a_default(): void { $this->respond(200, ['data' => [$this->preference('global', 'appearance', 'light')]]); $owner = $this->owner(); $this->assertSame('light', $owner->globalPreference('appearance')); $this->assertSame('system', $owner->globalPreference('unknown', 'system')); $this->assertCount(1, $this->sent); $this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0)); } public function test_set_global_preference(): void { $this->respond(201, ['data' => $this->preference('global', 'appearance', 'dark', 8)]); $this->respond(200, ['data' => $this->preference('global', 'appearance', 'light', 9)]); $created = $this->owner()->setGlobalPreference('appearance', 'dark'); $updated = $this->owner()->setGlobalPreference('appearance', 'light', ifVersion: 8); $this->assertTrue($created->wasCreated()); $this->assertFalse($updated->wasCreated()); $this->assertSame(9, $updated->version); $this->assertSame('PUT /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0)); $this->assertSame(['value' => 'dark'], $this->sentJson(0)); $this->assertSame(['', ''], $this->conditions(0)); $this->assertSame(['"8"', ''], $this->conditions(1)); } public function test_delete_global_preference(): void { $this->respond(204); $this->respond(204); $this->owner()->deleteGlobalPreference('appearance'); $this->owner()->deleteGlobalPreference('appearance', ifVersion: 9); $this->assertSame('DELETE /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0)); $this->assertSame('', (string) $this->sent[0]['request']->getBody()); $this->assertSame(['', ''], $this->conditions(0)); $this->assertSame(['"9"', ''], $this->conditions(1)); } public function test_preferences_by_user_id(): void { $this->respond(200, ['data' => [$this->preference('acme', 'plan', 'pro'), $this->preference('acme', 'theme', ['accent' => 'blue'])]]); $preferences = app(AnikeenId::class)->preferencesByUserId('01JUSER', 'acme'); $this->assertEquals(['plan' => 'pro', 'theme' => (object) ['accent' => 'blue']], $preferences->values()); $this->assertSame('blue', $preferences->find('theme')->value->accent); $this->assertNull($preferences->find('missing')); $this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme Bearer app-token', $this->sentCall(0)); } public function test_preference_by_user_id(): void { $this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]); $preference = app(AnikeenId::class)->preferenceByUserId('01JUSER', 'acme', 'plan'); $this->assertSame('pro', $preference->value); $this->assertSame(7, $preference->version); $this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0)); } public function test_set_preference_by_user_id(): void { $this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]); $this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]); $this->respond(201, ['data' => $this->preference('acme', 'plan', 'pro')]); $anikeenId = app(AnikeenId::class); $anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro'); $anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7); $anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true); $this->assertSame('PUT /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0)); $this->assertSame(['value' => 'pro'], $this->sentJson(0)); $this->assertSame(['', ''], $this->conditions(0)); $this->assertSame(['"7"', ''], $this->conditions(1)); $this->assertSame(['', '*'], $this->conditions(2)); } public function test_delete_preference_by_user_id(): void { $this->respond(204); $this->respond(204); app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan'); app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan', ifVersion: 7); $this->assertSame('DELETE /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0)); $this->assertSame(['', ''], $this->conditions(0)); $this->assertSame(['"7"', ''], $this->conditions(1)); } public function test_find_user_id_by_preference(): void { $this->respond(200, ['data' => ['user_id' => '01JUSER', 'namespace' => 'acme', 'key' => 'customer_id', 'version' => 7, 'updated_at' => '2026-11-06T10:00:00.000000Z']]); $this->assertSame('01JUSER', app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123')); $this->assertSame('POST /api/v1/client/preferences/acme/lookup Bearer app-token', $this->sentCall(0)); $this->assertSame(['key' => 'customer_id', 'value' => 'cus_123'], $this->sentJson(0)); $this->assertSame('', $this->sent[0]['request']->getUri()->getQuery()); } }