Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
READ GUIDE at AGENTS.md
11 changes: 11 additions & 0 deletions apps/dash/app/(dash)/event/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { AttachmentReferenceType } from "@domus/core/entity/enums";
import { notFound, redirect } from "next/navigation";
import { EventDetailPage } from "@/pages/event";
import { getAuthContext } from "@/shared/auth/server";
import {
attachment as attachmentService,
attendance as attendanceService,
event as eventService,
rsvp as rsvpService,
Expand Down Expand Up @@ -69,12 +71,21 @@ export default async function Page({
rsvpSummary = summary || null;
}

// Fetch Attachments
const [attachmentsData, _attError] = await attachmentService.findByReference(
auth,
id,
AttachmentReferenceType.Event,
);
const attachments = attachmentsData || [];

return (
<EventDetailPage
event={event}
attendance={attendance}
rsvp={rsvp}
rsvpSummary={rsvpSummary}
attachments={attachments}
isAdmin={isAdmin}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { TransactionCreatePage } from "@/pages/finance";

export default async function Page({
params,
}: {
params: Promise<{ periodId: string }>;
}) {
const { periodId } = await params;

return <TransactionCreatePage periodId={periodId} />;
}
1 change: 1 addition & 0 deletions apps/dash/app/(dash)/setting/categories/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CategorySettingsPage as default } from "@/pages/finance/ui/CategorySettingsPage";
Binary file added apps/dash/e2e/assets/dummy.pdf
Binary file not shown.
79 changes: 79 additions & 0 deletions apps/dash/e2e/features/finance/transaction-recording.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import path from "node:path";
import { TransactionType } from "@domus/core";
import { expect, test } from "@playwright/test";
import { iHaveLoggedInAsTreasurer } from "../../helper/auth";
import {
iHaveOpenFinancialPeriod,
iHaveTransactionCategory,
} from "../../helper/finance";
import { FinancePage } from "../../pages/finance/FinancePage";

test.describe("Finance - Transaction Recording", () => {
// Run serially to avoid race conditions in shared DB test data setup
test.describe.configure({ mode: "serial" });

let financePage: FinancePage;

test.beforeEach(async ({ context, page }) => {
// 1. Ensure test data exists
const now = new Date();
await iHaveOpenFinancialPeriod(now.getMonth() + 1, now.getFullYear());
await iHaveTransactionCategory("Kolekte", TransactionType.Income);
await iHaveTransactionCategory("Listrik", TransactionType.Expense);

// 2. Login as Treasurer
await iHaveLoggedInAsTreasurer(context);

financePage = new FinancePage(page);
});

test("should successfully record an income transaction with receipt", async ({
page,
}) => {
await financePage.goto();
await financePage.switchToPeriodsTab();

// 1. Click record button for the open period
await financePage.btnRecordTransaction.first().click();
await expect(page).toHaveURL(/\/transaction\/create/);

// 2. Fill the form
const receiptPath = path.resolve(__dirname, "../../assets/dummy-id.png");

await financePage.fillTransactionForm({
type: TransactionType.Income,
date: new Date().toISOString().split("T")[0],
category: "Kolekte",
amount: "500000",
description: "Kolekte Misa Minggu Ke-3",
receiptPath,
});

// 3. Submit
await financePage.submitTransaction();

// 4. Verify success
await financePage.expectSuccessToast();
await expect(page).toHaveURL("/finance");
});

test("should show validation errors for missing mandatory fields", async ({
page,
}) => {
await financePage.goto();
await financePage.switchToPeriodsTab();
await financePage.btnRecordTransaction.first().click();

// Touch fields to trigger onChange validation, then submit
await financePage.inputAmount.click();
await financePage.inputAmount.fill("0");
await financePage.inputDescription.click();
await financePage.inputDescription.fill("");

// Submit empty/invalid form
await financePage.submitTransaction();

// Should stay on the transaction create page (not navigate back to /finance)
await expect(page).toHaveURL(/\/transaction\/create/);
});
});
128 changes: 128 additions & 0 deletions apps/dash/e2e/features/notifications/dropdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { expect, test } from "@playwright/test";
import { v7 as uuidv7 } from "uuid";
import {
deleteNotificationsForUser,
iHaveLoggedInAsSuperAdmin,
iHaveNotification,
} from "../../helper";
import { NotificationPage } from "../../pages/NotificationPage";

// We run in serial mode to prevent database conflicts between tests using the same user.
test.describe.configure({ mode: "serial" });

test.describe("In-App Notification System", () => {
let notificationPage: NotificationPage;
let user: { id: string; name: string; email: string };

test.beforeEach(async ({ page, context }) => {
notificationPage = new NotificationPage(page);
user = await iHaveLoggedInAsSuperAdmin(context);
await deleteNotificationsForUser(user.id);

await page.goto("/");
await expect(page).toHaveURL("/", { timeout: 15000 });
await page.waitForLoadState("networkidle");
});

test("should show empty state when no notifications exist", async () => {
await notificationPage.openDropdown();
await expect(notificationPage.emptyState).toBeVisible();
await expect(notificationPage.notificationItems).toHaveCount(0);
});

test("should show unread badge when a new notification arrives", async ({
page,
}) => {
const title = `New Notification ${Date.now()}`;
await iHaveNotification({
userId: user.id,
title: title,
referenceType: "Event",
referenceId: uuidv7(),
});

await page.goto("/");
await page.waitForLoadState("networkidle");

// Poll for the badge count to match DB state
await expect(async () => {
const count = await notificationPage.getUnreadCount();
expect(count).toBeGreaterThan(0);
}).toPass({ timeout: 15000 });

await expect(notificationPage.unreadBadge).toBeVisible();
});

test("should open dropdown and show notification items", async ({ page }) => {
const title = `Dashboard Update ${Date.now()}`;
await iHaveNotification({
userId: user.id,
title: title,
});

await page.goto("/");
await page.waitForLoadState("networkidle");
await notificationPage.openDropdown();

await expect(page.getByText(title)).toBeVisible();
});

test("should mark notification as read when clicked", async ({ page }) => {
const title = `Click Me ${Date.now()}`;
await iHaveNotification({
userId: user.id,
title: title,
referenceType: "Event",
referenceId: uuidv7(),
});

await page.goto("/");
await page.waitForLoadState("networkidle");
await notificationPage.openDropdown();

// Verify unread indicator exists initially
const item = notificationPage.notificationItems.filter({ hasText: title });
const indicator = item.locator('[data-slot="notif-unread-indicator"]');
await expect(indicator).toBeVisible();

// Click item
await notificationPage.clickNotification(title);

// Wait for navigation
await page.waitForURL("**/event/**", { timeout: 10000 });
await page.waitForLoadState("networkidle");

// Go back and check read state
await page.goto("/");
await page.waitForLoadState("networkidle");
await notificationPage.openDropdown();

const refreshedItem = notificationPage.notificationItems.filter({
hasText: title,
});
const refreshedIndicator = refreshedItem.locator(
'[data-slot="notif-unread-indicator"]',
);

// Indicator should be gone if read
await expect(refreshedIndicator).toBeHidden();
});

test("should mark all as read", async ({ page }) => {
await iHaveNotification({ userId: user.id, title: "Notif 1" });
await iHaveNotification({ userId: user.id, title: "Notif 2" });

await page.goto("/");
await page.waitForLoadState("networkidle");
await notificationPage.openDropdown();

await notificationPage.markAllAsRead();

// The badge should disappear from the main trigger
await expect(notificationPage.unreadBadge).toBeHidden();

// Indicators inside the items should also be gone
const indicators = page.locator('[data-slot="notif-unread-indicator"]');
await expect(indicators).toHaveCount(0);
});
});
83 changes: 83 additions & 0 deletions apps/dash/e2e/features/org/term-management.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import path from "node:path";
import { expect, test } from "@playwright/test";
import { iHaveLoggedInAsSuperAdmin } from "../../helper/auth";
import { OrgCreatePage } from "../../pages/org/OrgCreatePage";
import { OrgDetailPage } from "../../pages/org/OrgDetailPage";

test.describe("Organizational Term Management", () => {
test("should manage terms in an organization", async ({ page, context }) => {
// Extend timeout: org create + term CRUD + Google Drive upload can take > 30s
test.setTimeout(90_000);

// 0. Setup authentication and page objects
await iHaveLoggedInAsSuperAdmin(context);
const detailPage = new OrgDetailPage(page);
const createPage = new OrgCreatePage(page);

// 1. Create a dedicated organization for this test to ensure isolation
const randomSuffix = Math.random().toString(36).substring(7);
const orgName = `Org E2E Term ${randomSuffix}`;
await createPage.goto();
await createPage.fillName(orgName);
await createPage.submit();

// 2. Wait for redirect to details page and get the URL
await expect(page).toHaveURL(/\/org\/(?!new$)[^/]+$/);

const termData = {
name: "Pengurus Periode 2025-2030",
startDate: "2025-01-01",
endDate: "2030-12-31",
skNumber: "SK/2025/001",
skDate: "2025-01-01",
description: "Masa jabatan pengurus periode baru.",
};

// 3. Create term
await detailPage.addTerm(termData);

// 2. Verify in list
await expect(page.getByText(termData.name)).toBeVisible();

// 3. Update term
const updatedName = "Pengurus Periode 2025-2030 (Updated)";
await detailPage.updateTermAt(0, { name: updatedName });

// 4. Verify update
await expect(page.getByText(updatedName)).toBeVisible();

// 5. Expand term
await detailPage.toggleTermExpandAt(0);

// 6. Upload attachment
const attachmentName = `SK Pelantikan ${randomSuffix}`;
const dummyFilePath = path.join(process.cwd(), "e2e/assets/dummy.pdf");
await detailPage.uploadTermAttachment(0, attachmentName, dummyFilePath);

// 7. Verify attachment is in list
// Hard reload ensures fresh server data and clean React state.
// (router.refresh() can preserve stale expandedTerms causing false-hidden elements)
await page.reload();
await page.waitForLoadState("networkidle");
await detailPage.ensureTermExpandedAt(0);

const attachmentItem = page
.getByTestId("attachment-item-name")
.filter({ hasText: attachmentName });

// Use toBeAttached — the <p> has class="truncate" (overflow:hidden) which can
// cause Playwright to report it as "hidden" even when the term section is visible.
await expect(attachmentItem).toBeAttached({ timeout: 10000 });

console.log(
"Term Management E2E: Successfully verified update and attachment",
);

// 8. Delete term
await detailPage.deleteTermAt(0);

// 9. Verify removed
await expect(page.getByText(updatedName)).toHaveCount(0);
await expect(page.getByText("Belum ada masa jabatan.")).toBeVisible();
});
});
50 changes: 50 additions & 0 deletions apps/dash/e2e/features/setting/transaction-category.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { test } from "@playwright/test";
import {
iHaveLoggedInAsApprovedParishioner,
iHaveLoggedInAsTreasurer,
} from "../../helper/auth";
import { CategorySettingsPage } from "../../pages/setting/CategorySettingsPage";

test.describe("Transaction Category Management", () => {
test("Treasurer can manage categories (CRUD)", async ({ page, context }) => {
await iHaveLoggedInAsTreasurer(context);
const categoryPage = new CategorySettingsPage(page);

await categoryPage.goto();

// 1. Create
await categoryPage.addCategory({
name: "Iuran Wajib",
type: "Pemasukan",
});
await categoryPage.expectCategoryVisible("Iuran Wajib");

// 2. Create another one
await categoryPage.addCategory({
name: "Listrik Gereja",
type: "Pengeluaran",
});
await categoryPage.expectCategoryVisible("Listrik Gereja");

// 3. Delete
await categoryPage.deleteCategory("Listrik Gereja");
await categoryPage.expectCategoryHidden("Listrik Gereja");
});

test("Ordinary parishioner cannot access category settings", async ({
page,
context,
}) => {
await iHaveLoggedInAsApprovedParishioner(context);

// Attempt to navigate
await page.goto("/setting/categories");

// Should be redirected to dashboard or see unauthorized
// In Domus, usually redirects to / or /pending if not allowed
// But since it's a treasurer/admin role required, the proxy might handle it
// Let's check for base dashboard element or breadcrumb that indicates we are NOT in categories
// For now, checking if URL is NOT categories or contains redirected status
await page.waitForURL(/\/(?!(setting\/categories))/);
});
});
Loading