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:
@@ -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$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user