feat: enhance admin API helpers with CRUD operations for collections and seed data management

- Added functions for creating, updating, deleting, and listing collection entries in admin API.
- Introduced seed data management for consistent test content across tests.
- Updated global setup and teardown processes to ensure seeded content is created and cleaned up.
- Refactored existing tests to utilize seeded content for improved reliability and maintainability.
This commit is contained in:
2026-05-12 20:36:06 +00:00
parent 491f495c66
commit 1b24bb2157
17 changed files with 697 additions and 444 deletions
+7 -41
View File
@@ -1,57 +1,23 @@
import { test as base, expect, APIRequestContext } from "@playwright/test"
import { ensureTestUser, type TestUserCredentials } from "./helpers/test-user"
import { test as base, expect, type APIRequestContext } from "@playwright/test"
import { getAdminContext } from "./helpers/admin-api"
import { TEST_BASE_URL } from "../fixtures/test-constants"
const API_BASE = "/api"
interface ActionSuccess {
success: true
[key: string]: unknown
}
interface ActionError {
error: string
}
type ApiWorkerFixtures = {
testUser: TestUserCredentials
}
type ApiFixtures = {
api: APIRequestContext
authedApi: APIRequestContext
accessToken: string
adminApi: APIRequestContext
}
export const test = base.extend<ApiFixtures, ApiWorkerFixtures>({
testUser: [
async ({ playwright }, use) => {
const baseURL = process.env.CODING_URL || "https://localhost:3000"
const user = await ensureTestUser(baseURL)
await use(user)
},
{ scope: "worker" },
],
export const test = base.extend<ApiFixtures>({
api: async ({ request }, use) => {
await use(request)
},
accessToken: async ({ testUser }, use) => {
await use(testUser.accessToken)
},
authedApi: async ({ playwright, baseURL, accessToken }, use) => {
const ctx = await playwright.request.newContext({
baseURL: baseURL!,
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
Authorization: `Bearer ${accessToken}`,
},
})
adminApi: async ({ baseURL }, use) => {
const ctx = await getAdminContext(baseURL || TEST_BASE_URL)
await use(ctx)
await ctx.dispose()
},
})
export { expect, API_BASE }
export type { ActionSuccess, ActionError }
+78 -18
View File
@@ -1,26 +1,86 @@
import { test, expect, API_BASE } from "./fixtures"
import { SEEDED_TEST_CONTENT } from "../fixtures/test-constants"
test.describe("API Health", () => {
test("should respond to API base endpoint", async ({ api }) => {
// tibi-server responds to the base API path
const res = await api.get(`${API_BASE}/`)
// Accept any successful response (200-299), 401 (auth required), or 404 (no root handler)
expect([200, 204, 401, 404]).toContain(res.status())
type ContentApiEntry = {
id?: string
translationKey?: string
lang?: string
path?: string
active?: boolean
blocks?: Array<{ type?: string }>
}
async function getContentEntries(api: { get: Function }, filter: Record<string, unknown>): Promise<ContentApiEntry[]> {
const res = await api.get(`${API_BASE}/content`, {
params: {
filter: JSON.stringify(filter),
},
})
test("should respond to SSR collection", async ({ api }) => {
// The ssr collection is part of the starter kit
const res = await api.get(`${API_BASE}/ssr`)
expect(res.status()).toBeLessThan(500)
})
expect(res.ok()).toBeTruthy()
test("should reject invalid action commands", async ({ api }) => {
const res = await api.post(`${API_BASE}/action`, {
params: { cmd: "nonexistent_action" },
data: {},
const body = (await res.json()) as unknown
return Array.isArray(body) ? (body as ContentApiEntry[]) : []
}
test.describe("Seeded Content API", () => {
test("returns the seeded localized page variants through the public content endpoint", async ({ api }) => {
const deEntries = await getContentEntries(api, {
lang: "de",
path: SEEDED_TEST_CONTENT.home.path,
active: true,
})
// Should return an error, not crash
expect(res.status()).toBeGreaterThanOrEqual(400)
expect(res.status()).toBeLessThan(500)
const enEntries = await getContentEntries(api, {
lang: "en",
path: SEEDED_TEST_CONTENT.home.path,
active: true,
})
expect(deEntries).toHaveLength(1)
expect(enEntries).toHaveLength(1)
expect(deEntries[0].translationKey).toBe(SEEDED_TEST_CONTENT.home.translationKey)
expect(enEntries[0].translationKey).toBe(SEEDED_TEST_CONTENT.home.translationKey)
expect(deEntries[0].blocks?.map((block) => block.type)).toEqual(["hero", "features", "richtext", "accordion"])
expect(enEntries[0].blocks?.map((block) => block.type)).toEqual(["hero", "features", "richtext", "accordion"])
})
test("returns the seeded contact page and an empty result for missing active routes", async ({ api }) => {
const contactEntries = await getContentEntries(api, {
lang: "de",
path: SEEDED_TEST_CONTENT.contact.path,
active: true,
})
const inactiveEntries = await getContentEntries(api, {
lang: "de",
path: SEEDED_TEST_CONTENT.inactive.path,
active: true,
})
const missingEntries = await getContentEntries(api, {
lang: "de",
path: "/playwright-e2e-missing",
active: true,
})
expect(contactEntries).toHaveLength(1)
expect(contactEntries[0].blocks?.map((block) => block.type)).toEqual(["hero", "contact-form"])
expect(inactiveEntries).toHaveLength(0)
expect(missingEntries).toHaveLength(0)
})
test("allows the admin API to inspect the inactive seeded entry", async ({ adminApi }) => {
const res = await adminApi.get(`${API_BASE}/content`, {
params: {
filter: JSON.stringify({
translationKey: SEEDED_TEST_CONTENT.inactive.translationKey,
lang: "de",
}),
},
})
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as ContentApiEntry[]
expect(body).toHaveLength(1)
expect(body[0].active).toBe(false)
expect(body[0].path).toBe(SEEDED_TEST_CONTENT.inactive.path)
})
})
+127 -38
View File
@@ -3,10 +3,63 @@ import { ADMIN_TOKEN, API_BASE } from "../../fixtures/test-constants"
let adminContext: APIRequestContext | null = null
type CollectionEntry = {
id?: string
_id?: string | { $oid?: string }
[key: string]: unknown
}
type CollectionQueryOptions = {
filter?: Record<string, unknown>
sort?: string
limit?: number
offset?: number
projection?: string
lookup?: string
}
function getEntryId(entry: CollectionEntry): string | undefined {
if (typeof entry.id === "string") return entry.id
if (typeof entry._id === "string") return entry._id
if (entry._id && typeof entry._id === "object" && typeof entry._id.$oid === "string") return entry._id.$oid
return undefined
}
function buildCollectionUrl(collection: string, options: CollectionQueryOptions = {}, id?: string): string {
const searchParams = new URLSearchParams()
if (options.filter) searchParams.set("filter", JSON.stringify(options.filter))
if (options.sort) searchParams.set("sort", options.sort)
if (typeof options.limit === "number") searchParams.set("limit", String(options.limit))
if (typeof options.offset === "number") searchParams.set("offset", String(options.offset))
if (options.projection) searchParams.set("projection", options.projection)
if (options.lookup) searchParams.set("lookup", options.lookup)
const path = `${API_BASE}/${collection}${id ? `/${id}` : ""}`
const query = searchParams.toString()
return query ? `${path}?${query}` : path
}
async function parseJsonResponse<T>(
res: Awaited<ReturnType<APIRequestContext["get"]>>,
contextLabel: string
): Promise<T> {
const contentType = res.headers()["content-type"] || ""
if (contentType.includes("text/html")) {
throw new Error(`${contextLabel} returned HTML instead of JSON. Check CODING_URL or API proxy setup.`)
}
return (await res.json()) as T
}
/**
* Get or create a singleton admin API context with the ADMIN_TOKEN.
*/
async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
export async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
if (ADMIN_TOKEN === "CHANGE_ME") {
throw new Error("ADMIN_TOKEN is not configured for Playwright seed and cleanup")
}
if (!adminContext) {
adminContext = await request.newContext({
baseURL,
@@ -19,50 +72,86 @@ async function getAdminContext(baseURL: string): Promise<APIRequestContext> {
return adminContext
}
/**
* Delete a user by ID via admin API.
*/
export async function deleteUser(baseURL: string, userId: string): Promise<boolean> {
export async function listCollectionEntries<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
options: CollectionQueryOptions = {}
): Promise<T[]> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/user/${userId}`)
const res = await ctx.get(buildCollectionUrl(collection, options))
if (!res.ok()) {
throw new Error(`Failed to list ${collection}: ${res.status()} ${res.statusText()}`)
}
const body = await parseJsonResponse<unknown>(res, `Listing ${collection}`)
if (Array.isArray(body)) return body as T[]
if (body && typeof body === "object" && "data" in body && Array.isArray(body.data)) return body.data as T[]
return []
}
export async function createCollectionEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
entry: Record<string, unknown>
): Promise<T> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.post(`${API_BASE}/${collection}`, { data: entry })
if (!res.ok()) {
throw new Error(`Failed to create ${collection}: ${res.status()} ${res.statusText()}`)
}
return await parseJsonResponse<T>(res, `Creating ${collection}`)
}
export async function updateCollectionEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
id: string,
entry: Record<string, unknown>
): Promise<T> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.put(`${API_BASE}/${collection}/${id}`, { data: entry })
if (!res.ok()) {
throw new Error(`Failed to update ${collection}/${id}: ${res.status()} ${res.statusText()}`)
}
return await parseJsonResponse<T>(res, `Updating ${collection}/${id}`)
}
export async function deleteCollectionEntry(baseURL: string, collection: string, id: string): Promise<boolean> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.delete(`${API_BASE}/${collection}/${id}`)
return res.ok()
}
/**
* Cleanup test users matching a pattern in their email.
*/
export async function cleanupTestUsers(baseURL: string, emailPattern: RegExp | string): Promise<number> {
const ctx = await getAdminContext(baseURL)
const res = await ctx.get(`${API_BASE}/user`)
if (!res.ok()) return 0
const users: { id?: string; _id?: string; email?: string }[] = await res.json()
if (!Array.isArray(users)) return 0
let deleted = 0
for (const user of users) {
const email = user.email || ""
const matches = typeof emailPattern === "string" ? email.includes(emailPattern) : emailPattern.test(email)
if (matches) {
const userId = user.id || user._id
if (userId) {
const ok = await deleteUser(baseURL, userId)
if (ok) deleted++
}
}
}
return deleted
export async function findFirstEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
options: CollectionQueryOptions = {}
): Promise<T | null> {
const entries = await listCollectionEntries<T>(baseURL, collection, { ...options, limit: 1 })
return entries[0] || null
}
export async function upsertCollectionEntry<T extends CollectionEntry = CollectionEntry>(
baseURL: string,
collection: string,
filter: Record<string, unknown>,
entry: Record<string, unknown>
): Promise<T> {
const existing = await findFirstEntry<T>(baseURL, collection, { filter })
const existingId = existing ? getEntryId(existing) : undefined
if (existingId) {
return updateCollectionEntry<T>(baseURL, collection, existingId, entry)
}
return createCollectionEntry<T>(baseURL, collection, entry)
}
/**
* Cleanup all test data (users and tokens matching @test.example.com).
*/
export async function cleanupAllTestData(baseURL: string): Promise<{ users: number }> {
const testPattern = "@test.example.com"
const users = await cleanupTestUsers(baseURL, testPattern)
return { users }
void baseURL
return { users: 0 }
}
/**
+297
View File
@@ -0,0 +1,297 @@
import { SEEDED_TEST_CONTENT } from "../../fixtures/test-constants"
import { createCollectionEntry, deleteCollectionEntry, listCollectionEntries } from "./admin-api"
type ContentEntry = {
id?: string
_id?: string | { $oid?: string }
translationKey?: string
[key: string]: unknown
}
function getEntryId(entry: ContentEntry): string | undefined {
if (typeof entry.id === "string") return entry.id
if (typeof entry._id === "string") return entry._id
if (entry._id && typeof entry._id === "object" && typeof entry._id.$oid === "string") return entry._id.$oid
return undefined
}
const SEEDED_TRANSLATION_KEYS = new Set<string>(Object.values(SEEDED_TEST_CONTENT).map((entry) => entry.translationKey))
const SEEDED_CONTENT_ENTRIES = [
{
active: true,
type: "page",
lang: "de",
translationKey: SEEDED_TEST_CONTENT.home.translationKey,
name: "Playwright Startseite",
path: SEEDED_TEST_CONTENT.home.path,
teaserText: "Deterministisch erzeugte Testseite fuer API- und E2E-Tests.",
meta: {
title: "Playwright Startseite",
description: "Seeded Startseite fuer stabile Playwright-Tests.",
keywords: ["playwright", "seed", "e2e"],
},
blocks: [
{
type: "hero",
headline: "Playwright Seed Startseite",
headlineH1: true,
tagline: "Deterministische Testdaten",
subline: "Diese Seite wird vor dem Testlauf frisch ueber die Admin-API angelegt.",
containerWidth: "wide",
callToAction: {
buttonText: "Zum Kontakt",
buttonLink: `/de${SEEDED_TEST_CONTENT.contact.path}`,
},
},
{
type: "features",
anchorId: "seed-features",
headline: "Stabile Grundlage fuer Frontend-Tests",
tagline: "Seed",
padding: { top: "md", bottom: "md" },
featureBoxes: [
{
icon: "lightning",
title: "Frisch angelegt",
text: "Die Inhalte werden in globalSetup erstellt statt aus Demo-Daten uebernommen.",
},
{
icon: "database",
title: "API-nah",
text: "Die Seed-Daten kommen ueber dieselben Collection-Endpunkte wie das CMS.",
},
{
icon: "globe",
title: "Mehrsprachig",
text: "DE und EN teilen sich denselben translationKey fuer Routing-Checks.",
},
],
},
{
type: "richtext",
anchorId: "seed-richtext",
headline: "Mehr Kontext",
tagline: "API + UI",
padding: { top: "md", bottom: "md" },
imagePosition: "none",
text: "<p>Dieser Richtext-Block prueft, dass formatierter HTML-Inhalt im SPA gerendert wird.</p>",
},
{
type: "accordion",
anchorId: "seed-faq",
headline: "Hauefige Fragen",
tagline: "Verhalten",
padding: { top: "md", bottom: "md" },
accordionItems: [
{
question: "Warum Seed-Daten?",
answer: "<p>Damit Tests nicht blind auf bestehende Inhalte oder Demo-Routen vertrauen.</p>",
open: true,
},
{
question: "Was wird geprueft?",
answer: "<p>API-Antworten, Routing, Sprachwechsel und Block-Rendering.</p>",
open: false,
},
],
},
],
},
{
active: true,
type: "page",
lang: "en",
translationKey: SEEDED_TEST_CONTENT.home.translationKey,
name: "Playwright Home",
path: SEEDED_TEST_CONTENT.home.path,
teaserText: "Deterministically seeded page for API and E2E coverage.",
meta: {
title: "Playwright Home",
description: "Seeded home page for stable Playwright tests.",
keywords: ["playwright", "seed", "home"],
},
blocks: [
{
type: "hero",
headline: "Playwright Seed Home",
headlineH1: true,
tagline: "Deterministic fixtures",
subline: "This page is recreated before every test run through the admin API.",
containerWidth: "wide",
callToAction: {
buttonText: "Go to contact",
buttonLink: `/en${SEEDED_TEST_CONTENT.contact.path}`,
},
},
{
type: "features",
anchorId: "seed-features",
headline: "Stable frontend coverage",
tagline: "Seed",
padding: { top: "md", bottom: "md" },
featureBoxes: [
{
icon: "lightning",
title: "Freshly created",
text: "The content is created during globalSetup instead of relying on demo data.",
},
{
icon: "database",
title: "API-backed",
text: "The seed uses the same collection endpoints as the CMS itself.",
},
{
icon: "globe",
title: "Localized",
text: "DE and EN share the same translationKey for route switching checks.",
},
],
},
{
type: "richtext",
anchorId: "seed-richtext",
headline: "More context",
tagline: "API + UI",
padding: { top: "md", bottom: "md" },
imagePosition: "none",
text: "<p>This richtext block proves that formatted HTML content renders in the SPA.</p>",
},
{
type: "accordion",
anchorId: "seed-faq",
headline: "Common questions",
tagline: "Behavior",
padding: { top: "md", bottom: "md" },
accordionItems: [
{
question: "Why seeded data?",
answer: "<p>So the tests do not depend on existing demo pages or editorial content.</p>",
open: true,
},
{
question: "What is covered?",
answer: "<p>API responses, routing, locale switching and block rendering.</p>",
open: false,
},
],
},
],
},
{
active: true,
type: "page",
lang: "de",
translationKey: SEEDED_TEST_CONTENT.contact.translationKey,
name: "Playwright Kontakt",
path: SEEDED_TEST_CONTENT.contact.path,
teaserText: "Seeded Kontaktseite fuer Formular- und Routing-Tests.",
meta: {
title: "Playwright Kontakt",
description: "Seeded Kontaktseite fuer Playwright.",
keywords: ["playwright", "kontakt"],
},
blocks: [
{
type: "hero",
headline: "Kontakt fuer Testlauf",
headlineH1: true,
tagline: "Seed",
subline: "Diese Seite prueft das aktuelle ContactForm-Rendering.",
},
{
type: "contact-form",
anchorId: "kontaktformular",
headline: "Schreibe uns",
padding: { top: "md", bottom: "md" },
},
],
},
{
active: true,
type: "page",
lang: "en",
translationKey: SEEDED_TEST_CONTENT.contact.translationKey,
name: "Playwright Contact",
path: SEEDED_TEST_CONTENT.contact.path,
teaserText: "Seeded contact page for form and routing tests.",
meta: {
title: "Playwright Contact",
description: "Seeded contact page for Playwright.",
keywords: ["playwright", "contact"],
},
blocks: [
{
type: "hero",
headline: "Contact for the test run",
headlineH1: true,
tagline: "Seed",
subline: "This page verifies the current contact form rendering.",
},
{
type: "contact-form",
anchorId: "contact-form",
headline: "Write to us",
padding: { top: "md", bottom: "md" },
},
],
},
{
active: false,
type: "page",
lang: "de",
translationKey: SEEDED_TEST_CONTENT.inactive.translationKey,
name: "Playwright Inaktiv",
path: SEEDED_TEST_CONTENT.inactive.path,
teaserText: "Nicht aktive Seed-Seite fuer 404-Checks.",
meta: {
title: "Playwright Inaktiv",
description: "Nicht aktive Seed-Seite fuer Routing-Tests.",
keywords: ["playwright", "inactive"],
},
blocks: [
{
type: "richtext",
anchorId: "inactive",
headline: "Sollte nicht sichtbar sein",
tagline: "Seed",
padding: { top: "md", bottom: "md" },
imagePosition: "none",
text: "<p>Diese Seite ist absichtlich inaktiv und darf im Frontend nicht erscheinen.</p>",
},
],
},
] as const
export async function cleanupSeededTestContent(baseURL: string): Promise<number> {
const contentEntries = await listCollectionEntries<ContentEntry>(baseURL, "content")
const seededEntries = contentEntries.filter(
(entry) => typeof entry.translationKey === "string" && SEEDED_TRANSLATION_KEYS.has(entry.translationKey)
)
let deleted = 0
for (const entry of seededEntries) {
const entryId = getEntryId(entry)
if (!entryId) continue
const ok = await deleteCollectionEntry(baseURL, "content", entryId)
if (ok) deleted++
}
return deleted
}
export async function seedTestContent(baseURL: string): Promise<number> {
let created = 0
for (const entry of SEEDED_CONTENT_ENTRIES) {
await createCollectionEntry(baseURL, "content", entry as unknown as Record<string, unknown>)
created++
}
return created
}
export async function ensureSeededTestContent(baseURL: string): Promise<{ deleted: number; created: number }> {
const deleted = await cleanupSeededTestContent(baseURL)
const created = await seedTestContent(baseURL)
return { deleted, created }
}