CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul
Nachtrag 11: Herkunfts-Sektion liest Ort, Entfernung und Rolle aus der Markenverwaltung (drei neue Felder an cms_brands, BrandOriginSeeder), Kartenpositionen im Code, „Marken“ in der Sidebar. Nachtrag 12: Inhalte-Editor zeigt die Seiten der Website mit ihren Sektionen in Reihenfolge (config/cms_pages.php, CmsPageTree), Felder in Klartext, fehlende Sektionen aus der Sprachdatei anlegbar, Vorschau der echten Seite im iframe mit Sprung zur Sektion; Anker an allen lebenden Sektionen nachgezogen. Nachtrag 13: Referenzen als eigenes Modul (CmsReference, Portal-Seite, /referenzen mit Fallback auf die Markengalerien); Immobilien-Projekte aus Sidebar und CMS-Übersicht genommen, Route bleibt. Dazu .gitignore für _b2in_interior_shop/ und docs/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7953a8c9a1
commit
90aa079cf7
50 changed files with 2580 additions and 324 deletions
|
|
@ -327,3 +327,69 @@ it('saves the widget label and the closing block', function () {
|
|||
->and($brand->getTranslation('cta_title', 'de'))->toBe('Ihre Küche. Persönlich geplant.')
|
||||
->and($brand->getTranslation('cta_text', 'de'))->toBe('Jeder Raum ist anders.');
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Fertigungsort für „Aus der Region gefertigt“ (Nachtrag 11)
|
||||
// ========================================
|
||||
|
||||
it('saves the origin of a brand per language', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Volt::test('admin.cms.brands-index')
|
||||
->call('openCreate')
|
||||
->set('slug', 'mit-herkunft')
|
||||
->set('brandName', 'Mit Herkunft')
|
||||
->set('originCity', 'Bünde')
|
||||
->set('originDistance', 'rund 30 km')
|
||||
->set('originRole', 'Küchen aus eigener Fertigung.')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$brand = CmsBrand::where('slug', 'mit-herkunft')->firstOrFail();
|
||||
|
||||
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
|
||||
->and($brand->getTranslation('origin_distance', 'de'))->toBe('rund 30 km')
|
||||
->and($brand->getTranslation('origin_role', 'de'))->toBe('Küchen aus eigener Fertigung.');
|
||||
|
||||
Volt::test('admin.cms.brands-index')
|
||||
->call('openEdit', $brand->id)
|
||||
->assertSet('originCity', 'Bünde')
|
||||
->call('switchLocale', 'en')
|
||||
->assertSet('originCity', '')
|
||||
->set('brandName', 'With origin')
|
||||
->set('originCity', 'Buende')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$brand->refresh();
|
||||
|
||||
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
|
||||
->and($brand->getTranslation('origin_city', 'en'))->toBe('Buende');
|
||||
});
|
||||
|
||||
it('clears the origin when the city is emptied', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$brand = CmsBrand::factory()->create(['origin_city' => ['de' => 'Herford']]);
|
||||
|
||||
Volt::test('admin.cms.brands-index')
|
||||
->call('openEdit', $brand->id)
|
||||
->set('originCity', '')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($brand->fresh()->getTranslation('origin_city', 'de', false))->toBe('');
|
||||
});
|
||||
|
||||
it('is reachable from the sidebar for admins', function () {
|
||||
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('Admin');
|
||||
|
||||
$html = $this->actingAs($user)
|
||||
->get(route('cms.brands.index'))
|
||||
->assertSuccessful()
|
||||
->getContent();
|
||||
|
||||
expect($html)->toContain(route('cms.brands.index'))
|
||||
->toMatch('#data-content>\s*Marken\s*</div>#');
|
||||
});
|
||||
|
|
|
|||
144
tests/Feature/Admin/Cms/CmsReferencesIndexTest.php
Normal file
144
tests/Feature/Admin/Cms/CmsReferencesIndexTest.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\CmsReference;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Referenzen verwalten (Nachtrag 13).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
url()->forceRootUrl('https://'.config('domains.domain_portal'));
|
||||
});
|
||||
|
||||
it('requires authentication', function () {
|
||||
$this->get(route('cms.references.index'))->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('renders for authenticated users', function () {
|
||||
$this->actingAs(User::factory()->create())
|
||||
->get(route('cms.references.index'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Referenzen')
|
||||
->assertSee('Keine Referenzen');
|
||||
});
|
||||
|
||||
it('creates a reference with brands, cover and gallery', function () {
|
||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('openCreate')
|
||||
->set('referenceTitle', 'Ankleide unter der Dachschräge')
|
||||
->set('location', 'Bielefeld')
|
||||
->set('year', '2025')
|
||||
->set('description', 'Maßschrank bis unter den First.')
|
||||
->set('image', 'referenzen/ankleide.webp')
|
||||
->set('brandSlugs', ['cabinet', 'tischlerei'])
|
||||
->call('addGalleryImage')
|
||||
->set('gallery.0.image', 'referenzen/ankleide-2.webp')
|
||||
->set('gallery.0.caption', 'Innenausbau')
|
||||
->call('addGalleryImage')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$reference = CmsReference::firstOrFail();
|
||||
|
||||
expect($reference->getTranslation('title', 'de'))->toBe('Ankleide unter der Dachschräge')
|
||||
->and($reference->location)->toBe('Bielefeld')
|
||||
->and($reference->year)->toBe('2025')
|
||||
->and($reference->image)->toBe('referenzen/ankleide.webp')
|
||||
->and($reference->brand_slugs)->toBe(['cabinet', 'tischlerei'])
|
||||
->and($reference->gallery)->toBe([['image' => 'referenzen/ankleide-2.webp', 'caption' => 'Innenausbau']])
|
||||
->and($reference->is_published)->toBeTrue();
|
||||
});
|
||||
|
||||
it('requires a title', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('openCreate')
|
||||
->set('referenceTitle', '')
|
||||
->call('save')
|
||||
->assertHasErrors(['referenceTitle']);
|
||||
|
||||
expect(CmsReference::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a brand slug that does not exist', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('openCreate')
|
||||
->set('referenceTitle', 'Test')
|
||||
->set('brandSlugs', ['gibt-es-nicht'])
|
||||
->call('save')
|
||||
->assertHasErrors(['brandSlugs.0']);
|
||||
});
|
||||
|
||||
it('edits an existing reference and keeps both languages', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$reference = CmsReference::factory()->create(['title' => ['de' => 'Küche', 'en' => 'Kitchen']]);
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('openEdit', $reference->id)
|
||||
->assertSet('referenceTitle', 'Küche')
|
||||
->set('referenceTitle', 'Küche in Hiddenhausen')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$reference->refresh();
|
||||
|
||||
expect($reference->getTranslation('title', 'de'))->toBe('Küche in Hiddenhausen')
|
||||
->and($reference->getTranslation('title', 'en'))->toBe('Kitchen');
|
||||
});
|
||||
|
||||
it('reorders and removes gallery images', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$reference = CmsReference::factory()->withGallery(3)->create();
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('openEdit', $reference->id)
|
||||
->call('moveGalleryImage', 2, -1)
|
||||
->call('moveGalleryImage', 0, -1)
|
||||
->call('removeGalleryImage', 2)
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(array_column($reference->fresh()->gallery, 'image'))
|
||||
->toBe(['referenzen/bild-1.webp', 'referenzen/bild-3.webp']);
|
||||
});
|
||||
|
||||
it('toggles the published state and deletes', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$reference = CmsReference::factory()->create();
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('togglePublished', $reference->id);
|
||||
|
||||
expect($reference->fresh()->is_published)->toBeFalse();
|
||||
|
||||
Volt::test('admin.cms.references-index')
|
||||
->call('deleteReference', $reference->id);
|
||||
|
||||
expect(CmsReference::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('is linked from the sidebar while the real estate projects are not', function () {
|
||||
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('Admin');
|
||||
|
||||
$html = $this->actingAs($user)
|
||||
->get(route('cms.references.index'))
|
||||
->assertSuccessful()
|
||||
->getContent();
|
||||
|
||||
expect($html)->toMatch('#data-content>\s*Referenzen\s*</div>#')
|
||||
->not->toContain(route('cms.projects.index'));
|
||||
});
|
||||
|
|
@ -52,8 +52,8 @@ test('cms dashboard renders for authenticated users', function () {
|
|||
|
||||
test('cms dashboard shows correct stats', function () {
|
||||
$user = User::factory()->create();
|
||||
CmsProject::factory()->published()->count(2)->create();
|
||||
CmsProject::factory()->unpublished()->create();
|
||||
\App\Models\CmsReference::factory()->published()->count(2)->create();
|
||||
\App\Models\CmsReference::factory()->unpublished()->create();
|
||||
|
||||
Volt::actingAs($user)
|
||||
->test('admin.cms.dashboard')
|
||||
|
|
@ -408,3 +408,129 @@ test('cms articles index can toggle published status', function () {
|
|||
|
||||
expect($article->refresh()->is_published)->toBeFalse();
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Inhalte-Editor: Seitenbaum, Beschriftungen, Vorschau (Nachtrag 12)
|
||||
// ========================================
|
||||
|
||||
test('cms content index shows the pages of the website instead of raw groups', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
CmsContent::create([
|
||||
'group' => 'home',
|
||||
'key' => 'hero',
|
||||
'type' => 'json',
|
||||
'value' => ['de' => ['title' => 'Test']],
|
||||
]);
|
||||
|
||||
Volt::actingAs($user)
|
||||
->test('admin.cms.content-index')
|
||||
->assertSee('Startseite')
|
||||
->assertSee('Aus der Region gefertigt')
|
||||
->assertSee('Auf jeder Seite')
|
||||
->assertSee('Rechtliches')
|
||||
->assertDontSee('Nicht mehr verwendet');
|
||||
});
|
||||
|
||||
test('cms content index selects one section and labels its fields in plain language', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$hero = CmsContent::create([
|
||||
'group' => 'home',
|
||||
'key' => 'hero',
|
||||
'type' => 'json',
|
||||
'value' => ['de' => ['title' => 'Hero Title', 'button_text' => 'Los']],
|
||||
]);
|
||||
CmsContent::create([
|
||||
'group' => 'home',
|
||||
'key' => 'sponsoring',
|
||||
'type' => 'json',
|
||||
'value' => ['de' => ['title' => 'Sponsoring Title']],
|
||||
]);
|
||||
|
||||
$component = Volt::actingAs($user)
|
||||
->test('admin.cms.content-index')
|
||||
->call('selectSection', 'home', 'hero')
|
||||
->assertSet('selectedGroup', 'home')
|
||||
->assertSet('selectedKey', 'hero')
|
||||
->assertSee('Überschrift')
|
||||
->assertSee('Button-Beschriftung')
|
||||
->assertSee('Hero Title')
|
||||
->assertDontSee('Sponsoring Title');
|
||||
|
||||
expect($component->instance()->previewUrl)->toEndWith('/?cms-preview=0#hero');
|
||||
|
||||
$component->call('startFieldEdit', $hero->id, 'title')
|
||||
->set('editValue', 'Neu')
|
||||
->call('saveEdit')
|
||||
->assertSet('previewVersion', 1);
|
||||
});
|
||||
|
||||
test('cms content index lists unregistered rows under "not used any more"', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
CmsContent::create([
|
||||
'group' => 'netzwerk',
|
||||
'key' => 'teasers',
|
||||
'type' => 'json',
|
||||
'value' => ['de' => ['title' => 'Alt']],
|
||||
]);
|
||||
|
||||
Volt::actingAs($user)
|
||||
->test('admin.cms.content-index')
|
||||
->assertSee('Nicht mehr verwendet')
|
||||
->assertSee('netzwerk / teasers')
|
||||
->call('selectSection', 'netzwerk', 'teasers')
|
||||
->assertSee('Alt');
|
||||
|
||||
expect(Volt::actingAs($user)->test('admin.cms.content-index')
|
||||
->call('selectSection', 'netzwerk', 'teasers')
|
||||
->instance()->previewUrl)->toBeNull();
|
||||
});
|
||||
|
||||
test('cms content index creates a missing section from the lang template', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect(CmsContent::where('group', 'global')->where('key', 'appointment_widget')->exists())->toBeFalse();
|
||||
|
||||
Volt::actingAs($user)
|
||||
->test('admin.cms.content-index')
|
||||
->call('createSection', 'global', 'appointment_widget')
|
||||
->assertSet('selectedGroup', 'global')
|
||||
->assertSet('selectedKey', 'appointment_widget');
|
||||
|
||||
$row = CmsContent::where('group', 'global')->where('key', 'appointment_widget')->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->getTranslation('value', 'de'))->toBe(cms_theme_section('appointment_widget'));
|
||||
});
|
||||
|
||||
test('cms content index refuses to create a section without a lang template', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::actingAs($user)
|
||||
->test('admin.cms.content-index')
|
||||
->call('createSection', 'global', 'contact')
|
||||
->assertSet('selectedKey', null);
|
||||
|
||||
expect(CmsContent::where('group', 'global')->where('key', 'contact')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('cms content index can hide the preview', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
CmsContent::create([
|
||||
'group' => 'home',
|
||||
'key' => 'hero',
|
||||
'type' => 'json',
|
||||
'value' => ['de' => ['title' => 'Test']],
|
||||
]);
|
||||
|
||||
Volt::actingAs($user)
|
||||
->test('admin.cms.content-index')
|
||||
->call('selectSection', 'home', 'hero')
|
||||
->assertSee('Vorschau der Website')
|
||||
->call('togglePreview')
|
||||
->assertSet('showPreview', false)
|
||||
->assertDontSee('Vorschau der Website');
|
||||
});
|
||||
|
|
|
|||
154
tests/Feature/Cms/CmsPageTreeTest.php
Normal file
154
tests/Feature/Cms/CmsPageTreeTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\CmsPageTree;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Seitenbaum des Inhalte-Editors (config/cms_pages.php, Nachtrag 12).
|
||||
*/
|
||||
function cmsRow(string $group, string $key, array $value = ['title' => 'x']): CmsContent
|
||||
{
|
||||
return CmsContent::create([
|
||||
'group' => $group,
|
||||
'key' => $key,
|
||||
'type' => 'json',
|
||||
'value' => ['de' => $value],
|
||||
'order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
it('lists the pages in menu order with the homepage sections top to bottom', function () {
|
||||
$pages = app(CmsPageTree::class)->pages();
|
||||
|
||||
expect(array_slice(array_keys($pages), 0, 7))->toBe(['home', 'marken', 'referenzen', 'magazin', 'about', 'contact', 'faq']);
|
||||
expect($pages['home']['title'])->toBe('Startseite')
|
||||
->and($pages['home']['path'])->toBe('/');
|
||||
|
||||
$keys = array_map(fn (array $s) => $s['group'].'/'.$s['key'], $pages['home']['sections']);
|
||||
|
||||
expect(array_slice($keys, 0, 5))->toBe(['home/hero', 'home/founder_bar', 'home/audience_switch', 'home/core_brands', 'home/origin_section'])
|
||||
->and(array_slice($keys, -2))->toBe(['home/immobilien_teaser', 'global/cta_section']);
|
||||
});
|
||||
|
||||
it('marks sections that exist in the database and leaves the rest without content id', function () {
|
||||
$hero = cmsRow('home', 'hero');
|
||||
|
||||
$pages = app(CmsPageTree::class)->pages();
|
||||
$sections = collect($pages['home']['sections'])->keyBy('key');
|
||||
|
||||
expect($sections['hero']['content_id'])->toBe($hero->id)
|
||||
->and($sections['founder_bar']['content_id'])->toBeNull()
|
||||
->and($sections['hero']['theme_key'])->toBe('hero')
|
||||
->and($sections['hero']['anchor'])->toBe('hero');
|
||||
});
|
||||
|
||||
it('collects database rows that no page lists under "not used any more"', function () {
|
||||
cmsRow('netzwerk', 'teasers');
|
||||
cmsRow('home', 'synergie_section');
|
||||
|
||||
$pages = app(CmsPageTree::class)->pages();
|
||||
|
||||
expect($pages)->toHaveKey(CmsPageTree::UNLISTED);
|
||||
expect(array_key_last($pages))->toBe(CmsPageTree::UNLISTED);
|
||||
|
||||
$titles = array_column($pages[CmsPageTree::UNLISTED]['sections'], 'title');
|
||||
|
||||
expect($titles)->toBe(['home / synergie_section', 'netzwerk / teasers']);
|
||||
});
|
||||
|
||||
it('omits the collection point while every row is listed on a page', function () {
|
||||
cmsRow('home', 'hero');
|
||||
|
||||
expect(app(CmsPageTree::class)->pages())->not->toHaveKey(CmsPageTree::UNLISTED);
|
||||
});
|
||||
|
||||
it('resolves a section by group and key', function () {
|
||||
cmsRow('marken', 'network');
|
||||
|
||||
$section = app(CmsPageTree::class)->section('marken', 'network');
|
||||
|
||||
expect($section['title'])->toBe('Mehr als Marken: unser Netzwerk')
|
||||
->and($section['page'])->toBe('marken')
|
||||
->and($section['path'])->toBe('/marken')
|
||||
->and($section['content_id'])->not->toBeNull();
|
||||
|
||||
expect(app(CmsPageTree::class)->section('marken', 'gibt-es-nicht'))->toBeNull();
|
||||
});
|
||||
|
||||
it('labels fields from the dictionary, a section override or a readable fallback', function () {
|
||||
config()->set('cms_pages.pages.home.sections.0.fields', ['title' => 'Große Überschrift']);
|
||||
|
||||
$tree = app(CmsPageTree::class);
|
||||
|
||||
expect($tree->fieldLabel('title', 'home', 'hero'))->toBe('Große Überschrift')
|
||||
->and($tree->fieldLabel('title', 'home', 'sponsoring'))->toBe('Überschrift')
|
||||
->and($tree->fieldLabel('button_text'))->toBe('Button-Beschriftung')
|
||||
->and($tree->fieldLabel('some_unknown_key'))->toBe('Some unknown key');
|
||||
});
|
||||
|
||||
it('builds the preview url from the frontend domain, path, version and anchor', function () {
|
||||
config()->set('domains.protocol', 'https://');
|
||||
config()->set('domains.domain_b2in', 'b2in.test');
|
||||
|
||||
$tree = app(CmsPageTree::class);
|
||||
|
||||
expect($tree->previewUrl($tree->section('home', 'origin_section'), 3))
|
||||
->toBe('https://b2in.test/?cms-preview=3#herkunft');
|
||||
expect($tree->previewUrl($tree->section('faq', 'faq_immobilien')))
|
||||
->toBe('https://b2in.test/immobilien/faq?cms-preview=0#faq');
|
||||
expect($tree->previewUrl($tree->section('magazin', 'detail')))
|
||||
->toBe('https://b2in.test/magazin?cms-preview=0');
|
||||
expect($tree->previewUrl(null))->toBeNull();
|
||||
expect($tree->previewUrl(['path' => null]))->toBeNull();
|
||||
});
|
||||
|
||||
it('finds the lang template for every registered section that has one', function () {
|
||||
$tree = app(CmsPageTree::class);
|
||||
|
||||
foreach (config('cms_pages.pages') as $page) {
|
||||
foreach ($page['sections'] as $section) {
|
||||
$themeKey = $tree->themeKey($section['group'], $section['key']);
|
||||
|
||||
// Kontaktdaten und die rechtlichen Seiten liegen nur in der Datenbank (CmsLegalSeeder), nicht in b2in.php.
|
||||
if ($section['group'] === 'legal' || ($section['group'] === 'global' && $section['key'] === 'contact')) {
|
||||
expect($themeKey)->toBeNull();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
expect($themeKey)->not->toBeNull("Kein Theme-Key für {$section['group']}/{$section['key']}");
|
||||
expect(cms_theme_section($themeKey))->toBeArray("Keine Lang-Vorlage für {$themeKey}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('renders an anchor on the website for every section the registry points to', function () {
|
||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||
|
||||
$expected = [];
|
||||
|
||||
foreach (config('cms_pages.pages') as $page) {
|
||||
foreach ($page['sections'] as $section) {
|
||||
$path = $section['path'] ?? $page['path'] ?? null;
|
||||
|
||||
if ($path === null || empty($section['anchor'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expected[$path][$section['anchor']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($expected as $path => $anchors) {
|
||||
$html = $this->get($path)->assertSuccessful()->getContent();
|
||||
|
||||
foreach (array_keys($anchors) as $anchor) {
|
||||
expect(str_contains($html, 'id="'.$anchor.'"'))->toBeTrue("Anker #{$anchor} fehlt auf {$path}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -3,6 +3,10 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Livewire\Web\Components\Sections\OriginSection;
|
||||
use App\Models\CmsBrand;
|
||||
use Database\Seeders\BrandOriginSeeder;
|
||||
use Database\Seeders\CmsBrandSeeder;
|
||||
use Database\Seeders\OriginSectionCmsSeeder;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
|
@ -10,7 +14,10 @@ use Livewire\Livewire;
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3).
|
||||
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
|
||||
*
|
||||
* Die Standorte kommen aus den Marken (origin_city, origin_distance, origin_role),
|
||||
* die Kartenpositionen aus OriginSection::MAP_POSITIONS.
|
||||
*/
|
||||
function originBody(string $html): string
|
||||
{
|
||||
|
|
@ -19,7 +26,21 @@ function originBody(string $html): string
|
|||
return substr($html, (int) strpos($html, '<main'));
|
||||
}
|
||||
|
||||
it('shows the four locations on the homepage', function () {
|
||||
function originSection(string $html): string
|
||||
{
|
||||
$html = originBody($html);
|
||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
||||
|
||||
return substr($section, 0, (int) strpos($section, '</section>'));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Standorte aus den Marken
|
||||
// ========================================
|
||||
|
||||
it('shows the four locations from the seeded brands on the homepage', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
|
||||
$this->get('/')
|
||||
->assertSuccessful()
|
||||
->assertSee('id="herkunft"', false)
|
||||
|
|
@ -27,9 +48,96 @@ it('shows the four locations on the homepage', function () {
|
|||
->assertSee('Bünde', false)
|
||||
->assertSee('Herford')
|
||||
->assertSee('Rheda-Wiedenbrück', false)
|
||||
->assertSee('Köln', false);
|
||||
->assertSee('Köln', false)
|
||||
->assertSee('rund 200 km');
|
||||
});
|
||||
|
||||
it('links every location to its brand page', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
|
||||
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
|
||||
expect($section)->toContain('href="'.$link.'"');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops an unpublished brand from the locations entirely', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
|
||||
|
||||
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
// „Herford“ steht auch im Alt-Text der Karte – geprüft wird die Kachel.
|
||||
expect($section)->toContain('href="/marken/cabinet"')
|
||||
->toContain('wire:key="origin-cabinet"')
|
||||
->not->toContain('wire:key="origin-februe"')
|
||||
->not->toContain('/marken/februe');
|
||||
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
|
||||
});
|
||||
|
||||
it('leaves out a brand whose origin city is empty', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
CmsBrand::query()->where('slug', 'februe')->update(['origin_city' => ['de' => '', 'en' => '']]);
|
||||
|
||||
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
expect($section)->toContain('wire:key="origin-rotpunkt"')
|
||||
->not->toContain('wire:key="origin-februe"');
|
||||
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
|
||||
});
|
||||
|
||||
it('shows a brand without a map position in the tiles only', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
CmsBrand::factory()->create([
|
||||
'slug' => 'neue-marke',
|
||||
'name' => ['de' => 'Neue Marke'],
|
||||
'origin_city' => ['de' => 'Verl'],
|
||||
'is_published' => true,
|
||||
'order' => 9,
|
||||
]);
|
||||
|
||||
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
expect($section)->toContain('Verl')
|
||||
->toContain('wire:key="origin-neue-marke"');
|
||||
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
|
||||
});
|
||||
|
||||
it('uses the lang fallback brands without links while no brand is maintained', function () {
|
||||
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
expect($section)->toContain('Bünde')
|
||||
->toContain('Köln')
|
||||
->not->toContain('href="/marken/');
|
||||
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
|
||||
});
|
||||
|
||||
it('draws Bielefeld as the hub with one spoke per location', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
|
||||
$html = $this->get('/')->assertSuccessful()->getContent();
|
||||
|
||||
expect($html)->toContain('class="origin-map"')
|
||||
->toContain('origin-map-hub')
|
||||
->toContain('Ostwestfalen');
|
||||
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
|
||||
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
|
||||
});
|
||||
|
||||
it('hides map and tiles but keeps the steps when no brand has an origin', function () {
|
||||
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null, 'is_published' => true]);
|
||||
|
||||
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
expect($section)->not->toContain('origin-map')
|
||||
->toContain('Fertigung in Köln');
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Platzierung und Ablauf-Block
|
||||
// ========================================
|
||||
|
||||
it('sits between the core brands and the founder story on the homepage', function () {
|
||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
||||
|
||||
|
|
@ -43,7 +151,7 @@ it('sits between the core brands and the founder story on the homepage', functio
|
|||
});
|
||||
|
||||
it('appears on the brands page after the brand overview and before the network', function () {
|
||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
|
||||
$html = originBody($this->get('/marken')->assertSuccessful()->getContent());
|
||||
|
||||
|
|
@ -56,50 +164,6 @@ it('appears on the brands page after the brand overview and before the network',
|
|||
expect($network)->toBeGreaterThan($origin);
|
||||
});
|
||||
|
||||
it('links every location to its brand page', function () {
|
||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||
|
||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
||||
$section = substr($section, 0, (int) strpos($section, '</section>'));
|
||||
|
||||
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
|
||||
expect($section)->toContain('href="'.$link.'"');
|
||||
}
|
||||
});
|
||||
|
||||
it('drops the link of an unpublished brand but keeps its location', function () {
|
||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||
\App\Models\CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
|
||||
|
||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
||||
$section = substr($section, 0, (int) strpos($section, '</section>'));
|
||||
|
||||
expect($section)->toContain('Herford')
|
||||
->toContain('href="/marken/cabinet"')
|
||||
->not->toContain('/marken/februe');
|
||||
});
|
||||
|
||||
it('finds the brand page by name when the lang fallback uses other slugs', function () {
|
||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
||||
$section = substr($section, 0, (int) strpos($section, '</section>'));
|
||||
|
||||
expect($section)->toContain('href="/marken/cabinet"')
|
||||
->toContain('href="/marken/tischlerei"');
|
||||
});
|
||||
|
||||
it('draws Bielefeld as the hub with one spoke per location', function () {
|
||||
$html = $this->get('/')->assertSuccessful()->getContent();
|
||||
|
||||
expect($html)->toContain('class="origin-map"')
|
||||
->toContain('origin-map-hub')
|
||||
->toContain('Ostwestfalen');
|
||||
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
|
||||
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
|
||||
});
|
||||
|
||||
it('separates manufacturing from sales for CABINET', function () {
|
||||
$this->get('/')
|
||||
->assertSuccessful()
|
||||
|
|
@ -109,17 +173,16 @@ it('separates manufacturing from sales for CABINET', function () {
|
|||
->assertSee('Aufbau vor Ort', false);
|
||||
});
|
||||
|
||||
it('prefers the CMS content over the lang fallback', function () {
|
||||
it('prefers the CMS content for the texts but keeps the locations from the brands', function () {
|
||||
$this->seed(CmsBrandSeeder::class);
|
||||
CmsContent::create([
|
||||
'group' => 'home',
|
||||
'key' => 'origin_section',
|
||||
'type' => 'json',
|
||||
'value' => ['de' => [
|
||||
'title' => 'Gefertigt in <span class="text-secondary">OWL</span>',
|
||||
'link_text' => 'Zur Manufaktur',
|
||||
'hub' => ['city' => 'Bielefeld', 'x' => 400, 'y' => 175],
|
||||
'locations' => [
|
||||
['city' => 'Verl', 'brand' => 'Testmarke', 'x' => 300, 'y' => 100],
|
||||
],
|
||||
]],
|
||||
'order' => 0,
|
||||
]);
|
||||
|
|
@ -127,43 +190,47 @@ it('prefers the CMS content over the lang fallback', function () {
|
|||
$this->get('/')
|
||||
->assertSuccessful()
|
||||
->assertSee('Gefertigt in <span class="text-secondary">OWL</span>', false)
|
||||
->assertSee('Verl')
|
||||
->assertSee('Testmarke')
|
||||
->assertDontSee('Rheda-Wiedenbrück');
|
||||
});
|
||||
|
||||
it('skips locations without brand or city instead of breaking the map', function () {
|
||||
Livewire::test(OriginSection::class)
|
||||
->set('content', [
|
||||
'title' => 'Herkunft',
|
||||
'locations' => [
|
||||
['city' => 'Bünde', 'brand' => 'Rotpunkt Küchen', 'x' => 1, 'y' => 1],
|
||||
['city' => '', 'brand' => 'Ohne Stadt'],
|
||||
'kaputt',
|
||||
['brand' => '', 'city' => 'Ohne Marke'],
|
||||
],
|
||||
])
|
||||
->assertSee('Rotpunkt Küchen', false)
|
||||
->assertDontSee('Ohne Stadt')
|
||||
->assertDontSee('Ohne Marke');
|
||||
->assertSee('Zur Manufaktur')
|
||||
->assertSee('Rheda-Wiedenbrück', false);
|
||||
});
|
||||
|
||||
it('renders without the steps block when the CMS leaves it empty', function () {
|
||||
Livewire::test(OriginSection::class)
|
||||
->set('content', ['title' => 'Herkunft', 'locations' => [], 'steps' => []])
|
||||
->set('content', ['title' => 'Herkunft', 'steps' => []])
|
||||
->assertSee('Herkunft')
|
||||
->assertDontSee('<ol', false);
|
||||
});
|
||||
|
||||
it('creates the CMS section only when it is missing', function () {
|
||||
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
|
||||
// ========================================
|
||||
// Seeder
|
||||
// ========================================
|
||||
|
||||
it('creates the CMS section only when it is missing and strips the legacy locations', function () {
|
||||
$this->seed(OriginSectionCmsSeeder::class);
|
||||
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->getTranslation('value', 'de')['locations'])->toHaveCount(4);
|
||||
expect($row->getTranslation('value', 'de'))->not->toHaveKey('locations');
|
||||
|
||||
$row->update(['value' => ['de' => ['title' => 'Vom Admin geändert']]]);
|
||||
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
|
||||
$row->update(['value' => ['de' => [
|
||||
'title' => 'Vom Admin geändert',
|
||||
'locations' => [['city' => 'Alt', 'brand' => 'Alt']],
|
||||
]]]);
|
||||
$this->seed(OriginSectionCmsSeeder::class);
|
||||
|
||||
expect($row->fresh()->getTranslation('value', 'de')['title'])->toBe('Vom Admin geändert');
|
||||
$value = $row->fresh()->getTranslation('value', 'de');
|
||||
expect($value['title'])->toBe('Vom Admin geändert');
|
||||
expect($value)->not->toHaveKey('locations');
|
||||
});
|
||||
|
||||
it('fills the origin of existing brands only where it is empty', function () {
|
||||
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null]);
|
||||
CmsBrand::factory()->create(['slug' => 'rotpunkt', 'origin_city' => ['de' => 'Eigener Ort']]);
|
||||
|
||||
$this->seed(BrandOriginSeeder::class);
|
||||
|
||||
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_city', 'de'))->toBe('Köln');
|
||||
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_distance', 'en'))->toBe('about 200 km');
|
||||
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_city', 'de'))->toBe('Eigener Ort');
|
||||
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_distance', 'de', false))->toBeEmpty();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,3 +73,65 @@ it('says so plainly when there is nothing to show yet', function () {
|
|||
->assertSee('sobald wir sie zeigen dürfen', false)
|
||||
->assertSee('Termin anfragen');
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Eigene Referenzen aus dem Portal (Nachtrag 13)
|
||||
// ========================================
|
||||
|
||||
it('prefers maintained references over the brand galleries', function () {
|
||||
CmsBrand::where('slug', 'cabinet')->firstOrFail()->update([
|
||||
'gallery' => [['image' => 'a.webp', 'category' => 'project', 'caption' => 'Aus der Galerie']],
|
||||
]);
|
||||
\App\Models\CmsReference::factory()->forBrands(['cabinet', 'tischlerei'])->withGallery(2)->create([
|
||||
'title' => ['de' => 'Ankleide unter der Dachschräge'],
|
||||
'location' => 'Bielefeld',
|
||||
'year' => '2025',
|
||||
'description' => ['de' => 'Maßschrank bis unter den First.'],
|
||||
'image' => 'referenzen/ankleide.webp',
|
||||
]);
|
||||
|
||||
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
|
||||
|
||||
expect($html)->toContain('Ankleide unter der Dachschräge')
|
||||
->toContain('Bielefeld · 2025')
|
||||
->toContain('Maßschrank bis unter den First.')
|
||||
->toContain('referenzen/bild-1.webp')
|
||||
->toContain('href="/marken/cabinet"')
|
||||
->toContain('href="/marken/tischlerei"')
|
||||
->not->toContain('Aus der Galerie');
|
||||
});
|
||||
|
||||
it('hides unpublished references and falls back to the galleries when none is published', function () {
|
||||
CmsBrand::where('slug', 'rotpunkt')->firstOrFail()->update([
|
||||
'gallery' => [['image' => 'c.webp', 'category' => 'project', 'caption' => 'Küche in Hiddenhausen']],
|
||||
]);
|
||||
\App\Models\CmsReference::factory()->unpublished()->create(['title' => ['de' => 'Noch nicht freigegeben']]);
|
||||
|
||||
$this->get('/referenzen')
|
||||
->assertSuccessful()
|
||||
->assertDontSee('Noch nicht freigegeben')
|
||||
->assertSee('Küche in Hiddenhausen', false);
|
||||
});
|
||||
|
||||
it('drops a brand that is unpublished and skips a reference without any image', function () {
|
||||
CmsBrand::where('slug', 'februe')->firstOrFail()->update(['is_published' => false]);
|
||||
\App\Models\CmsReference::factory()->forBrands(['februe', 'cabinet'])->create(['title' => ['de' => 'Büro in Herford']]);
|
||||
\App\Models\CmsReference::factory()->create(['title' => ['de' => 'Ohne Bild'], 'image' => null, 'gallery' => []]);
|
||||
|
||||
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
|
||||
|
||||
expect($html)->toContain('Büro in Herford')
|
||||
->toContain('href="/marken/cabinet"')
|
||||
->not->toContain('/marken/februe')
|
||||
->not->toContain('Ohne Bild');
|
||||
});
|
||||
|
||||
it('uses the first gallery image as cover when no cover is set', function () {
|
||||
\App\Models\CmsReference::factory()->withGallery(2)->create(['title' => ['de' => 'Nur Galerie'], 'image' => null]);
|
||||
|
||||
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
|
||||
|
||||
expect($html)->toContain('Nur Galerie')
|
||||
->toContain('referenzen/bild-1.webp');
|
||||
expect(substr_count($html, 'referenzen/bild-1.webp'))->toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue