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/
|
node_modules/
|
||||||
.pnp/
|
.pnp/
|
||||||
.pnp.js
|
.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 / secrets
|
||||||
.env
|
.env
|
||||||
@@ -21,14 +25,12 @@ frontend/dist/
|
|||||||
.nitro/
|
.nitro/
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
# strapi
|
# playwright
|
||||||
cms/.strapi/
|
frontend/test-results/
|
||||||
cms/dist/
|
frontend/playwright-report/
|
||||||
cms/build/
|
|
||||||
cms/.tmp/
|
# nuxt module-setup marker (regenerated, not meaningful project config)
|
||||||
cms/public/uploads/*
|
frontend/.nuxtrc
|
||||||
!cms/public/uploads/.gitkeep
|
|
||||||
cms/.strapi-updater.json
|
|
||||||
|
|
||||||
# caddy runtime
|
# caddy runtime
|
||||||
caddy/data/
|
caddy/data/
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Stato
|
## Stato
|
||||||
|
|
||||||
Struttura, content type, pagine blog, Docker e Caddy sono in piedi. Restano da fare:
|
Struttura, content type, pagine blog, Docker, Caddy e i test (unit/integration/e2e) sono in
|
||||||
sitemap e `robots.txt` dinamici, ricerca, e i test (nessun framework ancora configurato).
|
piedi. Restano da fare: sitemap e `robots.txt` dinamici, ricerca.
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
@@ -67,6 +67,16 @@ npm run build # build produzione
|
|||||||
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
npm run typecheck # nuxi typecheck — obbligatorio prima di dichiarare fatto
|
||||||
npm run lint
|
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)
|
# 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
|
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
|
`docker-compose.dev.yml` va passato **sempre esplicitamente**: non è un `override.yml` proprio
|
||||||
per non finire per sbaglio in produzione esponendo le porte.
|
per non finire per sbaglio in produzione esponendo le porte.
|
||||||
|
|
||||||
Nessun test framework è ancora configurato. Se ne aggiungi uno, documenta qui il comando per
|
`tests/` sta nella root del repo (non sotto `frontend/`), perché l'e2e esercita frontend e
|
||||||
lanciare **un singolo test**.
|
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
|
## 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,
|
HTML (no client-only content). Accessibility requirements (focus visibility, labeled inputs,
|
||||||
meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
|
meaningful alt text, descriptive links, full keyboard navigation) apply across all pages/components
|
||||||
— see `CLAUDE.md`.
|
— 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",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"typecheck": "nuxt typecheck",
|
"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": {
|
"dependencies": {
|
||||||
"marked": "^18.0.11",
|
"marked": "^18.0.11",
|
||||||
@@ -20,8 +25,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint": "^1.17.0",
|
"@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",
|
"eslint": "^10.10.0",
|
||||||
|
"happy-dom": "^20.14.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
"vue-tsc": "^3.3.11"
|
"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