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:
2026-09-11 11:48:43 +02:00
parent 6609e3ae28
commit 91181f2170
23 changed files with 1767 additions and 75 deletions
+10 -8
View File
@@ -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/
+23 -4
View File
@@ -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
+26
View File
@@ -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.
+1042 -62
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -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"
}
}
+35
View File
@@ -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,
},
},
})
+13
View File
@@ -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,
},
})
+8
View File
@@ -0,0 +1,8 @@
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
include: ['../tests/unit/**/*.test.ts'],
},
})
+11
View File
@@ -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')
})
+24
View File
@@ -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()
})
+9
View File
@@ -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)
})
+14
View File
@@ -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)
})
+18
View File
@@ -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()
}
}
+11
View File
@@ -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'
)
})
+91
View File
@@ -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}/_/`)
})
})
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+47
View File
@@ -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',
},
]
+165
View File
@@ -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 })
},
}
}
+45
View File
@@ -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('')
})
})
+37
View File
@@ -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('')
})
})
+66
View File
@@ -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![alt](img.png) [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,
})
})
})
+36
View File
@@ -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\\""')
})
})
+21
View File
@@ -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')
})
})