feat: adding tests..

This commit is contained in:
Paulo Castellano 2026-01-18 20:33:45 -03:00
parent d72c076658
commit e48ba8b521
21 changed files with 1537 additions and 625 deletions

2
.env.ci Normal file
View file

@ -0,0 +1,2 @@
APP_ENV=testing
APP_KEY=base64:Zfo+2dkSaHvem5LCiZS/baYHv2Pv1vrSc0F2NaF29Ec=

View file

@ -2,8 +2,6 @@
<img src="public/images/trypost/logo-dark.png" alt="TryPost" width="200">
</p>
<h1 align="center">TryPost</h1>
<p align="center">
<strong>The open-source social media scheduling platform</strong>
</p>

View file

@ -59,7 +59,10 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse
->with('message', 'Subscribe to create more workspaces.');
}
$workspace = $user->workspaces()->create($request->validated());
$workspace = $user->workspaces()->create([
...$request->validated(),
'timezone' => config('app.timezone', 'UTC'),
]);
// Set as current workspace
$user->switchWorkspace($workspace);
@ -156,7 +159,7 @@ public function destroy(Request $request, Workspace $workspace): RedirectRespons
$user->decrementWorkspaceQuantity();
}
return redirect()->route('dashboard')
return redirect()->route('workspaces.index')
->with('success', 'Workspace deleted successfully!');
}
}

View file

@ -111,8 +111,8 @@
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
'address' => env('MAIL_FROM_ADDRESS', 'no-reply@trypost.it'),
'name' => env('MAIL_FROM_NAME', 'TryPost'),
],
];

View file

@ -28,18 +28,9 @@ public function definition(): array
'token' => Str::random(64),
'role' => Role::Member,
'status' => InviteStatus::Pending,
'expires_at' => now()->addDays(7),
];
}
public function expired(): static
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subDay(),
'status' => InviteStatus::Expired,
]);
}
public function accepted(): static
{
return $this->state(fn (array $attributes) => [
@ -47,11 +38,4 @@ public function accepted(): static
'accepted_at' => now(),
]);
}
public function cancelled(): static
{
return $this->state(fn (array $attributes) => [
'status' => InviteStatus::Cancelled,
]);
}
}

View file

@ -19,7 +19,6 @@ public function up(): void
$table->string('token', 64)->unique();
$table->string('role')->default('member');
$table->string('status')->default('pending');
$table->timestamp('expires_at');
$table->timestamp('accepted_at')->nullable();
$table->timestamps();

View file

