fix(m4): plans page — correct API field names (speedDownMbps/speedUpMbps/monthlyPrice), plain array response; fix E2E tests

This commit is contained in:
root
2026-03-26 10:09:47 +00:00
parent bd006aca15
commit eebdf21505
2 changed files with 115 additions and 127 deletions

View File

@@ -17,23 +17,30 @@ interface Plan {
id: string; id: string;
name: string; name: string;
type: "PREPAID" | "POSTPAID"; type: "PREPAID" | "POSTPAID";
speed: string; speedDownMbps: number;
price: string | number; speedUpMbps: number;
monthlyPrice: number;
description?: string; description?: string;
isActive: boolean;
} }
interface PlansResponse { data: Plan[]; total: number; page: number; limit: number; }
const typeVariant: Record<string, "success" | "muted"> = { const typeVariant: Record<string, "success" | "muted"> = {
PREPAID: "success", PREPAID: "success",
POSTPAID: "muted", POSTPAID: "muted",
}; };
const emptyForm = { name: "", type: "PREPAID" as "PREPAID" | "POSTPAID", speed: "", price: "", description: "" }; const emptyForm = {
name: "",
type: "PREPAID" as "PREPAID" | "POSTPAID",
speedDown: "",
speedUp: "",
monthlyPrice: "",
description: "",
};
export default function PlansPage() { export default function PlansPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// Modals // Modals
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@@ -44,12 +51,13 @@ export default function PlansPage() {
const [createForm, setCreateForm] = useState({ ...emptyForm }); const [createForm, setCreateForm] = useState({ ...emptyForm });
const [editForm, setEditForm] = useState({ ...emptyForm }); const [editForm, setEditForm] = useState({ ...emptyForm });
const { data, isLoading, isError, refetch } = useQuery<PlansResponse>({ // GET /api/v1/plans returns a plain array
queryKey: ["plans", search, page], const { data: plans = [], isLoading, isError, refetch } = useQuery<Plan[]>({
queryKey: ["plans", search],
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ page: String(page), limit: "20" }); const params = new URLSearchParams({ limit: "100" });
if (search) params.set("search", search); if (search) params.set("search", search);
const res = await api.get<PlansResponse>(`/api/v1/plans?${params}`); const res = await api.get<Plan[]>(`/api/v1/plans?${params}`);
return res.data; return res.data;
}, },
}); });
@@ -59,8 +67,9 @@ export default function PlansPage() {
await api.post("/api/v1/plans", { await api.post("/api/v1/plans", {
name: createForm.name, name: createForm.name,
type: createForm.type, type: createForm.type,
speed: createForm.speed, speedDownMbps: Number(createForm.speedDown),
price: Number(createForm.price), speedUpMbps: Number(createForm.speedUp),
monthlyPrice: Number(createForm.monthlyPrice),
description: createForm.description || undefined, description: createForm.description || undefined,
}); });
}, },
@@ -70,7 +79,11 @@ export default function PlansPage() {
setCreateForm({ ...emptyForm }); setCreateForm({ ...emptyForm });
qc.invalidateQueries({ queryKey: ["plans"] }); qc.invalidateQueries({ queryKey: ["plans"] });
}, },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to create plan"), onError: (e: any) => toast.error(
Array.isArray(e.response?.data?.message)
? e.response.data.message.join(", ")
: (e.response?.data?.message ?? "Failed to create plan")
),
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
@@ -78,8 +91,9 @@ export default function PlansPage() {
await api.patch(`/api/v1/plans/${editPlan!.id}`, { await api.patch(`/api/v1/plans/${editPlan!.id}`, {
name: editForm.name, name: editForm.name,
type: editForm.type, type: editForm.type,
speed: editForm.speed, speedDownMbps: Number(editForm.speedDown),
price: Number(editForm.price), speedUpMbps: Number(editForm.speedUp),
monthlyPrice: Number(editForm.monthlyPrice),
description: editForm.description || undefined, description: editForm.description || undefined,
}); });
}, },
@@ -103,15 +117,17 @@ export default function PlansPage() {
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete plan"), onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to delete plan"),
}); });
const plans = data?.data ?? []; const filtered = search
const total = data?.total ?? 0; ? plans.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
: plans;
const openEdit = (plan: Plan) => { const openEdit = (plan: Plan) => {
setEditForm({ setEditForm({
name: plan.name, name: plan.name,
type: plan.type, type: plan.type,
speed: plan.speed, speedDown: String(plan.speedDownMbps),
price: String(plan.price), speedUp: String(plan.speedUpMbps),
monthlyPrice: String(plan.monthlyPrice),
description: plan.description ?? "", description: plan.description ?? "",
}); });
setEditPlan(plan); setEditPlan(plan);
@@ -123,7 +139,7 @@ export default function PlansPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Plans</h1> <h1 className="text-2xl font-bold text-gray-900">Plans</h1>
<p className="text-sm text-gray-500 mt-1">{total} total plans</p> <p className="text-sm text-gray-500 mt-1">{filtered.length} total plans</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => refetch()} variant="outline" size="sm" data-testid="btn-refresh"> <Button onClick={() => refetch()} variant="outline" size="sm" data-testid="btn-refresh">
@@ -141,7 +157,7 @@ export default function PlansPage() {
className="w-full max-w-sm border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full max-w-sm border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Search plans..." placeholder="Search plans..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => setSearch(e.target.value)}
data-testid="input-search" data-testid="input-search"
/> />
</CardHeader> </CardHeader>
@@ -151,15 +167,15 @@ export default function PlansPage() {
<TableRow> <TableRow>
<Th>Name</Th> <Th>Name</Th>
<Th>Type</Th> <Th>Type</Th>
<Th>Speed</Th> <Th>Speed (Down/Up)</Th>
<Th>Price</Th> <Th>Monthly Price</Th>
<Th>Description</Th> <Th>Description</Th>
<Th></Th> <Th></Th>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 6 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
<Td colSpan={6}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td> <Td colSpan={6}><div className="h-4 bg-gray-100 rounded animate-pulse" /></Td>
</TableRow> </TableRow>
@@ -173,17 +189,17 @@ export default function PlansPage() {
</p> </p>
</Td> </Td>
</TableRow> </TableRow>
) : plans.length === 0 ? ( ) : filtered.length === 0 ? (
<EmptyState colSpan={6} message="No plans found" icon={<Package size={24} />} /> <EmptyState colSpan={6} message="No plans found" icon={<Package size={24} />} />
) : ( ) : (
plans.map(plan => ( filtered.map(plan => (
<TableRow key={plan.id} data-testid="plan-row"> <TableRow key={plan.id} data-testid="plan-row">
<Td className="font-medium">{plan.name}</Td> <Td className="font-medium">{plan.name}</Td>
<Td> <Td>
<Badge variant={typeVariant[plan.type] ?? "muted"}>{plan.type}</Badge> <Badge variant={typeVariant[plan.type] ?? "muted"}>{plan.type}</Badge>
</Td> </Td>
<Td className="font-mono text-sm">{plan.speed}</Td> <Td className="font-mono text-sm">{plan.speedDownMbps}/{plan.speedUpMbps} Mbps</Td>
<Td className="font-semibold">{formatCurrency(Number(plan.price))}</Td> <Td className="font-semibold">{formatCurrency(plan.monthlyPrice)}</Td>
<Td className="text-gray-500 text-sm max-w-xs truncate">{plan.description ?? "—"}</Td> <Td className="text-gray-500 text-sm max-w-xs truncate">{plan.description ?? "—"}</Td>
<Td> <Td>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
@@ -210,22 +226,12 @@ export default function PlansPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
{total > 20 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-sm text-gray-500">
<span>Showing {(page - 1) * 20 + 1}{Math.min(page * 20, total)} of {total}</span>
<div className="flex gap-2">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Prev</button>
<button disabled={page * 20 >= total} onClick={() => setPage(p => p + 1)} className="px-3 py-1 border rounded-md disabled:opacity-40">Next</button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{/* Create Plan Modal */} {/* Create Plan Modal */}
<Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setCreateForm({ ...emptyForm }); }} title="Add Plan" className="max-w-md" data-testid="modal-create-plan"> <Modal isOpen={showCreate} onClose={() => { setShowCreate(false); setCreateForm({ ...emptyForm }); }} title="Add Plan" className="max-w-md">
<div className="space-y-4" data-testid="modal-create-plan"> <div className="space-y-4">
<Input <Input
label="Plan Name" label="Plan Name"
value={createForm.name} value={createForm.name}
@@ -245,18 +251,29 @@ export default function PlansPage() {
<option value="POSTPAID">POSTPAID</option> <option value="POSTPAID">POSTPAID</option>
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Download (Mbps)"
type="number"
value={createForm.speedDown}
onChange={e => setCreateForm(f => ({ ...f, speedDown: e.target.value }))}
placeholder="e.g. 25"
data-testid="input-plan-speed-down"
/>
<Input
label="Upload (Mbps)"
type="number"
value={createForm.speedUp}
onChange={e => setCreateForm(f => ({ ...f, speedUp: e.target.value }))}
placeholder="e.g. 10"
data-testid="input-plan-speed-up"
/>
</div>
<Input <Input
label="Speed" label="Monthly Price (₱)"
value={createForm.speed}
onChange={e => setCreateForm(f => ({ ...f, speed: e.target.value }))}
placeholder="e.g. 25Mbps"
data-testid="input-plan-speed"
/>
<Input
label="Price (₱)"
type="number" type="number"
value={createForm.price} value={createForm.monthlyPrice}
onChange={e => setCreateForm(f => ({ ...f, price: e.target.value }))} onChange={e => setCreateForm(f => ({ ...f, monthlyPrice: e.target.value }))}
placeholder="e.g. 999" placeholder="e.g. 999"
data-testid="input-plan-price" data-testid="input-plan-price"
/> />
@@ -273,7 +290,7 @@ export default function PlansPage() {
size="sm" size="sm"
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending} isLoading={createMutation.isPending}
disabled={!createForm.name || !createForm.speed || !createForm.price} disabled={!createForm.name || !createForm.speedDown || !createForm.speedUp || !createForm.monthlyPrice}
data-testid="btn-submit-create" data-testid="btn-submit-create"
> >
Create Plan Create Plan
@@ -284,7 +301,7 @@ export default function PlansPage() {
{/* Edit Plan Modal */} {/* Edit Plan Modal */}
<Modal isOpen={!!editPlan} onClose={() => setEditPlan(null)} title={`Edit Plan: ${editPlan?.name ?? ""}`} className="max-w-md"> <Modal isOpen={!!editPlan} onClose={() => setEditPlan(null)} title={`Edit Plan: ${editPlan?.name ?? ""}`} className="max-w-md">
<div className="space-y-4" data-testid="modal-edit-plan"> <div className="space-y-4">
<Input <Input
label="Plan Name" label="Plan Name"
value={editForm.name} value={editForm.name}
@@ -303,17 +320,27 @@ export default function PlansPage() {
<option value="POSTPAID">POSTPAID</option> <option value="POSTPAID">POSTPAID</option>
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Download (Mbps)"
type="number"
value={editForm.speedDown}
onChange={e => setEditForm(f => ({ ...f, speedDown: e.target.value }))}
data-testid="input-edit-speed-down"
/>
<Input
label="Upload (Mbps)"
type="number"
value={editForm.speedUp}
onChange={e => setEditForm(f => ({ ...f, speedUp: e.target.value }))}
data-testid="input-edit-speed-up"
/>
</div>
<Input <Input
label="Speed" label="Monthly Price (₱)"
value={editForm.speed}
onChange={e => setEditForm(f => ({ ...f, speed: e.target.value }))}
data-testid="input-edit-speed"
/>
<Input
label="Price (₱)"
type="number" type="number"
value={editForm.price} value={editForm.monthlyPrice}
onChange={e => setEditForm(f => ({ ...f, price: e.target.value }))} onChange={e => setEditForm(f => ({ ...f, monthlyPrice: e.target.value }))}
data-testid="input-edit-price" data-testid="input-edit-price"
/> />
<Input <Input
@@ -328,7 +355,7 @@ export default function PlansPage() {
size="sm" size="sm"
onClick={() => updateMutation.mutate()} onClick={() => updateMutation.mutate()}
isLoading={updateMutation.isPending} isLoading={updateMutation.isPending}
disabled={!editForm.name || !editForm.speed || !editForm.price} disabled={!editForm.name || !editForm.speedDown || !editForm.speedUp || !editForm.monthlyPrice}
data-testid="btn-submit-edit" data-testid="btn-submit-edit"
> >
Save Changes Save Changes
@@ -339,7 +366,7 @@ export default function PlansPage() {
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
<Modal isOpen={!!deletePlan} onClose={() => setDeletePlan(null)} title="Delete Plan" className="max-w-sm"> <Modal isOpen={!!deletePlan} onClose={() => setDeletePlan(null)} title="Delete Plan" className="max-w-sm">
<div className="space-y-4" data-testid="modal-delete-plan"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Are you sure you want to delete <strong>{deletePlan?.name}</strong>? This action cannot be undone. Are you sure you want to delete <strong>{deletePlan?.name}</strong>? This action cannot be undone.
</p> </p>

View File

@@ -1,118 +1,79 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { login } from './helpers/auth'; import { login } from './helpers/auth';
const API = 'https://fiberops-api.juankibin.space/api/v1';
test.describe('Plans', () => { test.describe('Plans', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await login(page); await login(page);
await page.goto('/plans'); await page.goto('/plans');
await page.waitForLoadState('networkidle'); await page.waitForURL(/\/plans/);
}); });
test('plans page renders with header and table', async ({ page }) => { test('plans page renders with header and table', async ({ page }) => {
await expect(page.locator('h1:has-text("Plans")')).toBeVisible(); await expect(page.locator('h1:has-text("Plans")')).toBeVisible();
await expect(page.locator('table')).toBeVisible(); await expect(page.locator('table')).toBeVisible();
await expect(page.locator('[data-testid="btn-add-plan"]')).toBeVisible(); await expect(page.locator('th:has-text("Name")')).toBeVisible();
await expect(page.locator('th:has-text("Type")')).toBeVisible();
}); });
test('plans list loads rows from API', async ({ page }) => { test('plans list loads rows from API', async ({ page }) => {
// Wait for either rows or empty state // Wait for at least one row (demo data has 5 plans)
await page.waitForTimeout(3000); await expect(page.locator('[data-testid="plan-row"]').first()).toBeVisible({ timeout: 10000 });
const rows = page.locator('[data-testid="plan-row"]'); const count = await page.locator('[data-testid="plan-row"]').count();
const empty = page.locator('text=No plans found'); expect(count).toBeGreaterThan(0);
const count = await rows.count();
const hasEmpty = await empty.isVisible();
// Either rows exist OR empty state is shown — both are valid
expect(count > 0 || hasEmpty).toBeTruthy();
}); });
test('add plan modal opens and submits', async ({ page }) => { test('add plan modal opens and submits', async ({ page }) => {
// Open create modal // Open create modal
await page.click('[data-testid="btn-add-plan"]'); await page.click('[data-testid="btn-add-plan"]');
// Modal should appear with form fields
await expect(page.locator('[data-testid="input-plan-name"]')).toBeVisible(); await expect(page.locator('[data-testid="input-plan-name"]')).toBeVisible();
await expect(page.locator('[data-testid="select-plan-type"]')).toBeVisible();
await expect(page.locator('[data-testid="input-plan-speed"]')).toBeVisible();
await expect(page.locator('[data-testid="input-plan-price"]')).toBeVisible();
// Fill form // Fill form with correct numeric fields
const planName = `E2E Test Plan ${Date.now()}`; const planName = `E2E Plan ${Date.now()}`;
await page.fill('[data-testid="input-plan-name"]', planName); await page.fill('[data-testid="input-plan-name"]', planName);
await page.selectOption('[data-testid="select-plan-type"]', 'PREPAID'); await page.selectOption('[data-testid="select-plan-type"]', 'PREPAID');
await page.fill('[data-testid="input-plan-speed"]', '25Mbps'); await page.fill('[data-testid="input-plan-speed-down"]', '25');
await page.fill('[data-testid="input-plan-speed-up"]', '10');
await page.fill('[data-testid="input-plan-price"]', '999'); await page.fill('[data-testid="input-plan-price"]', '999');
await page.fill('[data-testid="input-plan-description"]', 'E2E test plan - safe to delete');
// Submit // Submit
await page.click('[data-testid="btn-submit-create"]'); await page.click('[data-testid="btn-submit-create"]');
// Modal should close and success toast or new row appears // Modal should close after successful API call
await page.waitForTimeout(2000); await expect(page.locator('[data-testid="input-plan-name"]')).not.toBeVisible({ timeout: 10000 });
// Modal should be closed // New plan should appear in table
const modalInput = page.locator('[data-testid="input-plan-name"]'); await expect(page.locator(`text=${planName}`)).toBeVisible({ timeout: 10000 });
await expect(modalInput).not.toBeVisible({ timeout: 5000 });
}); });
test('edit plan modal opens and submits', async ({ page }) => { test('edit plan modal opens and submits', async ({ page }) => {
// Wait for rows to load // Wait for rows
await page.waitForTimeout(3000); await expect(page.locator('[data-testid="plan-row"]').first()).toBeVisible({ timeout: 10000 });
const editBtns = page.locator('[data-testid="btn-edit-plan"]');
const count = await editBtns.count();
if (count === 0) {
// No plans exist yet — create one first via API, then test edit
test.skip();
return;
}
// Click first edit button // Click first edit button
await editBtns.first().click(); await page.locator('[data-testid="btn-edit-plan"]').first().click();
await expect(page.locator('[data-testid="input-edit-name"]')).toBeVisible();
// Edit modal should open // Edit the name
await expect(page.locator('[data-testid="input-edit-name"]')).toBeVisible({ timeout: 5000 }); await page.fill('[data-testid="input-edit-name"]', 'Updated Plan Name');
await expect(page.locator('[data-testid="input-edit-speed"]')).toBeVisible();
await expect(page.locator('[data-testid="input-edit-price"]')).toBeVisible();
// Change the speed
await page.fill('[data-testid="input-edit-speed"]', '50Mbps');
// Submit
await page.click('[data-testid="btn-submit-edit"]'); await page.click('[data-testid="btn-submit-edit"]');
// Modal should close // Modal should close
await page.waitForTimeout(2000); await expect(page.locator('[data-testid="input-edit-name"]')).not.toBeVisible({ timeout: 10000 });
const editModal = page.locator('[data-testid="input-edit-name"]');
await expect(editModal).not.toBeVisible({ timeout: 5000 });
}); });
test('delete plan shows confirmation dialog', async ({ page }) => { test('delete plan shows confirmation dialog', async ({ page }) => {
// Wait for rows // Wait for rows
await page.waitForTimeout(3000); await expect(page.locator('[data-testid="plan-row"]').first()).toBeVisible({ timeout: 10000 });
const deleteBtns = page.locator('[data-testid="btn-delete-plan"]');
const count = await deleteBtns.count();
if (count === 0) {
test.skip();
return;
}
// Click first delete button // Click first delete button
await deleteBtns.first().click(); await page.locator('[data-testid="btn-delete-plan"]').first().click();
// Confirmation modal should appear // Confirmation modal should appear
await expect(page.locator('[data-testid="modal-delete-plan"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('[data-testid="btn-confirm-delete"]')).toBeVisible(); await expect(page.locator('[data-testid="btn-confirm-delete"]')).toBeVisible();
await expect(page.locator('[data-testid="btn-cancel-delete"]')).toBeVisible(); await expect(page.locator('[data-testid="btn-cancel-delete"]')).toBeVisible();
// Cancel the delete (don't actually delete in tests) // Cancel don't actually delete
await page.click('[data-testid="btn-cancel-delete"]'); await page.click('[data-testid="btn-cancel-delete"]');
await expect(page.locator('[data-testid="btn-confirm-delete"]')).not.toBeVisible();
// Modal should close
await page.waitForTimeout(500);
const deleteModal = page.locator('[data-testid="modal-delete-plan"]');
await expect(deleteModal).not.toBeVisible({ timeout: 5000 });
}); });
}); });