Add unit, integration and e2e test suites
tests/{unit,integration,e2e}/, at the repo root (not under frontend/,
since e2e exercises the frontend and PocketBase together) — all
against real code rather than mocks:
- unit/: Vitest (nuxt environment) — pure functions in server/utils
and shared/utils, plus components/composables via
@nuxt/test-utils/runtime's mountSuspended.
- integration/: Vitest (node environment) — @nuxt/test-utils/e2e's
setup() builds and runs the real Nitro server against a real
ephemeral PocketBase instance, exercising the actual /content/* and
/admin routes end to end.
- e2e/: Playwright, a real browser against the built app and another
ephemeral PocketBase, covering the user-facing flows (home, blog
archive, article detail incl. JSON-LD, category filter, 404s, the
/admin redirect).
The Vitest/Playwright tooling (and node_modules) stays in frontend/,
the repo's only npm project; its configs just point at ../tests/.
Playwright resolves packages from the node_modules nearest each spec
file, so `npm run test:e2e` first symlinks tests/node_modules to
frontend/node_modules (pretest:e2e, idempotent, gitignored).
tests/support/pocketbase.ts is the shared piece behind integration
and e2e: it downloads the same pinned PocketBase binary
pocketbase/Dockerfile uses, starts it against the real
pocketbase/pb_migrations/ (not a copy), and seeds it from
tests/support/fixtures.ts — so these tests run against the exact
schema and API rules that ship to production, catching the class of
bug that only shows up when PocketBase actually enforces them (e.g. a
wrong filter/fields query string silently returning nothing, or too
much).
CLAUDE.md and docs/frontend.md document the new npm scripts and the
single-test commands for each layer.
This commit is contained in:
+10
-8
@@ -2,6 +2,10 @@
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
# symlink to frontend/node_modules (created by `npm run pretest:e2e`); the
|
||||
# trailing-slash pattern above doesn't match a symlink even when it points to
|
||||
# a directory
|
||||
tests/node_modules
|
||||
|
||||
# env / secrets
|
||||
.env
|
||||
@@ -21,14 +25,12 @@ frontend/dist/
|
||||
.nitro/
|
||||
.cache/
|
||||
|
||||
# strapi
|
||||
cms/.strapi/
|
||||
cms/dist/
|
||||
cms/build/
|
||||
cms/.tmp/
|
||||
cms/public/uploads/*
|
||||
!cms/public/uploads/.gitkeep
|
||||
cms/.strapi-updater.json
|
||||
# playwright
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
# nuxt module-setup marker (regenerated, not meaningful project config)
|
||||
frontend/.nuxtrc
|
||||
|
||||
# caddy runtime
|
||||
caddy/data/
|
||||
|
||||
@@ -10,8 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Stato
|
||||
|
||||
Struttura, content type, pagine blog, Docker e Caddy sono in piedi. Restano da fare:
|
||||
sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora configurato).
|
||||
Struttura, content type, pagine blog, Docker, Caddy e i test (unit/integration/e2e) sono in
|
||||
piedi. Restano da fare: sitemap e `robots.txt` dinamici, ricerca.
|
||||
|
||||
## Architettura
|
||||
|
||||
@@ -67,6 +67,16 @@ npm run build # build produzione
|
||||
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
||||
npm run lint
|
||||
|
||||
npm run test:unit # Vitest, ambiente Nuxt: funzioni pure + componenti/composable
|
||||
npm run test:integration # Vitest, server Nitro reale + PocketBase effimero reale
|
||||
npm run test:e2e # Playwright, browser reale contro build + PocketBase effimero reale
|
||||
npm run test # i tre, in sequenza
|
||||
|
||||
# singolo test:
|
||||
npx vitest run --config vitest.unit.config.ts tests/unit/queries.utils.test.ts
|
||||
npx vitest run --config vitest.integration.config.ts -t "lists only published articles"
|
||||
npx playwright test tests/e2e/home.spec.ts
|
||||
|
||||
# pocketbase/ (nessun npm script: binario singolo, le migration si applicano da sole all'avvio)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build pocketbase frontend
|
||||
|
||||
@@ -81,8 +91,17 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build poc
|
||||
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
|
||||
per non finire per sbaglio in produzione esponendo le porte.
|
||||
|
||||
Nessun test framework è ancora configurato. Se ne aggiungi uno, documenta qui il comando per
|
||||
lanciare **un singolo test**.
|
||||
`tests/` sta nella root del repo (non sotto `frontend/`), perché l'e2e esercita frontend e
|
||||
PocketBase insieme — gli strumenti (config Vitest/Playwright, `node_modules`) restano comunque in
|
||||
`frontend/`, l'unico progetto npm del repo, e puntano a `../tests/`. Playwright risolve i pacchetti
|
||||
dal `node_modules` più vicino al file di test: per questo `npm run test:e2e` crea prima (script
|
||||
`pretest:e2e`, idempotente) il symlink `tests/node_modules → ../frontend/node_modules`.
|
||||
|
||||
I test di integration/e2e avviano un vero binario PocketBase effimero (scaricato una volta in
|
||||
`.cache/pocketbase/` nella root, versione allineata a `pocketbase/Dockerfile`) contro le vere
|
||||
migration in `pocketbase/pb_migrations/` — niente PocketBase mockato. `npm run test:e2e` richiede
|
||||
Chromium installato una tantum: `npx playwright install chromium` (senza `--with-deps`, che
|
||||
richiede `sudo`; se mancano librerie di sistema per il browser, installarle a parte).
|
||||
|
||||
## Modelli di contenuto
|
||||
|
||||
|
||||
@@ -87,3 +87,29 @@ JSON-LD `BlogPosting` structured data, with the article body already present in
|
||||
HTML (no client-only content). Accessibility requirements (focus visibility, labeled inputs,
|
||||
meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
|
||||
— see `CLAUDE.md`.
|
||||
|
||||
## Tests (`tests/`, repo root)
|
||||
|
||||
Kept at the repo root, not under `frontend/`, since e2e exercises the frontend *and* PocketBase
|
||||
together — but the tooling (Vitest/Playwright configs, `node_modules`) still lives in `frontend/`,
|
||||
the only npm project in the repo; the configs there just point `include`/`testDir` at `../tests/`.
|
||||
|
||||
Three layers, all against real code (no mocked PocketBase):
|
||||
|
||||
- `tests/unit/` — Vitest, `environment: 'nuxt'` (`frontend/vitest.unit.config.ts`). Pure functions
|
||||
in `frontend/server/utils/*` and `frontend/shared/utils/*` (imported directly, not via
|
||||
auto-import) plus components and composables via `@nuxt/test-utils/runtime`'s `mountSuspended`.
|
||||
- `tests/integration/` — Vitest, node environment (`frontend/vitest.integration.config.ts`).
|
||||
`@nuxt/test-utils/e2e`'s `setup()` builds and runs the real Nitro server, pointed (via
|
||||
`nuxtConfig.runtimeConfig` overrides) at a real ephemeral PocketBase instance from
|
||||
`tests/support/pocketbase.ts`. Exercises the actual `/content/*` and `/admin` routes.
|
||||
- `tests/e2e/` — Playwright (`frontend/playwright.config.ts`), a real browser against the built
|
||||
app (`node .output/server/index.mjs`) and another ephemeral PocketBase on a fixed port (needed
|
||||
so the Nuxt server's env and Playwright's `globalSetup` can reference each other without an
|
||||
async hand-off — see the comments in `playwright.config.ts`/`tests/e2e/global-setup.ts`).
|
||||
|
||||
`tests/support/pocketbase.ts` is the shared piece: it downloads the same pinned PocketBase binary
|
||||
`pocketbase/Dockerfile` uses (cached in `.cache/pocketbase/` at the repo root, gitignored), starts
|
||||
it against the real `pocketbase/pb_migrations/`, and seeds it from `tests/support/fixtures.ts` —
|
||||
so integration and e2e tests run against the exact schema/rules that ship to production, not a
|
||||
hand-maintained approximation of them.
|
||||
|
||||
Generated
+1042
-62
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -9,7 +9,12 @@
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"pretest:e2e": "ln -sfn ../frontend/node_modules ../tests/node_modules",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "npm run test:unit && npm run test:integration && npm run test:e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.11",
|
||||
@@ -20,8 +25,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.17.0",
|
||||
"@nuxt/test-utils": "^4.3.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"@vue/test-utils": "^2.5.0",
|
||||
"eslint": "^10.10.0",
|
||||
"happy-dom": "^20.14.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^5.0.0",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
// Fixed ports so the PocketBase instance (started in global-setup.ts) and the
|
||||
// built Nuxt server (started by Playwright's webServer below) can reference
|
||||
// each other statically — see tests/e2e/global-setup.ts for why.
|
||||
export const E2E_POCKETBASE_PORT = 8095
|
||||
const APP_PORT = 3100
|
||||
const BASE_URL = `http://127.0.0.1:${APP_PORT}`
|
||||
const POCKETBASE_URL = `http://127.0.0.1:${E2E_POCKETBASE_PORT}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '../tests/e2e',
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
globalSetup: fileURLToPath(new URL('../tests/e2e/global-setup.ts', import.meta.url)),
|
||||
webServer: {
|
||||
command: 'npm run build && node .output/server/index.mjs',
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(APP_PORT),
|
||||
NUXT_POCKETBASE_URL: POCKETBASE_URL,
|
||||
NUXT_PUBLIC_POCKETBASE_URL: POCKETBASE_URL,
|
||||
NUXT_PUBLIC_SITE_URL: BASE_URL,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['../tests/integration/**/*.test.ts'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 60000,
|
||||
// Integration tests boot a real Nuxt server + PocketBase instance; running
|
||||
// several of those concurrently is wasteful, not parallel-safe by design.
|
||||
fileParallelism: false,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineVitestConfig } from '@nuxt/test-utils/config'
|
||||
|
||||
export default defineVitestConfig({
|
||||
test: {
|
||||
environment: 'nuxt',
|
||||
include: ['../tests/unit/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('/admin redirects to the real PocketBase dashboard', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
await expect(page).toHaveURL(/\/_\/(#.*)?$/)
|
||||
// The static <title> is "PocketBase"; the dashboard SPA then renders its
|
||||
// login view and retitles the page — asserting the live title also proves
|
||||
// the dashboard's own JS bundle loaded and ran correctly through the proxy.
|
||||
await expect(page).toHaveTitle('Superuser login')
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('article detail renders body, breadcrumb and structured data', async ({ page }) => {
|
||||
await page.goto('/blog/articolo-pubblicato')
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Economia' })).toHaveAttribute(
|
||||
'href',
|
||||
'/category/economia'
|
||||
)
|
||||
await expect(page.locator('.prose strong')).toHaveText('markdown')
|
||||
|
||||
const jsonLd = await page.locator('script[type="application/ld+json"]').textContent()
|
||||
const data = JSON.parse(jsonLd ?? '{}')
|
||||
expect(data['@type']).toBe('BlogPosting')
|
||||
expect(data.headline).toBe('Articolo pubblicato')
|
||||
})
|
||||
|
||||
test('unknown article slug renders the 404 page', async ({ page }) => {
|
||||
const response = await page.goto('/blog/non-esiste')
|
||||
|
||||
expect(response?.status()).toBe(404)
|
||||
await expect(page.getByRole('heading', { name: 'Pagina non trovata' })).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('blog archive lists only published articles', async ({ page }) => {
|
||||
await page.goto('/blog')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Secondo articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Articolo pubblicato', exact: true })).toBeVisible()
|
||||
await expect(page.getByText('Articolo in bozza')).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('category page filters articles to that category only', async ({ page }) => {
|
||||
await page.goto('/category/attualita')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Secondo articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Articolo pubblicato', exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('unknown category slug 404s', async ({ page }) => {
|
||||
const response = await page.goto('/category/non-esiste')
|
||||
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { startTestPocketbase } from '../support/pocketbase'
|
||||
import { E2E_POCKETBASE_PORT } from '../../frontend/playwright.config'
|
||||
|
||||
/**
|
||||
* Starts one PocketBase instance, shared by the whole e2e run, on the fixed
|
||||
* port `playwright.config.ts` also bakes into the Nuxt server's env — so both
|
||||
* sides can reference it without needing an async hand-off between them.
|
||||
*
|
||||
* Playwright keeps this module's closure alive and calls the returned
|
||||
* function as teardown once all tests finish (no separate globalTeardown
|
||||
* file needed).
|
||||
*/
|
||||
export default async function globalSetup() {
|
||||
const pb = await startTestPocketbase({ port: E2E_POCKETBASE_PORT })
|
||||
return async () => {
|
||||
await pb.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('home page shows the latest published article as featured', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Secondo articolo pubblicato' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: "Leggi l'ultimo articolo" })).toHaveAttribute(
|
||||
'href',
|
||||
'/blog/secondo-articolo'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { $fetch, fetch, setup } from '@nuxt/test-utils/e2e'
|
||||
import { startTestPocketbase } from '../support/pocketbase'
|
||||
import type { Article, ArticleSummary, Category, Paginated } from '../../frontend/shared/types/blog'
|
||||
|
||||
// setup() registers its own beforeAll/afterAll hooks, so — like startTestPocketbase()
|
||||
// below — it must run at module scope (top-level await), not nested inside another
|
||||
// hook: Vitest only picks up hooks registered during the initial collection phase.
|
||||
const pb = await startTestPocketbase()
|
||||
|
||||
await setup({
|
||||
rootDir: fileURLToPath(new URL('../../frontend', import.meta.url)),
|
||||
server: true,
|
||||
nuxtConfig: {
|
||||
runtimeConfig: {
|
||||
pocketbaseUrl: pb.url,
|
||||
public: { pocketbaseUrl: pb.url },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function expectStatus(promise: Promise<unknown>, statusCode: number) {
|
||||
const error = await promise.catch((caught) => caught)
|
||||
expect(error).toBeDefined()
|
||||
expect(error?.statusCode ?? error?.response?.status).toBe(statusCode)
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await pb.stop()
|
||||
})
|
||||
|
||||
describe('GET /content/articles', () => {
|
||||
it('lists only published articles, newest first', async () => {
|
||||
const response = await $fetch<Paginated<ArticleSummary>>('/content/articles')
|
||||
expect(response.items.map((a) => a.slug)).toEqual(['secondo-articolo', 'articolo-pubblicato'])
|
||||
expect(response.total).toBe(2)
|
||||
})
|
||||
|
||||
it('filters by category slug', async () => {
|
||||
const response = await $fetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { category: 'attualita' },
|
||||
})
|
||||
expect(response.items.map((a) => a.slug)).toEqual(['secondo-articolo'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/articles/:slug', () => {
|
||||
it('returns the full article with rendered html, summary and author', async () => {
|
||||
const article = await $fetch<Article>('/content/articles/articolo-pubblicato')
|
||||
expect(article.title).toBe('Articolo pubblicato')
|
||||
expect(article.html).toContain('<strong>markdown</strong>')
|
||||
expect(article.summary.length).toBeGreaterThan(0)
|
||||
expect(article.author).toBe('Redazione')
|
||||
expect(article.category?.slug).toBe('economia')
|
||||
})
|
||||
|
||||
it('404s for an unknown slug', async () => {
|
||||
await expectStatus($fetch('/content/articles/does-not-exist'), 404)
|
||||
})
|
||||
|
||||
it('404s for a draft (unpublished) slug', async () => {
|
||||
await expectStatus($fetch('/content/articles/articolo-bozza'), 404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/categories', () => {
|
||||
it('lists categories sorted by name', async () => {
|
||||
const categories = await $fetch<Category[]>('/content/categories')
|
||||
expect(categories.map((c) => c.name)).toEqual(['Attualità', 'Economia'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /content/categories/:slug', () => {
|
||||
it('returns one category by slug', async () => {
|
||||
const category = await $fetch<Category>('/content/categories/economia')
|
||||
expect(category.name).toBe('Economia')
|
||||
})
|
||||
|
||||
it('404s for an unknown slug', async () => {
|
||||
await expectStatus($fetch('/content/categories/does-not-exist'), 404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /admin', () => {
|
||||
it('redirects to the PocketBase dashboard', async () => {
|
||||
const response = await fetch('/admin', { redirect: 'manual' })
|
||||
expect(response.status).toBe(302)
|
||||
expect(response.headers.get('location')).toBe(`${pb.url}/_/`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Shared seed data for integration and e2e tests against a real ephemeral PocketBase. */
|
||||
|
||||
export interface FixtureCategory {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface FixtureArticle {
|
||||
title: string
|
||||
slug: string
|
||||
content: string
|
||||
category: string
|
||||
/** `null` = draft (never returned by the public API). */
|
||||
publishedAt: string | null
|
||||
authorName?: string
|
||||
}
|
||||
|
||||
export const FIXTURE_CATEGORIES: FixtureCategory[] = [
|
||||
{ name: 'Economia', slug: 'economia' },
|
||||
{ name: 'Attualità', slug: 'attualita' },
|
||||
]
|
||||
|
||||
export const FIXTURE_ARTICLES: FixtureArticle[] = [
|
||||
{
|
||||
title: 'Articolo pubblicato',
|
||||
slug: 'articolo-pubblicato',
|
||||
content: '# Titolo\n\nCorpo **markdown** di prova con [un link](https://example.com).',
|
||||
category: 'economia',
|
||||
publishedAt: '2025-01-15 10:00:00',
|
||||
authorName: 'Redazione',
|
||||
},
|
||||
{
|
||||
title: 'Articolo in bozza',
|
||||
slug: 'articolo-bozza',
|
||||
content: 'Contenuto non ancora pubblicato.',
|
||||
category: 'economia',
|
||||
publishedAt: null,
|
||||
},
|
||||
{
|
||||
title: 'Secondo articolo pubblicato',
|
||||
slug: 'secondo-articolo',
|
||||
content: 'Un altro articolo pubblicato, in un\'altra categoria.',
|
||||
category: 'attualita',
|
||||
publishedAt: '2025-02-01 08:00:00',
|
||||
authorName: 'Redazione',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FIXTURE_ARTICLES, FIXTURE_CATEGORIES } from './fixtures'
|
||||
|
||||
// Keep in sync with pocketbase/Dockerfile's PB_VERSION build arg.
|
||||
const PB_VERSION = '0.40.3'
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const MIGRATIONS_DIR = join(REPO_ROOT, 'pocketbase', 'pb_migrations')
|
||||
const BIN_DIR = join(REPO_ROOT, '.cache', 'pocketbase')
|
||||
const BIN_PATH = join(BIN_DIR, 'pocketbase')
|
||||
|
||||
const TEST_ADMIN_EMAIL = 'test-admin@example.com'
|
||||
const TEST_ADMIN_PASSWORD = 'test-password-not-for-production'
|
||||
|
||||
async function ensureBinary(): Promise<string> {
|
||||
if (existsSync(BIN_PATH)) return BIN_PATH
|
||||
|
||||
mkdirSync(BIN_DIR, { recursive: true })
|
||||
const platform = process.platform === 'darwin' ? 'darwin' : 'linux'
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
|
||||
const downloadUrl = `https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_${platform}_${arch}.zip`
|
||||
|
||||
const response = await fetch(downloadUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download PocketBase test binary: ${response.status} ${downloadUrl}`)
|
||||
}
|
||||
|
||||
const zipPath = join(BIN_DIR, 'pocketbase.zip')
|
||||
writeFileSync(zipPath, Buffer.from(await response.arrayBuffer()))
|
||||
execFileSync('unzip', ['-o', zipPath, 'pocketbase', '-d', BIN_DIR])
|
||||
rmSync(zipPath)
|
||||
execFileSync('chmod', ['+x', BIN_PATH])
|
||||
|
||||
return BIN_PATH
|
||||
}
|
||||
|
||||
function findFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (address && typeof address === 'object') {
|
||||
server.close(() => resolve(address.port))
|
||||
} else {
|
||||
reject(new Error('Could not determine a free port'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, timeoutMs = 15000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${url}/api/health`)
|
||||
if (response.ok) return
|
||||
} catch {
|
||||
// Not listening yet.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
throw new Error(`PocketBase did not become healthy within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
export interface TestArticle {
|
||||
id: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
export interface TestCategory {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface TestPocketbase {
|
||||
url: string
|
||||
categories: TestCategory[]
|
||||
articles: TestArticle[]
|
||||
stop(): Promise<void>
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
/** Fixed port, for setups (e2e) that need it known ahead of time. Random by default. */
|
||||
port?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a real, disposable PocketBase instance — using `pocketbase/pb_migrations/`
|
||||
* directly, so tests run against the exact schema/rules that ship to production —
|
||||
* then seeds it with FIXTURE_CATEGORIES/FIXTURE_ARTICLES.
|
||||
*/
|
||||
export async function startTestPocketbase(options: StartOptions = {}): Promise<TestPocketbase> {
|
||||
const bin = await ensureBinary()
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'pb-test-'))
|
||||
const port = options.port ?? (await findFreePort())
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
const proc = spawn(
|
||||
bin,
|
||||
['serve', `--http=127.0.0.1:${port}`, `--dir=${dataDir}`, `--migrationsDir=${MIGRATIONS_DIR}`],
|
||||
{ stdio: 'ignore' }
|
||||
)
|
||||
|
||||
const crashed = new Promise<never>((_, reject) => {
|
||||
proc.on('exit', (code) => reject(new Error(`PocketBase exited early with code ${code}`)))
|
||||
})
|
||||
|
||||
await Promise.race([waitForHealth(url), crashed])
|
||||
|
||||
execFileSync(bin, ['superuser', 'upsert', TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, `--dir=${dataDir}`])
|
||||
|
||||
const authResponse = await fetch(`${url}/api/collections/_superusers/auth-with-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: TEST_ADMIN_EMAIL, password: TEST_ADMIN_PASSWORD }),
|
||||
})
|
||||
const { token } = (await authResponse.json()) as { token: string }
|
||||
|
||||
const categories: TestCategory[] = []
|
||||
for (const category of FIXTURE_CATEGORIES) {
|
||||
const response = await fetch(`${url}/api/collections/categories/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(category),
|
||||
})
|
||||
categories.push((await response.json()) as TestCategory)
|
||||
}
|
||||
|
||||
const articles: TestArticle[] = []
|
||||
for (const article of FIXTURE_ARTICLES) {
|
||||
const category = categories.find((c) => c.slug === article.category)
|
||||
const response = await fetch(`${url}/api/collections/articles/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
content: article.content,
|
||||
category: category?.id,
|
||||
publishedAt: article.publishedAt ?? '',
|
||||
authorName: article.authorName ?? '',
|
||||
}),
|
||||
})
|
||||
articles.push((await response.json()) as TestArticle)
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
categories,
|
||||
articles,
|
||||
async stop() {
|
||||
proc.kill()
|
||||
rmSync(dataDir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import ArticleCard from '~/components/ArticleCard.vue'
|
||||
import type { ArticleSummary } from '#shared/types/blog'
|
||||
|
||||
const baseArticle: ArticleSummary = {
|
||||
title: 'Titolo di prova',
|
||||
slug: 'titolo-di-prova',
|
||||
publishedAt: '2025-01-15T10:00:00.000Z',
|
||||
cover: null,
|
||||
category: { name: 'Economia', slug: 'economia' },
|
||||
}
|
||||
|
||||
describe('ArticleCard', () => {
|
||||
it('renders title, category and date, with no cover link when cover is null', async () => {
|
||||
const wrapper = await mountSuspended(ArticleCard, { props: { article: baseArticle } })
|
||||
expect(wrapper.text()).toContain('Titolo di prova')
|
||||
expect(wrapper.text()).toContain('Economia')
|
||||
expect(wrapper.find('.cover-link').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the cover image with alt text and no width/height attributes', async () => {
|
||||
const article: ArticleSummary = {
|
||||
...baseArticle,
|
||||
cover: { url: '/api/files/articles/abc/cover.jpg', alt: 'Testo alternativo' },
|
||||
}
|
||||
const wrapper = await mountSuspended(ArticleCard, { props: { article } })
|
||||
const img = wrapper.find('img.cover')
|
||||
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('alt')).toBe('Testo alternativo')
|
||||
expect(img.attributes('src')).toContain('/api/files/articles/abc/cover.jpg')
|
||||
expect(img.attributes('width')).toBeUndefined()
|
||||
expect(img.attributes('height')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to an empty alt when the cover has none', async () => {
|
||||
const article: ArticleSummary = {
|
||||
...baseArticle,
|
||||
cover: { url: '/api/files/articles/abc/cover.jpg', alt: null },
|
||||
}
|
||||
const wrapper = await mountSuspended(ArticleCard, { props: { article } })
|
||||
expect(wrapper.find('img.cover').attributes('alt')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatDate, formatDateTime, isoDate } from '../../frontend/shared/utils/format'
|
||||
|
||||
const SAMPLE = '2025-01-15T10:30:00.000Z'
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats a date in long it-IT style', () => {
|
||||
expect(formatDate(SAMPLE)).toBe('15 gennaio 2025')
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(formatDate(null)).toBe('')
|
||||
expect(formatDate(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('includes both date and time', () => {
|
||||
const result = formatDateTime(SAMPLE)
|
||||
expect(result).toContain('15 gennaio 2025')
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(formatDateTime(null)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isoDate', () => {
|
||||
it('returns an ISO 8601 string', () => {
|
||||
expect(isoDate(SAMPLE)).toBe(new Date(SAMPLE).toISOString())
|
||||
})
|
||||
|
||||
it('returns an empty string when unset', () => {
|
||||
expect(isoDate(null)).toBe('')
|
||||
expect(isoDate(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMarkdown, summarise, toMediaImage } from '../../frontend/server/utils/pocketbase'
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('returns an empty string for null/undefined/empty input', () => {
|
||||
expect(renderMarkdown(null)).toBe('')
|
||||
expect(renderMarkdown(undefined)).toBe('')
|
||||
expect(renderMarkdown('')).toBe('')
|
||||
})
|
||||
|
||||
it('converts Markdown to HTML', () => {
|
||||
const html = renderMarkdown('# Titolo\n\nCorpo **grassetto**.')
|
||||
expect(html).toContain('<h1>Titolo</h1>')
|
||||
expect(html).toContain('<strong>grassetto</strong>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarise', () => {
|
||||
it('returns short text unchanged, stripped of Markdown syntax', () => {
|
||||
expect(summarise('# Titolo\n\nTesto breve.')).toBe('Titolo Testo breve.')
|
||||
})
|
||||
|
||||
it('strips code fences, images and unwraps links', () => {
|
||||
const source = '```js\nconst x = 1\n```\n [testo](https://example.com) fine.'
|
||||
expect(summarise(source)).toBe('testo fine.')
|
||||
})
|
||||
|
||||
it('clips long text on a word boundary with an ellipsis', () => {
|
||||
const source = 'parola '.repeat(40).trim()
|
||||
const result = summarise(source, 20)
|
||||
expect(result.length).toBeLessThanOrEqual(21)
|
||||
expect(result.endsWith('…')).toBe(true)
|
||||
expect(result).not.toContain(' …')
|
||||
})
|
||||
|
||||
it('returns an empty string for null/undefined input', () => {
|
||||
expect(summarise(null)).toBe('')
|
||||
expect(summarise(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toMediaImage', () => {
|
||||
it('returns null when there is no filename', () => {
|
||||
expect(toMediaImage('articles', 'abc123', '', 'alt')).toBeNull()
|
||||
expect(toMediaImage('articles', 'abc123', null, 'alt')).toBeNull()
|
||||
expect(toMediaImage('articles', 'abc123', undefined, 'alt')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds the PocketBase file URL from collection, id and filename', () => {
|
||||
expect(toMediaImage('articles', 'abc123', 'cover.jpg', 'Testo alternativo')).toEqual({
|
||||
url: '/api/files/articles/abc123/cover.jpg',
|
||||
alt: 'Testo alternativo',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalises a missing/empty alt to null', () => {
|
||||
expect(toMediaImage('articles', 'abc123', 'cover.jpg', null)).toEqual({
|
||||
url: '/api/files/articles/abc123/cover.jpg',
|
||||
alt: null,
|
||||
})
|
||||
expect(toMediaImage('articles', 'abc123', 'cover.jpg', '')).toEqual({
|
||||
url: '/api/files/articles/abc123/cover.jpg',
|
||||
alt: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PAGE_SIZE, pageParam, pagination, quote } from '../../frontend/server/utils/queries'
|
||||
|
||||
describe('pagination', () => {
|
||||
it('defaults to PAGE_SIZE', () => {
|
||||
expect(pagination(2)).toBe(`page=2&perPage=${PAGE_SIZE}`)
|
||||
})
|
||||
|
||||
it('accepts a custom page size', () => {
|
||||
expect(pagination(1, 5)).toBe('page=1&perPage=5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageParam', () => {
|
||||
it('accepts a positive integer', () => {
|
||||
expect(pageParam('3')).toBe(3)
|
||||
expect(pageParam(3)).toBe(3)
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'abc', '0', '-1', '1.5', NaN])(
|
||||
'falls back to 1 for invalid input %p',
|
||||
(value) => {
|
||||
expect(pageParam(value)).toBe(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('quote', () => {
|
||||
it('wraps a plain value in double quotes', () => {
|
||||
expect(quote('economia')).toBe('"economia"')
|
||||
})
|
||||
|
||||
it('escapes embedded double quotes', () => {
|
||||
expect(quote('la "crisi"')).toBe('"la \\"crisi\\""')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('useMediaUrl', () => {
|
||||
it('returns null for a null or missing image', () => {
|
||||
const mediaUrl = useMediaUrl()
|
||||
expect(mediaUrl(null)).toBeNull()
|
||||
expect(mediaUrl(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('prefixes a relative url with the configured PocketBase origin', () => {
|
||||
const mediaUrl = useMediaUrl()
|
||||
const result = mediaUrl({ url: '/api/files/articles/abc/cover.jpg', alt: null })
|
||||
expect(result).toBe('http://localhost:8090/api/files/articles/abc/cover.jpg')
|
||||
})
|
||||
|
||||
it('leaves an already-absolute url unchanged', () => {
|
||||
const mediaUrl = useMediaUrl()
|
||||
const result = mediaUrl({ url: 'https://cdn.example.com/cover.jpg', alt: null })
|
||||
expect(result).toBe('https://cdn.example.com/cover.jpg')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user