@ -1,7 +1,4 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { home, privacy, terms } from '@/routes';
defineProps<{
title?: string;
}>();
@ -12,18 +9,10 @@ defineProps<{
<!-- Header -->
<header class="border-b">
<div class="mx-auto max-w-5xl px-6 py-4 flex items-center justify-between">
<Link :href="home()" class="flex items-center gap-2">
<div class="flex items-center gap-2">
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-7 w-auto" />
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-7 w-auto" />
</Link>
<nav class="flex items-center gap-4">
<Link
:href="home()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Home
</Link>
</nav>
</div>
</div>
</header>
@ -36,25 +25,9 @@ defineProps<{
<!-- Footer -->
<footer class="border-t mt-auto">
<div class="mx-auto max-w-5xl px-6 py-8">
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">
&copy; {{ new Date().getFullYear() }} TryPost. All rights reserved.
</p>
<nav class="flex items-center gap-6">
<Link
:href="privacy.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
:href="terms.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Terms of Service
</Link>
</nav>
</div>
<p class="text-sm text-muted-foreground text-center">
&copy; {{ new Date().getFullYear() }} TryPost. All rights reserved.
</p>
</div>
</footer>
</div>

View file

@ -1,229 +0,0 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { CalendarDays, Clock, Share2, Sparkles, CheckCircle } from 'lucide-vue-next';
import { calendar, login, privacy, register, terms } from '@/routes';
import { Button } from '@/components/ui/button';
withDefaults(
defineProps<{
canRegister: boolean;
}>(),
{
canRegister: true,
},
);
const features = [
{
icon: CalendarDays,
title: 'Visual Calendar',
description: 'Plan and visualize your content with an intuitive calendar interface.',
},
{
icon: Clock,
title: 'Smart Scheduling',
description: 'Schedule posts for the perfect time across all your social platforms.',
},
{
icon: Share2,
title: 'Multi-Platform',
description: 'Publish to LinkedIn, X, TikTok, YouTube, Facebook, Instagram, and Threads.',
},
{
icon: Sparkles,
title: 'Workspace Collaboration',
description: 'Invite team members and collaborate on your social media strategy.',
},
];
const platforms = [
{ name: 'LinkedIn', logo: '/images/accounts/linkedin.png' },
{ name: 'X', logo: '/images/accounts/x.png' },
{ name: 'TikTok', logo: '/images/accounts/tiktok.png' },
{ name: 'YouTube', logo: '/images/accounts/youtube.png' },
{ name: 'Facebook', logo: '/images/accounts/facebook.png' },
{ name: 'Instagram', logo: '/images/accounts/instagram.png' },
{ name: 'Threads', logo: '/images/accounts/threads.png' },
];
</script>
<template>
<Head title="Social Media Scheduling Made Simple">
<meta name="description" content="Schedule and publish your social media content to multiple platforms with TryPost. Support for LinkedIn, X, TikTok, YouTube, Facebook, Instagram, and Threads." />
</Head>
<div class="min-h-svh bg-background">
<!-- Header -->
<header class="border-b">
<div class="mx-auto max-w-6xl px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-7 w-auto" />
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-7 w-auto" />
</div>
<nav class="flex items-center gap-3">
<Link
v-if="$page.props.auth.user"
:href="calendar.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Calendar
</Link>
<template v-else>
<Link :href="login()">
<Button variant="ghost" size="sm">Log in</Button>
</Link>
<Link v-if="canRegister" :href="register()">
<Button size="sm">Get Started</Button>
</Link>
</template>
</nav>
</div>
</header>
<!-- Hero Section -->
<section class="py-20 md:py-32">
<div class="mx-auto max-w-6xl px-6 text-center">
<h1 class="text-4xl md:text-6xl font-bold tracking-tight mb-6">
Social Media Scheduling<br />
<span class="text-primary">Made Simple</span>
</h1>
<p class="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
Plan, schedule, and publish your content across all major social platforms from one place.
Save time and grow your audience.
</p>
<div class="flex items-center justify-center gap-4">
<Link v-if="canRegister" :href="register()">
<Button size="lg">Start Free Trial</Button>
</Link>
<Link :href="login()">
<Button variant="outline" size="lg">Log in</Button>
</Link>
</div>
</div>
</section>
<!-- Platforms Section -->
<section class="py-16 bg-muted/30">
<div class="mx-auto max-w-6xl px-6">
<p class="text-center text-sm text-muted-foreground mb-8">
Publish to all your favorite platforms
</p>
<div class="flex items-center justify-center gap-8 flex-wrap">
<div
v-for="platform in platforms"
:key="platform.name"
class="flex items-center gap-2 text-muted-foreground"
>
<img :src="platform.logo" :alt="platform.name" class="h-8 w-8" />
<span class="text-sm font-medium">{{ platform.name }}</span>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-20">
<div class="mx-auto max-w-6xl px-6">
<h2 class="text-3xl font-bold text-center mb-12">
Everything you need to manage your social presence
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
<div
v-for="feature in features"
:key="feature.title"
class="p-6 rounded-lg border bg-card"
>
<component :is="feature.icon" class="h-10 w-10 text-primary mb-4" />
<h3 class="font-semibold mb-2">{{ feature.title }}</h3>
<p class="text-sm text-muted-foreground">{{ feature.description }}</p>
</div>
</div>
</div>
</section>
<!-- Benefits Section -->
<section class="py-20 bg-muted/30">
<div class="mx-auto max-w-6xl px-6">
<div class="grid md:grid-cols-2 gap-12 items-center">
<div>
<h2 class="text-3xl font-bold mb-6">
Focus on creating, not scheduling
</h2>
<ul class="space-y-4">
<li class="flex items-start gap-3">
<CheckCircle class="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
<span>Schedule weeks of content in minutes</span>
</li>
<li class="flex items-start gap-3">
<CheckCircle class="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
<span>Customize content for each platform</span>
</li>
<li class="flex items-start gap-3">
<CheckCircle class="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
<span>Upload images and videos with ease</span>
</li>
<li class="flex items-start gap-3">
<CheckCircle class="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
<span>Collaborate with your team</span>
</li>
<li class="flex items-start gap-3">
<CheckCircle class="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
<span>Track your publishing history</span>
</li>
</ul>
</div>
<div class="bg-card border rounded-lg p-8 text-center">
<p class="text-4xl font-bold text-primary mb-2">$20</p>
<p class="text-muted-foreground mb-6">per workspace / month</p>
<Link v-if="canRegister" :href="register()">
<Button class="w-full" size="lg">Start Free Trial</Button>
</Link>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-20">
<div class="mx-auto max-w-6xl px-6 text-center">
<h2 class="text-3xl font-bold mb-4">Ready to streamline your social media?</h2>
<p class="text-muted-foreground mb-8">
Join thousands of creators and businesses who trust TryPost.
</p>
<Link v-if="canRegister" :href="register()">
<Button size="lg">Get Started Now</Button>
</Link>
</div>
</section>
<!-- Footer -->
<footer class="border-t py-12">
<div class="mx-auto max-w-6xl px-6">
<div class="flex flex-col md:flex-row items-center justify-between gap-6">
<div class="flex items-center gap-2">
<img src="/images/trypost/logo-light.png" alt="TryPost" class="dark:hidden h-6 w-auto" />
<img src="/images/trypost/logo-dark.png" alt="TryPost" class="hidden dark:block h-6 w-auto" />
</div>
<nav class="flex items-center gap-6">
<Link
:href="privacy.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
<Link
:href="terms.url()"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Terms of Service
</Link>
</nav>
<p class="text-sm text-muted-foreground">
&copy; {{ new Date().getFullYear() }} TryPost. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</template>

View file

@ -1,144 +0,0 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import GuestLayout from '@/layouts/GuestLayout.vue';
</script>
<template>
<Head title="Privacy Policy" />
<GuestLayout title="Privacy Policy">
<div class="prose prose-gray dark:prose-invert max-w-none">
<p class="text-muted-foreground">Last updated: January 15, 2025</p>
<h2>1. Introduction</h2>
<p>
Welcome to TryPost ("we," "our," or "us"). We are committed to protecting your personal information
and your right to privacy. This Privacy Policy explains how we collect, use, disclose, and safeguard
your information when you use our social media scheduling service.
</p>
<h2>2. Information We Collect</h2>
<h3>2.1 Personal Information</h3>
<p>We collect personal information that you voluntarily provide to us when you:</p>
<ul>
<li>Register for an account</li>
<li>Connect your social media accounts</li>
<li>Create and schedule posts</li>
<li>Contact us for support</li>
</ul>
<p>This information may include:</p>
<ul>
<li>Name and email address</li>
<li>Social media account credentials and tokens</li>
<li>Content you create for posting (text, images, videos)</li>
<li>Billing information (processed securely by Stripe)</li>
</ul>
<h3>2.2 Social Media Data</h3>
<p>
When you connect your social media accounts (Facebook, Instagram, LinkedIn, X/Twitter, TikTok, YouTube, Threads),
we access certain information as permitted by each platform's API, including:
</p>
<ul>
<li>Account profile information (name, username, profile picture)</li>
<li>Page/channel information for business accounts</li>
<li>Access tokens required to post on your behalf</li>
</ul>
<h3>2.3 Automatically Collected Information</h3>
<p>We automatically collect certain information when you visit our platform, including:</p>
<ul>
<li>IP address and browser type</li>
<li>Device information</li>
<li>Usage data and analytics</li>
</ul>
<h2>3. How We Use Your Information</h2>
<p>We use the information we collect to:</p>
<ul>
<li>Provide and maintain our service</li>
<li>Schedule and publish your content to connected social media platforms</li>
<li>Process your transactions and manage your subscription</li>
<li>Send you service-related communications</li>
<li>Respond to your inquiries and provide customer support</li>
<li>Improve our service and develop new features</li>
<li>Protect against fraud and unauthorized access</li>
</ul>
<h2>4. How We Share Your Information</h2>
<p>We may share your information in the following situations:</p>
<ul>
<li><strong>With Social Media Platforms:</strong> We share your content with the platforms you've connected to publish your scheduled posts.</li>
<li><strong>With Service Providers:</strong> We work with third-party service providers (hosting, payment processing, analytics) who need access to your information to perform services for us.</li>
<li><strong>For Legal Compliance:</strong> We may disclose your information if required by law or in response to valid legal requests.</li>
<li><strong>Business Transfers:</strong> In connection with any merger, sale, or acquisition of our business.</li>
</ul>
<p>We do not sell your personal information to third parties.</p>
<h2>5. Data Security</h2>
<p>
We implement appropriate technical and organizational security measures to protect your personal information.
This includes encryption of sensitive data, secure HTTPS connections, and regular security audits.
However, no method of transmission over the Internet is 100% secure, and we cannot guarantee absolute security.
</p>
<h2>6. Data Retention</h2>
<p>
We retain your personal information for as long as your account is active or as needed to provide you services.
You may request deletion of your account and associated data at any time by contacting us.
</p>
<h2>7. Your Privacy Rights</h2>
<p>Depending on your location, you may have the right to:</p>
<ul>
<li>Access the personal information we hold about you</li>
<li>Request correction of inaccurate information</li>
<li>Request deletion of your personal information</li>
<li>Object to or restrict processing of your information</li>
<li>Data portability</li>
<li>Withdraw consent where processing is based on consent</li>
</ul>
<p>To exercise these rights, please contact us at the email address provided below.</p>
<h2>8. Third-Party Services</h2>
<p>
Our service integrates with third-party social media platforms. Each platform has its own privacy policy,
and we encourage you to review them:
</p>
<ul>
<li><a href="https://www.facebook.com/privacy/policy" target="_blank" rel="noopener">Meta (Facebook, Instagram, Threads) Privacy Policy</a></li>
<li><a href="https://www.linkedin.com/legal/privacy-policy" target="_blank" rel="noopener">LinkedIn Privacy Policy</a></li>
<li><a href="https://twitter.com/privacy" target="_blank" rel="noopener">X (Twitter) Privacy Policy</a></li>
<li><a href="https://www.tiktok.com/legal/privacy-policy" target="_blank" rel="noopener">TikTok Privacy Policy</a></li>
<li><a href="https://policies.google.com/privacy" target="_blank" rel="noopener">Google (YouTube) Privacy Policy</a></li>
</ul>
<h2>9. Children's Privacy</h2>
<p>
Our service is not intended for individuals under the age of 18. We do not knowingly collect personal
information from children. If you believe we have collected information from a child, please contact us immediately.
</p>
<h2>10. International Data Transfers</h2>
<p>
Your information may be transferred to and processed in countries other than your country of residence.
These countries may have different data protection laws. We ensure appropriate safeguards are in place
for such transfers.
</p>
<h2>11. Changes to This Privacy Policy</h2>
<p>
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the
new Privacy Policy on this page and updating the "Last updated" date. We encourage you to review this
Privacy Policy periodically.
</p>
<h2>12. Contact Us</h2>
<p>If you have questions or concerns about this Privacy Policy, please contact us at:</p>
<p>
<strong>Email:</strong> privacy@trypost.com<br />
<strong>Website:</strong> https://trypost.com
</p>
</div>
</GuestLayout>
</template>

View file

@ -1,180 +0,0 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import GuestLayout from '@/layouts/GuestLayout.vue';
</script>
<template>
<Head title="Terms of Service" />
<GuestLayout title="Terms of Service">
<div class="prose prose-gray dark:prose-invert max-w-none">
<p class="text-muted-foreground">Last updated: January 15, 2025</p>
<h2>1. Agreement to Terms</h2>
<p>
By accessing or using TryPost ("Service"), you agree to be bound by these Terms of Service ("Terms").
If you disagree with any part of these terms, you may not access the Service.
</p>
<h2>2. Description of Service</h2>
<p>
TryPost is a social media scheduling platform that allows you to create, schedule, and publish content
to multiple social media platforms including Facebook, Instagram, LinkedIn, X (Twitter), TikTok, YouTube, and Threads.
</p>
<h2>3. Account Registration</h2>
<p>To use our Service, you must:</p>
<ul>
<li>Be at least 18 years old</li>
<li>Provide accurate and complete registration information</li>
<li>Maintain the security of your account credentials</li>
<li>Notify us immediately of any unauthorized account use</li>
</ul>
<p>
You are responsible for all activities that occur under your account. We reserve the right to suspend
or terminate accounts that violate these Terms.
</p>
<h2>4. Subscription and Payments</h2>
<h3>4.1 Pricing</h3>
<p>
TryPost operates on a subscription model. Pricing is based on the number of workspaces you maintain.
Current pricing and features are available on our website.
</p>
<h3>4.2 Billing</h3>
<p>
Subscriptions are billed in advance on a recurring basis (monthly or annually, depending on your plan).
Payment is processed securely through Stripe. By providing payment information, you authorize us to
charge your payment method for the applicable fees.
</p>
<h3>4.3 Refunds</h3>
<p>
Subscription fees are generally non-refundable except where required by law. You may cancel your
subscription at any time, and you will continue to have access until the end of your billing period.
</p>
<h2>5. User Content</h2>
<h3>5.1 Ownership</h3>
<p>
You retain all rights to the content you create and schedule through our Service. By using TryPost,
you grant us a limited license to store, process, and transmit your content solely for the purpose
of providing the Service.
</p>
<h3>5.2 Content Responsibility</h3>
<p>You are solely responsible for the content you publish through our Service. You agree not to post content that:</p>
<ul>
<li>Violates any applicable laws or regulations</li>
<li>Infringes on intellectual property rights of others</li>
<li>Contains malware, spam, or malicious code</li>
<li>Is defamatory, harassing, or discriminatory</li>
<li>Violates the terms of service of connected social media platforms</li>
</ul>
<h3>5.3 Content Removal</h3>
<p>
We reserve the right to remove any content that violates these Terms or applicable laws.
We may also remove content in response to valid legal requests or DMCA takedown notices.
</p>
<h2>6. Social Media Platform Compliance</h2>
<p>
When using TryPost to connect to third-party social media platforms, you agree to comply with each
platform's terms of service and community guidelines. We are not responsible for any actions taken
by social media platforms against your accounts.
</p>
<h2>7. Prohibited Uses</h2>
<p>You agree not to use the Service to:</p>
<ul>
<li>Violate any laws or regulations</li>
<li>Impersonate others or misrepresent your affiliation</li>
<li>Engage in automated data collection without authorization</li>
<li>Attempt to gain unauthorized access to our systems</li>
<li>Interfere with the proper operation of the Service</li>
<li>Circumvent any security measures</li>
<li>Use the Service for spam or bulk unsolicited messaging</li>
</ul>
<h2>8. Intellectual Property</h2>
<p>
The Service, including its original content, features, and functionality, is owned by TryPost and
is protected by international copyright, trademark, and other intellectual property laws. Our
trademarks may not be used without prior written consent.
</p>
<h2>9. Disclaimer of Warranties</h2>
<p>
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS
OR IMPLIED. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, SECURE, OR ERROR-FREE.
</p>
<p>
We do not guarantee that posts will be published successfully, as this depends on third-party
platform availability and their API functionality.
</p>
<h2>10. Limitation of Liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, TRYPOST SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA,
OR BUSINESS OPPORTUNITIES, ARISING FROM YOUR USE OF THE SERVICE.
</p>
<p>
Our total liability for any claims arising from these Terms or your use of the Service shall not
exceed the amount you paid us in the twelve (12) months preceding the claim.
</p>
<h2>11. Indemnification</h2>
<p>
You agree to indemnify and hold harmless TryPost, its officers, directors, employees, and agents
from any claims, damages, losses, or expenses (including reasonable attorney fees) arising from
your use of the Service or violation of these Terms.
</p>
<h2>12. Termination</h2>
<p>
We may terminate or suspend your account immediately, without prior notice, for any breach of
these Terms. Upon termination, your right to use the Service will cease immediately.
</p>
<p>
You may terminate your account at any time by contacting us or through your account settings.
Upon termination, we will delete your data in accordance with our Privacy Policy.
</p>
<h2>13. Changes to Terms</h2>
<p>
We reserve the right to modify these Terms at any time. We will notify you of significant changes
by posting the new Terms on this page and updating the "Last updated" date. Your continued use
of the Service after changes constitute acceptance of the new Terms.
</p>
<h2>14. Governing Law</h2>
<p>
These Terms shall be governed by and construed in accordance with applicable laws, without regard
to conflict of law principles.
</p>
<h2>15. Dispute Resolution</h2>
<p>
Any disputes arising from these Terms or your use of the Service shall first be attempted to be
resolved through good faith negotiation. If negotiation fails, disputes shall be resolved through
binding arbitration or in the courts of competent jurisdiction.
</p>
<h2>16. Severability</h2>
<p>
If any provision of these Terms is found to be unenforceable, the remaining provisions will
continue in full force and effect.
</p>
<h2>17. Contact Information</h2>
<p>For questions about these Terms, please contact us at:</p>
<p>
<strong>Email:</strong> legal@trypost.com<br />
<strong>Website:</strong> https://trypost.com
</p>
</div>
</GuestLayout>
</template>

View file

@ -22,22 +22,10 @@
use App\Http\Controllers\WorkspaceLabelController;
use App\Http\Middleware\EnsureUserSetupIsComplete;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
Route::get('/', function () {
return Inertia::render('Welcome', [
'canRegister' => Features::enabled(Features::registration()),
]);
})->name('home');
Route::get('/privacy', function () {
return Inertia::render('legal/Privacy');
})->name('privacy');
Route::get('/terms', function () {
return Inertia::render('legal/Terms');
})->name('terms');
return redirect()->route('calendar');
})->name('home')->middleware(['auth', 'verified']);
// Subscription selection (requires auth but not subscription)
Route::middleware(['auth', 'verified'])->group(function () {

View file

@ -0,0 +1,96 @@
<?php
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Subscribe tests
test('subscribe requires authentication', function () {
$response = $this->get(route('subscribe'));
$response->assertRedirect(route('login'));
});
test('subscribe shows subscription page', function () {
$response = $this->actingAs($this->user)->get(route('subscribe'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Subscribe', false)
->has('trialDays')
);
});
// Index tests
test('billing index requires authentication', function () {
$response = $this->get(route('billing.index'));
$response->assertRedirect(route('login'));
});
test('billing index shows billing dashboard', function () {
$response = $this->actingAs($this->user)->get(route('billing.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Index', false)
->has('hasSubscription')
->has('workspacesCount')
);
});
// Processing tests
test('billing processing requires authentication', function () {
$response = $this->get(route('billing.processing'));
$response->assertRedirect(route('login'));
});
test('billing processing shows processing page', function () {
$response = $this->actingAs($this->user)->get(route('billing.processing'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('billing/Processing', false)
->has('userId')
->has('status')
);
});
test('billing processing accepts status parameter', function () {
$response = $this->actingAs($this->user)->get(route('billing.processing', ['status' => 'success']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('status', 'success')
);
});
test('billing processing validates status parameter', function () {
$response = $this->actingAs($this->user)->get(route('billing.processing', ['status' => 'invalid']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('status', 'processing')
);
});
// Checkout tests
test('checkout requires authentication', function () {
$response = $this->post(route('billing.checkout'));
$response->assertRedirect(route('login'));
});
// Portal tests
test('portal requires authentication', function () {
$response = $this->get(route('billing.portal'));
$response->assertRedirect(route('login'));
});

View file

@ -1,7 +1,17 @@
<?php
test('returns a successful response', function () {
use App\Models\User;
test('home redirects to login for guests', function () {
$response = $this->get(route('home'));
$response->assertOk();
});
$response->assertRedirect(route('login'));
});
test('home redirects to calendar for authenticated users', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('home'));
$response->assertRedirect(route('calendar'));
});

View file

@ -0,0 +1,140 @@
<?php
use App\Enums\User\Setup;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('local');
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->socialAccount = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
});
// Store tests
test('store media requires authentication', function () {
$response = $this->post(route('medias.store'), [
'model' => 'postPlatform',
'model_id' => $this->postPlatform->id,
'media' => UploadedFile::fake()->image('test.jpg'),
]);
$response->assertRedirect(route('login'));
});
test('store media uploads file', function () {
$response = $this->actingAs($this->user)->post(route('medias.store'), [
'model' => 'App\Models\PostPlatform',
'model_id' => $this->postPlatform->id,
'media' => UploadedFile::fake()->image('test.jpg'),
]);
$response->assertOk();
$response->assertJsonStructure(['id', 'url', 'type', 'original_filename']);
});
test('store media validates required fields', function () {
$response = $this->actingAs($this->user)->post(route('medias.store'), [
'model' => '',
'model_id' => '',
]);
$response->assertSessionHasErrors(['model', 'model_id', 'media']);
});
// Destroy tests
test('destroy media requires authentication', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->delete(route('medias.destroy', [$this->postPlatform->id, $media]));
$response->assertRedirect(route('login'));
});
test('destroy media deletes the media', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->actingAs($this->user)->delete(route('medias.destroy', [$this->postPlatform->id, $media]));
$response->assertOk();
$response->assertJson(['success' => true]);
expect(Media::find($media->id))->toBeNull();
});
test('destroy media returns 403 for mismatched model', function () {
$otherPostPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
$media = Media::factory()->create([
'mediable_id' => $otherPostPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->actingAs($this->user)->delete(route('medias.destroy', [$this->postPlatform->id, $media]));
$response->assertForbidden();
});
// Duplicate tests
test('duplicate media requires authentication', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$response = $this->post(route('medias.duplicate', $media), [
'targets' => [],
]);
$response->assertRedirect(route('login'));
});
test('duplicate media creates copies', function () {
$media = Media::factory()->create([
'mediable_id' => $this->postPlatform->id,
'mediable_type' => 'postPlatform',
]);
$otherPostPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->post(route('medias.duplicate', $media), [
'targets' => [
[
'model' => 'postPlatform',
'model_id' => $otherPostPlatform->id,
],
],
]);
$response->assertOk();
$response->assertJsonCount(1);
expect(Media::where('mediable_id', $otherPostPlatform->id)->count())->toBe(1);
});

View file

@ -0,0 +1,141 @@
<?php
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Persona;
use App\Enums\User\Setup;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Role]);
});
// Step 1 tests
test('step1 requires authentication', function () {
$response = $this->get(route('onboarding.step1'));
$response->assertRedirect(route('login'));
});
test('step1 shows persona selection', function () {
$response = $this->actingAs($this->user)->get(route('onboarding.step1'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Step1', false)
->has('personas')
);
});
// Store Step 1 tests
test('store step1 requires authentication', function () {
$response = $this->post(route('onboarding.step1.store'), [
'persona' => Persona::Founder->value,
]);
$response->assertRedirect(route('login'));
});
test('store step1 saves persona and redirects to step2', function () {
$response = $this->actingAs($this->user)->post(route('onboarding.step1.store'), [
'persona' => Persona::Founder->value,
]);
$response->assertRedirect(route('onboarding.step2'));
$this->user->refresh();
expect($this->user->persona)->toBe(Persona::Founder);
expect($this->user->setup)->toBe(Setup::Connections);
});
test('store step1 validates persona is required', function () {
$response = $this->actingAs($this->user)->post(route('onboarding.step1.store'), [
'persona' => '',
]);
$response->assertSessionHasErrors('persona');
});
test('store step1 validates persona is valid enum', function () {
$response = $this->actingAs($this->user)->post(route('onboarding.step1.store'), [
'persona' => 'invalid',
]);
$response->assertSessionHasErrors('persona');
});
// Step 2 tests
test('step2 requires authentication', function () {
$response = $this->get(route('onboarding.step2'));
$response->assertRedirect(route('login'));
});
test('step2 shows social accounts connection', function () {
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->get(route('onboarding.step2'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('onboarding/Step2', false)
->has('platforms')
->has('hasWorkspace')
);
});
test('step2 shows connected accounts for workspace', function () {
$this->user->update(['setup' => Setup::Connections]);
$workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $workspace->id]);
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::LinkedIn,
]);
$response = $this->actingAs($this->user)->get(route('onboarding.step2'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('hasWorkspace', true)
);
});
// Store Step 2 tests
test('store step2 requires authentication', function () {
$response = $this->post(route('onboarding.step2.store'));
$response->assertRedirect(route('login'));
});
test('store step2 completes setup in self-hosted mode', function () {
config(['trypost.self_hosted' => true]);
$this->user->update(['setup' => Setup::Connections]);
$response = $this->actingAs($this->user)->post(route('onboarding.step2.store'));
$response->assertRedirect(route('calendar'));
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Completed);
});
// Complete tests
test('complete requires authentication', function () {
$response = $this->get(route('onboarding.complete'));
$response->assertRedirect(route('login'));
});
test('complete marks setup as completed', function () {
$this->user->update(['setup' => Setup::Subscription]);
$response = $this->actingAs($this->user)->get(route('onboarding.complete'));
$response->assertRedirect(route('calendar'));
$this->user->refresh();
expect($this->user->setup)->toBe(Setup::Completed);
});

