fix(bio): plan-aware URL + plan-seeder + ulid→slug fix

- Migration: add bio_pages_limit to plans, make limit cols nullable (agency=unlimited)
- PlanSeeder: 4 plans (free/pro/business/agency) with tier/features/bio_pages_limit
- workspaces:assign-default-plan command: assigns free plan to unassigned workspaces
- BioPage::publicUrl(): free=apex-path, pro+=subdomain, custom=custom-domain
  - Security: str_ends_with check uses '.'.$apex to prevent evilnimu.li bypass
- BioPage::shortPublicUrl(): URL without scheme for display
- PlanLimitsService: use bio_pages_limit column (was features['bio_pages']), null=unlimited
- BioPageController: fix $bio → $page variable (view expects $page)
- Bio index/edit views: use publicUrl()/shortPublicUrl(), not hardcoded ULID URLs
- Bio edit view: publicUrlBase/displayUrlBase from component (plan-aware prefix)
- Create-bio modal: plan-aware URL prefix display
- PlanFactory/WorkspaceFactory: free()/pro() states for tests
- Tests: PublicUrlTest (5 cases), Free404FixTest (3 cases)

Known pre-existing failure (not caused by this change):
- Tests\Feature\Auth\EmailVerificationTest::email can be verified

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
boban 2026-05-17 22:44:54 +02:00
parent c479e46f55
commit deb5f310a1
15 changed files with 1631 additions and 45 deletions

View File

@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Domains\Subscription\Models\Plan;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\Console\Command;
class AssignDefaultPlan extends Command
{
protected $signature = 'workspaces:assign-default-plan';
protected $description = 'Assign free plan to workspaces without a plan';
public function handle(): int
{
$freePlan = Plan::firstWhere('tier', 'free');
if (! $freePlan) {
$this->error('Free plan not found. Run db:seed --class=PlanSeeder first.');
return self::FAILURE;
}
$count = Workspace::whereNull('plan_id')->update(['plan_id' => $freePlan->id]);
$this->info("{$count} workspace(s) assigned to Free plan");
return self::SUCCESS;
}
}

View File

@ -60,4 +60,34 @@ class BioPage extends Model
{
return $this->belongsTo(Domain::class);
}
/** Canonical public URL — path-based for Free, subdomain/custom for Pro+ */
public function publicUrl(): string
{
$apex = config('services.hetzner.zone', 'nimu.li');
$ws = $this->workspace;
if (! $ws) {
return '';
}
// Custom domain (Pro+ only, not apex subdomain). Require dot prefix so
// "evilnimu.li" is not treated as an apex subdomain of nimu.li.
if ($this->subdomain_host && ! str_ends_with($this->subdomain_host, '.'.$apex)) {
return "https://{$this->subdomain_host}/b/{$this->slug}";
}
// Pro+ workspace subdomain
if (! $ws->isFreePlan() && $ws->subdomain_full) {
return "https://{$ws->subdomain_full}/b/{$this->slug}";
}
// Free plan (or fallback): apex path-based
return "https://{$apex}/b/{$ws->slug}/{$this->slug}";
}
public function shortPublicUrl(): string
{
return (string) preg_replace('|^https?://|', '', $this->publicUrl());
}
}

View File

