Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ A Finance management app with transaction tracking, budget management, savings g

### Features

- 5 routes including Overview, Transactions, Budgets, Pots and Recurring bills.
- 6 routes including Overview, Transactions, Budgets, Pots, Recurring Bills and Account Settings.
- Transactions
- Create incoming or outgoing transactions.
- View past transactions: Supports searching, filtering, sorting and pagination.
Expand Down
2 changes: 1 addition & 1 deletion src/app/(dashboard)/budgets/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default async function BudgetsPage() {
<main className="@container grid gap-8">
<PageHeader
title="Budgets"
description="Set monthly limits and track how much you’ve spent in each category."
description="Set monthly spending limits by category. Each budget tracks spending for the current month."
action={<AddBudgetDialog categories={categories} colors={colors} />}
/>

Expand Down
21 changes: 17 additions & 4 deletions src/components/ui/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode } from "react"
import { type ReactNode, type ComponentProps } from "react"
import { tv, VariantProps } from "tailwind-variants"

const cardStyles = tv({
Expand All @@ -17,8 +17,21 @@ const cardStyles = tv({

type CardVariants = VariantProps<typeof cardStyles>

type CardProps = { children: ReactNode; className?: string } & CardVariants
type CardProps = {
children: ReactNode
className?: string
} & ComponentProps<"div"> &
CardVariants

export default function Card({ children, className, size }: CardProps) {
return <div className={cardStyles({ size, className })}>{children}</div>
export default function Card({
children,
className,
size,
...props
}: CardProps) {
return (
<div {...props} className={cardStyles({ size, className })}>
{children}
</div>
)
}
9 changes: 6 additions & 3 deletions src/features/budgets/components/BudgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function BudgetCard({
Number(budget.maximumSpend) - Number(budget.totalSpent)

return (
<Card size="lg">
<Card data-testid="budget-card" size="lg">
<div className="grid gap-8 self-start">
<div className="flex items-center justify-start gap-4">
<span
Expand Down Expand Up @@ -66,10 +66,13 @@ export default function BudgetCard({

<div className="grid gap-8">
<p>
<span className="text-primary text-3xl leading-tight font-semibold">
<span
data-testid="budget-current-amount"
className="text-primary text-3xl leading-tight font-semibold"
>
{currencyFormatter.format(budget.totalSpent)}
</span>
<span className="font-medium" data-testid="maximum-amount">
<span data-testid="budget-max-amount" className="font-medium">
{" "}
of {currencyFormatter.format(budget.maximumSpend)}
</span>
Expand Down
12 changes: 9 additions & 3 deletions src/features/pots/components/PotCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function PotCard({ pot, colors }: PotCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)

return (
<Card size="lg" className="grid gap-8">
<Card data-testid="pot-card" size="lg" className="grid gap-8">
<div className="flex items-center justify-start gap-4">
<span
className="size-4 rounded-full"
Expand Down Expand Up @@ -52,13 +52,19 @@ export default function PotCard({ pot, colors }: PotCardProps) {
<div className="flex items-start justify-between gap-2">
<p className="grid gap-1">
<span className="text-sm font-medium">Saved</span>
<span className="text-primary text-3xl leading-tight font-semibold">
<span
data-testid="pot-saved-amount"
className="text-primary text-3xl leading-tight font-semibold"
>
{currencyFormatter.format(pot.currentAmount)}
</span>
</p>
<p className="grid justify-items-end gap-1">
<span className="text-sm font-medium">Target</span>
<span className="text-lg leading-tight font-semibold">
<span
data-testid="pot-target-amount"
className="text-lg leading-tight font-semibold"
>
{currencyFormatter.format(pot.target)}
</span>
</p>
Expand Down
120 changes: 64 additions & 56 deletions tests/budgets.spec.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,98 @@
import { prisma } from "@/lib/prisma"
import { currencyFormatter } from "@/lib/utils"

import { test, expect } from "./fixtures/auth"
import { BudgetsPage } from "./page-objects/budgets-page"
import { test, expect } from "./fixtures/fixtures"

test.describe("Budgets Page", () => {
test("renders empty state", async ({ page, userSession }) => {
const budgetsPage = new BudgetsPage(page)
await budgetsPage.goto()
test("renders empty state", async ({ budgetsPage }) => {
await expect(budgetsPage.heading).toBeVisible()
await expect(budgetsPage.emptyState).toBeVisible()
await expect(budgetsPage.addBudgetButton).toBeVisible()
})

test("shows form errors on empty submit", async ({ page, userSession }) => {
const budgetsPage = new BudgetsPage(page)
await budgetsPage.goto()
await budgetsPage.dialogTrigger.click()
await expect(budgetsPage.dialogHeading).toBeVisible()
test("shows form errors on empty submit", async ({ budgetsPage }) => {
await budgetsPage.addBudgetButton.click()
await expect(budgetsPage.dialogHeading).toBeVisible()
await budgetsPage.maxSpendInput.fill("")
await budgetsPage.addBudgetSubmit.click()

await budgetsPage.expectErrorMessage("Please select a category.")
await budgetsPage.expectErrorMessage("Maximum spend cannot be empty.")
await budgetsPage.expectErrorMessage("Please select a color.")
})

test("can create a budget", async ({ page, userSession }) => {
const budgetsPage = new BudgetsPage(page)
await budgetsPage.goto()
test("can create a budget", async ({ budgetsPage }) => {
const categories = await prisma.category.findMany()
const colors = await prisma.color.findMany()
const categoryLabel = categories[0].label
const colorLabel = colors[0].label
const budgetAmount = 100

await budgetsPage.dialogTrigger.click()
await expect(budgetsPage.dialogHeading).toBeVisible()
await budgetsPage.categorySelect.click()
await budgetsPage.categoryItem.click()
await budgetsPage.amountInput.fill("100")
await budgetsPage.themeSelect.click()
await budgetsPage.themeItem.click()
await budgetsPage.addBudgetButton.click()

await expect(budgetsPage.budgetCardHeading).toBeVisible()
await expect(budgetsPage.dialogHeading).toBeVisible()
await budgetsPage.selectCategory(categoryLabel)
await budgetsPage.maxSpendInput.fill(budgetAmount.toString())
await budgetsPage.selectTheme(colorLabel)
await budgetsPage.addBudgetSubmit.click()

await expect(budgetsPage.budgetHeading(categoryLabel)).toBeVisible()
await expect(budgetsPage.budgetMaxAmount(categoryLabel)).toContainText(
currencyFormatter.format(budgetAmount)
)
})

test("can edit a budget", async ({ page, userSession }) => {
test("can edit a budget", async ({ userSession, budgetsPage }) => {
const categories = await prisma.category.findMany()
const colors = await prisma.color.findMany()
const updatedAmount = 200
const [primaryColor, secondaryColor] = colors
const [primaryCategory, secondaryCategory] = categories
const updatedMaxSpend = 200

await prisma.budget.create({
data: {
userId: userSession.userId,
categoryId: categories[0].id,
categoryId: primaryCategory.id,
maximumSpend: 100,
colorId: colors[0].id,
colorId: primaryColor.id,
},
})

const budgetsPage = new BudgetsPage(page)
await budgetsPage.goto()

await budgetsPage.optionsButton.click()
await budgetsPage.page.reload()
await budgetsPage.budgetOptionsButton(primaryCategory.label).click()
await budgetsPage.editMenuItem.click()
await budgetsPage.amountInput.fill(updatedAmount.toString())
await budgetsPage.saveChangesButton.click()

await expect(budgetsPage.budgetCardMaximumAmount).toContainText(
`of $${updatedAmount}.00`
)
await budgetsPage.selectCategory(secondaryCategory.label)
await budgetsPage.maxSpendInput.fill(updatedMaxSpend.toString())
await budgetsPage.selectTheme(secondaryColor.label)
await budgetsPage.editDialogSaveButton.click()

await expect(
budgetsPage.budgetHeading(secondaryCategory.label)
).toBeVisible()
await expect(
budgetsPage.budgetMaxAmount(secondaryCategory.label)
).toContainText(currencyFormatter.format(updatedMaxSpend))
})

// Below test randomly fails. Not sure what's reason
// test("can delete a budget", async ({ page, userSession }) => {
// const categories = await prisma.category.findMany()
// const colors = await prisma.color.findMany()

// await prisma.budget.create({
// data: {
// userId: userSession.userId,
// categoryId: categories[0].id,
// maximumSpend: 100,
// colorId: colors[0].id,
// },
// })
test("can delete a budget", async ({ userSession, budgetsPage }) => {
const categories = await prisma.category.findMany()
const colors = await prisma.color.findMany()
const category = categories[0]
const color = colors[0]

// const budgetsPage = new BudgetsPage(page)
// await budgetsPage.goto()
await prisma.budget.create({
data: {
userId: userSession.userId,
categoryId: category.id,
maximumSpend: 100,
colorId: color.id,
},
})

// await budgetsPage.optionsButton.click()
// await budgetsPage.deleteMenuItem.click()
// await budgetsPage.confirmDeleteButton.click()
await budgetsPage.page.reload()
await budgetsPage.budgetOptionsButton(category.label).click()
await budgetsPage.deleteMenuItem.click()
await budgetsPage.deleteConfirmButton.click()

// await expect(budgetsPage.emptyState).toBeVisible()
// })
await expect(budgetsPage.emptyState).toBeVisible()
})
})
34 changes: 0 additions & 34 deletions tests/fixtures/auth.ts

This file was deleted.

102 changes: 102 additions & 0 deletions tests/fixtures/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { test as base } from "@playwright/test"

import { createToken } from "@/lib/session"
import { BudgetsPage } from "tests/page-objects/budgets-page"
import { LoginPage } from "tests/page-objects/login-page"
import { OverviewPage } from "tests/page-objects/overview-page"
import { PotsPage } from "tests/page-objects/pots-page"
import { SignUpPage } from "tests/page-objects/signup-page"
import { TransactionsPage } from "tests/page-objects/transactions-page"
import {
createDummyUserData,
createDummyUser,
deleteDummyUser,
} from "tests/utils"

type TestFixtures = {
dummyUserData: {
name: string
email: string
password: string
}
dummyUser: {
id: string
name: string
email: string
password: string
}
userSession: { userId: string }
loginPage: LoginPage
signUpPage: SignUpPage
overviewPage: OverviewPage
transactionsPage: TransactionsPage
budgetsPage: BudgetsPage
potsPage: PotsPage
}

export const test = base.extend<TestFixtures>({
dummyUserData: async ({}, use) => {
const userData = createDummyUserData()
use(userData)
},

dummyUser: async ({}, use) => {
const user = await createDummyUser()
await use(user)
await deleteDummyUser(user.id)
},

userSession: async ({ context }, use) => {
const user = await createDummyUser()
const sessionToken = await createToken({ userId: user.id })
await context.addCookies([
{
name: "session",
value: sessionToken,
httpOnly: true,
domain: "localhost",
path: "/",
},
])
await use({ userId: user.id })
await deleteDummyUser(user.id)
},

signUpPage: async ({ page }, use) => {
const signUpPage = new SignUpPage(page)
await signUpPage.goto()
await use(signUpPage)
},

loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await use(loginPage)
},

overviewPage: async ({ page, userSession }, use) => {
const overviewPage = new OverviewPage(page)
await overviewPage.goto()
await use(overviewPage)
},

transactionsPage: async ({ page, userSession }, use) => {
const transactionsPage = new TransactionsPage(page)
await transactionsPage.goto()
await use(transactionsPage)
},

budgetsPage: async ({ page, userSession }, use) => {
const budgetsPage = new BudgetsPage(page)
await budgetsPage.goto()
await use(budgetsPage)
},

potsPage: async ({ page, userSession }, use) => {
const potsPage = new PotsPage(page)
await potsPage.goto()
await use(potsPage)
},
})

export const expect = base.expect
Loading