View file

@ -0,0 +1,289 @@
<?php
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Setup;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
});
// Index tests
test('posts index requires authentication', function () {
$response = $this->get(route('posts.index'));
$response->assertRedirect(route('login'));
});
test('posts index shows posts for current workspace', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->actingAs($this->user)->get(route('posts.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('posts/Index', false)
->has('posts.data', 1)
);
});
test('posts index redirects to create workspace if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('posts.index'));
$response->assertRedirect(route('workspaces.create'));
});
// Calendar tests
test('calendar requires authentication', function () {
$response = $this->get(route('calendar'));
$response->assertRedirect(route('login'));
});
test('calendar shows posts for current week', function () {
$response = $this->actingAs($this->user)->get(route('calendar'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('posts/Calendar')
->has('workspace')
->has('posts')
->has('currentWeekStart')
->has('view')
);
});
test('calendar supports month view', function () {
$response = $this->actingAs($this->user)->get(route('calendar', ['view' => 'month']));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('view', 'month')
);
});
// Create tests
test('create post requires authentication', function () {
$response = $this->get(route('posts.create'));
$response->assertRedirect(route('login'));
});
test('create post redirects to accounts if no social accounts connected', function () {
$this->socialAccount->delete();
$response = $this->actingAs($this->user)->get(route('posts.create'));
$response->assertRedirect(route('accounts'));
});
test('create post creates draft and redirects to edit', function () {
$response = $this->actingAs($this->user)->get(route('posts.create'));
$response->assertRedirect();
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post)->not->toBeNull();
expect($post->status)->toBe(PostStatus::Draft);
expect($post->postPlatforms)->toHaveCount(1);
});
// Edit tests
test('edit post requires authentication', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->get(route('posts.edit', $post));
$response->assertRedirect(route('login'));
});
test('edit post shows edit page', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->get(route('posts.edit', $post));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('posts/Edit')
->has('post')
->has('socialAccounts')
);
});
test('edit post returns 404 for post from different workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$response = $this->actingAs($this->user)->get(route('posts.edit', $post));
$response->assertNotFound();
});
test('edit post redirects to show for published posts', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
]);
$response = $this->actingAs($this->user)->get(route('posts.edit', $post));
$response->assertRedirect(route('posts.show', $post));
});
// Show tests
test('show post requires authentication', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->get(route('posts.show', $post));
$response->assertRedirect(route('login'));
});
test('show post displays post details', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
]);
$response = $this->actingAs($this->user)->get(route('posts.show', $post));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('posts/Show')
->has('post')
);
});
// Update tests
test('update post requires authentication', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->put(route('posts.update', $post), []);
$response->assertRedirect(route('login'));
});
test('update post saves changes', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'content' => 'Original content',
]);
$response = $this->actingAs($this->user)->put(route('posts.update', $post), [
'status' => 'draft',
'platforms' => [
[
'id' => $postPlatform->id,
'content' => 'Updated content',
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$postPlatform->refresh();
expect($postPlatform->content)->toBe('Updated content');
});
test('update post cannot update published posts', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
]);
$response = $this->actingAs($this->user)->put(route('posts.update', $post), [
'status' => 'draft',
]);
$response->assertRedirect();
});
// Destroy tests
test('destroy post requires authentication', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->delete(route('posts.destroy', $post));
$response->assertRedirect(route('login'));
});
test('destroy post deletes the post', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$response = $this->actingAs($this->user)->delete(route('posts.destroy', $post));
$response->assertRedirect(route('calendar'));
expect(Post::find($post->id))->toBeNull();
});
test('destroy post returns 404 for post from different workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$response = $this->actingAs($this->user)->delete(route('posts.destroy', $post));
$response->assertNotFound();
});