@ -8,12 +8,24 @@ use Illuminate\Support\Facades\Redis;
class PlanLimitsService
{
public function canCreateBioPage(Workspace $workspace): bool
{
/** @var Plan|null $plan */
$plan = $workspace->plan;
if (! $plan || is_null($plan->bio_pages_limit)) {
return true;
}
$count = \App\Domains\Bio\Models\BioPage::where('workspace_id', $workspace->id)->count();
return $count < $plan->bio_pages_limit;
}
public function canCreateLink(Workspace $workspace): bool
{
/** @var Plan|null $plan */
$plan = $workspace->plan;
if (! $plan) {
return true; // No plan = allow (trial or free)
if (! $plan || is_null($plan->link_limit)) {
return true;
}
$count = $workspace->links()->count();
@ -31,7 +43,7 @@ class PlanLimitsService
{
/** @var Plan|null $plan */
$plan = $workspace->plan;
if (! $plan) {
if (! $plan || is_null($plan->click_limit_monthly)) {
return false;
}
$used = $this->monthlyClicksUsed($workspace);
@ -43,7 +55,7 @@ class PlanLimitsService
{
/** @var Plan|null $plan */
$plan = $workspace->plan;
if (! $plan) {
if (! $plan || is_null($plan->click_limit_monthly)) {
return false;
}

View File

@ -4,22 +4,83 @@ namespace App\Http\Controllers;
use App\Domains\Bio\Models\BioPage;
use App\Domains\Domain\Models\Domain;
use Illuminate\Http\Response;
use App\Domains\Workspace\Models\Workspace;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class BioPageController extends Controller
{
public function show(string $slug): View|Response
/**
* Free-plan: nimu.li/b/{workspaceSlug}/{slug}
* Pro+ workspaces: 301 redirect to their subdomain URL.
*/
public function showFree(string $workspaceSlug, string $slug): View|Response
{
$host = request()->getHost();
$domain = Domain::where('hostname', $host)->first();
$ws = Workspace::where('slug', $workspaceSlug)->firstOrFail();
$page = BioPage::where('slug', $slug)
->where('domain_id', $domain?->id)
if ($ws->isPaidPlan() && $ws->subdomain_full) {
return redirect("https://{$ws->subdomain_full}/b/{$slug}", 301);
}
$bio = BioPage::where('workspace_id', $ws->id)
->where('slug', $slug)
->whereNull('subdomain_host')
->where('is_published', true)
->with('blocks')
->firstOrFail();
return view('bio.show', compact('page'));
return view('bio.show', ['page' => $bio]);
}
/**
* Pro+ subdomain ({ws-slug}.nimu.li/b/{slug}) or verified custom domain.
* Workspace resolved by ResolveWorkspaceBySubdomain middleware or host lookup here.
*/
public function showScoped(string $slug, Request $request): View|Response
{
$host = $request->getHost();
$apex = config('services.hetzner.zone', 'nimu.li');
if (str_ends_with($host, '.'.$apex)) {
$wsSlug = substr($host, 0, -(strlen($apex) + 1));
$ws = Workspace::where('slug', $wsSlug)
->whereNotNull('subdomain_full')
->firstOrFail();
abort_unless($ws->isPaidPlan(), 404);
$bio = BioPage::where('workspace_id', $ws->id)
->where('subdomain_host', $host)
->where('slug', $slug)
->where('is_published', true)
->with('blocks')
->firstOrFail();
} else {
Domain::where('hostname', $host)
->where('scope', 'custom')
->whereNotNull('verified_at')
->firstOrFail();
$bio = BioPage::where('subdomain_host', $host)
->where('slug', $slug)
->where('is_published', true)
->with('blocks')
->firstOrFail();
}
return view('bio.show', ['page' => $bio]);
}
public function preview(Workspace $workspace, string $bioUlid): View|Response
{
$bio = BioPage::where('ulid', $bioUlid)
->where('workspace_id', $workspace->id)
->with('blocks')
->firstOrFail();
$editUrl = route('w.bio.edit', [$workspace->ulid, $bioUlid]);
return view('bio.show', ['page' => $bio, 'isPreview' => true, 'editUrl' => $editUrl]);
}
}

View File

@ -14,6 +14,10 @@ class CreateBioPage extends ModalComponent
{
public string $workspaceUlid = '';
public string $workspaceSlug = '';
public bool $isFreePlan = true;
public int $workspaceId = 0;
public string $title = '';
@ -50,9 +54,16 @@ class CreateBioPage extends ModalComponent
$workspace->members()->where('user_id', auth()->id())->exists(),
404
);
$this->workspaceUlid = $workspaceUlid;
$this->workspaceId = $workspace->id;
} elseif ($workspace = current_workspace()) {
// no-op: falls through to shared setup below
} else {
return;
}
$this->workspaceUlid = $workspace->ulid;
$this->workspaceSlug = $workspace->slug;
$this->isFreePlan = $workspace->isFreePlan();
$this->workspaceId = $workspace->id;
}
public function save(): void

View File

@ -0,0 +1,137 @@
<?php
namespace App\Livewire\Pages\Bio;
use App\Domains\Bio\Models\BioBlock;
use App\Domains\Bio\Models\BioPage;
use Illuminate\View\View;
use Livewire\Component;
class Edit extends Component
{
public int $pageId = 0;
public string $workspaceUlid = '';
public string $publicUrlBase = '';
public string $displayUrlBase = '';
public function mount(string $bioUlid): void
{
$workspace = require_workspace();
$page = BioPage::where('ulid', $bioUlid)
->where('workspace_id', $workspace->id)
->firstOrFail();
$this->pageId = $page->id;
$this->workspaceUlid = $workspace->ulid;
$apex = config('services.hetzner.zone', 'nimu.li');
if (! $workspace->isFreePlan() && $workspace->subdomain_full) {
$this->publicUrlBase = "https://{$workspace->subdomain_full}/b";
$this->displayUrlBase = "{$workspace->subdomain_full}/b";
} else {
$this->publicUrlBase = "https://{$apex}/b/{$workspace->slug}";
$this->displayUrlBase = "{$apex}/b/{$workspace->slug}";
}
}
public function save(array $state): void
{
$page = BioPage::findOrFail($this->pageId);
$displayName = $state['profile']['displayName'] ?? '';
$page->update([
'slug' => $state['slug'] ?? $page->slug,
'title' => ['en' => $displayName, 'de' => $displayName],
'is_published' => false,
'theme' => [
'profile' => $state['profile'] ?? [],
'theme' => $state['theme'] ?? [],
'socials' => $state['socials'] ?? [],
],
]);
BioBlock::where('bio_page_id', $this->pageId)->delete();
foreach ($state['blocks'] ?? [] as $i => $block) {
BioBlock::create([
'bio_page_id' => $this->pageId,
'position' => $i,
'type' => $block['type'] ?? 'link',
'config' => array_diff_key($block, array_flip(['id', 'type', '_collapsed'])),
]);
}
$this->dispatch('toast', message: 'Gespeichert', type: 'success');
}
public function publish(array $state): void
{
$this->save($state);
BioPage::where('id', $this->pageId)->update(['is_published' => true]);
$this->dispatch('toast', message: 'Veröffentlicht', type: 'success');
}
public function unpublish(): void
{
BioPage::where('id', $this->pageId)->update(['is_published' => false]);
$this->dispatch('toast', message: 'Als Entwurf gespeichert', type: 'success');
}
public function render(): View
{
$page = BioPage::with('blocks')->findOrFail($this->pageId);
$theme = is_array($page->theme) ? $page->theme : [];
$initialState = [
'uid' => $page->blocks->count() + 100,
'slug' => $page->slug,
'isPublished' => $page->is_published,
'profile' => $theme['profile'] ?? [
'avatar' => null,
'displayName' => $page->getTranslation('title', 'en'),
'handle' => $page->slug,
'bio' => '',
'verified' => false,
],
'theme' => array_merge([
'bgType' => 'gradient',
'bg1' => '#0f0a2e',
'bg2' => '#6b1b9a',
'bgImage' => null,
'dark' => true,
'button' => 'rounded',
'font' => 'geist',
'borderColor' => '#7c3aed',
'shadowColor' => '#18181b',
], $theme['theme'] ?? []),
'socials' => $theme['socials'] ?? [],
'blocks' => $page->blocks
->sortBy('position')
->map(fn ($b) => array_merge(
['id' => $b->id, 'type' => $b->type],
$b->config ?? []
))
->values()
->toArray(),
];
return view('livewire.pages.bio.edit', [
'page' => $page,
'workspaceUlid' => $this->workspaceUlid,
'bioUlid' => $page->ulid,
'initialState' => $initialState,
'publicUrlBase' => $this->publicUrlBase,
'displayUrlBase' => $this->displayUrlBase,
])->layout('layouts.nimuli-app', ['title' => 'Bio Editor']);
}
}

View File

@ -12,13 +12,32 @@ class PlanFactory extends Factory
public function definition(): array
{
return [
'name' => fake()->unique()->word(),
'name' => 'Free',
'tier' => 'free',
'monthly_price_cents' => 0,
'workspace_limit' => 1,
'member_limit' => 1,
'workspace_limit' => 1,
'member_limit' => 1,
'custom_domain_limit' => 0,
'link_limit' => 50,
'link_limit' => 50,
'click_limit_monthly' => 1000,
'bio_pages_limit' => 1,
'features' => [],
];
}
public function free(): static
{
return $this->state(['tier' => 'free', 'name' => 'Free', 'custom_domain_limit' => 0, 'bio_pages_limit' => 1]);
}
public function pro(): static
{
return $this->state([
'tier' => 'pro',
'name' => 'Pro',
'monthly_price_cents' => 1900,
'custom_domain_limit' => 1,
'bio_pages_limit' => 5,
]);
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Domains\Subscription\Models\Plan;
use App\Domains\Workspace\Models\Workspace;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -14,9 +15,24 @@ class WorkspaceFactory extends Factory
public function definition(): array
{
return [
'name' => fake()->company(),
'slug' => Str::random(12),
'name' => fake()->company(),
'slug' => Str::slug(fake()->unique()->words(3, true)),
'owner_id' => User::factory(),
];
}
public function free(): static
{
return $this->state(fn () => [
'plan_id' => Plan::factory()->free()->create()->id,
]);
}
public function pro(): static
{
return $this->state(fn () => [
'plan_id' => Plan::factory()->pro()->create()->id,
'subdomain_full' => null,
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('plans', function (Blueprint $table) {
// nullable bio_pages_limit (null = unlimited)
$table->unsignedSmallInteger('bio_pages_limit')->nullable()->after('click_limit_monthly');
// make numeric limits nullable so Agency can set null = unlimited
$table->unsignedSmallInteger('workspace_limit')->nullable()->change();
$table->unsignedSmallInteger('member_limit')->nullable()->change();
$table->unsignedSmallInteger('custom_domain_limit')->nullable()->change();
$table->unsignedInteger('link_limit')->nullable()->change();
});
// tier is already a string(32) column (added in 400000); no DDL change needed.
// agency value is valid — string columns accept any value.
}
public function down(): void
{
// Revert agency → free so numeric limits can be restored safely
DB::table('plans')->where('tier', 'agency')->update(['tier' => 'free']);
// Fill NULLs with defaults before restoring NOT NULL constraints
DB::table('plans')->whereNull('workspace_limit')->update(['workspace_limit' => 1]);
DB::table('plans')->whereNull('member_limit')->update(['member_limit' => 1]);
DB::table('plans')->whereNull('custom_domain_limit')->update(['custom_domain_limit' => 0]);
DB::table('plans')->whereNull('link_limit')->update(['link_limit' => 50]);
Schema::table('plans', function (Blueprint $table) {
if (Schema::hasColumn('plans', 'bio_pages_limit')) {
$table->dropColumn('bio_pages_limit');
}
$table->unsignedSmallInteger('workspace_limit')->default(1)->nullable(false)->change();
$table->unsignedSmallInteger('member_limit')->default(1)->nullable(false)->change();
$table->unsignedSmallInteger('custom_domain_limit')->default(0)->nullable(false)->change();
$table->unsignedInteger('link_limit')->default(50)->nullable(false)->change();
});
}
};

View File

@ -10,14 +10,93 @@ class PlanSeeder extends Seeder
public function run(): void
{
$plans = [
['name' => 'free', 'monthly_price_cents' => 0, 'workspace_limit' => 1, 'member_limit' => 1, 'custom_domain_limit' => 0, 'link_limit' => 50, 'click_limit_monthly' => 1000],
['name' => 'pro', 'monthly_price_cents' => 1900, 'workspace_limit' => 1, 'member_limit' => 3, 'custom_domain_limit' => 1, 'link_limit' => 1000, 'click_limit_monthly' => 25000],
['name' => 'business', 'monthly_price_cents' => 7900, 'workspace_limit' => 3, 'member_limit' => 10, 'custom_domain_limit' => 5, 'link_limit' => 10000, 'click_limit_monthly' => 250000],
['name' => 'agency', 'monthly_price_cents' => 19900, 'workspace_limit' => 999, 'member_limit' => 999, 'custom_domain_limit' => 999, 'link_limit' => 999999, 'click_limit_monthly' => 5000000],
[
'name' => 'Free',
'tier' => 'free',
'stripe_price_id' => null,
'monthly_price_cents' => 0,
'workspace_limit' => 1,
'member_limit' => 1,
'custom_domain_limit' => 0,
'link_limit' => 50,
'click_limit_monthly' => 1000,
'bio_pages_limit' => 1,
'features' => [
'workspace_subdomain' => false,
'pixel_injection' => false,
'ai_suite' => false,
'webhooks' => false,
'api_tokens' => false,
'agency_features' => false,
],
],
[
'name' => 'Pro',
'tier' => 'pro',
'stripe_price_id' => env('STRIPE_PRICE_PRO'),
'monthly_price_cents' => 1900,
'workspace_limit' => 1,
'member_limit' => 3,
'custom_domain_limit' => 1,
'link_limit' => 1000,
'click_limit_monthly' => 25000,
'bio_pages_limit' => 5,
'features' => [
'workspace_subdomain' => true,
'pixel_injection' => true,
'ai_suite' => true,
'webhooks' => true,
'api_tokens' => true,
'agency_features' => false,
],
],
[
'name' => 'Business',
'tier' => 'business',
'stripe_price_id' => env('STRIPE_PRICE_BUSINESS'),
'monthly_price_cents' => 7900,
'workspace_limit' => 3,
'member_limit' => 10,
'custom_domain_limit' => 5,
'link_limit' => 10000,
'click_limit_monthly' => 250000,
'bio_pages_limit' => 25,
'features' => [
'workspace_subdomain' => true,
'pixel_injection' => true,
'ai_suite' => true,
'webhooks' => true,
'api_tokens' => true,
'agency_features' => true,
],
],
[
'name' => 'Agency',
'tier' => 'agency',
'stripe_price_id' => env('STRIPE_PRICE_AGENCY'),
'monthly_price_cents' => 19900,
'workspace_limit' => null,
'member_limit' => null,
'custom_domain_limit' => null,
'link_limit' => null,
'click_limit_monthly' => 5000000,
'bio_pages_limit' => null,
'features' => [
'workspace_subdomain' => true,
'pixel_injection' => true,
'ai_suite' => true,
'webhooks' => true,
'api_tokens' => true,
'agency_features' => true,
'whitelabel_reseller' => true,
],
],
];
foreach ($plans as $plan) {
Plan::updateOrCreate(['name' => $plan['name']], $plan);
foreach ($plans as $p) {
Plan::updateOrCreate(['tier' => $p['tier']], $p);
}
$this->command->info('✓ 4 Plans seeded (free/pro/business/agency)');
}
}

View File

@ -13,7 +13,13 @@
<div>
<label class="block text-xs font-medium text-t2 mb-1.5">Slug (optional)</label>
<div class="flex items-center gap-1">
<span class="text-t3 text-xs shrink-0">nimu.li/bio/</span>
@php
$apex = config('services.hetzner.zone', 'nimu.li');
$urlPrefix = $isFreePlan
? $apex.'/b/'.$workspaceSlug.'/'
: $workspaceSlug.'.'.$apex.'/b/';
@endphp
<span class="text-t3 text-xs shrink-0">{{ $urlPrefix }}</span>
<input wire:model="slug" type="text" placeholder="my-bio"
class="flex-1 px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
<div>
@php $ws = current_workspace(); @endphp
<div x-on:livewire:navigated.window="$wire.$refresh()">
<div class="flex items-center justify-between mb-6 nim-reveal nim-reveal-1">
<div>
<h1 class="text-t1">Link-in-Bio</h1>
<p class="text-xs text-t3 mt-0.5">{{ $bioPages->total() }} pages</p>
</div>
@if($ws)
@if($workspaceUlid)
<button
@click="$dispatch('openModal', {component: 'modals.create-bio-page', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity flex items-center gap-1.5">
wire:click="createNew"
wire:loading.attr="disabled"
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity flex items-center gap-1.5 disabled:opacity-60">
<x-heroicon-o-plus class="w-4 h-4" />
New Bio Page
</button>
@ -25,7 +25,18 @@
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-t1">{{ $title }}</div>
<div class="text-xs text-t3 font-mono mt-0.5">nimu.li/<span class="text-blue">{{ $page->slug }}</span></div>
<div class="text-xs text-t3 font-mono mt-0.5">{{ $page->shortPublicUrl() }}</div>
</div>
<div class="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-all duration-150">
<x-ui.copy-button :value="$page->publicUrl()" label="Copy" />
<a
href="{{ route('w.bio.edit', [$workspaceUlid, $page->ulid]) }}"
wire:navigate
class="inline-flex items-center px-2.5 py-1 bg-s2 border border-white/[.06] rounded-lg text-xs text-t2 hover:text-t1 hover:bg-s3 transition-colors">Edit</a>
<button
@click="$dispatch('openModal', {component: 'modals.delete-bio-page', arguments: {bioUlid: '{{ $page->ulid }}'}})"
class="inline-flex items-center px-2.5 py-1 bg-s2 border border-white/[.06] rounded-lg text-xs text-t3 hover:text-red hover:border-red/30 transition-colors">Delete</button>
</div>
<span class="badge {{ $page->is_published ? 'bg-green/10 text-green' : 'bg-white/[.06] text-t3' }}">
@ -34,16 +45,6 @@
@endif
{{ $page->is_published ? 'Published' : 'Draft' }}
</span>
<div class="flex items-center gap-3 opacity-0 group-hover:opacity-100 transition-all duration-150">
<x-ui.copy-button :value="'https://nimu.li/b/'.$page->slug" label="Copy" />
<button
@click="$dispatch('openModal', {component: 'modals.edit-bio-page', arguments: {bioUlid: '{{ $page->ulid }}'}})"
class="text-t2 text-xs hover:text-t1 transition-colors">Edit</button>
<button
@click="$dispatch('openModal', {component: 'modals.delete-bio-page', arguments: {bioUlid: '{{ $page->ulid }}'}})"
class="text-t3 text-xs hover:text-red transition-colors">Delete</button>
</div>
</div>
@empty
<div class="p-16 text-center">
@ -53,10 +54,11 @@
</svg>
<p class="text-sm text-t2 mb-1.5">No bio pages yet</p>
<p class="text-xs text-t3 mb-6">Create a link-in-bio page for Instagram, TikTok and more.</p>
@if($ws)
@if($workspaceUlid)
<button
@click="$dispatch('openModal', {component: 'modals.create-bio-page', arguments: {workspaceUlid: '{{ $ws->ulid }}'}})"
class="px-4 py-2.5 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity">
wire:click="createNew"
wire:loading.attr="disabled"
class="px-4 py-2.5 bg-accent-gradient text-white rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-60">
Create first Bio Page
</button>
@endif

View File

@ -0,0 +1,48 @@
<?php
use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace;
it('returns free path-url for free plan workspace', function () {
$ws = Workspace::factory()->free()->create(['slug' => 'nexxo']);
$bio = BioPage::factory()->for($ws)->create(['slug' => 'my-bio']);
expect($bio->publicUrl())->toBe('https://nimu.li/b/nexxo/my-bio');
});
it('returns shortPublicUrl without scheme', function () {
$ws = Workspace::factory()->free()->create(['slug' => 'nexxo']);
$bio = BioPage::factory()->for($ws)->create(['slug' => 'my-bio']);
expect($bio->shortPublicUrl())->toBe('nimu.li/b/nexxo/my-bio');
});
it('returns pro subdomain-url when subdomain_full set', function () {
$ws = Workspace::factory()->pro()->create([
'slug' => 'acme',
'subdomain_full' => 'acme.nimu.li',
]);
$bio = BioPage::factory()->for($ws)->create(['slug' => 'john']);
expect($bio->publicUrl())->toBe('https://acme.nimu.li/b/john');
});
it('returns custom-domain-url when subdomain_host is non-apex domain', function () {
$ws = Workspace::factory()->pro()->create(['slug' => 'acme']);
$bio = BioPage::factory()->for($ws)->create([
'slug' => 'page',
'subdomain_host' => 'links.acme.de',
]);
expect($bio->publicUrl())->toBe('https://links.acme.de/b/page');
});
it('falls back to free path when no subdomain_full on paid plan', function () {
$ws = Workspace::factory()->pro()->create([
'slug' => 'acme',
'subdomain_full' => null,
]);
$bio = BioPage::factory()->for($ws)->create(['slug' => 'test']);
expect($bio->publicUrl())->toBe('https://nimu.li/b/acme/test');
});

View File

@ -0,0 +1,35 @@
<?php
use App\Domains\Bio\Models\BioPage;
use App\Domains\Workspace\Models\Workspace;
it('serves bio at workspace-slug-path on apex domain', function () {
$ws = Workspace::factory()->free()->create(['slug' => 'nexxo']);
BioPage::factory()->for($ws)->create([
'slug' => 'my-bio',
'is_published' => true,
]);
$this->get('http://nimu.li/b/nexxo/my-bio')->assertOk();
});
it('returns 404 for ulid-path attempt on apex domain', function () {
$ws = Workspace::factory()->free()->create(['slug' => 'nexxo']);
BioPage::factory()->for($ws)->create([
'slug' => 'my-bio',
'is_published' => true,
]);
// ULID starts with digits/uppercase — fails workspaceSlug regex [a-z][a-z0-9-]{1,62}
$this->get('http://nimu.li/b/'.$ws->ulid.'/my-bio')->assertNotFound();
});
it('unpublished bio returns 404 on apex domain', function () {
$ws = Workspace::factory()->free()->create(['slug' => 'nexxo']);
BioPage::factory()->for($ws)->create([
'slug' => 'draft',
'is_published' => false,
]);
$this->get('http://nimu.li/b/nexxo/draft')->assertNotFound();
});