diff --git a/.astro/content-assets.mjs b/.astro/content-assets.mjs deleted file mode 100644 index 2b8b823..0000000 --- a/.astro/content-assets.mjs +++ /dev/null @@ -1 +0,0 @@ -export default new Map(); \ No newline at end of file diff --git a/.astro/content-modules.mjs b/.astro/content-modules.mjs deleted file mode 100644 index 2b8b823..0000000 --- a/.astro/content-modules.mjs +++ /dev/null @@ -1 +0,0 @@ -export default new Map(); \ No newline at end of file diff --git a/.astro/data-store.json b/.astro/data-store.json deleted file mode 100644 index daa2b96..0000000 --- a/.astro/data-store.json +++ /dev/null @@ -1 +0,0 @@ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.7.5","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[]},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}"] \ No newline at end of file diff --git a/.astro/settings.json b/.astro/settings.json deleted file mode 100644 index 5b2b775..0000000 --- a/.astro/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_variables": { - "lastUpdateCheck": 1745454555490 - } -} \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts deleted file mode 100644 index 03d7cc4..0000000 --- a/.astro/types.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d476e3f..07d81cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,7 +9,7 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele * **Framework:** Astro (latest version) * **Language:** TypeScript, JavaScript (client-side scripts), HTML, CSS * **Styling:** Plain CSS (`src/styles/global.css`) -* **Data:** Currently using in-memory arrays. **The goal is to eventually integrate with a backend API.** +* **Data:** Using Astro's built-in API routes in `src/pages/api/` with a temporary in-memory store (`src/data/store.ts`). **The goal is to eventually replace the in-memory store with a persistent database.** ## Current State & Key Features @@ -17,7 +17,18 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele * **Sidebar:** (`src/components/Sidebar.astro`) Contains account selection dropdown and a collapsible section for adding new transactions. Includes an account summary section. * **Main Content:** (`src/components/MainContent.astro`) Displays the header with the current account name and the transaction list. * **Components:** Separate Astro components exist for major UI sections (Sidebar, MainContent, TransactionTable, AddTransactionForm, AccountSummary). -* **Data Loading:** Currently using empty arrays for accounts and transactions, initialized in `src/pages/index.astro`. +* **API Integration:** + * API routes structure implemented in `src/pages/api/` + * Temporary data store in `src/data/store.ts` + * All API endpoints implemented and ready to use: + * GET /api/accounts - List all accounts + * GET /api/accounts/:id - Get single account details + * GET /api/accounts/:id/transactions - Get transactions for an account + * POST /api/transactions - Create new transaction + * PUT /api/transactions/:id - Update existing transaction + * DELETE /api/transactions/:id - Delete transaction + * Error handling and validation included + * Prepared for future database integration with modular store design * **Account Switching:** Selecting an account from the dropdown in the sidebar correctly updates the Main Content area (header, transaction table) and the Account Summary section using client-side JavaScript (` \ No newline at end of file diff --git a/src/components/TransactionTable.astro b/src/components/TransactionTable.astro index f62b66b..3382cfd 100644 --- a/src/components/TransactionTable.astro +++ b/src/components/TransactionTable.astro @@ -10,10 +10,18 @@ const { transactions } = Astro.props; // Sort transactions by date descending for display const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + +// TODO: UI/UX Improvements +// - Add sorting functionality for all columns +// - Implement pagination for large transaction lists +// - Add transaction filtering capabilities +// - Implement row hover actions +// - Add transaction details expansion/collapse +// - Consider adding bulk actions (delete, categorize) --- -
+
- + @@ -30,8 +38,8 @@ const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).get {formatCurrency(txn.amount)} ))} diff --git a/src/data/store.ts b/src/data/store.ts new file mode 100644 index 0000000..d2cb2a8 --- /dev/null +++ b/src/data/store.ts @@ -0,0 +1,57 @@ +// TODO: Database Integration & Persistence +// - Implement database schema design +// - Add database connection pooling +// - Implement data migration strategy +// - Add database backup and recovery plans +// - Implement data validation layer +// - Add transaction logging +// - Implement audit trail +// - Add data archival strategy + +import type { Account, Transaction } from "../types"; + +// TODO: Replace in-memory store with persistent database +// - Implement database connection and configuration +// - Create database schema for accounts and transactions +// - Add migration system for schema changes +// - Update all CRUD operations to use database instead of arrays + +// Temporary in-memory store for development +export const accounts: Account[] = [ + { + id: "1", + name: "Checking Account", + last4: "4321", + balance: 2500.0, + }, + { + id: "2", + name: "Savings Account", + last4: "8765", + balance: 10000.0, + }, +]; + +export const transactions: Transaction[] = [ + { + id: "1", + accountId: "1", + date: "2025-04-20", + description: "Grocery Store", + amount: -75.5, + }, + { + id: "2", + accountId: "1", + date: "2025-04-21", + description: "Salary Deposit", + amount: 3000.0, + }, + { + id: "3", + accountId: "2", + date: "2025-04-22", + description: "Transfer to Savings", + amount: 500.0, + }, +]; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 817b9d3..7a1c02d 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -3,6 +3,15 @@ interface Props { title: string; } +// TODO: Accessibility Improvements +// - Add ARIA landmarks for main regions +// - Implement keyboard navigation +// - Add skip navigation links +// - Ensure proper heading hierarchy +// - Add focus management for modals/dialogs +// - Implement proper announcements for dynamic content +// - Add high contrast theme support + const { title } = Astro.props; --- diff --git a/src/pages/api/accounts/[id]/index.ts b/src/pages/api/accounts/[id]/index.ts new file mode 100644 index 0000000..2479188 --- /dev/null +++ b/src/pages/api/accounts/[id]/index.ts @@ -0,0 +1,22 @@ +import type { APIRoute } from "astro"; +import { accounts } from "../../../../data/store"; + +export const GET: APIRoute = async ({ params }) => { + const account = accounts.find((a) => a.id === params.id); + + if (!account) { + return new Response(JSON.stringify({ error: "Account not found" }), { + status: 404, + headers: { + "Content-Type": "application/json", + }, + }); + } + + return new Response(JSON.stringify(account), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); +}; diff --git a/src/pages/api/accounts/[id]/transactions/index.ts b/src/pages/api/accounts/[id]/transactions/index.ts new file mode 100644 index 0000000..bb8234f --- /dev/null +++ b/src/pages/api/accounts/[id]/transactions/index.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from "astro"; +import { transactions } from "../../../../../data/store"; + +export const GET: APIRoute = async ({ params }) => { + const accountTransactions = transactions.filter( + (t) => t.accountId === params.id + ); + + return new Response(JSON.stringify(accountTransactions), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); +}; diff --git a/src/pages/api/accounts/index.ts b/src/pages/api/accounts/index.ts new file mode 100644 index 0000000..f569def --- /dev/null +++ b/src/pages/api/accounts/index.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from "astro"; +import { accounts } from "../../../data/store"; + +export const GET: APIRoute = async () => { + return new Response(JSON.stringify(accounts), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); +}; diff --git a/src/pages/api/transactions/[id]/index.ts b/src/pages/api/transactions/[id]/index.ts new file mode 100644 index 0000000..f84ea5e --- /dev/null +++ b/src/pages/api/transactions/[id]/index.ts @@ -0,0 +1,126 @@ +import type { APIRoute } from "astro"; +import { transactions, accounts } from "../../../../data/store"; +import type { Transaction } from "../../../../types"; + +export const PUT: APIRoute = async ({ request, params }) => { + const { id } = params; + + if (!id) { + return new Response( + JSON.stringify({ error: "Transaction ID is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + try { + const updates = (await request.json()) as Partial; + const transactionIndex = transactions.findIndex((t) => t.id === id); + + if (transactionIndex === -1) { + return new Response(JSON.stringify({ error: "Transaction not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const oldTransaction = transactions[transactionIndex]; + + // Get the old account first + const oldAccount = accounts.find((a) => a.id === oldTransaction.accountId); + if (!oldAccount) { + return new Response(JSON.stringify({ error: "Account not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // If account is changing, validate new account exists + let newAccount = oldAccount; + if (updates.accountId && updates.accountId !== oldTransaction.accountId) { + const foundAccount = accounts.find((a) => a.id === updates.accountId); + if (!foundAccount) { + return new Response(JSON.stringify({ error: "Account not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + newAccount = foundAccount; + } + + // First, remove the old transaction's effect on the old account + oldAccount.balance -= oldTransaction.amount; + + // Create updated transaction + const updatedTransaction: Transaction = { + ...oldTransaction, + ...updates, + id: id, // Ensure ID doesn't change + }; + + // Then add the new transaction's effect to the appropriate account + if (newAccount === oldAccount) { + // If same account, just add the new amount + oldAccount.balance += updatedTransaction.amount; + } else { + // If different account, add to the new account + newAccount.balance += updatedTransaction.amount; + } + + // Update transaction in array + transactions[transactionIndex] = updatedTransaction; + + return new Response(JSON.stringify(updatedTransaction), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: "Invalid request body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const { id } = params; + + if (!id) { + return new Response( + JSON.stringify({ error: "Transaction ID is required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const transactionIndex = transactions.findIndex((t) => t.id === id); + + if (transactionIndex === -1) { + return new Response(JSON.stringify({ error: "Transaction not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const transaction = transactions[transactionIndex]; + const account = accounts.find((a) => a.id === transaction.accountId); + + if (!account) { + return new Response(JSON.stringify({ error: "Account not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Update account balance + account.balance -= transaction.amount; + + // Remove transaction from array + transactions.splice(transactionIndex, 1); + + return new Response(null, { status: 204 }); +}; diff --git a/src/pages/api/transactions/index.ts b/src/pages/api/transactions/index.ts new file mode 100644 index 0000000..ef5d74e --- /dev/null +++ b/src/pages/api/transactions/index.ts @@ -0,0 +1,79 @@ +/** + * TODO: Security Improvements + * - Add input validation and sanitization + * - Implement rate limiting for API endpoints + * - Add request authentication + * - Implement CSRF protection + * - Add request logging and monitoring + * - Implement secure session management + * - Add API versioning + * - Set up proper CORS configuration + */ + +import type { APIRoute } from "astro"; +import { transactions, accounts } from "../../../data/store"; +import type { Transaction } from "../../../types"; + +/** + * TODO: API Improvements + * - Add request rate limiting + * - Implement proper API authentication + * - Add input sanitization + * - Implement request validation middleware + * - Add API versioning + * - Consider implementing GraphQL for more flexible queries + * - Add proper logging and monitoring + */ + +export const POST: APIRoute = async ({ request }) => { + try { + const transaction = (await request.json()) as Omit; + + // Validate required fields + if ( + !transaction.accountId || + !transaction.date || + !transaction.description || + transaction.amount === undefined + ) { + return new Response( + JSON.stringify({ error: "Missing required fields" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // Validate account exists + const account = accounts.find((a) => a.id === transaction.accountId); + if (!account) { + return new Response(JSON.stringify({ error: "Account not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Create new transaction with generated ID + const newTransaction: Transaction = { + ...transaction, + id: (transactions.length + 1).toString(), // Simple ID generation for demo + }; + + // Update account balance + account.balance += transaction.amount; + + // Add to transactions array + transactions.push(newTransaction); + + return new Response(JSON.stringify(newTransaction), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: "Invalid request body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } +}; diff --git a/src/pages/index.astro b/src/pages/index.astro index 19b86f1..4a51af1 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,106 +3,283 @@ import BaseLayout from '../layouts/BaseLayout.astro'; import Sidebar from '../components/Sidebar.astro'; import MainContent from '../components/MainContent.astro'; import type { Account, Transaction } from '../types'; -import { formatCurrency, formatDate } from '../utils'; -// Initialize with empty arrays until API integration -const accounts: Account[] = []; -const allTransactions: Transaction[] = []; +// Fetch accounts from API +const accountsResponse = await fetch('http://localhost:4321/api/accounts'); +const accounts: Account[] = await accountsResponse.json(); -// Create an empty initial account -const initialAccount: Account = { +// Initialize with first account or empty account if none exist +const initialAccount: Account = accounts[0] || { id: '', name: 'No accounts available', last4: '0000', balance: 0 }; -const initialTransactions: Transaction[] = []; + +// Fetch initial transactions if we have an account +let initialTransactions: Transaction[] = []; +if (initialAccount.id) { + const transactionsResponse = await fetch(`http://localhost:4321/api/accounts/${initialAccount.id}/transactions`); + initialTransactions = await transactionsResponse.json(); +} --- + + + + + -
- - -
+
+ + +
- \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index 80a8eef..08e85f1 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -232,3 +232,48 @@ tbody tr:hover { padding: 20px; } } + +/* Loading States */ +.loading { + opacity: 0.6; + pointer-events: none; + position: relative; +} + +.loading::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 1.5em; + height: 1.5em; + margin: -0.75em 0 0 -0.75em; + border: 3px solid rgba(0, 123, 255, 0.2); + border-top-color: #007bff; + border-radius: 50%; + animation: spinner 0.6s linear infinite; +} + +@keyframes spinner { + to { + transform: rotate(360deg); + } +} + +.form-submit-btn { + position: relative; + min-width: 100px; +} + +.form-submit-btn.loading { + padding-right: 35px; +} + +.form-submit-btn.loading::after { + width: 1em; + height: 1em; + margin: -0.5em 0 0 0; + left: auto; + right: 10px; + border-width: 2px; +} diff --git a/src/test/accounts.test.ts b/src/test/accounts.test.ts new file mode 100644 index 0000000..47eeed6 --- /dev/null +++ b/src/test/accounts.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { GET as listAccounts } from "../pages/api/accounts/index"; +import { GET as getAccount } from "../pages/api/accounts/[id]/index"; +import { GET as listTransactions } from "../pages/api/accounts/[id]/transactions/index"; +import { createMockAPIContext } from "./setup"; + +describe("Accounts API", () => { + describe("GET /api/accounts", () => { + it("should return all accounts", async () => { + const response = await listAccounts(createMockAPIContext() as any); + const accounts = await response.json(); + + expect(response.status).toBe(200); + expect(accounts).toHaveLength(2); + expect(accounts[0]).toHaveProperty("id", "1"); + expect(accounts[1]).toHaveProperty("id", "2"); + }); + }); + + describe("GET /api/accounts/:id", () => { + it("should return a specific account", async () => { + const response = await getAccount( + createMockAPIContext({ params: { id: "1" } }) as any + ); + const account = await response.json(); + + expect(response.status).toBe(200); + expect(account).toHaveProperty("id", "1"); + expect(account).toHaveProperty("name", "Test Checking"); + }); + + it("should return 404 for non-existent account", async () => { + const response = await getAccount( + createMockAPIContext({ params: { id: "999" } }) as any + ); + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Account not found"); + }); + }); + + describe("GET /api/accounts/:id/transactions", () => { + it("should return transactions for an account", async () => { + const response = await listTransactions( + createMockAPIContext({ params: { id: "1" } }) as any + ); + const transactions = await response.json(); + + expect(response.status).toBe(200); + expect(transactions).toHaveLength(1); + expect(transactions[0]).toHaveProperty("accountId", "1"); + }); + + it("should return empty array for account with no transactions", async () => { + const response = await listTransactions( + createMockAPIContext({ params: { id: "999" } }) as any + ); + const transactions = await response.json(); + + expect(response.status).toBe(200); + expect(transactions).toHaveLength(0); + }); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..da93d5a --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,67 @@ +import { beforeEach } from "vitest"; +import { accounts, transactions } from "../data/store"; +import type { APIContext } from "astro"; + +// Create a mock APIContext factory +export function createMockAPIContext< + T extends Record = Record +>({ params = {} as T } = {}): Partial { + return { + params, + props: {}, + request: new Request("http://localhost:4321"), + site: new URL("http://localhost:4321"), + generator: "test", + url: new URL("http://localhost:4321"), + clientAddress: "127.0.0.1", + cookies: new Headers() as any, // Cast Headers to cookies as we don't need cookie functionality in tests + redirect: () => new Response(), + locals: {}, + preferredLocale: undefined, + preferredLocaleList: [], + currentLocale: undefined, + routePattern: "/api/[...path]", + originPathname: "/api", + getActionResult: () => undefined, + isPrerendered: false, + }; +} + +// Reset test data before each test +beforeEach(() => { + // Reset accounts to initial state + accounts.length = 0; + accounts.push( + { + id: "1", + name: "Test Checking", + last4: "1234", + balance: 1000.0, + }, + { + id: "2", + name: "Test Savings", + last4: "5678", + balance: 5000.0, + } + ); + + // Reset transactions to initial state + transactions.length = 0; + transactions.push( + { + id: "1", + accountId: "1", + date: "2025-04-24", + description: "Test Transaction 1", + amount: -50.0, + }, + { + id: "2", + accountId: "2", + date: "2025-04-24", + description: "Test Transaction 2", + amount: 100.0, + } + ); +}); diff --git a/src/test/transactions.test.ts b/src/test/transactions.test.ts new file mode 100644 index 0000000..08cea63 --- /dev/null +++ b/src/test/transactions.test.ts @@ -0,0 +1,332 @@ +// TODO: Testing Improvements +// - Add integration tests for API endpoints +// - Add end-to-end tests with Playwright/Cypress +// - Add performance testing +// - Add accessibility testing with axe-core +// - Add visual regression testing +// - Add load testing for API endpoints +// - Implement test data factories + +import { describe, it, expect } from "vitest"; +import { POST as createTransaction } from "../pages/api/transactions/index"; +import { + PUT as updateTransaction, + DELETE as deleteTransaction, +} from "../pages/api/transactions/[id]/index"; +import { accounts, transactions } from "../data/store"; +import type { Transaction } from "../types"; +import { createMockAPIContext } from "./setup"; + +describe("Transactions API", () => { + describe("POST /api/transactions", () => { + it("should create a new transaction", async () => { + const initialBalance = accounts[0].balance; + const newTransaction = { + accountId: "1", + date: "2025-04-24", + description: "Test New Transaction", + amount: -25.0, + }; + + const ctx = createMockAPIContext() as any; + ctx.request = new Request("http://localhost:4321/api/transactions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newTransaction), + }); + + const response = await createTransaction(ctx); + const result = await response.json(); + + expect(response.status).toBe(201); + expect(result).toHaveProperty("id"); + expect(result.description).toBe(newTransaction.description); + expect(accounts[0].balance).toBe(initialBalance + newTransaction.amount); + }); + + it("should reject transaction with missing fields", async () => { + const invalidTransaction = { + accountId: "1", + // Missing required fields + }; + + const ctx = createMockAPIContext() as any; + ctx.request = new Request("http://localhost:4321/api/transactions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(invalidTransaction), + }); + + const response = await createTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(400); + expect(error).toHaveProperty("error", "Missing required fields"); + }); + + it("should reject transaction with invalid account", async () => { + const invalidTransaction = { + accountId: "999", + date: "2025-04-24", + description: "Invalid Account Test", + amount: 100, + }; + + const ctx = createMockAPIContext() as any; + ctx.request = new Request("http://localhost:4321/api/transactions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(invalidTransaction), + }); + + const response = await createTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Account not found"); + }); + + it("should reject invalid request body", async () => { + const ctx = createMockAPIContext() as any; + ctx.request = new Request("http://localhost:4321/api/transactions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "invalid json", + }); + + const response = await createTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(400); + expect(error).toHaveProperty("error", "Invalid request body"); + }); + }); + + describe("PUT /api/transactions/:id", () => { + it("should update an existing transaction", async () => { + const initialBalance = accounts[0].balance; + const originalAmount = transactions[0].amount; + const updates = { + description: "Updated Description", + amount: -75.0, + }; + + const ctx = createMockAPIContext({ params: { id: "1" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }); + + const response = await updateTransaction(ctx); + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result.description).toBe(updates.description); + expect(result.amount).toBe(updates.amount); + expect(accounts[0].balance).toBe( + initialBalance - originalAmount + updates.amount + ); + }); + + it("should reject update with invalid request body", async () => { + const ctx = createMockAPIContext({ params: { id: "1" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "invalid json", + }); + + const response = await updateTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(400); + expect(error).toHaveProperty("error", "Invalid request body"); + }); + + it("should reject update for non-existent transaction", async () => { + const ctx = createMockAPIContext({ params: { id: "999" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/999", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description: "Test" }), + }); + + const response = await updateTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Transaction not found"); + }); + + it("should reject update for non-existent account", async () => { + // First update the transaction to point to a non-existent account + transactions[0].accountId = "999"; + + const ctx = createMockAPIContext({ params: { id: "1" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: -100 }), + }); + + const response = await updateTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Account not found"); + + // Reset account ID for other tests + transactions[0].accountId = "1"; + }); + + it("should handle account balance updates correctly when switching accounts", async () => { + // Create initial state + const oldAccount = accounts[0]; + const newAccount = accounts[1]; + const initialOldBalance = oldAccount.balance; + const initialNewBalance = newAccount.balance; + const oldTransaction = transactions.find((t) => t.id === "1"); + if (!oldTransaction) throw new Error("Test transaction not found"); + + const ctx = createMockAPIContext({ params: { id: "1" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountId: newAccount.id, + amount: -100, + }), + }); + + const response = await updateTransaction(ctx); + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result.accountId).toBe(newAccount.id); + + // Old account should have the old amount removed + expect(oldAccount.balance).toBe( + initialOldBalance + Math.abs(oldTransaction.amount) + ); + + // New account should have the new amount added + expect(newAccount.balance).toBe(initialNewBalance - 100); + }); + + it("should reject update without transaction ID", async () => { + const ctx = createMockAPIContext() as any; + ctx.request = new Request( + "http://localhost:4321/api/transactions/undefined", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description: "Test" }), + } + ); + + const response = await updateTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(400); + expect(error).toHaveProperty("error", "Transaction ID is required"); + }); + + it("should reject update when old account is missing", async () => { + // Store current accounts and clear the array + const savedAccounts = [...accounts]; + accounts.length = 0; + + const ctx = createMockAPIContext({ params: { id: "1" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount: -100 }), + }); + + const response = await updateTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Account not found"); + + // Restore accounts + accounts.push(...savedAccounts); + }); + + it("should reject update when new account doesn't exist", async () => { + const ctx = createMockAPIContext({ params: { id: "1" } }) as any; + ctx.request = new Request("http://localhost:4321/api/transactions/1", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + accountId: "999", // Non-existent account + amount: -100, + }), + }); + + const response = await updateTransaction(ctx); + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Account not found"); + }); + }); + + describe("DELETE /api/transactions/:id", () => { + it("should delete a transaction", async () => { + const initialBalance = accounts[0].balance; + const transactionAmount = transactions[0].amount; + const initialCount = transactions.length; + + const response = await deleteTransaction( + createMockAPIContext({ params: { id: "1" } }) as any + ); + + expect(response.status).toBe(204); + expect(transactions).toHaveLength(initialCount - 1); + expect(accounts[0].balance).toBe(initialBalance - transactionAmount); + }); + + it("should reject delete without transaction ID", async () => { + const response = await deleteTransaction(createMockAPIContext() as any); + + const error = await response.json(); + + expect(response.status).toBe(400); + expect(error).toHaveProperty("error", "Transaction ID is required"); + }); + + it("should return 404 for non-existent transaction", async () => { + const response = await deleteTransaction( + createMockAPIContext({ params: { id: "999" } }) as any + ); + + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Transaction not found"); + }); + + it("should handle deletion with non-existent account", async () => { + // Create a transaction then remove its account + const testTransaction: Transaction = { + id: "test-delete", + accountId: "test-account", + date: "2025-04-24", + description: "Test Delete", + amount: 100, + }; + transactions.push(testTransaction); + + const response = await deleteTransaction( + createMockAPIContext({ params: { id: "test-delete" } }) as any + ); + + const error = await response.json(); + + expect(response.status).toBe(404); + expect(error).toHaveProperty("error", "Account not found"); + }); + }); +}); diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 0000000..410e49a --- /dev/null +++ b/src/types/events.ts @@ -0,0 +1,13 @@ +import type { Transaction } from "../types"; + +export interface TransactionEventDetail { + transaction: Transaction; +} + +declare global { + interface WindowEventMap { + transactionCreated: CustomEvent; + transactionUpdated: CustomEvent; + editTransaction: CustomEvent; + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f1bdb78 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +/// +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // Increase timeout for slower CI environments + testTimeout: 10000, + // Use the setup file we created + setupFiles: ["./src/test/setup.ts"], + // Ensure we're using the right environment + environment: "node", + // Only include test files + include: ["src/test/**/*.{test,spec}.{ts,js}"], + // Configure coverage collection + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules/", "src/test/**/*", "**/*.d.ts"], + }, + }, +});
Date Description - - + +