View file

@ -0,0 +1,71 @@
<?php
use App\Enums\SocialAccount\Platform;
use App\Enums\User\Setup;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Index tests
test('accounts index requires authentication', function () {
$response = $this->get(route('accounts'));
$response->assertRedirect(route('login'));
});
test('accounts index shows platforms and connected accounts', function () {
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
$response = $this->actingAs($this->user)->get(route('accounts'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('accounts/Index', false)
->has('workspace')
->has('platforms')
);
});
test('accounts index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('accounts'));
$response->assertRedirect(route('workspaces.create'));
});
// Disconnect tests
test('disconnect requires authentication', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->delete(route('accounts.disconnect', $account));
$response->assertRedirect(route('login'));
});
test('disconnect removes social account', function () {
$account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('accounts.disconnect', $account));
$response->assertRedirect();
expect(SocialAccount::find($account->id))->toBeNull();
});
test('disconnect returns 403 for other workspace account', function () {
$otherWorkspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->delete(route('accounts.disconnect', $account));
$response->assertForbidden();
});

View file

@ -0,0 +1,237 @@
<?php
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Index tests
test('workspaces index requires authentication', function () {
$response = $this->get(route('workspaces.index'));
$response->assertRedirect(route('login'));
});
test('workspaces index shows all workspaces for user', function () {
Workspace::factory()->count(2)->create(['user_id' => $this->user->id]);
$response = $this->actingAs($this->user)->get(route('workspaces.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('workspaces/Index', false)
->has('workspaces', 3)
->has('currentWorkspaceId')
);
});
// Create tests
test('create workspace requires authentication', function () {
$response = $this->get(route('workspaces.create'));
$response->assertRedirect(route('login'));
});
test('create workspace shows form for user with no workspaces', function () {
// Delete existing workspace so user has none
$this->user->update(['current_workspace_id' => null]);
$this->workspace->delete();
$response = $this->actingAs($this->user)->get(route('workspaces.create'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('workspaces/Create', false)
);
});
test('create workspace redirects to billing if user already has workspace and no subscription', function () {
$response = $this->actingAs($this->user)->get(route('workspaces.create'));
$response->assertRedirect(route('billing.index'));
});
// Store tests
test('store workspace requires authentication', function () {
$response = $this->post(route('workspaces.store'), ['name' => 'Test Workspace']);
$response->assertRedirect(route('login'));
});
test('store workspace creates first workspace', function () {
// Delete existing workspace so user has none
$this->user->update(['current_workspace_id' => null]);
$this->workspace->delete();
$response = $this->actingAs($this->user)->post(route('workspaces.store'), [
'name' => 'New Workspace',
]);
$response->assertRedirect(route('calendar'));
$this->assertDatabaseHas('workspaces', [
'name' => 'New Workspace',
'user_id' => $this->user->id,
]);
});
test('store workspace redirects to billing for second workspace without subscription', function () {
$response = $this->actingAs($this->user)->post(route('workspaces.store'), [
'name' => 'Second Workspace',
]);
$response->assertRedirect(route('billing.index'));
});
test('store workspace validates name is required', function () {
$response = $this->actingAs($this->user)->post(route('workspaces.store'), [
'name' => '',
]);
$response->assertSessionHasErrors('name');
});
test('store workspace sets new workspace as current', function () {
// Delete existing workspace so user has none
$this->user->update(['current_workspace_id' => null]);
$this->workspace->delete();
$this->actingAs($this->user)->post(route('workspaces.store'), [
'name' => 'New Workspace',
]);
$this->user->refresh();
$newWorkspace = Workspace::where('name', 'New Workspace')->first();
expect($this->user->current_workspace_id)->toBe($newWorkspace->id);
});
// Switch tests
test('switch workspace requires authentication', function () {
$response = $this->post(route('workspaces.switch', $this->workspace));
$response->assertRedirect(route('login'));
});
test('switch workspace changes current workspace', function () {
$otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$response = $this->actingAs($this->user)->post(route('workspaces.switch', $otherWorkspace));
$response->assertRedirect(route('calendar'));
$this->user->refresh();
expect($this->user->current_workspace_id)->toBe($otherWorkspace->id);
});
test('switch workspace returns 403 for workspace user does not belong to', function () {
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($this->user)->post(route('workspaces.switch', $otherWorkspace));
$response->assertForbidden();
});
// Settings tests
test('workspace settings requires authentication', function () {
$response = $this->get(route('workspace.settings'));
$response->assertRedirect(route('login'));
});
test('workspace settings shows settings page', function () {
$response = $this->actingAs($this->user)->get(route('workspace.settings'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Workspace', false)
->has('workspace')
->has('timezones')
);
});
test('workspace settings redirects to create if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('workspace.settings'));
$response->assertRedirect(route('workspaces.create'));
});
// Update settings tests
test('update workspace settings requires authentication', function () {
$response = $this->put(route('workspace.settings.update'), [
'name' => 'Updated Name',
'timezone' => 'America/New_York',
]);
$response->assertRedirect(route('login'));
});
test('update workspace settings updates workspace', function () {
$response = $this->actingAs($this->user)->put(route('workspace.settings.update'), [
'name' => 'Updated Name',
'timezone' => 'America/New_York',
]);
$response->assertRedirect(route('workspace.settings'));
$this->workspace->refresh();
expect($this->workspace->name)->toBe('Updated Name');
expect($this->workspace->timezone)->toBe('America/New_York');
});
test('update workspace settings validates required fields', function () {
$response = $this->actingAs($this->user)->put(route('workspace.settings.update'), [
'name' => '',
'timezone' => '',
]);
$response->assertSessionHasErrors(['name', 'timezone']);
});
test('update workspace settings validates timezone', function () {
$response = $this->actingAs($this->user)->put(route('workspace.settings.update'), [
'name' => 'Valid Name',
'timezone' => 'Invalid/Timezone',
]);
$response->assertSessionHasErrors('timezone');
});
// Destroy tests
test('destroy workspace requires authentication', function () {
$response = $this->delete(route('workspaces.destroy', $this->workspace));
$response->assertRedirect(route('login'));
});
test('destroy workspace deletes the workspace', function () {
$workspaceId = $this->workspace->id;
$response = $this->actingAs($this->user)->delete(route('workspaces.destroy', $this->workspace));
$response->assertRedirect(route('workspaces.index'));
expect(Workspace::find($workspaceId))->toBeNull();
});
test('destroy workspace clears current workspace if deleting current', function () {
$this->actingAs($this->user)->delete(route('workspaces.destroy', $this->workspace));
$this->user->refresh();
expect($this->user->current_workspace_id)->toBeNull();
});
test('destroy workspace returns 403 for non-owner', function () {
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
$response = $this->actingAs($otherUser)->delete(route('workspaces.destroy', $this->workspace));
$response->assertForbidden();
});

View file

@ -0,0 +1,140 @@
<?php
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceHashtag;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Index tests
test('hashtags index requires authentication', function () {
$response = $this->get(route('hashtags.index'));
$response->assertRedirect(route('login'));
});
test('hashtags index shows hashtags for workspace', function () {
WorkspaceHashtag::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('hashtags.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('hashtags/Index', false)
->has('workspace')
->has('hashtags', 3)
);
});
test('hashtags index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('hashtags.index'));
$response->assertRedirect(route('workspaces.create'));
});
// Store tests
test('store hashtag requires authentication', function () {
$response = $this->post(route('hashtags.store'), [
'name' => 'Marketing',
'hashtags' => '#marketing #digital #growth',
]);
$response->assertRedirect(route('login'));
});
test('store hashtag creates hashtag group', function () {
$response = $this->actingAs($this->user)->post(route('hashtags.store'), [
'name' => 'Marketing',
'hashtags' => '#marketing #digital #growth',
]);
$response->assertRedirect(route('hashtags.index'));
$this->assertDatabaseHas('workspace_hashtags', [
'workspace_id' => $this->workspace->id,
'name' => 'Marketing',
'hashtags' => '#marketing #digital #growth',
]);
});
test('store hashtag validates required fields', function () {
$response = $this->actingAs($this->user)->post(route('hashtags.store'), [
'name' => '',
'hashtags' => '',
]);
$response->assertSessionHasErrors(['name', 'hashtags']);
});
// Update tests
test('update hashtag requires authentication', function () {
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->put(route('hashtags.update', $hashtag), [
'name' => 'Updated Name',
'hashtags' => '#updated #hashtags',
]);
$response->assertRedirect(route('login'));
});
test('update hashtag updates the hashtag group', function () {
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->put(route('hashtags.update', $hashtag), [
'name' => 'Updated Name',
'hashtags' => '#updated #hashtags',
]);
$response->assertRedirect(route('hashtags.index'));
$hashtag->refresh();
expect($hashtag->name)->toBe('Updated Name');
expect($hashtag->hashtags)->toBe('#updated #hashtags');
});
test('update hashtag returns 404 for other workspace hashtag', function () {
$otherWorkspace = Workspace::factory()->create();
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->put(route('hashtags.update', $hashtag), [
'name' => 'Updated Name',
'hashtags' => '#updated #hashtags',
]);
$response->assertNotFound();
});
// Destroy tests
test('destroy hashtag requires authentication', function () {
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->delete(route('hashtags.destroy', $hashtag));
$response->assertRedirect(route('login'));
});
test('destroy hashtag deletes the hashtag group', function () {
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('hashtags.destroy', $hashtag));
$response->assertRedirect(route('hashtags.index'));
expect(WorkspaceHashtag::find($hashtag->id))->toBeNull();
});
test('destroy hashtag returns 404 for other workspace hashtag', function () {
$otherWorkspace = Workspace::factory()->create();
$hashtag = WorkspaceHashtag::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->delete(route('hashtags.destroy', $hashtag));
$response->assertNotFound();
});

