where('code', 'DE')->first() ?? Country::query()->forceCreate([ 'code' => 'DE', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Germanija', ]); } function apilogMakeUser(int $adminLevel = 7): User { $user = User::query()->create([ 'email' => uniqid('apilog_', true).'@test.example', 'password' => bcrypt('password'), ]); $account = UserAccount::query()->create([ 'm_first_name' => 'Api', 'm_last_name' => 'Partner', 'country_id' => apilogCountry()->id, ]); $user->forceFill([ 'admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false, 'account_id' => $account->id, ])->save(); return $user->fresh(); } function apilogMakeShoppingUser(int $wpOrderNumber): ShoppingUser { $country = apilogCountry(); $shipping = Shipping::query()->forceCreate(['name' => 'Standard']); ShippingCountry::query()->forceCreate([ 'shipping_id' => $shipping->id, 'country_id' => $country->id, ]); return ShoppingUser::query()->forceCreate([ 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, 'billing_firstname' => 'Api', 'billing_lastname' => 'Kunde', 'wp_order_number' => $wpOrderNumber, 'member_id' => apilogMakeUser(0)->id, 'auth_user_id' => apilogMakeUser(0)->id, ]); } function apilogMakeProduct(string $name, int $wpNumber, string $price = '19,90'): Product { return Product::query()->create([ 'name' => $name, 'title' => $name, 'active' => true, 'is_set' => false, 'wp_number' => $wpNumber, 'price' => $price, 'tax' => '19', 'weight' => 100, ]); } test('uebertragung mit unbekannter sku wird protokolliert und markiert', function () { Mail::fake(); Passport::actingAs(apilogMakeUser()); $product = apilogMakeProduct('Apilog Creme', 100); $shoppingUser = apilogMakeShoppingUser(4711); $response = $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4711, 'wp_order' => [ ['article' => 100, 'qty' => 2, 'price' => 1990], ['article' => 999, 'qty' => 1, 'price' => 500], ], ]); $response->assertOk()->assertJsonPath('success', true); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeTrue() ->and($log->error_summary)->toBe('1 von 2 Positionen ohne Produktzuordnung') ->and($log->wp_order_number)->toBe(4711) ->and($log->shopping_user_id)->toBe($shoppingUser->id) ->and($log->shopping_order_id)->not->toBeNull() ->and($log->payload['lines'])->toHaveCount(2) ->and($log->payload['lines'][0]['product_id'])->toBe($product->id) ->and($log->payload['lines'][0]['failed'])->toBeFalse() ->and($log->payload['lines'][1]['product_id'])->toBeNull() ->and($log->payload['lines'][1]['failed'])->toBeTrue() ->and($log->payload['lines'][1]['messages'])->toContain('article not found'); // Die nicht zugeordnete Position fehlt in der Bestellung (Bestand: kein Abzug möglich) expect(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->count())->toBe(1); }); test('bekannte sku ohne menge wird als fehler markiert (position fehlt in der bestellung)', function () { // Review 20.07.2026: vorher hing `failed` nur am SKU-Lookup — eine Zeile mit // gültigem Artikel, aber fehlender Menge/Preis wurde verworfen und trotzdem // grün protokolliert. Mail::fake(); Passport::actingAs(apilogMakeUser()); $product = apilogMakeProduct('Apilog Öl', 300); apilogMakeProduct('Apilog Balsam', 301); apilogMakeShoppingUser(4715); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4715, 'wp_order' => [ ['article' => 300, 'price' => 1990], // qty fehlt — wird nicht übernommen ['article' => 301, 'qty' => 1, 'price' => 1990], ], ])->assertOk(); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeTrue() ->and($log->error_summary)->toBe('1 von 2 Positionen ohne Produktzuordnung') ->and($log->payload['lines'][0]['failed'])->toBeTrue() ->and($log->payload['lines'][0]['messages'])->toContain('article parameter is missing') ->and($log->payload['lines'][1]['failed'])->toBeFalse(); // Nur die vollständige Position landet in der Bestellung expect(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->count())->toBe(1) ->and(ShoppingOrderItem::query()->where('product_id', $product->id)->count())->toBe(0); }); test('AP-34: jede variante wird über ihre eigene volle SKU dem richtigen produkt zugeordnet', function () { // Ab Connector v1.6 sendet der Shop die Variations-Artikel-Nr. selbst. In der DB // ist jede Variante eine eigene products-Zeile mit eigener wp_number (kein // main_product_id, kein „-"-Suffix). Eine Bestellung der MINI-Variante (100026) // darf NICHT beim 50-ml-Basisprodukt (100006) landen. Mail::fake(); Passport::actingAs(apilogMakeUser()); $parent = apilogMakeProduct('Deocreme Angekommen 50 ml', 100006); $mini = apilogMakeProduct('Deocreme Angekommen MINI 5 ml', 100026); apilogMakeShoppingUser(4730); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4730, 'wp_order' => [ ['article' => 100026, 'qty' => 1, 'price' => 1990], ], ])->assertOk(); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeFalse() ->and($log->payload['lines'][0]['product_id'])->toBe($mini->id) ->and($log->payload['lines'][0]['product_id'])->not->toBe($parent->id) ->and($log->payload['lines'][0]['failed'])->toBeFalse(); expect(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->where('product_id', $mini->id)->count())->toBe(1) ->and(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->where('product_id', $parent->id)->count())->toBe(0); }); test('AP-34: shop-duplikat „kernnummer-suffix" bucht dasselbe kern-produkt', function () { // Kopiert ein Anwender ein Produkt für eine Aktion / ein neues Bild, verlangt // WooCommerce eine eindeutige SKU (Kernnummer + Suffix, z. B. 10042-1). Das ist // derselbe physische Artikel und muss auf das Kern-Produkt (10042) gebucht werden. Mail::fake(); Passport::actingAs(apilogMakeUser()); $product = apilogMakeProduct('Apilog Aktionskopie', 10042); apilogMakeShoppingUser(4716); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4716, 'wp_order' => [ ['article' => '10042-1', 'qty' => 1, 'price' => 1990], ], ])->assertOk(); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeFalse() ->and($log->payload['lines'][0]['article'])->toBe('10042-1') // Original-SKU bleibt im Protokoll ->and($log->payload['lines'][0]['product_id'])->toBe($product->id) ->and($log->payload['lines'][0]['failed'])->toBeFalse(); expect(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->where('product_id', $product->id)->count())->toBe(1); }); test('produkt nur zur internen berechnung (show_on 10) wird per api zugeordnet', function () { // show_on steuert nur die Anzeige (Shop, Bestelllisten, Microsite …) — // die API-Zuordnung läuft ausschließlich über die WP-Nummer. Mail::fake(); Passport::actingAs(apilogMakeUser()); $product = apilogMakeProduct('Apilog Intern', 10050); $product->update(['show_on' => ['10']]); apilogMakeShoppingUser(4718); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4718, 'wp_order' => [ ['article' => 10050, 'qty' => 1, 'price' => 1990], ], ])->assertOk(); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeFalse() ->and($log->payload['lines'][0]['product_id'])->toBe($product->id); expect(ShoppingOrderItem::query()->where('shopping_order_id', $log->shopping_order_id)->where('product_id', $product->id)->count())->toBe(1); }); test('sku ohne basis vor dem minus wird nicht zugeordnet', function () { Mail::fake(); Passport::actingAs(apilogMakeUser()); apilogMakeProduct('Apilog NullNummer', 10043); apilogMakeShoppingUser(4717); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4717, 'wp_order' => [ ['article' => '-1', 'qty' => 1, 'price' => 1990], ], ])->assertOk(); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeTrue() ->and($log->payload['lines'][0]['failed'])->toBeTrue() ->and($log->payload['lines'][0]['messages'])->toContain('article not found'); }); test('fehlerfreie uebertragung wird als ok protokolliert', function () { Mail::fake(); Passport::actingAs(apilogMakeUser()); apilogMakeProduct('Apilog Seife', 200); apilogMakeShoppingUser(4712); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4712, 'wp_order' => [ ['article' => 200, 'qty' => 1, 'price' => 1990], ], ])->assertOk(); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeFalse() ->and($log->error_summary)->toBeNull() ->and($log->shopping_order_id)->not->toBeNull(); }); test('bereits existierende bestellung wird als fehler protokolliert', function () { Mail::fake(); Passport::actingAs(apilogMakeUser()); $shoppingUser = apilogMakeShoppingUser(4713); ShoppingOrder::query()->forceCreate([ 'shopping_user_id' => $shoppingUser->id, 'auth_user_id' => apilogMakeUser(0)->id, 'country_id' => ShippingCountry::query()->first()->id, 'shipped' => 0, ]); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4713, 'wp_order' => [['article' => 1, 'qty' => 1, 'price' => 100]], ])->assertStatus(400); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeTrue() ->and($log->error_summary)->toContain('existiert bereits'); }); test('unbekannte wp-bestellnummer wird protokolliert', function () { Mail::fake(); Passport::actingAs(apilogMakeUser()); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 9999, 'wp_order' => [['article' => 1, 'qty' => 1, 'price' => 100]], ])->assertStatus(400); $log = ApiOrderLog::query()->sole(); expect($log->has_errors)->toBeTrue() ->and($log->shopping_user_id)->toBeNull() ->and($log->wp_order_number)->toBe(9999); }); test('uebersicht rendert eintraege mit fehler-markierung', function () { $shoppingUser = apilogMakeShoppingUser(4714); ApiOrderLog::query()->create([ 'shopping_user_id' => $shoppingUser->id, 'wp_order_number' => 4714, 'endpoint' => 'order', 'payload' => ['lines' => [['article' => 5, 'qty' => 1, 'price' => 100, 'product_id' => null, 'product_name' => null, 'messages' => ['article not found'], 'failed' => true]]], 'has_errors' => true, 'error_summary' => '1 von 1 Positionen ohne Produktzuordnung', ]); $this->actingAs(apilogMakeUser(1)) ->get(route('admin.inventory.api-orders.index')) ->assertOk() ->assertSee('API-Bestellungen') ->assertSee('4714') ->assertSee('1 von 1 Positionen ohne Produktzuordnung'); }); test('positions_count wird bei der uebertragung gesetzt', function () { Mail::fake(); Passport::actingAs(apilogMakeUser()); apilogMakeProduct('Apilog OnDemand', 500); apilogMakeShoppingUser(4720); $this->postJson('https://api.'.config('app.api_domain').'/wp/order', [ 'wp_order_number' => 4720, 'wp_order' => [ ['article' => 500, 'qty' => 2, 'price' => 1990], ['article' => 999, 'qty' => 1, 'price' => 100], ], ])->assertOk(); expect(ApiOrderLog::query()->sole()->positions_count)->toBe(2); }); test('der inhalt einer uebertragung wird on-demand geliefert', function () { // Review 20.07.2026: die Liste kommt ohne payload aus, der Inhalt lädt per show() $shoppingUser = apilogMakeShoppingUser(4721); $log = ApiOrderLog::query()->create([ 'shopping_user_id' => $shoppingUser->id, 'wp_order_number' => 4721, 'endpoint' => 'order', 'positions_count' => 2, 'payload' => ['lines' => [ ['article' => 500, 'qty' => 2, 'price' => 1990, 'product_id' => 1, 'product_name' => 'X', 'messages' => [], 'failed' => false], ['article' => 999, 'qty' => 1, 'price' => 100, 'product_id' => null, 'product_name' => null, 'messages' => ['article not found'], 'failed' => true], ]], 'has_errors' => true, 'error_summary' => '1 von 2 Positionen ohne Produktzuordnung', ]); // Die Liste selektiert bewusst ohne payload $listed = ApiOrderLog::query() ->select(['id', 'shopping_order_id', 'shopping_user_id', 'wp_order_number', 'positions_count', 'has_errors', 'error_summary', 'created_at']) ->first(); expect($listed->getAttributes())->not->toHaveKey('payload'); $this->actingAs(apilogMakeUser(1)) ->getJson(route('admin.inventory.api-orders.show', $log->id)) ->assertOk() ->assertJsonPath('lines.0.article', 500) ->assertJsonPath('lines.1.failed', true) ->assertJsonCount(2, 'lines'); }); test('doppelte wp-nummern aktiver produkte werden gewarnt', function () { apilogMakeProduct('Apilog Duplikat A', 300); apilogMakeProduct('Apilog Duplikat B', 300); $this->actingAs(apilogMakeUser(1)) ->get(route('admin.inventory.api-orders.index')) ->assertOk() ->assertSee('Doppelte WP-Nummern') ->assertSee('Apilog Duplikat A') ->assertSee('Apilog Duplikat B'); }); test('produkte ohne wp-nummer werden gezaehlt', function () { apilogMakeProduct('Apilog Mit Nummer', 400); Product::query()->create([ 'name' => 'Apilog Ohne Nummer', 'title' => 'Apilog Ohne Nummer', 'active' => true, 'is_set' => false, ]); $this->actingAs(apilogMakeUser(1)) ->get(route('admin.inventory.api-orders.index')) ->assertOk() ->assertSee('aktive Produkte ohne WP-Nummer', false) ->assertSee('Apilog Ohne Nummer'); }); test('ohne copyreader-rechte kein zugriff', function () { $this->actingAs(apilogMakeUser(0)) ->get(route('admin.inventory.api-orders.index')) ->assertRedirect(); });