feat(T06): Mietkatalog frontend – equipment list, detail, search/filter, SSR

- composables/useApi.ts: base API client with runtimeConfig
- composables/useEquipment.ts: equipment API wrapper (list, detail, categories)
- components/EquipmentCard.vue: card with image/placeholder, badge, name, price, add-to-cart
- pages/mietkatalog.vue: SSR list with search, category chips, sort, pagination, skeleton, empty/error states
- pages/mietkatalog/[id].vue: SSR detail with specs table, related items, breadcrumb, add-to-cart
- nuxt.config.ts: runtimeConfig with apiBase
- 101 vitest tests passing (5 files)
- Build: 0 errors, 2.09 MB
This commit is contained in:
Implementation Engineer
2026-07-10 00:54:40 +02:00
parent 88cff68f73
commit e9da21b57e
12 changed files with 1230 additions and 66 deletions
@@ -0,0 +1,54 @@
import { test, expect } from "@playwright/test";
test.describe("Equipment Detail Page", () => {
test("should show error for non-existent ID", async ({ page }) => {
await page.goto("/mietkatalog/999999");
await expect(page.getByText("nicht gefunden")).toBeVisible();
});
test("should have link back to mietkatalog on error", async ({ page }) => {
await page.goto("/mietkatalog/999999");
const link = page.getByText("Zurück zum Mietkatalog");
await expect(link).toBeVisible();
});
test("should display breadcrumb navigation", async ({ page }) => {
await page.goto("/mietkatalog/1");
await expect(page.getByText("Home")).toBeVisible();
await expect(page.getByText("Mietkatalog")).toBeVisible();
});
test("should show equipment name as heading", async ({ page }) => {
await page.goto("/mietkatalog/1");
const name = page.getByTestId("equipment-name");
const errorState = page.getByText("nicht gefunden");
const hasContent = await name.isVisible().catch(() => false) || await errorState.isVisible().catch(() => false);
expect(hasContent).toBeTruthy();
});
test("should show specs section if equipment found", async ({ page }) => {
await page.goto("/mietkatalog/1");
const specs = page.getByTestId("equipment-specs");
const errorState = page.getByText("nicht gefunden");
const hasContent = await specs.isVisible().catch(() => false) || await errorState.isVisible().catch(() => false);
expect(hasContent).toBeTruthy();
});
test("should have Mietanfrage button", async ({ page }) => {
await page.goto("/mietkatalog/1");
const btn = page.getByTestId("add-to-cart-detail");
const errorState = page.getByText("nicht gefunden");
const hasContent = await btn.isVisible().catch(() => false) || await errorState.isVisible().catch(() => false);
expect(hasContent).toBeTruthy();
});
test("should navigate back to mietkatalog via breadcrumb", async ({ page }) => {
await page.goto("/mietkatalog/1");
const breadcrumbLink = page.locator('a[href="/mietkatalog"]').first();
if (await breadcrumbLink.isVisible()) {
await breadcrumbLink.click();
await page.waitForURL("/mietkatalog");
await expect(page).toHaveURL(/\/mietkatalog$/);
}
});
});
+76
View File
@@ -0,0 +1,76 @@
import { test, expect } from "@playwright/test";
test.describe("Mietkatalog Page", () => {
test("should render SSR HTML with Mietkatalog heading", async ({ page }) => {
await page.goto("/mietkatalog");
await expect(page.locator("h1")).toContainText("Mietkatalog");
});
test("should have search input visible", async ({ page }) => {
await page.goto("/mietkatalog");
const searchInput = page.getByTestId("equipment-search");
await expect(searchInput).toBeVisible();
});
test("should have sort dropdown with name options", async ({ page }) => {
await page.goto("/mietkatalog");
const sortSelect = page.getByTestId("sort-select");
await expect(sortSelect).toBeVisible();
await expect(sortSelect.locator("option")).toHaveCount(2);
});
test("should have category filter chips", async ({ page }) => {
await page.goto("/mietkatalog");
const chips = page.getByTestId("category-chips");
await expect(chips).toBeVisible();
});
test("should have reset filters button", async ({ page }) => {
await page.goto("/mietkatalog");
const resetBtn = page.getByTestId("reset-filters");
await expect(resetBtn).toBeVisible();
});
test("should filter equipment by search input", async ({ page }) => {
await page.goto("/mietkatalog");
const searchInput = page.getByTestId("equipment-search");
await searchInput.fill("Lautsprecher");
await page.waitForTimeout(500);
const grid = page.getByTestId("equipment-grid");
await expect(grid).toBeVisible();
});
test("should reset all filters when reset button clicked", async ({ page }) => {
await page.goto("/mietkatalog");
const searchInput = page.getByTestId("equipment-search");
await searchInput.fill("Test");
const resetBtn = page.getByTestId("reset-filters");
await resetBtn.click();
await expect(searchInput).toHaveValue("");
});
test("should show result count", async ({ page }) => {
await page.goto("/mietkatalog");
const resultCount = page.getByTestId("result-count");
await expect(resultCount).toBeVisible();
});
test("should display equipment cards or empty state", async ({ page }) => {
await page.goto("/mietkatalog");
const grid = page.getByTestId("equipment-grid");
const emptyState = page.getByText("Keine Artikel gefunden");
const isVisible = await grid.isVisible().catch(() => false) || await emptyState.isVisible().catch(() => false);
expect(isVisible).toBeTruthy();
});
test("should navigate to detail page on card click", async ({ page }) => {
await page.goto("/mietkatalog");
const card = page.getByTestId("equipment-card").first();
if (await card.isVisible()) {
const link = card.locator("a[href*='/mietkatalog/']").first();
await link.click();
await page.waitForURL(/\/mietkatalog\/\d+/);
await expect(page).toHaveURL(/\/mietkatalog\/\d+/);
}
});
});
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("Equipment Detail Page", () => {
const pagePath = resolve(__dirname, "../../pages/mietkatalog/[id].vue");
const content = readFileSync(pagePath, "utf-8");
it("should use useAsyncData for SSR data fetching", () => {
expect(content).toContain("useAsyncData");
});
it("should use useEquipment composable", () => {
expect(content).toContain("useEquipment");
});
it("should fetch detail by route param id", () => {
expect(content).toContain("route.params.id");
expect(content).toContain("equipmentApi.detail");
});
it("should have breadcrumb navigation Home > Mietkatalog > Item", () => {
expect(content).toContain("Breadcrumb");
expect(content).toContain('to="/"');
expect(content).toContain('to="/mietkatalog"');
expect(content).toContain("Home");
expect(content).toContain("Mietkatalog");
});
it("should display equipment name", () => {
expect(content).toContain('data-testid="equipment-name"');
expect(content).toContain("data.name");
});
it("should display equipment description", () => {
expect(content).toContain('data-testid="equipment-description"');
});
it("should display specs table", () => {
expect(content).toContain('data-testid="equipment-specs"');
expect(content).toContain("Spezifikationen");
expect(content).toContain("specifications");
});
it("should display image with placeholder fallback", () => {
expect(content).toContain("data.images");
expect(content).toContain("v-if");
expect(content).toContain("svg");
});
it("should have Mietanfrage add-to-cart button", () => {
expect(content).toContain('data-testid="add-to-cart-detail"');
expect(content).toContain("Mietanfrage");
expect(content).toContain("onAddToCart");
});
it("should show ErrorState for non-existent ID", () => {
expect(content).toContain("ErrorState");
expect(content).toContain("error");
expect(content).toContain("nicht gefunden");
});
it("should have link back to /mietkatalog on error", () => {
expect(content).toContain("/mietkatalog");
});
it("should fetch related items by same category", () => {
expect(content).toContain("related");
expect(content).toContain("category");
expect(content).toContain("EquipmentCard");
});
it("should display rental price", () => {
expect(content).toContain('data-testid="equipment-price"');
expect(content).toContain("rental_price");
});
it("should use useHead for dynamic page title", () => {
expect(content).toContain("useHead");
});
it("should use TypeScript with lang=ts", () => {
expect(content).toContain('script setup lang="ts"');
});
it("should use Tailwind classes, no inline styles", () => {
expect(content).not.toContain('style="');
});
it("should display availability status", () => {
expect(content).toContain("available");
expect(content).toContain("Verfügbar");
});
it("should show brand if available", () => {
expect(content).toContain("data.brand");
expect(content).toContain("Marke");
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("Mietkatalog Page (list)", () => {
const pagePath = resolve(__dirname, "../../pages/mietkatalog.vue");
const content = readFileSync(pagePath, "utf-8");
it("should use useAsyncData for SSR data fetching", () => {
expect(content).toContain("useAsyncData");
});
it("should use useEquipment composable", () => {
expect(content).toContain("useEquipment");
});
it("should have search input with data-testid equipment-search", () => {
expect(content).toContain('data-testid="equipment-search"');
expect(content).toContain('type="text"');
});
it("should have sort dropdown with name_asc and name_desc options", () => {
expect(content).toContain('data-testid="sort-select"');
expect(content).toContain('value="name_asc"');
expect(content).toContain('value="name_desc"');
});
it("should have category filter chips", () => {
expect(content).toContain('data-testid="category-chips"');
});
it("should have reset filters button", () => {
expect(content).toContain('data-testid="reset-filters"');
expect(content).toContain('resetFilters');
});
it("should show result count", () => {
expect(content).toContain('data-testid="result-count"');
});
it("should have pagination with prev/next buttons", () => {
expect(content).toContain('data-testid="pagination"');
expect(content).toContain('data-testid="prev-page"');
expect(content).toContain('data-testid="next-page"');
});
it("should show loading skeleton state", () => {
expect(content).toContain('data-testid="loading-state"');
expect(content).toContain("skeleton-shimmer");
});
it("should use EmptyState component for empty results", () => {
expect(content).toContain("EmptyState");
expect(content).toContain("Keine Artikel gefunden");
});
it("should use ErrorState component for errors", () => {
expect(content).toContain("ErrorState");
expect(content).toContain("retry");
});
it("should render EquipmentCard for each item", () => {
expect(content).toContain("EquipmentCard");
expect(content).toContain('v-for="item in data.items"');
});
it("should have debounce on search input", () => {
expect(content).toContain("setTimeout");
expect(content).toContain("clearTimeout");
});
it("should reset to page 1 on search, sort, or category change", () => {
expect(content).toContain("currentPage.value = 1");
});
it("should use useHead for page title", () => {
expect(content).toContain('useHead');
expect(content).toContain("Mietkatalog");
});
it("should use Tailwind classes, no inline styles", () => {
expect(content).not.toContain('style="');
});
it("should use TypeScript with lang=ts", () => {
expect(content).toContain('<script setup lang="ts">');
});
it("should use equipment grid layout", () => {
expect(content).toContain('data-testid="equipment-grid"');
expect(content).toContain("grid-cols");
});
});
describe("EquipmentCard Component", () => {
const cardPath = resolve(__dirname, "../../components/EquipmentCard.vue");
const content = readFileSync(cardPath, "utf-8");
it("should have image with fallback placeholder", () => {
expect(content).toContain("v-if=\"equipment.image_url\"");
expect(content).toContain("v-else");
expect(content).toContain("svg");
});
it("should show category badge", () => {
expect(content).toContain("equipment.category");
});
it("should display equipment name", () => {
expect(content).toContain("equipment.name");
});
it("should show truncated description", () => {
expect(content).toContain("equipment.description");
expect(content).toContain("line-clamp");
});
it("should display rental_price formatted", () => {
expect(content).toContain("equipment.rental_price");
expect(content).toContain("formatPrice");
});
it("should emit add-to-cart with id and name", () => {
expect(content).toContain("add-to-cart");
expect(content).toContain("equipment.id");
});
it("should have Mietanfrage button", () => {
expect(content).toContain("Mietanfrage");
expect(content).toContain("btn-primary");
});
it("should link to detail page", () => {
expect(content).toContain("NuxtLink");
expect(content).toContain("/mietkatalog/");
});
it("should use TypeScript with EquipmentItem type", () => {
expect(content).toContain('<script setup lang="ts">');
expect(content).toContain("EquipmentItem");
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("useApi composable", () => {
const path = resolve(__dirname, "../../composables/useApi.ts");
const content = readFileSync(path, "utf-8");
it("should export useApi function", () => {
expect(content).toContain("export function useApi");
});
it("should use useRuntimeConfig for apiBase", () => {
expect(content).toContain("useRuntimeConfig");
expect(content).toContain("apiBase");
});
it("should create $fetch client with baseURL", () => {
expect(content).toContain("$fetch.create");
expect(content).toContain("baseURL");
});
it("should export get, post, put, del methods", () => {
expect(content).toContain("async function get");
expect(content).toContain("async function post");
expect(content).toContain("async function put");
expect(content).toContain("async function del");
});
});
describe("useEquipment composable", () => {
const path = resolve(__dirname, "../../composables/useEquipment.ts");
const content = readFileSync(path, "utf-8");
it("should export EquipmentItem interface", () => {
expect(content).toContain("export interface EquipmentItem");
});
it("should export EquipmentDetail interface", () => {
expect(content).toContain("export interface EquipmentDetail");
});
it("should export PaginatedEquipment interface", () => {
expect(content).toContain("export interface PaginatedEquipment");
});
it("should export SortOption type", () => {
expect(content).toContain("export type SortOption");
});
it("should use useApi internally", () => {
expect(content).toContain("useApi");
});
it("should have list method with search, category, sort, page params", () => {
expect(content).toContain("async function list");
expect(content).toContain("search");
expect(content).toContain("category");
expect(content).toContain("sort");
expect(content).toContain("page");
});
it("should call /api/equipment for list", () => {
expect(content).toContain("/api/equipment");
expect(content).toContain("/api/equipment/categories");
expect(content).toContain("/api/equipment/${id}");
});
it("should have detail method", () => {
expect(content).toContain("async function detail");
});
it("should have categories method", () => {
expect(content).toContain("async function categories");
expect(content).toContain("/api/equipment/categories");
});
it("should return list, detail, categories from composable", () => {
expect(content).toContain("return");
expect(content).toContain("list");
expect(content).toContain("detail");
expect(content).toContain("categories");
});
});