View file

@ -0,0 +1,245 @@
<?php
use App\Enums\User\Setup;
use App\Enums\UserWorkspace\Role as WorkspaceRole;
use App\Mail\WorkspaceInvite as WorkspaceInviteMail;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceInvite;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Index tests
test('members index requires authentication', function () {
$response = $this->get(route('members'));
$response->assertRedirect(route('login'));
});
test('members index shows members and invites', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$response = $this->actingAs($this->user)->get(route('members'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('settings/Members', false)
->has('workspace')
->has('invites')
->has('members')
->has('owner')
->has('roles')
);
});
test('members index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('members'));
$response->assertRedirect(route('workspaces.create'));
});
// Store invite tests
test('store invite requires authentication', function () {
$response = $this->post(route('invites.store'), [
'email' => 'test@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertRedirect(route('login'));
});
test('store invite creates invite and sends email', function () {
$response = $this->actingAs($this->user)->post(route('invites.store'), [
'email' => 'newmember@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertRedirect();
$this->assertDatabaseHas('workspace_invites', [
'workspace_id' => $this->workspace->id,
'email' => 'newmember@example.com',
]);
Mail::assertQueued(WorkspaceInviteMail::class);
});
test('store invite fails if invite already exists', function () {
WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
'email' => 'existing@example.com',
]);
$response = $this->actingAs($this->user)->post(route('invites.store'), [
'email' => 'existing@example.com',
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('email');
});
test('store invite fails if user is already member', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->post(route('invites.store'), [
'email' => $member->email,
'role' => WorkspaceRole::Member->value,
]);
$response->assertSessionHasErrors('email');
});
// Destroy invite tests
test('destroy invite requires authentication', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$response = $this->delete(route('invites.destroy', $invite));
$response->assertRedirect(route('login'));
});
test('destroy invite deletes invite', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$response = $this->actingAs($this->user)->delete(route('invites.destroy', $invite));
$response->assertRedirect();
expect(WorkspaceInvite::find($invite->id))->toBeNull();
});
test('destroy invite returns 404 for other workspace invite', function () {
$otherWorkspace = Workspace::factory()->create();
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $otherWorkspace->id,
]);
$response = $this->actingAs($this->user)->delete(route('invites.destroy', $invite));
$response->assertNotFound();
});
// Show invite tests
test('show invite displays invite details', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$response = $this->get(route('invites.show', $invite->token));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('invites/Accept', false)
->has('invite')
);
});
test('show invite redirects for accepted invite', function () {
$invite = WorkspaceInvite::factory()->accepted()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$response = $this->get(route('invites.show', $invite->token));
$response->assertRedirect(route('login'));
});
// Accept invite tests
test('accept invite redirects to login if not authenticated', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$response = $this->post(route('invites.accept', $invite->token));
$response->assertRedirect(route('login'));
});
test('accept invite adds user to workspace', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$newUser = User::factory()->create(['setup' => Setup::Completed]);
$response = $this->actingAs($newUser)->post(route('invites.accept', $invite->token));
$response->assertRedirect(route('calendar'));
expect($this->workspace->hasMember($newUser))->toBeTrue();
});
test('accept invite redirects for accepted invite', function () {
$invite = WorkspaceInvite::factory()->accepted()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$newUser = User::factory()->create(['setup' => Setup::Completed]);
$response = $this->actingAs($newUser)->post(route('invites.accept', $invite->token));
$response->assertRedirect(route('login'));
});
test('accept invite handles already member', function () {
$invite = WorkspaceInvite::factory()->create([
'workspace_id' => $this->workspace->id,
'invited_by' => $this->user->id,
]);
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($member)->post(route('invites.accept', $invite->token));
$response->assertRedirect(route('calendar'));
$response->assertSessionHas('flash.banner', 'You are already a member of this workspace.');
});
// Remove member tests
test('remove member requires authentication', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->delete(route('members.remove', $member));
$response->assertRedirect(route('login'));
});
test('remove member removes user from workspace', function () {
$member = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]);
$response = $this->actingAs($this->user)->delete(route('members.remove', $member));
$response->assertRedirect();
expect($this->workspace->hasMember($member))->toBeFalse();
});
test('remove member fails for owner', function () {
$response = $this->actingAs($this->user)->delete(route('members.remove', $this->user));
$response->assertSessionHasErrors('member');
});

