diff --git a/app/Actions/ViewDataAction.php b/app/Actions/ViewDataAction.php index f756f2b..a1d7cf4 100644 --- a/app/Actions/ViewDataAction.php +++ b/app/Actions/ViewDataAction.php @@ -117,7 +117,7 @@ public function openSource(string $locale): Collection }); } - public function contacts(string $locale): object + public function contacts(string $locale): \stdClass { $key = Str::slug("contacts_published_{$locale}"); diff --git a/app/Console/Commands/SendNetworkInvitesCommand.php b/app/Console/Commands/SendNetworkInvitesCommand.php index 9ef5042..751f1c6 100644 --- a/app/Console/Commands/SendNetworkInvitesCommand.php +++ b/app/Console/Commands/SendNetworkInvitesCommand.php @@ -11,7 +11,8 @@ class SendNetworkInvitesCommand extends Command { protected $signature = 'network:invite {--locale= : Language of the invitation (de or en)} - {--email= : Only invite this email address}'; + {--email= : Only invite this email address} + {--force : Send without asking for confirmation (for non-interactive environments)}'; protected $description = 'Invite network users via a signed link (valid 96 hours) to manage and publish their profile'; @@ -46,7 +47,7 @@ public function handle(): int $users->map(fn (NetworkUser $user): array => [$user->name, $user->email, $user->network_key])->all(), ); - if (! $this->confirm(sprintf('Send %d invitation(s) in %s?', $users->count(), $locale->value))) { + if (! $this->option('force') && ! $this->confirm(sprintf('Send %d invitation(s) in %s?', $users->count(), $locale->value))) { $this->info('Aborted — nothing sent.'); return self::SUCCESS; diff --git a/app/Helpers/Facades/HelperMoney.php b/app/Helpers/Facades/HelperMoney.php index d690b10..efda19b 100644 --- a/app/Helpers/Facades/HelperMoney.php +++ b/app/Helpers/Facades/HelperMoney.php @@ -7,8 +7,8 @@ /** * @see \App\Helpers\HelperMoney * - * @method static string format(int|float $number, string $currency = 'CHF') - * @method static string formatLeft(int|float $number, string $currency = 'CHF') + * @method static string format(int|float|null $number, string $currency = 'CHF') + * @method static string formatLeft(int|float|null $number, string $currency = 'CHF') * @method static float roundMoney(int|float $money) * @method static float roundMoneyUp(int|float $money) * @method static float roundMoneyDown(int|float $money) diff --git a/app/Http/Controllers/Jobs/JobsIndexController.php b/app/Http/Controllers/Jobs/JobsIndexController.php index f71d036..7368627 100644 --- a/app/Http/Controllers/Jobs/JobsIndexController.php +++ b/app/Http/Controllers/Jobs/JobsIndexController.php @@ -4,22 +4,14 @@ use App\Actions\PageAction; use App\Http\Controllers\Controller; -use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Str; use Illuminate\View\View; class JobsIndexController extends Controller { - /** - * Display the user's profile form. - */ - public function __invoke(): View|RedirectResponse + public function __invoke(): View { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - // @todo Notification - /* return view('app.jobs.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'jobs.index'))->default(), - ]);*/ + return view('app.jobs.index')->with([ + 'page' => (new PageAction(locale: null, routeName: 'jobs.index'))->default(), + ]); } } diff --git a/app/Http/Controllers/Legal/TermsIndexController.php b/app/Http/Controllers/Legal/TermsIndexController.php index 5f4395f..fb768e9 100644 --- a/app/Http/Controllers/Legal/TermsIndexController.php +++ b/app/Http/Controllers/Legal/TermsIndexController.php @@ -4,22 +4,14 @@ use App\Actions\PageAction; use App\Http\Controllers\Controller; -use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Str; use Illuminate\View\View; class TermsIndexController extends Controller { - /** - * Display the user's profile form. - */ - public function __invoke(): View|RedirectResponse + public function __invoke(): View { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - // @todo Notification - /* return view('app.legal.terms.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'legal.terms.index'))->default(), - ]);*/ + return view('app.legal.terms.index')->with([ + 'page' => (new PageAction(locale: null, routeName: 'legal.terms.index'))->default(), + ]); } } diff --git a/app/Http/Middleware/ValidateCsrfToken.php b/app/Http/Middleware/ValidateCsrfToken.php new file mode 100644 index 0000000..a9ee5b0 --- /dev/null +++ b/app/Http/Middleware/ValidateCsrfToken.php @@ -0,0 +1,16 @@ +replace(FrameworkValidateCsrfToken::class, ValidateCsrfToken::class); $middleware->alias([ 'role' => RoleMiddleware::class, ]); diff --git a/composer.json b/composer.json index 81310ba..f4720cc 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "spatie/laravel-responsecache": "^8.0", "spatie/laravel-sitemap": "^8.0", "spatie/security-advisories-health-check": "^1.2", + "symfony/http-client": "^8.1", "symfony/postmark-mailer": "^8.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 49462d0..c8d99f4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b26b6e3fa6095164bbfd5143ce051891", + "content-hash": "69c21e8498181dd8717ae5e7aac88d47", "packages": [ { "name": "aws/aws-crt-php", @@ -6243,6 +6243,185 @@ ], "time": "2026-06-27T09:05:56+00:00" }, + { + "name": "symfony/http-client", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "d41e9778eed03c64b095532c6d414e92e3c520d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/d41e9778eed03c64b095532c6d414e92e3c520d9", + "reference": "d41e9778eed03c64b095532c6d414e92e3c520d9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/guzzle": "^7.10", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T12:55:20+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/41fc42d276aeff21192465331ebbab7d83a743c0", + "reference": "41fc42d276aeff21192465331ebbab7d83a743c0", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, { "name": "symfony/http-foundation", "version": "v8.1.1", diff --git a/config/session.php b/config/session.php index 1d1e28b..c2ff545 100644 --- a/config/session.php +++ b/config/session.php @@ -14,9 +14,12 @@ | */ + // The __Host- prefix requires Secure, Path=/ and no Domain attribute, + // so it is only applied when SESSION_SECURE_COOKIE is enabled. 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel'), '_').'_session' + (env('SESSION_SECURE_COOKIE') ? '__Host-' : '') + .Str::slug((string) env('APP_NAME', 'laravel'), '_').'_session' ), ]; diff --git a/database/factories/OpenSourceFactory.php b/database/factories/OpenSourceFactory.php index b63ee49..36c1188 100644 --- a/database/factories/OpenSourceFactory.php +++ b/database/factories/OpenSourceFactory.php @@ -18,7 +18,8 @@ class OpenSourceFactory extends Factory */ public function definition(): array { - $title = (string) fake()->unique()->words(3, true); + /** @var string $title words() returns a string when $asText is true */ + $title = fake()->unique()->words(3, true); return [ 'published' => true, diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 2e20bbc..174444c 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -18,7 +18,8 @@ class ProductFactory extends Factory */ public function definition(): array { - $name = (string) fake()->unique()->words(3, true); + /** @var string $name words() returns a string when $asText is true */ + $name = fake()->unique()->words(3, true); return [ 'published' => true, diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php index b12f152..23bf996 100644 --- a/database/factories/ServiceFactory.php +++ b/database/factories/ServiceFactory.php @@ -18,7 +18,8 @@ class ServiceFactory extends Factory */ public function definition(): array { - $name = (string) fake()->unique()->words(3, true); + /** @var string $name words() returns a string when $asText is true */ + $name = fake()->unique()->words(3, true); return [ 'published' => true, diff --git a/database/factories/TechnologyFactory.php b/database/factories/TechnologyFactory.php index 156fc7b..e173ff2 100644 --- a/database/factories/TechnologyFactory.php +++ b/database/factories/TechnologyFactory.php @@ -18,7 +18,8 @@ class TechnologyFactory extends Factory */ public function definition(): array { - $title = (string) fake()->unique()->words(3, true); + /** @var string $title words() returns a string when $asText is true */ + $title = fake()->unique()->words(3, true); return [ 'published' => true, diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 8f50f16..c4c8087 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -9,12 +9,15 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +/** + * @extends Factory + */ class UserFactory extends Factory { /** * The name of the factory's corresponding model. * - * @var class-string + * @var class-string */ protected $model = User::class; diff --git a/database/migrations/0001_01_01_000010_create_auth_provider_table.php b/database/migrations/0001_01_01_000010_create_auth_provider_table.php index 1a3d4e2..2ea19f8 100644 --- a/database/migrations/0001_01_01_000010_create_auth_provider_table.php +++ b/database/migrations/0001_01_01_000010_create_auth_provider_table.php @@ -6,7 +6,7 @@ return new class extends Migration { - public function up() + public function up(): void { Schema::create('auth_providers', function (Blueprint $table) { $table->id(); diff --git a/database/seeders/AiModelsTableSeeder.php b/database/seeders/AiModelsTableSeeder.php index 9c46304..f86f539 100644 --- a/database/seeders/AiModelsTableSeeder.php +++ b/database/seeders/AiModelsTableSeeder.php @@ -40,7 +40,7 @@ public function run(): void } $models->firstWhere('id', $idsByCsvId->get($row['id'])) - ->update(['replaced_by_id' => $idsByCsvId->get($row['replaced_by_id'])]); + ?->update(['replaced_by_id' => $idsByCsvId->get($row['replaced_by_id'])]); }); } } diff --git a/database/seeders/NewsTableSeeder.php b/database/seeders/NewsTableSeeder.php index 62c561d..1dbd64d 100644 --- a/database/seeders/NewsTableSeeder.php +++ b/database/seeders/NewsTableSeeder.php @@ -62,10 +62,13 @@ public function run(): void } + /** + * @param array> $localizedData + */ private function seed(Carbon $publishedAt, string $author, array $localizedData): void { - $entries = collect($localizedData)->map(function ($data, $locale) use ($author, $publishedAt) { - $slug = Str::slug(Arr::get($data, 'slug'), '-', $locale); + $entries = collect($localizedData)->map(function (array $data, string $locale) use ($author, $publishedAt) { + $slug = Str::slug(Arr::string($data, 'slug'), '-', $locale); return News::updateOrCreate( [ diff --git a/database/seeders/ProductsTableSeeder.php b/database/seeders/ProductsTableSeeder.php index a011de1..ced3f3d 100644 --- a/database/seeders/ProductsTableSeeder.php +++ b/database/seeders/ProductsTableSeeder.php @@ -86,9 +86,12 @@ public function run(): void ); } + /** + * @param array> $localizedData + */ private function seed(int $order, string $sharedSlug, array $localizedData): void { - $entries = collect($localizedData)->map(function ($data, $locale) use ($sharedSlug, $order) { + $entries = collect($localizedData)->map(function (array $data, string $locale) use ($sharedSlug, $order) { $slug = Str::slug($sharedSlug, '-', $locale); return Product::updateOrCreate( diff --git a/database/seeders/SeoImageCleanupSeeder.php b/database/seeders/SeoImageCleanupSeeder.php index 3090e88..7dd6c4f 100644 --- a/database/seeders/SeoImageCleanupSeeder.php +++ b/database/seeders/SeoImageCleanupSeeder.php @@ -2,14 +2,15 @@ namespace Database\Seeders; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; class SeoImageCleanupSeeder extends Seeder { - private const BROKEN_IMAGE_PATTERN = '%seo_codebar.webp%'; + private const string BROKEN_IMAGE_PATTERN = '%seo_codebar.webp%'; - private const LEGACY_IMAGE_PATTERN = '%seo_paperflakes.webp%'; + private const string LEGACY_IMAGE_PATTERN = '%seo_paperflakes.webp%'; /** * Clears out references to two SEO images that were replaced — a broken @@ -19,7 +20,7 @@ class SeoImageCleanupSeeder extends Seeder public function run(): void { DB::table('pages') - ->where(function ($query) { + ->where(function (Builder $query) { $query->where('image', 'like', self::BROKEN_IMAGE_PATTERN) ->orWhere('image', 'like', self::LEGACY_IMAGE_PATTERN); }) @@ -27,7 +28,7 @@ public function run(): void foreach (['news', 'technologies', 'open_sources'] as $table) { DB::table($table) - ->where(function ($query) { + ->where(function (Builder $query) { $query->where('image', 'like', self::BROKEN_IMAGE_PATTERN) ->orWhere('image', 'like', self::LEGACY_IMAGE_PATTERN); }) diff --git a/database/seeders/ServicesTableSeeder.php b/database/seeders/ServicesTableSeeder.php index b1b8fff..7b84fde 100644 --- a/database/seeders/ServicesTableSeeder.php +++ b/database/seeders/ServicesTableSeeder.php @@ -192,9 +192,12 @@ public function run(): void ); } + /** + * @param array> $localizedData + */ private function seed(int $order, string $sharedSlug, array $localizedData): void { - $entries = collect($localizedData)->map(function ($data, $locale) use ($sharedSlug, $order) { + $entries = collect($localizedData)->map(function (array $data, string $locale) use ($sharedSlug, $order) { $slug = Str::slug($sharedSlug, '-', $locale); return Service::updateOrCreate( diff --git a/lang/de_CH.json b/lang/de_CH.json index 0bce7c5..9a2d7f2 100644 --- a/lang/de_CH.json +++ b/lang/de_CH.json @@ -229,5 +229,74 @@ "What’s included": "Das ist inbegriffen", "Wilkhahn seating designed for long, healthy workdays.": "Wilkhahn Bürostühle — ergonomisch, für gesunde Arbeitstage.", "With 5G failover for uninterrupted connectivity.": "Mit 5G-Fallback für Ausfallsicherheit.", - "Workplace": "Arbeitsplatz" + "Workplace": "Arbeitsplatz", + "Jobs intro": "Wir entwickeln Software mit offenen Technologien – als kleines Team, in dem alle Verantwortung übernehmen und ständig dazulernen.", + "Jobs open positions heading": "Offene Stellen", + "Jobs no open positions": "Zurzeit haben wir keine offenen Stellen.", + "Jobs spontaneous heading": "Initiativbewerbung", + "Jobs spontaneous body": "Du bist überzeugt, dass du zu codebar passt? Wir freuen uns auch ohne ausgeschriebene Stelle über deine Bewerbung:", + "Terms page heading": "Allgemeine Geschäftsbedingungen (AGB)", + "Terms 1 heading": "1. Allgemeiner Teil", + "Terms 1.1 heading": "1.1 Anwendungsbereich", + "Terms 1.1 body": "Diese Allgemeinen Geschäftsbedingungen (AGB) regeln die Zusammenarbeit zwischen der codebar Solutions AG (nachfolgend «codebar») und ihren Kunden. Sie gelten für sämtliche Angebote, Dienstleistungen und Produkte von codebar, sofern im Einzelvertrag nichts Abweichendes vereinbart ist. Abweichende Bedingungen des Kunden gelten nur, wenn sie von codebar ausdrücklich und schriftlich anerkannt wurden.", + "Terms 1.2 heading": "1.2 Angebote & Auftragserteilung", + "Terms 1.2 body": "Offerten sind 30 Tage gültig, sofern nichts anderes vermerkt ist. Bei Softwareentwicklungsleistungen basieren Offerten auf Schätzungen; Aufwand und Termine können im Projektverlauf variieren. Ein Auftrag kommt zustande, wenn der Kunde das Angebot schriftlich bestätigt oder digital signiert. Änderungen des Leistungsumfangs (Change Requests) sind jederzeit möglich und werden nach Aufwand separat verrechnet, sofern nichts anderes vereinbart wird.", + "Terms 1.3 heading": "1.3 Kein Widerrufsrecht", + "Terms 1.3 body": "Bestellungen können nach der Auftragserteilung nicht widerrufen werden. Dies gilt insbesondere für Lizenzbestellungen: Bestellte Lizenzen sind vom Widerruf ausgeschlossen.", + "Terms 1.4 heading": "1.4 Vertragsbeginn", + "Terms 1.4 body": "Dienstleistungen treten mit dem vereinbarten Datum oder, mangels Datum, mit dem Start der Arbeiten in Kraft.", + "Terms 1.5 heading": "1.5 Vertragslaufzeit & Vertragsende", + "Terms 1.5 body": "Projektverträge enden mit der Übergabe bzw. Abnahme der Leistungen. Wiederkehrende Verträge (z. B. Support-, Wartungs- und Lizenzverträge) laufen grundsätzlich vom 1. Januar bis zum 31. Dezember. Die Verträge verlängern sich nicht automatisch; eine Fortführung erfolgt durch Erneuerung.", + "Terms 1.6 heading": "1.6 Preise", + "Terms 1.6 body": "Alle Preise verstehen sich netto in Schweizer Franken (CHF), exklusive Mehrwertsteuer. Dienstleistungen werden nach effektivem Aufwand zu den vereinbarten Stundensätzen verrechnet, sofern nicht schriftlich ein Fixpreis vereinbart wurde.", + "Terms 1.7 heading": "1.7 Zahlungsbedingungen", + "Terms 1.7 body": "Rechnungen sind innert 14 Tagen ohne Abzug zahlbar. Bei Zahlungsverzug wird ein Verzugszins von 5 % geschuldet.", + "Terms 1.8 heading": "1.8 Zahlungsverzug des Kunden", + "Terms 1.8 body": "Bei Zahlungsverzug kann codebar laufende Arbeiten pausieren, Datenzugriffe einschränken (sofern das Hosting durch codebar erfolgt) sowie Verzugszins und entstandenen Aufwand in Rechnung stellen.", + "Terms 1.9 heading": "1.9 Ausserordentliche Kündigung", + "Terms 1.9 body": "Eine sofortige Vertragsauflösung ist möglich bei schwerem Vertragsbruch, bei Zahlungsunfähigkeit oder Insolvenz sowie bei Verweigerung der Mitwirkung durch den Kunden.", + "Terms 2 heading": "2. Dienstleistungen & Projektabwicklung", + "Terms 2.1 heading": "2.1 Leistungserbringung", + "Terms 2.1 body": "codebar erbringt alle Leistungen nach bestem Wissen und gemäss aktuellem Stand der Technik. Dienstleistungen können teilweise durch qualifizierte Dritte erbracht werden.", + "Terms 2.2 heading": "2.2 Projektmethodik", + "Terms 2.2 body": "Sofern nicht anders vereinbart, erfolgen Projekte agil (z. B. Scrum/Kanban). Der Kunde verpflichtet sich zur Priorisierung von Anforderungen, zu Feedback innerhalb nützlicher Frist sowie zu Freigaben pro Sprint bzw. Release.", + "Terms 2.3 heading": "2.3 Termine", + "Terms 2.3 body": "Termine sind Schätzwerte und nicht verbindlich, sofern nicht ausdrücklich zugesichert. Verzögerungen aufgrund von Mitwirkungen des Kunden (z. B. fehlende Daten, Inputs oder Entscheidungen) verlängern die Termine entsprechend.", + "Terms 2.4 heading": "2.4 Übergabe & Abnahme", + "Terms 2.4 body": "Die Abnahme erfolgt formal durch Abnahmeprotokoll, automatisch 14 Tage nach Bereitstellung, falls keine schriftliche Mängelmeldung erfolgt, oder durch produktiven Einsatz der Software.", + "Terms 2.5 heading": "2.5 Gewährleistung", + "Terms 2.5 body": "codebar gewährleistet während 3 Monaten ab Abnahme, dass die Software den vereinbarten Anforderungen entspricht. Nicht als Mängel gelten neue Anforderungen oder Funktionswünsche, Drittprobleme (Hosting, Browser, Betriebssysteme, externe APIs) sowie Anpassungen durch den Kunden oder Dritte. Die Gewährleistung beschränkt sich auf Nachbesserung.", + "Terms 2.6 heading": "2.6 Selbstverschulden", + "Terms 2.6 body": "Bei eigenmächtigen Änderungen (Code, Systeme, Deployments) oder unsachgemässer Nutzung entfallen sämtliche Ansprüche.", + "Terms 2.7 heading": "2.7 Change Management", + "Terms 2.7 body": "Sämtliche Änderungen des Leistungsumfangs gelten als Change Request und werden nach Aufwand verrechnet, sofern nicht schriftlich anders festgelegt.", + "Terms 2.8 heading": "2.8 Zusatzaufwand", + "Terms 2.8 body": "Separat verrechnet werden insbesondere die Fehleranalyse bei Fremdsoftware oder Kundensystemen, die Wiederherstellung nach Datenverlust ohne Backupvertrag, zusätzliche Iterationen aufgrund fehlender Kundenauskünfte sowie Arbeiten ausserhalb der üblichen Arbeitszeiten.", + "Terms 2.9 heading": "2.9 Mitwirkungspflichten des Kunden", + "Terms 2.9 body": "Der Kunde erbringt alle notwendigen Mitwirkungen rechtzeitig, insbesondere die Bereitstellung von Systemzugängen und Testdaten, rechtzeitiges Feedback, Freigaben und Ansprechpersonen, die Sicherstellung der IT-Sicherheit seiner Infrastruktur sowie die Durchführung eigener Abnahmetests. Kommt der Kunde seinen Pflichten nicht nach, kann codebar den Mehraufwand verrechnen.", + "Terms 3 heading": "3. Nutzungsrechte & geistiges Eigentum", + "Terms 3.1 heading": "3.1 Eigentum & Nutzungsrechte", + "Terms 3.1 body": "Sofern im Einzelvertrag nichts anderes festgelegt ist, behält codebar sämtliche Rechte an generischen Komponenten, Frameworks, Libraries, Tools und Entwicklungs-Know-how. Der Kunde erhält ein nicht-exklusives, zeitlich unbeschränktes Nutzungsrecht an den spezifisch für ihn entwickelten Teilen.", + "Terms 3.2 heading": "3.2 Herausgabe von Quellcode", + "Terms 3.2 body": "Der Kunde erhält den Quellcode, sofern dies Vertragsbestandteil ist. Ohne besondere Vereinbarung erhält der Kunde Zugriff auf Build-Artefakte, jedoch keinen Anspruch auf interne Tools, Frameworks oder Prozessdokumentationen.", + "Terms 3.3 heading": "3.3 Drittsoftware & Open Source", + "Terms 3.3 body": "Beim Einsatz von Drittsoftware oder Open-Source-Komponenten gelten deren jeweilige Lizenzbedingungen.", + "Terms 4 heading": "4. Haftung", + "Terms 4.1 heading": "4.1 Haftungsumfang", + "Terms 4.1 body": "Die Haftung von codebar ist – soweit gesetzlich zulässig – beschränkt auf den Wert der letzten drei Monatsrechnungen, höchstens jedoch CHF 50'000, je nachdem, welcher Betrag tiefer ist.", + "Terms 4.2 heading": "4.2 Haftungsausschlüsse", + "Terms 4.2 body": "codebar haftet nicht für indirekte Schäden (z. B. Umsatzverlust, Produktionsausfall oder Datenverlust), Probleme durch Drittsoftware oder externe Systeme, Hosting- oder Infrastrukturfehler des Kunden sowie Sicherheitsvorfälle ohne entsprechenden Sicherheitsvertrag.", + "Terms 4.3 heading": "4.3 Datensicherung", + "Terms 4.3 body": "Der Kunde ist für Backups verantwortlich, sofern kein Hosting- oder Backupvertrag abgeschlossen wurde.", + "Terms 5 heading": "5. Support, Wartung & SLAs", + "Terms 5 body": "Supportleistungen erfolgen nach Aufwand, sofern kein Wartungsvertrag besteht. SLA-Level werden in separaten Verträgen geregelt.", + "Terms 6 heading": "6. Vertraulichkeit", + "Terms 6 body": "Beide Parteien verpflichten sich zur Geheimhaltung aller vertraulichen Informationen, auch über das Vertragsende hinaus. codebar darf die Zusammenarbeit als Referenz erwähnen, sofern der Kunde nicht schriftlich widerspricht.", + "Terms 7 heading": "7. Übertragung", + "Terms 7 body": "codebar kann Verträge oder Teile davon an verbundene Unternehmen übertragen.", + "Terms 8 heading": "8. Salvatorische Klausel", + "Terms 8 body": "Sollten einzelne Bestimmungen dieser AGB ungültig sein, werden sie durch Bestimmungen ersetzt, die dem wirtschaftlichen Zweck am nächsten kommen. Die Gültigkeit der übrigen Bestimmungen bleibt unberührt.", + "Terms 9 heading": "9. Gerichtsstand & anwendbares Recht", + "Terms 9 body": "Es gilt Schweizer Recht. Gerichtsstand ist Basel-Landschaft bzw. der Sitz der codebar Solutions AG, sofern nicht anders vereinbart.", + "Terms language body": "Bei Abweichungen zwischen der deutschen und der englischen Fassung dieser AGB ist die deutsche Fassung massgebend." } diff --git a/lang/de_CH/errors.php b/lang/de_CH/errors.php new file mode 100644 index 0000000..7e7b406 --- /dev/null +++ b/lang/de_CH/errors.php @@ -0,0 +1,9 @@ + 'Anfrage konnte nicht ausgeführt werden', + 'title_server' => 'Etwas ist schiefgelaufen', + 'default_client' => 'Die angeforderte Seite oder Aktion ist ungültig oder nicht mehr verfügbar.', + 'default_server' => 'Auf unserer Seite ist ein Problem aufgetreten. Bitte versuchen Sie es in Kürze erneut.', + 'back_home' => 'Zur Startseite', +]; diff --git a/lang/en_CH.json b/lang/en_CH.json index 32d8e98..a93bff1 100644 --- a/lang/en_CH.json +++ b/lang/en_CH.json @@ -227,5 +227,74 @@ "What’s included": "What’s included", "Wilkhahn seating designed for long, healthy workdays.": "Wilkhahn seating designed for long, healthy workdays.", "With 5G failover for uninterrupted connectivity.": "With 5G failover for uninterrupted connectivity.", - "Workplace": "Workplace" + "Workplace": "Workplace", + "Jobs intro": "We build software with open technologies — as a small team where everyone takes responsibility and keeps learning.", + "Jobs open positions heading": "Open positions", + "Jobs no open positions": "We currently have no open positions.", + "Jobs spontaneous heading": "Spontaneous applications", + "Jobs spontaneous body": "Convinced you would be a great fit for codebar? We are happy to hear from you even without an advertised position:", + "Terms page heading": "Terms & Conditions", + "Terms 1 heading": "1. General provisions", + "Terms 1.1 heading": "1.1 Scope of application", + "Terms 1.1 body": "These General Terms and Conditions (GTC) govern the collaboration between codebar Solutions AG (hereinafter “codebar”) and its customers. They apply to all offers, services and products of codebar unless otherwise agreed in the individual contract. Deviating terms of the customer apply only if expressly acknowledged by codebar in writing.", + "Terms 1.2 heading": "1.2 Offers & placing of orders", + "Terms 1.2 body": "Offers are valid for 30 days unless stated otherwise. For software development services, offers are based on estimates; effort and deadlines may vary over the course of the project. An order is concluded when the customer confirms the offer in writing or signs it digitally. Changes to the scope of services (change requests) are possible at any time and are charged separately on a time-and-materials basis unless otherwise agreed.", + "Terms 1.3 heading": "1.3 No right of revocation", + "Terms 1.3 body": "Orders cannot be revoked once placed. This applies in particular to licence orders: ordered licences are excluded from revocation.", + "Terms 1.4 heading": "1.4 Start of contract", + "Terms 1.4 body": "Services take effect on the agreed date or, in the absence of a date, upon commencement of the work.", + "Terms 1.5 heading": "1.5 Contract term & termination", + "Terms 1.5 body": "Project contracts end with the handover or acceptance of the deliverables. Recurring contracts (e.g. support, maintenance and licence contracts) generally run from 1 January to 31 December. Contracts do not renew automatically; continuation requires renewal.", + "Terms 1.6 heading": "1.6 Prices", + "Terms 1.6 body": "All prices are net in Swiss francs (CHF), excluding VAT. Services are charged based on actual effort at the agreed hourly rates unless a fixed price has been agreed in writing.", + "Terms 1.7 heading": "1.7 Payment terms", + "Terms 1.7 body": "Invoices are payable within 14 days without deduction. In the event of late payment, default interest of 5% is owed.", + "Terms 1.8 heading": "1.8 Customer default of payment", + "Terms 1.8 body": "In the event of default of payment, codebar may pause ongoing work, restrict data access (where hosting is provided by codebar), and charge default interest as well as the resulting effort.", + "Terms 1.9 heading": "1.9 Extraordinary termination", + "Terms 1.9 body": "Immediate termination of the contract is possible in the event of a serious breach of contract, insolvency or bankruptcy, or refusal of cooperation by the customer.", + "Terms 2 heading": "2. Services & project delivery", + "Terms 2.1 heading": "2.1 Provision of services", + "Terms 2.1 body": "codebar provides all services to the best of its knowledge and in accordance with the current state of the art. Services may be provided in part by qualified third parties.", + "Terms 2.2 heading": "2.2 Project methodology", + "Terms 2.2 body": "Unless otherwise agreed, projects are carried out using agile methods (e.g. Scrum/Kanban). The customer undertakes to prioritise requirements, provide feedback within a reasonable time, and grant approvals per sprint or release.", + "Terms 2.3 heading": "2.3 Deadlines", + "Terms 2.3 body": "Deadlines are estimates and not binding unless expressly guaranteed. Delays caused by the customer (e.g. missing data, input or decisions) extend the deadlines accordingly.", + "Terms 2.4 heading": "2.4 Handover & acceptance", + "Terms 2.4 body": "Acceptance takes place formally by acceptance protocol, automatically 14 days after delivery if no written notice of defects is received, or through productive use of the software.", + "Terms 2.5 heading": "2.5 Warranty", + "Terms 2.5 body": "codebar warrants for 3 months from acceptance that the software complies with the agreed requirements. The following are not considered defects: new requirements or feature requests, third-party issues (hosting, browsers, operating systems, external APIs), and modifications made by the customer or third parties. The warranty is limited to rectification.", + "Terms 2.6 heading": "2.6 Customer's own fault", + "Terms 2.6 body": "In the case of unauthorised modifications (code, systems, deployments) or improper use, all claims lapse.", + "Terms 2.7 heading": "2.7 Change management", + "Terms 2.7 body": "All changes to the scope of services are considered change requests and are charged on a time-and-materials basis unless otherwise agreed in writing.", + "Terms 2.8 heading": "2.8 Additional effort", + "Terms 2.8 body": "The following in particular are charged separately: fault analysis of third-party software or customer systems, restoration after data loss without a backup contract, additional iterations due to missing information from the customer, and work outside normal working hours.", + "Terms 2.9 heading": "2.9 Customer's duties to cooperate", + "Terms 2.9 body": "The customer provides all necessary cooperation in good time, in particular the provision of system access and test data, timely feedback, approvals and contact persons, ensuring the IT security of its own infrastructure, and carrying out its own acceptance tests. If the customer fails to meet these obligations, codebar may charge for the additional effort.", + "Terms 3 heading": "3. Rights of use & intellectual property", + "Terms 3.1 heading": "3.1 Ownership & rights of use", + "Terms 3.1 body": "Unless otherwise specified in the individual contract, codebar retains all rights to generic components, frameworks, libraries, tools and development know-how. The customer receives a non-exclusive right of use, unlimited in time, to the parts developed specifically for the customer.", + "Terms 3.2 heading": "3.2 Delivery of source code", + "Terms 3.2 body": "The customer receives the source code if this is part of the contract. Without a specific agreement, the customer receives access to build artefacts but has no claim to internal tools, frameworks or process documentation.", + "Terms 3.3 heading": "3.3 Third-party software & open source", + "Terms 3.3 body": "Where third-party software or open-source components are used, their respective licence terms apply.", + "Terms 4 heading": "4. Liability", + "Terms 4.1 heading": "4.1 Scope of liability", + "Terms 4.1 body": "To the extent permitted by law, codebar's liability is limited to the value of the last three monthly invoices or a maximum of CHF 50,000, whichever is lower.", + "Terms 4.2 heading": "4.2 Exclusions of liability", + "Terms 4.2 body": "codebar is not liable for indirect damage (e.g. loss of revenue, production downtime or data loss), problems caused by third-party software or external systems, hosting or infrastructure failures on the customer's side, or security incidents in the absence of a corresponding security contract.", + "Terms 4.3 heading": "4.3 Data backup", + "Terms 4.3 body": "The customer is responsible for backups unless a hosting or backup contract has been concluded.", + "Terms 5 heading": "5. Support, maintenance & SLAs", + "Terms 5 body": "Support services are provided on a time-and-materials basis unless a maintenance contract exists. SLA levels are governed by separate contracts.", + "Terms 6 heading": "6. Confidentiality", + "Terms 6 body": "Both parties undertake to keep all confidential information secret, including beyond the end of the contract. codebar may refer to the collaboration as a reference unless the customer objects in writing.", + "Terms 7 heading": "7. Assignment", + "Terms 7 body": "codebar may assign contracts or parts thereof to affiliated companies.", + "Terms 8 heading": "8. Severability clause", + "Terms 8 body": "Should individual provisions of these GTC be invalid, they shall be replaced by provisions that come closest to the economic purpose. The validity of the remaining provisions remains unaffected.", + "Terms 9 heading": "9. Place of jurisdiction & applicable law", + "Terms 9 body": "Swiss law applies. The place of jurisdiction is Basel-Landschaft or the registered office of codebar Solutions AG, unless otherwise agreed.", + "Terms language body": "In the event of discrepancies between the German and English versions of these GTC, the German version prevails." } diff --git a/lang/en_CH/errors.php b/lang/en_CH/errors.php new file mode 100644 index 0000000..bf42c96 --- /dev/null +++ b/lang/en_CH/errors.php @@ -0,0 +1,14 @@ + 'Request could not be completed', + 'title_server' => 'Something went wrong', + + // Shown when the framework message is empty or generic + 'default_client' => 'The page or action you requested is not valid or is no longer available.', + 'default_server' => 'We hit a problem on our side. Please try again in a moment.', + + // Primary CTA on error pages + 'back_home' => 'Back to home', +]; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index db41086..4769541 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -16,3 +16,5 @@ parameters: - database/migrations/2024_06_06_100452_create_permission_tables.php # Nova is not installed; this config references Laravel\Nova classes. - config/nova.php + # Pest's architecture-testing DSL is not statically analysable. + - tests/Core/ArchitectureTest.php diff --git a/resources/views/app/jobs/index.blade.php b/resources/views/app/jobs/index.blade.php index b0a9c88..1069770 100644 --- a/resources/views/app/jobs/index.blade.php +++ b/resources/views/app/jobs/index.blade.php @@ -1,3 +1,24 @@ - \ No newline at end of file + + + +

{{ __('Jobs intro') }}

+
+
+ + + +

{{ __('Jobs no open positions') }}

+
+ + + + +

+ {{ __('Jobs spontaneous body') }} + +

+
+
+ diff --git a/resources/views/app/legal/terms/index.blade.php b/resources/views/app/legal/terms/index.blade.php index 8f75fc9..c23e423 100644 --- a/resources/views/app/legal/terms/index.blade.php +++ b/resources/views/app/legal/terms/index.blade.php @@ -1,3 +1,133 @@ - + + +

{{ __('Last updated at: :date', ['date' => '2026-07-23']) }}

+ + + + +

{{ __('Terms 1.1 heading') }}

+

{{ __('Terms 1.1 body') }}

+ +

{{ __('Terms 1.2 heading') }}

+

{{ __('Terms 1.2 body') }}

+ +

{{ __('Terms 1.3 heading') }}

+

{{ __('Terms 1.3 body') }}

+ +

{{ __('Terms 1.4 heading') }}

+

{{ __('Terms 1.4 body') }}

+ +

{{ __('Terms 1.5 heading') }}

+

{{ __('Terms 1.5 body') }}

+ +

{{ __('Terms 1.6 heading') }}

+

{{ __('Terms 1.6 body') }}

+ +

{{ __('Terms 1.7 heading') }}

+

{{ __('Terms 1.7 body') }}

+ +

{{ __('Terms 1.8 heading') }}

+

{{ __('Terms 1.8 body') }}

+ +

{{ __('Terms 1.9 heading') }}

+

{{ __('Terms 1.9 body') }}

+
+
+ + + + +

{{ __('Terms 2.1 heading') }}

+

{{ __('Terms 2.1 body') }}

+ +

{{ __('Terms 2.2 heading') }}

+

{{ __('Terms 2.2 body') }}

+ +

{{ __('Terms 2.3 heading') }}

+

{{ __('Terms 2.3 body') }}

+ +

{{ __('Terms 2.4 heading') }}

+

{{ __('Terms 2.4 body') }}

+ +

{{ __('Terms 2.5 heading') }}

+

{{ __('Terms 2.5 body') }}

+ +

{{ __('Terms 2.6 heading') }}

+

{{ __('Terms 2.6 body') }}

+ +

{{ __('Terms 2.7 heading') }}

+

{{ __('Terms 2.7 body') }}

+ +

{{ __('Terms 2.8 heading') }}

+

{{ __('Terms 2.8 body') }}

+ +

{{ __('Terms 2.9 heading') }}

+

{{ __('Terms 2.9 body') }}

+
+
+ + + + +

{{ __('Terms 3.1 heading') }}

+

{{ __('Terms 3.1 body') }}

+ +

{{ __('Terms 3.2 heading') }}

+

{{ __('Terms 3.2 body') }}

+ +

{{ __('Terms 3.3 heading') }}

+

{{ __('Terms 3.3 body') }}

+
+
+ + + + +

{{ __('Terms 4.1 heading') }}

+

{{ __('Terms 4.1 body') }}

+ +

{{ __('Terms 4.2 heading') }}

+

{{ __('Terms 4.2 body') }}

+ +

{{ __('Terms 4.3 heading') }}

+

{{ __('Terms 4.3 body') }}

+
+
+ + + + +

{{ __('Terms 5 body') }}

+
+
+ + + + +

{{ __('Terms 6 body') }}

+
+
+ + + + +

{{ __('Terms 7 body') }}

+
+
+ + + + +

{{ __('Terms 8 body') }}

+
+
+ + + + +

{{ __('Terms 9 body') }}

+

{{ __('Terms language body') }}

+
+
diff --git a/resources/views/errors/401.blade.php b/resources/views/errors/401.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/401.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/402.blade.php b/resources/views/errors/402.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/402.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/419.blade.php b/resources/views/errors/419.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/419.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/429.blade.php b/resources/views/errors/429.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/429.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/4xx.blade.php b/resources/views/errors/4xx.blade.php new file mode 100644 index 0000000..24d892a --- /dev/null +++ b/resources/views/errors/4xx.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-client') diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 0000000..8efd145 --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-server') diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 0000000..8efd145 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-server') diff --git a/resources/views/errors/5xx.blade.php b/resources/views/errors/5xx.blade.php new file mode 100644 index 0000000..8efd145 --- /dev/null +++ b/resources/views/errors/5xx.blade.php @@ -0,0 +1 @@ +@include('errors.partials._http-error-server') diff --git a/resources/views/errors/partials/_auth-error.blade.php b/resources/views/errors/partials/_auth-error.blade.php new file mode 100644 index 0000000..075713c --- /dev/null +++ b/resources/views/errors/partials/_auth-error.blade.php @@ -0,0 +1,49 @@ + + + + + + {{ $statusCode }} — {{ $title }} — codebar Solutions AG + + + +
+

{{ $statusCode }}

+

{{ $title }}

+

{{ $message }}

+ {{ __('errors.back_home') }} +
+ + diff --git a/resources/views/errors/partials/_http-error-client.blade.php b/resources/views/errors/partials/_http-error-client.blade.php new file mode 100644 index 0000000..415af9e --- /dev/null +++ b/resources/views/errors/partials/_http-error-client.blade.php @@ -0,0 +1,27 @@ +@php + /** @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $exception */ + $statusCode = $exception->getStatusCode(); + $raw = trim($exception->getMessage()); + $generic = [ + 'Not Found', + 'Forbidden', + 'Unauthorized', + 'Bad Request', + 'Method Not Allowed', + 'Gone', + 'Request Timeout', + 'Conflict', + 'Too Many Requests', + 'Page Expired', + 'Payment Required', + ]; + $message = ($raw !== '' && ! in_array($raw, $generic, true)) + ? $raw + : __('errors.default_client'); +@endphp + +@include('errors.partials._auth-error', [ + 'statusCode' => $statusCode, + 'title' => __('errors.title_client'), + 'message' => $message, +]) diff --git a/resources/views/errors/partials/_http-error-server.blade.php b/resources/views/errors/partials/_http-error-server.blade.php new file mode 100644 index 0000000..f182654 --- /dev/null +++ b/resources/views/errors/partials/_http-error-server.blade.php @@ -0,0 +1,20 @@ +@php + /** @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $exception */ + $statusCode = $exception->getStatusCode(); + $raw = trim($exception->getMessage()); + $generic = [ + 'Server Error', + 'Service Unavailable', + 'Bad Gateway', + 'Gateway Timeout', + ]; + $message = ($raw !== '' && ! in_array($raw, $generic, true)) + ? $raw + : __('errors.default_server'); +@endphp + +@include('errors.partials._auth-error', [ + 'statusCode' => $statusCode, + 'title' => __('errors.title_server'), + 'message' => $message, +]) diff --git a/resources/views/layouts/_partials/_footer.blade.php b/resources/views/layouts/_partials/_footer.blade.php index e52c2d3..819dbef 100644 --- a/resources/views/layouts/_partials/_footer.blade.php +++ b/resources/views/layouts/_partials/_footer.blade.php @@ -14,6 +14,19 @@ class="text-lg"/> +
  • + +
  • + + +
    +

    {{ __('Company') }}

    +
      +
    • + +
    diff --git a/tests/Feature/Actions/FetchLlmUsageActionTest.php b/tests/Feature/Actions/FetchLlmUsageActionTest.php index 62b4547..9244dd5 100644 --- a/tests/Feature/Actions/FetchLlmUsageActionTest.php +++ b/tests/Feature/Actions/FetchLlmUsageActionTest.php @@ -26,7 +26,7 @@ expect($rows)->toHaveCount(2); - $qwen = $rows->firstWhere('model', 'qwen3.6:35b'); + $qwen = $rows->firstOrFail(fn (array $row): bool => $row['model'] === 'qwen3.6:35b'); expect($qwen['date'])->toBe('2026-07-20') ->and($qwen['prompt_tokens'])->toBe(110) diff --git a/tests/Feature/Commands/FetchLlmAnalyticsCommandTest.php b/tests/Feature/Commands/FetchLlmAnalyticsCommandTest.php index 7778fd0..6b17bb6 100644 --- a/tests/Feature/Commands/FetchLlmAnalyticsCommandTest.php +++ b/tests/Feature/Commands/FetchLlmAnalyticsCommandTest.php @@ -8,7 +8,7 @@ it('dispatches one job per day for the default range', function () { Queue::fake(); - $this->artisan('llm:fetch-analytics')->assertSuccessful(); + runArtisan('llm:fetch-analytics')->assertSuccessful(); Queue::assertPushed(FetchLlmUsageJob::class, 4); })->group('llm-analytics'); @@ -16,7 +16,7 @@ it('backfills from the initial sync start with the full flag', function () { Queue::fake(); - $this->artisan('llm:fetch-analytics', ['--full' => true])->assertSuccessful(); + runArtisan('llm:fetch-analytics', ['--full' => true])->assertSuccessful(); $expectedDays = (int) CarbonImmutable::parse('2026-01-01')->diffInDays(CarbonImmutable::today()) + 1; @@ -28,7 +28,7 @@ it('dispatches one job per day for a custom range', function () { Queue::fake(); - $this->artisan('llm:fetch-analytics', ['--from' => '2026-01-01', '--to' => '2026-01-05']) + runArtisan('llm:fetch-analytics', ['--from' => '2026-01-01', '--to' => '2026-01-05']) ->assertSuccessful(); Queue::assertPushed(FetchLlmUsageJob::class, 5); @@ -40,13 +40,13 @@ it('throws on an invalid date', function () { Queue::fake(); - $this->artisan('llm:fetch-analytics', ['--from' => 'not-a-date']); + runArtisan('llm:fetch-analytics', ['--from' => 'not-a-date']); })->throws(InvalidFormatException::class)->group('llm-analytics'); it('fails when from is after to', function () { Queue::fake(); - $this->artisan('llm:fetch-analytics', ['--from' => '2026-02-01', '--to' => '2026-01-01']) + runArtisan('llm:fetch-analytics', ['--from' => '2026-02-01', '--to' => '2026-01-01']) ->assertFailed(); Queue::assertNothingPushed(); diff --git a/tests/Feature/Commands/SendNetworkInvitesCommandTest.php b/tests/Feature/Commands/SendNetworkInvitesCommandTest.php index e51c69a..9a13103 100644 --- a/tests/Feature/Commands/SendNetworkInvitesCommandTest.php +++ b/tests/Feature/Commands/SendNetworkInvitesCommandTest.php @@ -6,8 +6,6 @@ use App\Models\NetworkUser; use Illuminate\Support\Facades\Bus; -use function Pest\Laravel\artisan; - function createInvitableUser(): NetworkUser { foreach (LocaleEnum::cases() as $locale) { @@ -28,8 +26,8 @@ function createInvitableUser(): NetworkUser it('requires a valid locale option', function () { Bus::fake(); - artisan('network:invite')->assertFailed(); - artisan('network:invite', ['--locale' => 'fr'])->assertFailed(); + runArtisan('network:invite')->assertFailed(); + runArtisan('network:invite', ['--locale' => 'fr'])->assertFailed(); Bus::assertNothingDispatched(); })->group('network'); @@ -39,7 +37,7 @@ function createInvitableUser(): NetworkUser $networkUser = createInvitableUser(); - artisan('network:invite', ['--locale' => 'de']) + runArtisan('network:invite', ['--locale' => 'de']) ->expectsConfirmation('Send 1 invitation(s) in de_CH?', 'yes') ->assertSuccessful(); @@ -48,12 +46,23 @@ function createInvitableUser(): NetworkUser }); })->group('network'); +it('skips the confirmation with --force', function () { + Bus::fake(); + + $networkUser = createInvitableUser(); + + runArtisan('network:invite', ['--locale' => 'de', '--force' => true]) + ->assertSuccessful(); + + Bus::assertDispatched(SendNetworkInviteJob::class, fn (SendNetworkInviteJob $job) => $job->email === $networkUser->email); +})->group('network'); + it('queues nothing when the confirmation is declined', function () { Bus::fake(); createInvitableUser(); - artisan('network:invite', ['--locale' => 'en']) + runArtisan('network:invite', ['--locale' => 'en']) ->expectsConfirmation('Send 1 invitation(s) in en_CH?', 'no') ->assertSuccessful(); @@ -66,7 +75,7 @@ function createInvitableUser(): NetworkUser createInvitableUser(); NetworkUser::factory()->create(['network_key' => 'docuware', 'email' => 'other@example.com']); - artisan('network:invite', ['--locale' => 'de', '--email' => 'vincenzo@example.com']) + runArtisan('network:invite', ['--locale' => 'de', '--email' => 'vincenzo@example.com']) ->expectsConfirmation('Send 1 invitation(s) in de_CH?', 'yes') ->assertSuccessful(); @@ -79,7 +88,7 @@ function createInvitableUser(): NetworkUser NetworkUser::factory()->create(['email' => null]); - artisan('network:invite', ['--locale' => 'de'])->assertSuccessful(); + runArtisan('network:invite', ['--locale' => 'de'])->assertSuccessful(); Bus::assertNothingDispatched(); })->group('network'); diff --git a/tests/Feature/Controllers/EntryIndexControllerTest.php b/tests/Feature/Controllers/EntryIndexControllerTest.php index 60854ee..7c5fc77 100644 --- a/tests/Feature/Controllers/EntryIndexControllerTest.php +++ b/tests/Feature/Controllers/EntryIndexControllerTest.php @@ -4,11 +4,13 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Str; +use function Pest\Laravel\get; + it('redirects to the correct localized start page', function () { $locale = LocaleEnum::DE->value; App::setLocale($locale); - $response = $this->get('/'); + $response = get('/'); $expectedRoute = route(Str::slug($locale).'.start.index'); diff --git a/tests/Feature/Controllers/RouteStatusTest.php b/tests/Feature/Controllers/RouteStatusTest.php index a91b2b5..1f19a90 100644 --- a/tests/Feature/Controllers/RouteStatusTest.php +++ b/tests/Feature/Controllers/RouteStatusTest.php @@ -12,6 +12,8 @@ [LocaleEnum::EN->value, 'about-us.index'], [LocaleEnum::EN->value, 'legal.imprint.index'], [LocaleEnum::EN->value, 'legal.privacy.index'], + [LocaleEnum::EN->value, 'legal.terms.index'], + [LocaleEnum::EN->value, 'jobs.index'], [LocaleEnum::EN->value, 'media.index'], [LocaleEnum::EN->value, 'contact.index'], [LocaleEnum::EN->value, 'ai.index'], @@ -26,6 +28,8 @@ [LocaleEnum::DE->value, 'about-us.index'], [LocaleEnum::DE->value, 'legal.imprint.index'], [LocaleEnum::DE->value, 'legal.privacy.index'], + [LocaleEnum::DE->value, 'legal.terms.index'], + [LocaleEnum::DE->value, 'jobs.index'], [LocaleEnum::DE->value, 'media.index'], [LocaleEnum::DE->value, 'contact.index'], [LocaleEnum::DE->value, 'ai.index'], diff --git a/tests/Feature/Controllers/SitemapControllerTest.php b/tests/Feature/Controllers/SitemapControllerTest.php index a9ee9b3..84b003f 100644 --- a/tests/Feature/Controllers/SitemapControllerTest.php +++ b/tests/Feature/Controllers/SitemapControllerTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Cache; use function Pest\Laravel\get; +use function Pest\Laravel\seed; it('generates and caches the sitemap XML', function () { Cache::flush(); @@ -30,7 +31,7 @@ })->group('sitemap'); it('includes live legal and media pages in the sitemap', function () { - $this->seed(PagesTableSeeder::class); + seed(PagesTableSeeder::class); Cache::flush(); @@ -49,7 +50,7 @@ })->group('sitemap'); it('does not include disabled pages in the sitemap', function () { - $this->seed(PagesTableSeeder::class); + seed(PagesTableSeeder::class); Cache::flush(); @@ -68,7 +69,7 @@ })->group('sitemap'); it('includes the AI pages in the sitemap', function () { - $this->seed(PagesTableSeeder::class); + seed(PagesTableSeeder::class); Cache::flush(); diff --git a/tests/Feature/HttpErrorPagesTest.php b/tests/Feature/HttpErrorPagesTest.php new file mode 100644 index 0000000..b28951b --- /dev/null +++ b/tests/Feature/HttpErrorPagesTest.php @@ -0,0 +1,26 @@ + false]); + app()->setLocale('en_CH'); +}); + +it('renders custom 404 page', function () { + $this->get('/nonexistent-route-for-error-page-test-8f3c2a1b') + ->assertStatus(404) + ->assertSee(__('errors.title_client'), false) + ->assertSee('404', false) + ->assertSee(__('errors.back_home'), false); +}); + +it('renders custom 500 page', function () { + Route::get('/__http_error_pages_500', fn () => abort(500)); + + $this->get('/__http_error_pages_500') + ->assertStatus(500) + ->assertSee(__('errors.title_server'), false) + ->assertSee('500', false) + ->assertSee(__('errors.back_home'), false); +}); diff --git a/tests/Feature/Jobs/FetchLlmUsageJobTest.php b/tests/Feature/Jobs/FetchLlmUsageJobTest.php index 6ff00a6..2fcbb78 100644 --- a/tests/Feature/Jobs/FetchLlmUsageJobTest.php +++ b/tests/Feature/Jobs/FetchLlmUsageJobTest.php @@ -9,11 +9,16 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use function Pest\Laravel\assertDatabaseHas; + beforeEach(function () { config()->set('services.litellm.url', 'https://llm.codebar.net'); config()->set('services.litellm.master_key', 'test-master-key'); }); +/** + * @return array> + */ function spendLogsPayload(int $promptTokens): array { return [ @@ -35,7 +40,7 @@ function spendLogsPayload(int $promptTokens): array expect(AiModelDailyUsage::count())->toBe(1); - $this->assertDatabaseHas('ai_model_daily_usages', [ + assertDatabaseHas('ai_model_daily_usages', [ 'model' => 'qwen3.6:35b', 'prompt_tokens' => 100, 'requests' => 1, @@ -48,7 +53,7 @@ function spendLogsPayload(int $promptTokens): array expect(AiModelDailyUsage::count())->toBe(1); - $this->assertDatabaseHas('ai_model_daily_usages', [ + assertDatabaseHas('ai_model_daily_usages', [ 'model' => 'qwen3.6:35b', 'prompt_tokens' => 200, ]); @@ -60,6 +65,7 @@ function spendLogsPayload(int $promptTokens): array ]); $versionBefore = Cache::get(LlmUsageStatsAction::VERSION_CACHE_KEY, 0); + assert(is_int($versionBefore)); (new FetchLlmUsageJob(CarbonImmutable::parse('2026-07-20')))->handle( app(FetchLlmUsageAction::class), diff --git a/tests/Feature/Jobs/SendNetworkInviteJobTest.php b/tests/Feature/Jobs/SendNetworkInviteJobTest.php index 1554c62..d9b5973 100644 --- a/tests/Feature/Jobs/SendNetworkInviteJobTest.php +++ b/tests/Feature/Jobs/SendNetworkInviteJobTest.php @@ -7,6 +7,7 @@ use App\Notifications\NetworkInviteNotification; use Illuminate\Support\Facades\Notification; +use function Pest\Laravel\get; use function Pest\Laravel\travel; function createInviteJobUser(): NetworkUser @@ -40,9 +41,9 @@ function createInviteJobUser(): NetworkUser // Still valid shortly before 96 hours, expired after. travel(95)->hours(); - expect($this->get($notification->url)->status())->toBe(200); + expect(get($notification->url)->status())->toBe(200); travel(2)->hours(); - expect($this->get($notification->url)->status())->toBe(403); + expect(get($notification->url)->status())->toBe(403); return true; }); @@ -64,5 +65,5 @@ function createInviteJobUser(): NetworkUser (new SendNetworkInviteJob('vincenzo@example.com', 'de_CH'))->handle(); expect($networkUser->notifications()->count())->toBe(1) - ->and($networkUser->notifications()->first()->type)->toBe(NetworkInviteNotification::class); + ->and($networkUser->notifications()->firstOrFail()->type)->toBe(NetworkInviteNotification::class); })->group('network'); diff --git a/tests/Feature/Seeders/AiModelDailyUsagesSeederTest.php b/tests/Feature/Seeders/AiModelDailyUsagesSeederTest.php index 9753003..8d2e87e 100644 --- a/tests/Feature/Seeders/AiModelDailyUsagesSeederTest.php +++ b/tests/Feature/Seeders/AiModelDailyUsagesSeederTest.php @@ -5,9 +5,11 @@ use Database\Seeders\AiModelDailyUsagesTableSeeder; use Database\Seeders\AiModelsTableSeeder; +use function Pest\Laravel\seed; + it('seeds usage rows from the export, linking rows whose model still exists', function () { - $this->seed(AiModelsTableSeeder::class); - $this->seed(AiModelDailyUsagesTableSeeder::class); + seed(AiModelsTableSeeder::class); + seed(AiModelDailyUsagesTableSeeder::class); $knownNames = AiModel::pluck('name'); @@ -17,20 +19,20 @@ })->group('seeders', 'ai'); it('seeds usage rows even when no models exist yet, leaving them unlinked', function () { - $this->seed(AiModelDailyUsagesTableSeeder::class); + seed(AiModelDailyUsagesTableSeeder::class); expect(AiModelDailyUsage::count())->toBeGreaterThan(0) ->and(AiModelDailyUsage::whereNull('ai_model_id')->count())->toBe(AiModelDailyUsage::count()); })->group('seeders', 'ai'); it('upserts on re-run instead of duplicating rows', function () { - $this->seed(AiModelsTableSeeder::class); - $this->seed(AiModelDailyUsagesTableSeeder::class); + seed(AiModelsTableSeeder::class); + seed(AiModelDailyUsagesTableSeeder::class); $firstRun = AiModelDailyUsage::select(['date', 'model'])->get() ->map(fn (AiModelDailyUsage $row): string => $row->date->format('Y-m-d').'|'.$row->model); - $this->seed(AiModelDailyUsagesTableSeeder::class); + seed(AiModelDailyUsagesTableSeeder::class); $secondRun = AiModelDailyUsage::select(['date', 'model'])->get() ->map(fn (AiModelDailyUsage $row): string => $row->date->format('Y-m-d').'|'.$row->model); diff --git a/tests/Feature/Seeders/NetworkSeedersTest.php b/tests/Feature/Seeders/NetworkSeedersTest.php index f182c37..abde3ff 100644 --- a/tests/Feature/Seeders/NetworkSeedersTest.php +++ b/tests/Feature/Seeders/NetworkSeedersTest.php @@ -7,8 +7,10 @@ use Database\Seeders\NetworksTableSeeder; use Database\Seeders\NetworkUsersTableSeeder; +use function Pest\Laravel\seed; + it('seeds every company in both locales', function () { - $this->seed(NetworksTableSeeder::class); + seed(NetworksTableSeeder::class); $expectedKeys = [ 'wieland-business-solutions', @@ -32,11 +34,11 @@ })->group('network', 'seeders'); it('seeds the tiers and categories', function () { - $this->seed(NetworksTableSeeder::class); + seed(NetworksTableSeeder::class); - $docuware = Network::where('key', 'docuware')->where('locale', LocaleEnum::DE->value)->first(); - $odoo = Network::where('key', 'odoo')->where('locale', LocaleEnum::DE->value)->first(); - $baselhack = Network::where('key', 'baselhack')->where('locale', LocaleEnum::DE->value)->first(); + $docuware = Network::where('key', 'docuware')->where('locale', LocaleEnum::DE->value)->firstOrFail(); + $odoo = Network::where('key', 'odoo')->where('locale', LocaleEnum::DE->value)->firstOrFail(); + $baselhack = Network::where('key', 'baselhack')->where('locale', LocaleEnum::DE->value)->firstOrFail(); expect($docuware->tier_label)->toBeNull() ->and($docuware->category)->toBe(NetworkCategoryEnum::SOFTWARE) @@ -46,14 +48,14 @@ })->group('network', 'seeders'); it('seeds unpublished contact persons with real channels where known', function () { - $this->seed(NetworkUsersTableSeeder::class); + seed(NetworkUsersTableSeeder::class); - $vincenzo = NetworkUser::where('network_key', 'docuware')->first(); - $dario = NetworkUser::where('network_key', 'wieland-business-solutions')->first(); - $sarah = NetworkUser::where('network_key', 'pst')->first(); - $domenik = NetworkUser::where('network_key', 'odoo')->first(); - $patrick = NetworkUser::where('network_key', 'iway')->first(); - $baselhack = NetworkUser::where('network_key', 'baselhack')->first(); + $vincenzo = NetworkUser::where('network_key', 'docuware')->firstOrFail(); + $dario = NetworkUser::where('network_key', 'wieland-business-solutions')->firstOrFail(); + $sarah = NetworkUser::where('network_key', 'pst')->firstOrFail(); + $domenik = NetworkUser::where('network_key', 'odoo')->firstOrFail(); + $patrick = NetworkUser::where('network_key', 'iway')->firstOrFail(); + $baselhack = NetworkUser::where('network_key', 'baselhack')->firstOrFail(); expect(NetworkUser::count())->toBe(6) ->and(NetworkUser::where('published', true)->count())->toBe(0) @@ -72,10 +74,10 @@ })->group('network', 'seeders'); it('is idempotent', function () { - $this->seed(NetworksTableSeeder::class); - $this->seed(NetworksTableSeeder::class); - $this->seed(NetworkUsersTableSeeder::class); - $this->seed(NetworkUsersTableSeeder::class); + seed(NetworksTableSeeder::class); + seed(NetworksTableSeeder::class); + seed(NetworkUsersTableSeeder::class); + seed(NetworkUsersTableSeeder::class); expect(Network::count())->toBe(18) ->and(NetworkUser::count())->toBe(6); diff --git a/tests/Feature/Seeders/ServicesTableSeederTest.php b/tests/Feature/Seeders/ServicesTableSeederTest.php index 4e45e03..f9269e9 100644 --- a/tests/Feature/Seeders/ServicesTableSeederTest.php +++ b/tests/Feature/Seeders/ServicesTableSeederTest.php @@ -4,8 +4,10 @@ use App\Models\Service; use Database\Seeders\ServicesTableSeeder; +use function Pest\Laravel\seed; + it('seeds the four services in both locales', function () { - $this->seed(ServicesTableSeeder::class); + seed(ServicesTableSeeder::class); $expectedSlugs = [ 'konzeption-prototyping', @@ -25,9 +27,9 @@ })->group('services', 'seeders'); it('seeds name, teaser, content and locale references', function () { - $this->seed(ServicesTableSeeder::class); + seed(ServicesTableSeeder::class); - $concept = Service::where('slug', 'konzeption-prototyping')->where('locale', LocaleEnum::DE->value)->first(); + $concept = Service::where('slug', 'konzeption-prototyping')->where('locale', LocaleEnum::DE->value)->firstOrFail(); expect($concept->name)->toBe('Konzeption & Prototyping') ->and($concept->teaser)->toBe('Wir bringen deine Idee aufs Papier – visuell. Mit Mockups und klickbaren Prototypen entsteht früh ein gemeinsames Bild, noch bevor die erste Zeile Code geschrieben ist. Am Ende hältst du ein technisches Konzept in der Hand, inklusive Technologiewahl – als Basis für die Umsetzung, bei uns oder bei anderen.') @@ -36,8 +38,8 @@ })->group('services', 'seeders'); it('is idempotent', function () { - $this->seed(ServicesTableSeeder::class); - $this->seed(ServicesTableSeeder::class); + seed(ServicesTableSeeder::class); + seed(ServicesTableSeeder::class); expect(Service::count())->toBe(8); })->group('services', 'seeders'); diff --git a/tests/Pest.php b/tests/Pest.php index 8733506..fc1697b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,16 +3,20 @@ use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; +use Illuminate\Testing\PendingCommand; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; use Tests\TestCase; +use function Pest\Laravel\artisan; +use function Pest\Laravel\seed; + uses(TestCase::class, RefreshDatabase::class) ->beforeEach(function () { - $this->seed(RolesAndPermissionsSeeder::class); + seed(RolesAndPermissionsSeeder::class); }) ->in(__DIR__); -function createRequest($method, $uri): Request +function createRequest(string $method, string $uri): Request { $symfonyRequest = SymfonyRequest::create( $uri, @@ -21,3 +25,18 @@ function createRequest($method, $uri): Request return Request::createFromBase($symfonyRequest); } + +/** + * Typed wrapper around the artisan() helper: the console-testing chain is + * only available on PendingCommand, never on the bare exit code. + * + * @param array $parameters + */ +function runArtisan(string $command, array $parameters = []): PendingCommand +{ + $result = artisan($command, $parameters); + + assert($result instanceof PendingCommand); + + return $result; +} diff --git a/tests/Unit/Actions/PageActionTest.php b/tests/Unit/Actions/PageActionTest.php index 5f94135..e058f75 100644 --- a/tests/Unit/Actions/PageActionTest.php +++ b/tests/Unit/Actions/PageActionTest.php @@ -68,7 +68,7 @@ $page = (new PageAction)->news($news, withReferences: true); expect($page->referencePages)->toHaveCount(1); - expect($page->referencePages->first()->locale)->toBe('en_CH'); + expect($page->referencePages?->firstOrFail()->locale)->toBe('en_CH'); }); it('skips reference pages pointing at a different model type', function () { diff --git a/tests/Unit/Actions/ViewDataActionTest.php b/tests/Unit/Actions/ViewDataActionTest.php index 7829cc2..ea0c533 100644 --- a/tests/Unit/Actions/ViewDataActionTest.php +++ b/tests/Unit/Actions/ViewDataActionTest.php @@ -1,6 +1,7 @@ create(['locale' => 'de_CH', 'published' => true, 'order' => 2]); @@ -40,7 +42,7 @@ $result = (new ViewDataAction)->news('de_CH'); expect($result)->toHaveCount(2); - expect($result->first()->published_at->isToday())->toBeTrue(); + expect($result->firstOrFail()->published_at?->isToday())->toBeTrue(); })->group('unit', 'actions'); it('only returns published technologies for the given locale, ordered', function () { @@ -62,7 +64,7 @@ $result = (new ViewDataAction)->openSource('de_CH'); expect($result)->toHaveCount(2); - expect($result->first()->downloads)->toBe(100); + expect($result->firstOrFail()->downloads)->toBe(100); })->group('unit', 'actions'); it('groups published contacts by section for the given locale', function () { @@ -84,7 +86,10 @@ $result = (new ViewDataAction)->contacts('de_CH'); - expect($result->{ContactSectionEnum::EMPLOYEES})->toHaveCount(1); - expect($result->{ContactSectionEnum::EMPLOYEES}->first()->name)->toBe('Alice'); + /** @var Collection $employees */ + $employees = $result->{ContactSectionEnum::EMPLOYEES}; + + expect($employees)->toHaveCount(1); + expect($employees->firstOrFail()->name)->toBe('Alice'); expect($result->{ContactSectionEnum::COLLABORATIONS})->toHaveCount(0); })->group('unit', 'actions'); diff --git a/tests/Unit/Http/Middleware/SetLanguageTest.php b/tests/Unit/Http/Middleware/SetLanguageTest.php index 87c8c93..b6f9f86 100644 --- a/tests/Unit/Http/Middleware/SetLanguageTest.php +++ b/tests/Unit/Http/Middleware/SetLanguageTest.php @@ -8,6 +8,9 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Session; +use function Pest\Laravel\actingAs; +use function Pest\Laravel\get; + beforeEach(function () { Route::middleware(['web', SetLanguage::class])->get('/test', function () { return 'Middleware Test'; @@ -19,7 +22,7 @@ 'locale' => LocaleEnum::DE, ]); - $this->actingAs($user) + actingAs($user) ->get('/test') ->assertOk(); @@ -29,14 +32,14 @@ it('sets locale based on session when user is not authenticated', function () { Session::put(SessionKeyEnum::LANGUAGE->value, LocaleEnum::EN->value); - $this->get('/test') + get('/test') ->assertOk(); expect(App::getLocale())->toBe(LocaleEnum::EN->value); })->group('middleware', 'locale'); it('defaults to English locale when no user or session language is set', function () { - $this->get('/test') + get('/test') ->assertOk(); expect(App::getLocale())->toBe(LocaleEnum::DE->value); diff --git a/tests/Unit/Models/AiModelDailyUsageModelTest.php b/tests/Unit/Models/AiModelDailyUsageModelTest.php index 540affe..3233b99 100644 --- a/tests/Unit/Models/AiModelDailyUsageModelTest.php +++ b/tests/Unit/Models/AiModelDailyUsageModelTest.php @@ -14,7 +14,7 @@ test('delete an AiModelDailyUsage model', function () { $model = AiModelDailyUsage::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('belongs to an AiModel', function () { @@ -27,5 +27,5 @@ $usage = AiModelDailyUsage::factory()->create(['ai_model_id' => $aiModel->id]); expect($usage->aiModel())->toBeInstanceOf(BelongsTo::class) - ->and($usage->aiModel->is($aiModel))->toBeTrue(); + ->and($usage->aiModel?->is($aiModel))->toBeTrue(); })->group('unit', 'models'); diff --git a/tests/Unit/Models/AiModelModelTest.php b/tests/Unit/Models/AiModelModelTest.php index f8766b0..82cc7e9 100644 --- a/tests/Unit/Models/AiModelModelTest.php +++ b/tests/Unit/Models/AiModelModelTest.php @@ -25,7 +25,7 @@ 'name' => 'qwen3.6:35b', ]); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('defines the replacedBy and dailyUsages relations', function () { diff --git a/tests/Unit/Models/ContactModelTest.php b/tests/Unit/Models/ContactModelTest.php index efe42d7..85d3979 100644 --- a/tests/Unit/Models/ContactModelTest.php +++ b/tests/Unit/Models/ContactModelTest.php @@ -10,7 +10,7 @@ test('delete a Contact model', function () { $model = Contact::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('clears the published cache when a contact is saved or deleted', function () { diff --git a/tests/Unit/Models/NewsModelTest.php b/tests/Unit/Models/NewsModelTest.php index 0d7fe69..fd43c99 100644 --- a/tests/Unit/Models/NewsModelTest.php +++ b/tests/Unit/Models/NewsModelTest.php @@ -9,7 +9,7 @@ test('delete a News model', function () { $model = News::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('resolves the route key name to slug', function () { @@ -28,5 +28,5 @@ ]); expect($news->references()->count())->toBe(1); - expect($news->references->first()->target)->toBeInstanceOf(News::class); + expect($news->references->firstOrFail()->target)->toBeInstanceOf(News::class); })->group('unit', 'models'); diff --git a/tests/Unit/Models/OpenSourceModelTest.php b/tests/Unit/Models/OpenSourceModelTest.php index 60008ba..3b4a1f0 100644 --- a/tests/Unit/Models/OpenSourceModelTest.php +++ b/tests/Unit/Models/OpenSourceModelTest.php @@ -9,7 +9,7 @@ test('delete a OpenSource model', function () { $model = OpenSource::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('resolves the route key name to slug', function () { @@ -28,5 +28,5 @@ ]); expect($openSource->references()->count())->toBe(1); - expect($openSource->references->first()->target)->toBeInstanceOf(OpenSource::class); + expect($openSource->references->firstOrFail()->target)->toBeInstanceOf(OpenSource::class); })->group('unit', 'models'); diff --git a/tests/Unit/Models/PageModelTest.php b/tests/Unit/Models/PageModelTest.php index bd3c9c4..85ace82 100644 --- a/tests/Unit/Models/PageModelTest.php +++ b/tests/Unit/Models/PageModelTest.php @@ -23,5 +23,5 @@ 'description' => 'Start page description', ]); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); diff --git a/tests/Unit/Models/ProductModelTest.php b/tests/Unit/Models/ProductModelTest.php index 366bd0f..8a94101 100644 --- a/tests/Unit/Models/ProductModelTest.php +++ b/tests/Unit/Models/ProductModelTest.php @@ -9,7 +9,7 @@ test('delete a Product model', function () { $model = Product::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('resolves the route key name to slug', function () { @@ -28,5 +28,5 @@ ]); expect($product->references()->count())->toBe(1); - expect($product->references->first()->target)->toBeInstanceOf(Product::class); + expect($product->references->firstOrFail()->target)->toBeInstanceOf(Product::class); })->group('unit', 'models'); diff --git a/tests/Unit/Models/ProductModuleModelTest.php b/tests/Unit/Models/ProductModuleModelTest.php index eb95be3..f40831b 100644 --- a/tests/Unit/Models/ProductModuleModelTest.php +++ b/tests/Unit/Models/ProductModuleModelTest.php @@ -12,7 +12,7 @@ test('delete a ProductModule model', function () { $model = ProductModule::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('uses the slug as route key and belongs to a product', function () { diff --git a/tests/Unit/Models/ReferenceModelTest.php b/tests/Unit/Models/ReferenceModelTest.php index 4039afa..b3830c5 100644 --- a/tests/Unit/Models/ReferenceModelTest.php +++ b/tests/Unit/Models/ReferenceModelTest.php @@ -17,9 +17,9 @@ expect($reference)->toBeInstanceOf(Reference::class); expect($reference->source)->toBeInstanceOf(News::class); - expect($reference->source->is($news))->toBeTrue(); + expect($reference->source?->is($news))->toBeTrue(); expect($reference->target)->toBeInstanceOf(News::class); - expect($reference->target->is($other))->toBeTrue(); + expect($reference->target?->is($other))->toBeTrue(); - $this->assertTrue($reference->delete()); + expect($reference->delete())->toBeTrue(); })->group('unit', 'models'); diff --git a/tests/Unit/Models/RoleModelTest.php b/tests/Unit/Models/RoleModelTest.php index 1a58400..9f98914 100644 --- a/tests/Unit/Models/RoleModelTest.php +++ b/tests/Unit/Models/RoleModelTest.php @@ -9,5 +9,5 @@ test('delete a Role model', function () { $model = Role::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); diff --git a/tests/Unit/Models/ServiceModelTest.php b/tests/Unit/Models/ServiceModelTest.php index 2629645..1e807d5 100644 --- a/tests/Unit/Models/ServiceModelTest.php +++ b/tests/Unit/Models/ServiceModelTest.php @@ -9,7 +9,7 @@ test('delete a Service model', function () { $model = Service::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('resolves the route key name to slug', function () { @@ -28,5 +28,5 @@ ]); expect($service->references()->count())->toBe(1); - expect($service->references->first()->target)->toBeInstanceOf(Service::class); + expect($service->references->firstOrFail()->target)->toBeInstanceOf(Service::class); })->group('unit', 'models'); diff --git a/tests/Unit/Models/TechnologyModelTest.php b/tests/Unit/Models/TechnologyModelTest.php index 20ca8ec..77b8131 100644 --- a/tests/Unit/Models/TechnologyModelTest.php +++ b/tests/Unit/Models/TechnologyModelTest.php @@ -9,7 +9,7 @@ test('delete a Technology model', function () { $model = Technology::factory()->create(); - $this->assertTrue($model->delete()); + expect($model->delete())->toBeTrue(); })->group('unit', 'models'); it('resolves the route key name to slug', function () { @@ -28,5 +28,5 @@ ]); expect($technology->references()->count())->toBe(1); - expect($technology->references->first()->target)->toBeInstanceOf(Technology::class); + expect($technology->references->firstOrFail()->target)->toBeInstanceOf(Technology::class); })->group('unit', 'models'); diff --git a/tests/Unit/Models/UserModelTest.php b/tests/Unit/Models/UserModelTest.php index 11977e0..eb5d97e 100644 --- a/tests/Unit/Models/UserModelTest.php +++ b/tests/Unit/Models/UserModelTest.php @@ -2,6 +2,8 @@ use App\Models\User; +use function Pest\Laravel\assertSoftDeleted; + it('can create a user model', function () { $model = User::factory()->create(); expect($model)->toBeInstanceOf(User::class); @@ -10,12 +12,12 @@ it('can soft delete a user', function () { $model = User::factory()->create(); $model->delete(); - $this->assertSoftDeleted($model); + assertSoftDeleted($model); })->group('unit', 'models'); it('can delete a user', function () { $model = User::factory()->create(); - $this->assertTrue($model->forceDelete()); + expect($model->forceDelete())->toBeTrue(); })->group('unit', 'models'); it('can get a user gravatar url', function () { diff --git a/tests/Unit/Observers/NetworkObserverTest.php b/tests/Unit/Observers/NetworkObserverTest.php index 6e4bbb8..cbb67b2 100644 --- a/tests/Unit/Observers/NetworkObserverTest.php +++ b/tests/Unit/Observers/NetworkObserverTest.php @@ -4,19 +4,15 @@ use Spatie\ResponseCache\Facades\ResponseCache; it('clears the response cache when a network is saved', function () { - ResponseCache::spy(); + ResponseCache::shouldReceive('clear')->once(); Network::factory()->create(); - - ResponseCache::shouldHaveReceived('clear')->once(); })->group('network'); it('clears the response cache when a network is deleted', function () { $network = Network::factory()->create(); - ResponseCache::spy(); + ResponseCache::shouldReceive('clear')->once(); $network->delete(); - - ResponseCache::shouldHaveReceived('clear')->once(); })->group('network'); diff --git a/tests/Unit/Observers/NetworkUserObserverTest.php b/tests/Unit/Observers/NetworkUserObserverTest.php index 1f3072f..f515e9a 100644 --- a/tests/Unit/Observers/NetworkUserObserverTest.php +++ b/tests/Unit/Observers/NetworkUserObserverTest.php @@ -4,19 +4,15 @@ use Spatie\ResponseCache\Facades\ResponseCache; it('clears the response cache when a network user is saved', function () { - ResponseCache::spy(); + ResponseCache::shouldReceive('clear')->once(); NetworkUser::factory()->create(); - - ResponseCache::shouldHaveReceived('clear')->once(); })->group('network'); it('clears the response cache when a network user is deleted', function () { $networkUser = NetworkUser::factory()->create(); - ResponseCache::spy(); + ResponseCache::shouldReceive('clear')->once(); $networkUser->delete(); - - ResponseCache::shouldHaveReceived('clear')->once(); })->group('network'); diff --git a/tests/Unit/Traits/HasUuuidTraitTest.php b/tests/Unit/Traits/HasUuuidTraitTest.php index 17d70b9..b3f4174 100644 --- a/tests/Unit/Traits/HasUuuidTraitTest.php +++ b/tests/Unit/Traits/HasUuuidTraitTest.php @@ -10,7 +10,7 @@ 'uuid' => $uuid, ]); - expect($model->uuid)->toBe(User::findByUuid($uuid)->uuid); + expect($model->uuid)->toBe(User::findByUuid($uuid)?->uuid); })->group('unit', 'traits', 'uuid'); it('find by uuid or fail', function () { @@ -32,7 +32,7 @@ User::factory(3)->create(); - expect($model->uuid)->toBe(User::withUuid($uuid)->first()->uuid); + expect($model->uuid)->toBe(User::withUuid($uuid)->firstOrFail()->uuid); })->group('unit', 'traits', 'uuid'); it('scope with uuids', function () {