Compare commits

...

5 Commits

Author SHA1 Message Date
ab9b59897a feat: add production testimonials seeder
Canonical upsert for five authorized couples so staging/prod
can load depoimentos without DatabaseSeeder/ContentSeeder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:03:00 -03:00
286522d8a2 fix: keep Livewire temp uploads local when using R2
Avoid browser PUT to R2 (CORS) by defaulting Filament/Livewire temp disk to local, and document Dokploy tinker + R2 CORS gotchas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:11:32 -03:00
3e68192cf3 fix: join Dokploy network so Compose reaches managed Postgres (#9)
Migrate could not resolve the database internal hostname because the
Compose stack used an isolated project network. Attach services to the
external dokploy-network used by Dokploy Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:15:52 -03:00
1a31dd1cf6 fix: surface Dokploy API errors and normalize panel URL (#8)
curl -f hid 404 bodies; strip accidental /api on DOKPLOY_URL so compose.deploy diagnostics are actionable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:14:12 -03:00
9060036024 test: refresh visual baselines from CI Ubuntu screenshots (#7)
Heritage Editorial redesign snaps were generated outside CI and fail
assertScreenshotMatches on FrankenPHP/Ubuntu Chromium. Promote the
browser-failure-artifacts PNGs from run 30729267028 into Pest snaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:23:58 -03:00
16 changed files with 418 additions and 94 deletions

View File

@@ -26,6 +26,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->configureLivewireTemporaryUploads();
$this->freezeClockWhenConfigured(); $this->freezeClockWhenConfigured();
View::composer('layouts.public', function (ViewInstance $view): void { View::composer('layouts.public', function (ViewInstance $view): void {
@@ -46,6 +47,22 @@ class AppServiceProvider extends ServiceProvider
}); });
} }
/**
* Keep Livewire/Filament temp uploads on the local disk.
*
* When FILESYSTEM_DISK=r2, Livewire would otherwise use the S3 driver and
* browser-PUT straight to R2 (CORS). Final media still uses the r2 disk via
* PublicImageUploadRules. Explicit LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK wins.
*/
private function configureLivewireTemporaryUploads(): void
{
if (filled(config('livewire.temporary_file_upload.disk'))) {
return;
}
config(['livewire.temporary_file_upload.disk' => 'local']);
}
private function freezeClockWhenConfigured(): void private function freezeClockWhenConfigured(): void
{ {
if ($this->app->environment('production')) { if ($this->app->environment('production')) {

View File

@@ -8,7 +8,6 @@ use App\Models\PortfolioCase;
use App\Models\PortfolioImage; use App\Models\PortfolioImage;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@@ -23,7 +22,7 @@ class ContentSeeder extends Seeder
$this->seedSiteSettings(); $this->seedSiteSettings();
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();
$this->seedTestimonials(); $this->call(TestimonialsSeeder::class);
} }
private function seedSiteSettings(): void private function seedSiteSettings(): void
@@ -170,67 +169,6 @@ class ContentSeeder extends Seeder
} }
} }
private function seedTestimonials(): void
{
// Real couples from depoimentos.md. Production publication still requires
// explicit couple authorization before setting published_at outside local/demo seeds.
$testimonials = [
[
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · 06/12/2025',
'sort_order' => 1,
'is_featured' => true,
],
[
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
'author_name' => 'Quesia e Jhonata',
'context' => 'Casamento · 21/12/2025',
'sort_order' => 2,
'is_featured' => true,
],
[
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
'author_name' => 'Milena e Weslley',
'context' => 'Casamento · 13/02/2026',
'sort_order' => 3,
'is_featured' => false,
],
[
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
'author_name' => 'Raquel e Pedro',
'context' => 'Casamento · 09/05/2026',
'sort_order' => 4,
'is_featured' => false,
],
[
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
'author_name' => 'Victoria e Pedro',
'context' => 'Casamento · 24/06/2026',
'sort_order' => 5,
'is_featured' => false,
],
];
$keepAuthors = array_column($testimonials, 'author_name');
Testimonial::query()
->whereNotIn('author_name', $keepAuthors)
->delete();
foreach ($testimonials as $testimonial) {
Testimonial::query()->updateOrCreate(
['author_name' => $testimonial['author_name']],
[
...$testimonial,
'photo_path' => null,
'photo_alt' => null,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
],
);
}
}
private function copyFixture(string $fixtureName, string $destination): string private function copyFixture(string $fixtureName, string $destination): string
{ {
$source = base_path('tests/fixtures/images/'.$fixtureName); $source = base_path('tests/fixtures/images/'.$fixtureName);

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Testimonial;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class TestimonialsSeeder extends Seeder
{
private const PUBLISHED_AT = '2026-08-05 00:00:00';
public function run(): void
{
$testimonials = [
[
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · 06/12/2025',
'sort_order' => 1,
'is_featured' => true,
],
[
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
'author_name' => 'Quesia e Jhonata',
'context' => 'Casamento · 21/12/2025',
'sort_order' => 2,
'is_featured' => true,
],
[
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
'author_name' => 'Milena e Weslley',
'context' => 'Casamento · 13/02/2026',
'sort_order' => 3,
'is_featured' => false,
],
[
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
'author_name' => 'Raquel e Pedro',
'context' => 'Casamento · 09/05/2026',
'sort_order' => 4,
'is_featured' => false,
],
[
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
'author_name' => 'Victoria e Pedro',
'context' => 'Casamento · 24/06/2026',
'sort_order' => 5,
'is_featured' => false,
],
];
DB::transaction(function () use ($testimonials): void {
foreach ($testimonials as $testimonial) {
Testimonial::query()->updateOrCreate(
['author_name' => $testimonial['author_name']],
[
'quote' => $testimonial['quote'],
'context' => $testimonial['context'],
'sort_order' => $testimonial['sort_order'],
'is_featured' => $testimonial['is_featured'],
'published_at' => self::PUBLISHED_AT,
],
);
}
});
}
}

View File

@@ -4,6 +4,8 @@
# IMAGE_TAG=staging|production|<git-sha> # IMAGE_TAG=staging|production|<git-sha>
# PostgreSQL is a separate Dokploy database service (not defined here). # PostgreSQL is a separate Dokploy database service (not defined here).
# Traefik/Dokploy domains should target service `web` port 8000. # Traefik/Dokploy domains should target service `web` port 8000.
# App services join dokploy-network so they can resolve Dokploy-managed
# Postgres internal hosts (e.g. amare-stg-pez43e).
services: services:
migrate: migrate:
@@ -13,6 +15,8 @@ services:
env_file: env_file:
- .env - .env
command: ["php", "artisan", "migrate", "--force", "--no-interaction"] command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
networks:
- dokploy-network
web: web:
image: ${APP_IMAGE}:${IMAGE_TAG} image: ${APP_IMAGE}:${IMAGE_TAG}
@@ -31,6 +35,8 @@ services:
timeout: 5s timeout: 5s
start_period: 40s start_period: 40s
retries: 3 retries: 3
networks:
- dokploy-network
queue: queue:
image: ${APP_IMAGE}:${IMAGE_TAG} image: ${APP_IMAGE}:${IMAGE_TAG}
@@ -44,6 +50,8 @@ services:
command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"] command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"]
stop_grace_period: 60s stop_grace_period: 60s
stop_signal: SIGTERM stop_signal: SIGTERM
networks:
- dokploy-network
scheduler: scheduler:
image: ${APP_IMAGE}:${IMAGE_TAG} image: ${APP_IMAGE}:${IMAGE_TAG}
@@ -57,3 +65,9 @@ services:
command: ["php", "artisan", "schedule:work"] command: ["php", "artisan", "schedule:work"]
stop_grace_period: 30s stop_grace_period: 30s
stop_signal: SIGTERM stop_signal: SIGTERM
networks:
- dokploy-network
networks:
dokploy-network:
external: true

View File

@@ -50,6 +50,8 @@ IMAGE_TAG=staging # or production
Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes. Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes.
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`. Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`.
## Required Laravel env (Dokploy only) ## Required Laravel env (Dokploy only)
@@ -68,7 +70,7 @@ APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Fortaleza APP_TIMEZONE=America/Fortaleza
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=<dokploy-postgres-host> DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=amare_staging DB_DATABASE=amare_staging
DB_USERNAME=... DB_USERNAME=...
@@ -98,19 +100,41 @@ LOG_LEVEL=warning
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs. Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
### R2 CORS (public/media reads from JS)
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
```json
[
{
"AllowedOrigins": ["https://hellomanoel.com"],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Type"],
"MaxAgeSeconds": 3600
}
]
```
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
## GitHub Actions secrets ## GitHub Actions secrets
Repository secrets used by workflows: Repository secrets used by workflows:
| Secret | Purpose | | Secret | Purpose |
|---|---| |---|---|
| `DOKPLOY_URL` | Base URL of the Dokploy panel (no trailing slash) | | `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI | | `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging compose id | | `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production compose id | | `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) | | `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke | | `PRODUCTION_URL` | Public origin for production smoke |
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline. `GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
## Workflows ## Workflows
@@ -135,27 +159,67 @@ Repository secrets used by workflows:
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later. Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
## First admin (no `db:seed` in production) ## First admin and authorized production seeding
Seed credentials are local-only. For staging/production: Seed credentials are local-only. For staging/production:
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
```bash ```bash
# From Dokploy → staging/production → Open terminal on `web` (or one-off run) # From Dokploy → staging/production → Open terminal on `web` (or one-off run)
php artisan tinker --execute=" XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\\App\\Models\\User::query()->updateOrCreate( \$user = \\App\\Models\\User::query()->updateOrCreate(
['email' => 'admin@example.com'], ['email' => 'admin@example.com'],
[ [
'name' => 'Admin', 'name' => 'Admin',
'password' => bcrypt('use-a-strong-password'), 'password' => 'use-a-strong-password',
'role' => 'admin', 'role' => 'admin',
'is_active' => true, 'is_active' => true,
'email_verified_at' => now(),
] ]
); );
\$user->forceFill(['email_verified_at' => now()])->save();
echo \$user->email.PHP_EOL;
" "
``` ```
Adjust field names if the model evolves. Never reuse `admin@amare.local` / `password`. Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
Confirm:
```bash
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
```
Never reuse `admin@amare.local` / `password`.
### Load authorized testimonials after migrations
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
```bash
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
```
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
Optional verification:
```bash
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
\$matches = \$rows->get(\$author, collect());
return \$matches->count() === 1
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
});
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
"
```
Expected output: `ok`.
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
## Backup and restore ## Backup and restore

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Trigger a Dokploy Compose deploy and wait until it finishes. # Trigger a Dokploy Compose deploy and wait until it finishes.
# Required env: # Required env:
# DOKPLOY_URL e.g. https://panel.example.com # DOKPLOY_URL e.g. https://panel.example.com (panel origin, no /api)
# DOKPLOY_API_KEY x-api-key value # DOKPLOY_API_KEY x-api-key value
# DOKPLOY_COMPOSE_ID target compose id # DOKPLOY_COMPOSE_ID target compose id
# Optional env: # Optional env:
@@ -15,21 +15,53 @@ set -euo pipefail
: "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}" : "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}"
: "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}" : "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}"
# Panel origin only: strip trailing slash and accidental OpenAPI /api suffix.
DOKPLOY_URL="${DOKPLOY_URL%/}" DOKPLOY_URL="${DOKPLOY_URL%/}"
DOKPLOY_URL="${DOKPLOY_URL%/api}"
DOKPLOY_URL="${DOKPLOY_URL%/}"
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}" DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}" DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}" DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
echo "Dokploy API base: ${DOKPLOY_URL}/api"
api() { api() {
local method="$1" local method="$1"
local path="$2" local path="$2"
shift 2 shift 2
curl -fsS -X "$method" \ local url="${DOKPLOY_URL}/api${path}"
local response http_code body
# Body then HTTP status on the last line (no -f so 4xx/5xx still return a body).
response="$(
curl -sS -w $'\n%{http_code}' -X "$method" \
-H "accept: application/json" \ -H "accept: application/json" \
-H "content-type: application/json" \ -H "content-type: application/json" \
-H "x-api-key: ${DOKPLOY_API_KEY}" \ -H "x-api-key: ${DOKPLOY_API_KEY}" \
"${DOKPLOY_URL}/api${path}" \ "$url" \
"$@" "$@"
)" || {
echo "Dokploy API request failed (curl transport error)." >&2
echo " method: ${method}" >&2
echo " url: ${url}" >&2
exit 1
}
http_code="$(printf '%s' "$response" | tail -n1)"
body="$(printf '%s' "$response" | sed '$d')"
if [[ ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
echo "Dokploy API error." >&2
echo " method: ${method}" >&2
echo " url: ${url}" >&2
echo " status: ${http_code}" >&2
echo " body:" >&2
printf '%s\n' "$body" >&2
exit 1
fi
printf '%s' "$body"
} }
echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..." echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..."

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
it('keeps livewire temporary uploads on the local disk when the default filesystem is r2', function (): void {
config(['filesystems.default' => 'r2']);
expect(config('livewire.temporary_file_upload.disk'))->toBe('local')
->and(FileUploadConfiguration::disk())->toBe('local')
->and(FileUploadConfiguration::isUsingS3())->toBeFalse();
});

