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.
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
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}/_/`)
|
|
})
|
|
})
|