View file

@ -0,0 +1,149 @@
<?php
use App\Enums\User\Setup;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// Index tests
test('labels index requires authentication', function () {
$response = $this->get(route('labels.index'));
$response->assertRedirect(route('login'));
});
test('labels index shows labels for workspace', function () {
WorkspaceLabel::factory()->count(3)->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->get(route('labels.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('labels/Index', false)
->has('workspace')
->has('labels', 3)
);
});
test('labels index redirects if no workspace', function () {
$this->user->update(['current_workspace_id' => null]);
$response = $this->actingAs($this->user)->get(route('labels.index'));
$response->assertRedirect(route('workspaces.create'));
});
// Store tests
test('store label requires authentication', function () {
$response = $this->post(route('labels.store'), [
'name' => 'Test Label',
'color' => '#FF5733',
]);
$response->assertRedirect(route('login'));
});
test('store label creates label', function () {
$response = $this->actingAs($this->user)->post(route('labels.store'), [
'name' => 'New Label',
'color' => '#FF5733',
]);
$response->assertRedirect(route('labels.index'));
$this->assertDatabaseHas('workspace_labels', [
'workspace_id' => $this->workspace->id,
'name' => 'New Label',
'color' => '#FF5733',
]);
});
test('store label validates required fields', function () {
$response = $this->actingAs($this->user)->post(route('labels.store'), [
'name' => '',
'color' => '',
]);
$response->assertSessionHasErrors(['name', 'color']);
});
test('store label validates color format', function () {
$response = $this->actingAs($this->user)->post(route('labels.store'), [
'name' => 'Valid Name',
'color' => 'invalid-color',
]);
$response->assertSessionHasErrors('color');
});
// Update tests
test('update label requires authentication', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->put(route('labels.update', $label), [
'name' => 'Updated Label',
'color' => '#00FF00',
]);
$response->assertRedirect(route('login'));
});
test('update label updates the label', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->put(route('labels.update', $label), [
'name' => 'Updated Label',
'color' => '#00FF00',
]);
$response->assertRedirect(route('labels.index'));
$label->refresh();
expect($label->name)->toBe('Updated Label');
expect($label->color)->toBe('#00FF00');
});
test('update label returns 404 for other workspace label', function () {
$otherWorkspace = Workspace::factory()->create();
$label = WorkspaceLabel::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->put(route('labels.update', $label), [
'name' => 'Updated Label',
'color' => '#00FF00',
]);
$response->assertNotFound();
});
// Destroy tests
test('destroy label requires authentication', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->delete(route('labels.destroy', $label));
$response->assertRedirect(route('login'));
});
test('destroy label deletes the label', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$response = $this->actingAs($this->user)->delete(route('labels.destroy', $label));
$response->assertRedirect(route('labels.index'));
expect(WorkspaceLabel::find($label->id))->toBeNull();
});
test('destroy label returns 404 for other workspace label', function () {
$otherWorkspace = Workspace::factory()->create();
$label = WorkspaceLabel::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = $this->actingAs($this->user)->delete(route('labels.destroy', $label));
$response->assertNotFound();
});