WIP: Sicherheitsnetz vor Phase-1-R\u00fcckbau

Enth\u00e4lt gemischt: Laravel-10-Upgrade + Phase 1 (Contacts-Modul, Duplicats-Commands,
Soft-Delete+Merge-Fields) + Phase 2 Code-Umstellungen (inquiry_id, $table='contacts'/'inquiries')
+ Offers-Modul (Migrationen, Models, offer_id in Booking, offer-Disk in filesystems.php).

Phase 2 + Offers werden im folgenden Commit nach dev/backups/phase2-offers-2026-04-17/
verschoben, damit der Workspace auf Phase-1-only (= Test-System-Stand) reduziert ist
und direkt auf Live deploybar wird.

Tarball-Backup zus\u00e4tzlich unter: ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz

Made-with: Cursor
This commit is contained in:
Phase-1-Rollback-Agent 2026-04-17 13:40:31 +00:00
parent 389d5d1820
commit e3dc1afd8e
165 changed files with 21914 additions and 3516 deletions

View file

@ -0,0 +1,45 @@
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class BookingImportTest extends TestCase
{
use RefreshDatabase;
private string $validKey = 'f6077389c9ce710e554763a5de02c8ec';
public function testImportRejectsRequestWithoutKey(): void
{
$response = $this->postJson('/api/booking/import', [
'travel_booking_id' => 1,
]);
$response->assertStatus(401);
$response->assertJson(['error' => 'key']);
}
public function testImportRejectsRequestWithWrongKey(): void
{
$response = $this->postJson('/api/booking/import', [
'key' => 'wrong-key',
'travel_booking_id' => 1,
]);
$response->assertStatus(401);
$response->assertJson(['error' => 'key']);
}
public function testImportReturnsErrorWhenBookingNotFound(): void
{
$response = $this->postJson('/api/booking/import', [
'key' => $this->validKey,
'travel_booking_id' => 999999,
]);
$response->assertStatus(200);
$response->assertJson(['error' => 'no-booking-found']);
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Tests\Feature\Auth;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class LoginTest extends TestCase
{
use RefreshDatabase;
public function testLoginPageIsAccessible(): void
{
$response = $this->get('/login');
$response->assertStatus(200);
}
public function testUnauthenticatedUserIsRedirectedToLogin(): void
{
$response = $this->get('/home');
$response->assertRedirect('/login');
}
public function testUserCanLoginWithValidCredentials(): void
{
$user = User::factory()->create([
'password' => bcrypt('password'),
]);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertAuthenticatedAs($user);
$response->assertRedirect();
}
public function testLoginFailsWithWrongPassword(): void
{
$user = User::factory()->create([
'password' => bcrypt('correct-password'),
]);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
$response->assertSessionHasErrors('email');
}
public function testLoginFailsWithUnknownEmail(): void
{
$response = $this->post('/login', [
'email' => 'nobody@example.com',
'password' => 'password',
]);
$this->assertGuest();
$response->assertSessionHasErrors('email');
}
public function testInactiveUserCannotLogin(): void
{
$user = User::factory()->inactive()->create([
'password' => bcrypt('password'),
]);
$response = $this->post('/login', [
'email' => $user->email,
'password' => 'password',
]);
$this->assertGuest();
}
public function testAuthenticatedUserIsRedirectedAwayFromLoginPage(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/login');
$response->assertRedirect();
}
public function testUserCanLogout(): void
{
$user = User::factory()->create();
$this->actingAs($user)->post('/logout');
$this->assertGuest();
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class BookingControllerTest extends TestCase
{
use RefreshDatabase;
public function testGuestIsRedirectedFromBookingIndex(): void
{
$response = $this->get('/booking');
$response->assertRedirect('/login');
}
public function testGuestIsRedirectedFromBookingDetail(): void
{
$response = $this->get('/booking/1');
$response->assertRedirect('/login');
}
public function testNonAdminUserCannotAccessBookingIndex(): void
{
$user = User::factory()->create(['admin' => 0]);
// Middleware 'admin' redirects non-admins to /home
$response = $this->actingAs($user)
->withoutMiddleware(\App\Http\Middleware\MiddleGoogle2FA::class)
->get('/booking');
$response->assertRedirect('/home');
}
public function testAdminUserCanAccessBookingIndexAfterAuthentication(): void
{
$user = User::factory()->admin()->create();
$response = $this->actingAs($user)
->withoutMiddleware([
\App\Http\Middleware\MiddleGoogle2FA::class,
])
->get('/booking');
// Either 200 (page loads) or a redirect due to missing 2FA in any
// case the admin middleware itself must not block the request.
$response->assertStatus(200);
}
}

View file

@ -0,0 +1,183 @@
<?php
namespace Tests\Unit\Services;
use App\Services\Util;
use PHPUnit\Framework\TestCase;
class UtilTest extends TestCase
{
// -------------------------------------------------------------------------
// _format_number
// -------------------------------------------------------------------------
public function testFormatNumberStripsNonNumericCharacters(): void
{
$this->assertSame('1234,56', Util::_format_number('1.234,56 €'));
$this->assertSame('1234,56', Util::_format_number('EUR 1.234,56'));
$this->assertSame('100', Util::_format_number('100'));
}
public function testFormatNumberReturnsEmptyStringForNonNumericInput(): void
{
$this->assertSame('', Util::_format_number('abc'));
}
// -------------------------------------------------------------------------
// _number_format
// -------------------------------------------------------------------------
public function testNumberFormatFormatsWithGermanLocale(): void
{
$this->assertSame('1.234,56', Util::_number_format(1234.56));
$this->assertSame('0,00', Util::_number_format(0));
$this->assertSame('1.000,00', Util::_number_format(1000));
}
public function testNumberFormatRespectsCustomDecimalPlaces(): void
{
$this->assertSame('1.234,5600', Util::_number_format(1234.56, 4));
$this->assertSame('1.235', Util::_number_format(1234.56, 0));
}
// -------------------------------------------------------------------------
// _clean_float
// -------------------------------------------------------------------------
public function testCleanFloatHandlesGermanCommaFormat(): void
{
$this->assertSame(1234.56, Util::_clean_float('1.234,56'));
$this->assertSame(1234.56, Util::_clean_float('1234,56'));
}
public function testCleanFloatHandlesDotDecimalFormat(): void
{
$this->assertSame(1234.56, Util::_clean_float('1234.56'));
}
public function testCleanFloatHandlesCurrencyStrings(): void
{
$this->assertSame(99.9, Util::_clean_float('99,90 €'));
$this->assertSame(0.0, Util::_clean_float('0'));
}
// -------------------------------------------------------------------------
// _first_replace
// -------------------------------------------------------------------------
public function testFirstReplaceStripsRePrefix(): void
{
$this->assertSame('Betreff', Util::_first_replace('re: Betreff'));
$this->assertSame('Betreff', Util::_first_replace('RE: Betreff'));
$this->assertSame('Betreff', Util::_first_replace('Re: re: Betreff'));
}
public function testFirstReplaceDoesNotAlterStringWithoutPrefix(): void
{
$this->assertSame('Betreff', Util::_first_replace('Betreff'));
}
// -------------------------------------------------------------------------
// _explodeLines / _implodeLines
// -------------------------------------------------------------------------
public function testExplodeLinesOnNewlines(): void
{
$result = Util::_explodeLines("Zeile1\nZeile2\nZeile3");
$this->assertSame(['Zeile1', 'Zeile2', 'Zeile3'], $result);
}
public function testExplodeLinesOnWindowsNewlines(): void
{
$result = Util::_explodeLines("Zeile1\r\nZeile2");
$this->assertSame(['Zeile1', 'Zeile2'], $result);
}
public function testExplodeLinesReturnsFalseForEmptyInput(): void
{
$this->assertNull(Util::_explodeLines(false));
$this->assertNull(Util::_explodeLines(''));
}
public function testImplodeLinesJoinsArray(): void
{
$this->assertSame("Zeile1\nZeile2", Util::_implodeLines(['Zeile1', 'Zeile2'], "\n"));
}
public function testImplodeLinesPassesThroughNonArray(): void
{
$this->assertSame('Zeile1', Util::_implodeLines('Zeile1'));
}
// -------------------------------------------------------------------------
// replacePlaceholders
// -------------------------------------------------------------------------
public function testReplacePlaceholdersSubstitutesValues(): void
{
$template = 'Hallo {{name}}, deine Buchungs-Nr. ist {{booking_id}}.';
$result = Util::replacePlaceholders($template, [
'name' => 'Max Mustermann',
'booking_id' => '12345',
]);
$this->assertSame('Hallo Max Mustermann, deine Buchungs-Nr. ist 12345.', $result);
}
public function testReplacePlaceholdersLeavesUnknownPlaceholdersIntact(): void
{
$template = 'Hallo {{name}}, {{unknown}}';
$result = Util::replacePlaceholders($template, ['name' => 'Max']);
$this->assertStringContainsString('{{unknown}}', $result);
}
public function testReplacePlaceholdersHandlesEmptyReplacements(): void
{
$template = 'Kein Platzhalter hier.';
$this->assertSame($template, Util::replacePlaceholders($template, []));
}
// -------------------------------------------------------------------------
// sanitize
// -------------------------------------------------------------------------
public function testSanitizeRemovesSpecialCharsAndLowercases(): void
{
$result = Util::sanitize('Hello World!');
$this->assertSame('hello_world', $result);
}
public function testSanitizeRespectsForceUppercase(): void
{
$result = Util::sanitize('Hello', false);
$this->assertSame('Hello', $result);
}
// -------------------------------------------------------------------------
// getExtensionFromMime
// -------------------------------------------------------------------------
public function testGetExtensionFromMimeReturnsKnownExtensions(): void
{
$this->assertSame('pdf', Util::getExtensionFromMime('application/pdf'));
$this->assertSame('jpg', Util::getExtensionFromMime('image/jpeg'));
$this->assertSame('png', Util::getExtensionFromMime('image/png'));
}
public function testGetExtensionFromMimeReturnsEmptyStringForUnknown(): void
{
$this->assertSame('', Util::getExtensionFromMime('application/unknown'));
}
// -------------------------------------------------------------------------
// _formatBytes
// -------------------------------------------------------------------------
public function testFormatBytesFormatsCorrectly(): void
{
$this->assertSame('1 KB', Util::_formatBytes(1024));
$this->assertSame('1 MB', Util::_formatBytes(1024 * 1024));
$this->assertSame('0', Util::_formatBytes(0));
}
}