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
+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')
})
})