View File

@@ -9,6 +9,7 @@ use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use App\Models\User; use App\Models\User;
use Database\Seeders\ContentSeeder; use Database\Seeders\ContentSeeder;
use Database\Seeders\TestimonialsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
@@ -18,6 +19,59 @@ class TestimonialsTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
/**
* @var list<array{
* quote: string,
* author_name: string,
* context: string,
* sort_order: int,
* is_featured: bool,
* published_at: string
* }>
*/
private const EXPECTED_TESTIMONIALS = [
[
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · 06/12/2025',
'sort_order' => 1,
'is_featured' => true,
'published_at' => '2026-08-05 00:00:00',
],
[
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
'author_name' => 'Quesia e Jhonata',
'context' => 'Casamento · 21/12/2025',
'sort_order' => 2,
'is_featured' => true,
'published_at' => '2026-08-05 00:00:00',
],
[
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
'author_name' => 'Milena e Weslley',
'context' => 'Casamento · 13/02/2026',
'sort_order' => 3,
'is_featured' => false,
'published_at' => '2026-08-05 00:00:00',
],
[
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
'author_name' => 'Raquel e Pedro',
'context' => 'Casamento · 09/05/2026',
'sort_order' => 4,
'is_featured' => false,
'published_at' => '2026-08-05 00:00:00',
],
[
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
'author_name' => 'Victoria e Pedro',
'context' => 'Casamento · 24/06/2026',
'sort_order' => 5,
'is_featured' => false,
'published_at' => '2026-08-05 00:00:00',
],
];
public function test_published_scope_excludes_unpublished_testimonials(): void public function test_published_scope_excludes_unpublished_testimonials(): void
{ {
Testimonial::factory()->create(['published_at' => null]); Testimonial::factory()->create(['published_at' => null]);
@@ -48,11 +102,27 @@ class TestimonialsTest extends TestCase
->assertForbidden(); ->assertForbidden();
} }
public function test_testimonials_seeder_maps_all_five_real_couples_from_depoimentos(): void
{
$this->runTestimonialsSeeder();
$testimonials = Testimonial::query()
->orderBy('sort_order')
->get()
->map(fn (Testimonial $testimonial): array => $this->sourceOwnedState($testimonial))
->all();
$this->assertSame(self::EXPECTED_TESTIMONIALS, $testimonials);
}
public function test_content_seeder_loads_five_real_couples_from_depoimentos(): void public function test_content_seeder_loads_five_real_couples_from_depoimentos(): void
{ {
Storage::fake('public'); Storage::fake('public');
$this->seed(ContentSeeder::class); $this->artisan('db:seed', [
'--class' => ContentSeeder::class,
'--no-interaction' => true,
])->assertExitCode(0);
$authors = Testimonial::query()->orderBy('sort_order')->pluck('author_name')->all(); $authors = Testimonial::query()->orderBy('sort_order')->pluck('author_name')->all();
@@ -64,11 +134,6 @@ class TestimonialsTest extends TestCase
'Victoria e Pedro', 'Victoria e Pedro',
], $authors); ], $authors);
$this->assertNull(
Testimonial::query()->where('author_name', 'Ana Souza')->first(),
'Fictional demo authors must not remain after seeding real testimonials.',
);
$jeniffer = Testimonial::query()->where('author_name', 'Jeniffer e Maick')->first(); $jeniffer = Testimonial::query()->where('author_name', 'Jeniffer e Maick')->first();
$this->assertNotNull($jeniffer); $this->assertNotNull($jeniffer);
@@ -77,6 +142,87 @@ class TestimonialsTest extends TestCase
$this->assertSame('Casamento · 06/12/2025', $jeniffer->context); $this->assertSame('Casamento · 06/12/2025', $jeniffer->context);
} }
public function test_testimonials_seeder_authoritatively_overwrites_source_owned_fields(): void
{
$testimonial = Testimonial::factory()->create([
'quote' => 'Depoimento desatualizado.',
'author_name' => 'Jeniffer e Maick',
'context' => 'Contexto desatualizado',
'sort_order' => 99,
'is_featured' => false,
'published_at' => null,
]);
$this->runTestimonialsSeeder();
$this->assertSame(
self::EXPECTED_TESTIMONIALS[0],
$this->sourceOwnedState($testimonial->refresh()),
);
}
public function test_testimonials_seeder_preserves_curated_photo_fields_for_known_author(): void
{
$testimonial = Testimonial::factory()->create([
'author_name' => 'Jeniffer e Maick',
'photo_path' => 'testimonials/jeniffer-e-maick.jpg',
'photo_alt' => 'Jeniffer e Maick durante o casamento',
]);
$this->runTestimonialsSeeder();
$testimonial->refresh();
$this->assertSame('testimonials/jeniffer-e-maick.jpg', $testimonial->photo_path);
$this->assertSame('Jeniffer e Maick durante o casamento', $testimonial->photo_alt);
}
public function test_testimonials_seeder_leaves_unrelated_testimonials_unchanged(): void
{
$unrelated = Testimonial::factory()->published()->featured()->create([
'quote' => 'Depoimento cadastrado pela equipe.',
'author_name' => 'Casal não presente na fonte',
'context' => 'Bodas · 02/08/2026',
'photo_path' => 'testimonials/casal-equipe.jpg',
'photo_alt' => 'Casal cadastrado pela equipe',
'sort_order' => 42,
]);
$originalState = $unrelated->getAttributes();
ksort($originalState);
$this->runTestimonialsSeeder();
$persisted = $unrelated->fresh();
$this->assertNotNull($persisted);
$persistedState = $persisted->getAttributes();
ksort($persistedState);
$this->assertSame($originalState, $persistedState);
}
public function test_testimonials_seeder_is_idempotent_without_duplicate_authors(): void
{
$this->runTestimonialsSeeder();
$firstRunState = Testimonial::query()
->orderBy('id')
->get()
->map(fn (Testimonial $testimonial): array => $testimonial->getAttributes())
->all();
$this->runTestimonialsSeeder();
$secondRunState = Testimonial::query()
->orderBy('id')
->get()
->map(fn (Testimonial $testimonial): array => $testimonial->getAttributes())
->all();
$this->assertCount(5, $secondRunState);
$this->assertSame($firstRunState, $secondRunState);
$this->assertSame(5, Testimonial::query()->distinct()->count('author_name'));
}
public function test_home_renders_multi_paragraph_testimonial_quotes(): void public function test_home_renders_multi_paragraph_testimonial_quotes(): void
{ {
SiteSetting::instance(); SiteSetting::instance();
@@ -97,4 +243,34 @@ class TestimonialsTest extends TestCase
false, false,
); );
} }
private function runTestimonialsSeeder(): void
{
$this->artisan('db:seed', [
'--class' => TestimonialsSeeder::class,
'--no-interaction' => true,
])->assertExitCode(0);
}
/**
* @return array{
* quote: string,
* author_name: string,
* context: string,
* sort_order: int,
* is_featured: bool,
* published_at: string
* }
*/
private function sourceOwnedState(Testimonial $testimonial): array
{
return [
'quote' => (string) $testimonial->quote,
'author_name' => (string) $testimonial->author_name,
'context' => (string) $testimonial->context,
'sort_order' => (int) $testimonial->sort_order,
'is_featured' => (bool) $testimonial->is_featured,
'published_at' => $testimonial->published_at?->format('Y-m-d H:i:s') ?? '',
];
}
} }