gruene-seele/tests/Feature/ApiOrderLogTest.php
Kevin Adametz f42e5a8fd4 Warenwirtschaft: AP-34 — „-Suffix"-Schema als gewollten Mechanismus dokumentieren
Kunde hat den eigentlichen Varianten-Fall präzisiert (28.08.): Kopiert ein
Anwender ein Produkt für eine Aktion / ein neues Bild, verlangt WooCommerce eine
eindeutige SKU. Konvention: Kernnummer + Suffix (100500, 100500-a, 100500-b) —
derselbe physische Artikel, der auf die Kernnummer gebucht werden muss.

Der bestehende Importer macht das bereits korrekt (volle SKU zuerst, sonst
„-"-Split auf die Kernnummer). Da wp_number eine Integer-Spalte ist, kann keine
echte Artikelnummer einen „-" enthalten — dashed SKUs fallen also immer sauber
auf die Kernnummer zurück, eigenständige Nummern treffen ihr eigenes Produkt.

Nur Doku korrigiert: Kommentare im ShoppingUserController und der Testname waren
als „Legacy-Fallback" fehletikettiert — es ist der beabsichtigte Mechanismus für
Shop-Duplikate. Kommentar erklärt jetzt beide Fälle; Test umbenannt auf
„shop-duplikat kernnummer-suffix bucht dasselbe kern-produkt". Keine Logikänderung.
Suite 338 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 11:53:34 +00:00

401 lines
15 KiB
PHP

<?php
use App\Models\ApiOrderLog;
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserAccount;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Laravel\Passport\Passport;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function apilogCountry(): Country
{
return Country::query()->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();
});