diff --git a/budget-tracker/.gitignore b/budget-tracker/.gitignore new file mode 100644 index 000000000..618c20f69 --- /dev/null +++ b/budget-tracker/.gitignore @@ -0,0 +1,8 @@ +build/ +.gradle/ +*.class +*.jar +*.log +budget_data.json +.idea/ +*.iml diff --git a/budget-tracker/build.gradle b/budget-tracker/build.gradle new file mode 100644 index 000000000..581533611 --- /dev/null +++ b/budget-tracker/build.gradle @@ -0,0 +1,72 @@ +plugins { + id 'java' + id 'application' + id 'jacoco' + id 'checkstyle' +} + +group = 'com.budgettracker' +version = '1.0.0' + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +application { + mainClass = 'com.budgettracker.BudgetTrackerApp' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.google.code.gson:gson:2.10.1' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} + +jacoco { + toolVersion = '0.8.11' +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + violationRules { + rule { + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.80 + } + } + } +} + +checkstyle { + toolVersion = '10.14.2' + maxWarnings = 0 +} + +tasks.withType(Checkstyle).configureEach { + reports { + xml.required = true + html.required = true + } +} + +check.dependsOn jacocoTestCoverageVerification diff --git a/budget-tracker/config/checkstyle/checkstyle.xml b/budget-tracker/config/checkstyle/checkstyle.xml new file mode 100644 index 000000000..bf6829f0d --- /dev/null +++ b/budget-tracker/config/checkstyle/checkstyle.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/budget-tracker/gradle/wrapper/gradle-wrapper.properties b/budget-tracker/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..b82aa23a4 --- /dev/null +++ b/budget-tracker/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/budget-tracker/gradlew b/budget-tracker/gradlew new file mode 100755 index 000000000..1aa94a426 --- /dev/null +++ b/budget-tracker/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/budget-tracker/gradlew.bat b/budget-tracker/gradlew.bat new file mode 100644 index 000000000..7101f8e46 --- /dev/null +++ b/budget-tracker/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/budget-tracker/settings.gradle b/budget-tracker/settings.gradle new file mode 100644 index 000000000..e2c1c81ee --- /dev/null +++ b/budget-tracker/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'budget-tracker' diff --git a/budget-tracker/src/main/java/com/budgettracker/BudgetTrackerApp.java b/budget-tracker/src/main/java/com/budgettracker/BudgetTrackerApp.java new file mode 100644 index 000000000..be02dc258 --- /dev/null +++ b/budget-tracker/src/main/java/com/budgettracker/BudgetTrackerApp.java @@ -0,0 +1,134 @@ +package com.budgettracker; + +import com.budgettracker.service.BudgetService; +import com.budgettracker.storage.StorageService; + +import java.util.Scanner; +import java.util.Set; + +public class BudgetTrackerApp { + + private static final String DATA_FILE = "budget_data.json"; + private static final String MENU = """ + + ============ BUDGET TRACKER ============ + 1. Add Income + 2. Add Expense + 3. View Summary + 4. List All Transactions + 5. Filter by Category + 6. Exit + ======================================== + Choose an option (1-6):\s"""; + + private final BudgetService budgetService; + private final Scanner scanner; + + public BudgetTrackerApp(BudgetService budgetService, Scanner scanner) { + this.budgetService = budgetService; + this.scanner = scanner; + } + + public static void main(String[] args) { + StorageService storageService = new StorageService(DATA_FILE); + BudgetService budgetService = new BudgetService(storageService); + Scanner scanner = new Scanner(System.in); + + BudgetTrackerApp app = new BudgetTrackerApp(budgetService, scanner); + app.run(); + } + + public void run() { + System.out.println("Welcome to Budget Tracker!"); + System.out.println("Your data is saved to: " + DATA_FILE); + + boolean running = true; + while (running) { + System.out.print(MENU); + String choice = scanner.nextLine().trim(); + + switch (choice) { + case "1" -> handleAddIncome(); + case "2" -> handleAddExpense(); + case "3" -> budgetService.printSummary(); + case "4" -> budgetService.printTransactions(); + case "5" -> handleFilterByCategory(); + case "6" -> { + System.out.println("Goodbye!"); + running = false; + } + default -> System.out.println("Error: Invalid option. Please enter a number between 1 and 6."); + } + } + } + + void handleAddIncome() { + System.out.print("Enter description: "); + String description = scanner.nextLine(); + + double amount = readAmount(); + if (amount < 0) { + return; + } + + try { + budgetService.addIncome(description, amount); + System.out.printf("Income added: %s - $%.2f%n", description.trim(), amount); + } catch (RuntimeException e) { + System.out.println("Error: " + e.getMessage()); + } + } + + void handleAddExpense() { + System.out.print("Enter category (e.g., Food, Transport, Rent): "); + String category = scanner.nextLine(); + + System.out.print("Enter description: "); + String description = scanner.nextLine(); + + double amount = readAmount(); + if (amount < 0) { + return; + } + + try { + budgetService.addExpense(category, description, amount); + System.out.printf("Expense added: [%s] %s - $%.2f%n", + category.trim(), description.trim(), amount); + } catch (RuntimeException e) { + System.out.println("Error: " + e.getMessage()); + } + } + + void handleFilterByCategory() { + Set categories = budgetService.getCategories(); + if (categories.isEmpty()) { + System.out.println("No categories found. Add some expenses first."); + return; + } + System.out.println("Available categories: " + String.join(", ", categories)); + System.out.print("Enter category to filter by: "); + String category = scanner.nextLine(); + try { + budgetService.printFilteredTransactions(category); + } catch (RuntimeException e) { + System.out.println("Error: " + e.getMessage()); + } + } + + double readAmount() { + System.out.print("Enter amount: "); + String input = scanner.nextLine().trim(); + try { + double amount = Double.parseDouble(input); + if (amount <= 0 || !Double.isFinite(amount)) { + System.out.println("Error: Amount must be a positive number."); + return -1; + } + return amount; + } catch (NumberFormatException e) { + System.out.println("Error: Invalid amount. Please enter a valid number (e.g., 100.50)."); + return -1; + } + } +} diff --git a/budget-tracker/src/main/java/com/budgettracker/model/Transaction.java b/budget-tracker/src/main/java/com/budgettracker/model/Transaction.java new file mode 100644 index 000000000..72deacc79 --- /dev/null +++ b/budget-tracker/src/main/java/com/budgettracker/model/Transaction.java @@ -0,0 +1,56 @@ +package com.budgettracker.model; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class Transaction { + + private static final DateTimeFormatter DISPLAY_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private TransactionType type; + private String category; + private String description; + private double amount; + private String timestamp; + + public Transaction(TransactionType type, String category, String description, double amount) { + this.type = type; + this.category = category; + this.description = description; + this.amount = amount; + this.timestamp = LocalDateTime.now().format(DISPLAY_FORMAT); + } + + public TransactionType getType() { + return type; + } + + public String getCategory() { + return category; + } + + public String getDescription() { + return description; + } + + public double getAmount() { + return amount; + } + + public String getTimestamp() { + return timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = timestamp; + } + + @Override + public String toString() { + String typeStr = type == TransactionType.INCOME ? "INCOME " : "EXPENSE"; + String categoryStr = (category == null || category.isEmpty()) ? "N/A" : category; + return String.format("%-22s %-8s %-14s %-25s $%.2f", + timestamp, typeStr, categoryStr, description, amount); + } +} diff --git a/budget-tracker/src/main/java/com/budgettracker/model/TransactionType.java b/budget-tracker/src/main/java/com/budgettracker/model/TransactionType.java new file mode 100644 index 000000000..b62a0aed4 --- /dev/null +++ b/budget-tracker/src/main/java/com/budgettracker/model/TransactionType.java @@ -0,0 +1,6 @@ +package com.budgettracker.model; + +public enum TransactionType { + INCOME, + EXPENSE +} diff --git a/budget-tracker/src/main/java/com/budgettracker/service/BudgetService.java b/budget-tracker/src/main/java/com/budgettracker/service/BudgetService.java new file mode 100644 index 000000000..ebc0d0fab --- /dev/null +++ b/budget-tracker/src/main/java/com/budgettracker/service/BudgetService.java @@ -0,0 +1,152 @@ +package com.budgettracker.service; + +import com.budgettracker.model.Transaction; +import com.budgettracker.model.TransactionType; +import com.budgettracker.storage.StorageService; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +public class BudgetService { + + private final List transactions; + private final StorageService storageService; + + public BudgetService(StorageService storageService) { + this.storageService = storageService; + this.transactions = storageService.load(); + } + + public Transaction addIncome(String description, double amount) { + if (description == null || description.trim().isEmpty()) { + throw new IllegalArgumentException("Description cannot be empty."); + } + if (amount <= 0 || !Double.isFinite(amount)) { + throw new IllegalArgumentException("Amount must be a positive number."); + } + Transaction transaction = new Transaction(TransactionType.INCOME, "", description.trim(), amount); + transactions.add(transaction); + if (!storageService.save(transactions)) { + transactions.remove(transactions.size() - 1); + throw new RuntimeException("Failed to save transaction to disk."); + } + return transaction; + } + + public Transaction addExpense(String category, String description, double amount) { + if (category == null || category.trim().isEmpty()) { + throw new IllegalArgumentException("Category cannot be empty."); + } + if (description == null || description.trim().isEmpty()) { + throw new IllegalArgumentException("Description cannot be empty."); + } + if (amount <= 0 || !Double.isFinite(amount)) { + throw new IllegalArgumentException("Amount must be a positive number."); + } + Transaction transaction = new Transaction( + TransactionType.EXPENSE, category.trim(), description.trim(), amount); + transactions.add(transaction); + if (!storageService.save(transactions)) { + transactions.remove(transactions.size() - 1); + throw new RuntimeException("Failed to save transaction to disk."); + } + return transaction; + } + + public double getTotalIncome() { + return transactions.stream() + .filter(t -> t.getType() == TransactionType.INCOME) + .mapToDouble(Transaction::getAmount) + .sum(); + } + + public double getTotalExpenses() { + return transactions.stream() + .filter(t -> t.getType() == TransactionType.EXPENSE) + .mapToDouble(Transaction::getAmount) + .sum(); + } + + public double getNetBalance() { + return getTotalIncome() - getTotalExpenses(); + } + + public List getTransactions() { + return Collections.unmodifiableList(transactions); + } + + public Set getCategories() { + return transactions.stream() + .map(Transaction::getCategory) + .filter(c -> c != null && !c.isEmpty()) + .collect(Collectors.toCollection(TreeSet::new)); + } + + public List filterByCategory(String category) { + if (category == null || category.trim().isEmpty()) { + throw new IllegalArgumentException("Category cannot be empty."); + } + String target = category.trim().toLowerCase(); + return transactions.stream() + .filter(t -> t.getCategory() != null + && t.getCategory().toLowerCase().equals(target)) + .collect(Collectors.toList()); + } + + public void printFilteredTransactions(String category) { + List filtered = filterByCategory(category); + System.out.println(); + if (filtered.isEmpty()) { + System.out.printf("No transactions found for category: %s%n", category.trim()); + System.out.println(); + return; + } + System.out.printf("============ TRANSACTIONS: %s ============%n", category.trim().toUpperCase()); + System.out.printf("%-22s %-8s %-14s %-25s %s%n", + "Date/Time", "Type", "Category", "Description", "Amount"); + System.out.println("--------------------------------------------------------"); + for (Transaction t : filtered) { + System.out.println(t); + } + System.out.println("========================================================"); + System.out.printf("Total: %d transaction(s)%n", filtered.size()); + System.out.println(); + } + + public void printSummary() { + double income = getTotalIncome(); + double expenses = getTotalExpenses(); + double balance = getNetBalance(); + + System.out.println(); + System.out.println("==================== BUDGET SUMMARY ===================="); + System.out.printf(" Total Income: $%10.2f%n", income); + System.out.printf(" Total Expenses: $%10.2f%n", expenses); + System.out.println("--------------------------------------------------------"); + System.out.printf(" Net Balance: $%10.2f%n", balance); + System.out.println("========================================================"); + System.out.println(); + } + + public void printTransactions() { + System.out.println(); + if (transactions.isEmpty()) { + System.out.println("No transactions recorded yet."); + System.out.println(); + return; + } + System.out.println("=================== ALL TRANSACTIONS ==================="); + System.out.printf("%-22s %-8s %-14s %-25s %s%n", + "Date/Time", "Type", "Category", "Description", "Amount"); + System.out.println("--------------------------------------------------------"); + for (Transaction t : transactions) { + System.out.println(t); + } + System.out.println("========================================================"); + System.out.printf("Total: %d transaction(s)%n", transactions.size()); + System.out.println(); + } +} diff --git a/budget-tracker/src/main/java/com/budgettracker/storage/StorageService.java b/budget-tracker/src/main/java/com/budgettracker/storage/StorageService.java new file mode 100644 index 000000000..5ecaa346b --- /dev/null +++ b/budget-tracker/src/main/java/com/budgettracker/storage/StorageService.java @@ -0,0 +1,82 @@ +package com.budgettracker.storage; + +import com.budgettracker.model.Transaction; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.reflect.TypeToken; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; + +public class StorageService { + + private static final Type TRANSACTION_LIST_TYPE = + new TypeToken>() {}.getType(); + + private final Path filePath; + private final Gson gson; + + public StorageService(String filePath) { + this.filePath = Path.of(filePath); + this.gson = new GsonBuilder().setPrettyPrinting().create(); + } + + public List load() { + if (!Files.exists(filePath)) { + return new ArrayList<>(); + } + try (Reader reader = Files.newBufferedReader(filePath)) { + List transactions = gson.fromJson(reader, TRANSACTION_LIST_TYPE); + return transactions != null ? new ArrayList<>(transactions) : new ArrayList<>(); + } catch (IOException e) { + System.err.println("Error reading data file: " + e.getMessage()); + return new ArrayList<>(); + } catch (com.google.gson.JsonSyntaxException e) { + System.err.println("Error: Data file is corrupted. Starting with empty data."); + return new ArrayList<>(); + } catch (JsonIOException e) { + System.err.println("Error reading data file: " + e.getMessage()); + return new ArrayList<>(); + } + } + + public boolean save(List transactions) { + Path tmpPath = Path.of(filePath + ".tmp"); + try { + Path parent = filePath.getParent(); + if (parent != null && !Files.exists(parent)) { + Files.createDirectories(parent); + } + try (Writer writer = Files.newBufferedWriter(tmpPath)) { + gson.toJson(transactions, writer); + } + Files.move(tmpPath, filePath, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + return true; + } catch (IOException e) { + System.err.println("Error saving data: " + e.getMessage()); + deleteTmpFile(tmpPath); + return false; + } catch (JsonIOException e) { + System.err.println("Error saving data: " + e.getMessage()); + deleteTmpFile(tmpPath); + return false; + } + } + + private void deleteTmpFile(Path tmpPath) { + try { + Files.deleteIfExists(tmpPath); + } catch (IOException ignored) { + // best-effort cleanup + } + } +} diff --git a/budget-tracker/src/test/java/com/budgettracker/BudgetTrackerAppTest.java b/budget-tracker/src/test/java/com/budgettracker/BudgetTrackerAppTest.java new file mode 100644 index 000000000..7713b667e --- /dev/null +++ b/budget-tracker/src/test/java/com/budgettracker/BudgetTrackerAppTest.java @@ -0,0 +1,312 @@ +package com.budgettracker; + +import com.budgettracker.service.BudgetService; +import com.budgettracker.storage.StorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Path; +import java.util.Scanner; + +import static org.junit.jupiter.api.Assertions.*; + +class BudgetTrackerAppTest { + + @TempDir + Path tempDir; + + private BudgetService budgetService; + private ByteArrayOutputStream outputStream; + private PrintStream originalOut; + + @BeforeEach + void setUp() { + String filePath = tempDir.resolve("test.json").toString(); + StorageService storageService = new StorageService(filePath); + budgetService = new BudgetService(storageService); + originalOut = System.out; + outputStream = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outputStream)); + } + + @org.junit.jupiter.api.AfterEach + void tearDown() { + System.setOut(originalOut); + } + + private BudgetTrackerApp createApp(String input) { + Scanner scanner = new Scanner(input); + return new BudgetTrackerApp(budgetService, scanner); + } + + @Test + void addIncomeFlow() { + String input = "1\nSalary\n5000\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Income added: Salary - $5000.00")); + assertEquals(1, budgetService.getTransactions().size()); + assertEquals(5000.0, budgetService.getTotalIncome(), 0.01); + + } + + @Test + void addExpenseFlow() { + String input = "2\nFood\nGroceries\n150.50\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Expense added: [Food] Groceries - $150.50")); + assertEquals(1, budgetService.getTransactions().size()); + assertEquals(150.50, budgetService.getTotalExpenses(), 0.01); + + } + + @Test + void viewSummaryFlow() { + budgetService.addIncome("Salary", 5000); + budgetService.addExpense("Food", "Lunch", 25); + + String input = "3\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("BUDGET SUMMARY")); + + } + + @Test + void listTransactionsFlow() { + budgetService.addIncome("Salary", 3000); + budgetService.addExpense("Rent", "Monthly", 1200); + + String input = "4\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("ALL TRANSACTIONS")); + assertTrue(output.contains("Salary")); + assertTrue(output.contains("Monthly")); + + } + + @Test + void invalidMenuOption() { + String input = "9\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Invalid option")); + + } + + @Test + void invalidAmountShowsError() { + String input = "1\nSalary\nabc\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Invalid amount")); + assertEquals(0, budgetService.getTransactions().size()); + + } + + @Test + void negativeAmountShowsError() { + String input = "1\nSalary\n-100\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("must be a positive number")); + assertEquals(0, budgetService.getTransactions().size()); + + } + + @Test + void zeroAmountShowsError() { + String input = "2\nFood\nLunch\n0\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("must be a positive number")); + + } + + @Test + void emptyDescriptionForIncomeShowsError() { + String input = "1\n\n100\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Description cannot be empty")); + + } + + @Test + void emptyFieldsForExpenseShowsError() { + String input = "2\n\nGroceries\n100\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Category cannot be empty")); + + } + + @Test + void exitShowsGoodbye() { + String input = "6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + assertTrue(outputStream.toString().contains("Goodbye!")); + + } + + @Test + void multipleOperationsInSequence() { + String input = "1\nSalary\n5000\n2\nFood\nGroceries\n200\n3\n4\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("Income added")); + assertTrue(output.contains("Expense added")); + assertTrue(output.contains("BUDGET SUMMARY")); + assertTrue(output.contains("ALL TRANSACTIONS")); + assertEquals(2, budgetService.getTransactions().size()); + + } + + @Test + void handleAddIncomeDirectly() { + String input = "Test Income\n250.75\n"; + BudgetTrackerApp app = createApp(input); + app.handleAddIncome(); + + String output = outputStream.toString(); + assertTrue(output.contains("Income added: Test Income - $250.75")); + + } + + @Test + void handleAddExpenseDirectly() { + String input = "Transport\nBus fare\n3.50\n"; + BudgetTrackerApp app = createApp(input); + app.handleAddExpense(); + + String output = outputStream.toString(); + assertTrue(output.contains("Expense added: [Transport] Bus fare - $3.50")); + + } + + @Test + void readAmountWithValidInput() { + String input = "42.99\n"; + BudgetTrackerApp app = createApp(input); + double amount = app.readAmount(); + assertEquals(42.99, amount, 0.01); + + } + + @Test + void readAmountWithInvalidInput() { + String input = "not_a_number\n"; + BudgetTrackerApp app = createApp(input); + double amount = app.readAmount(); + assertEquals(-1, amount, 0.01); + + } + + @Test + void readAmountWithNegativeInput() { + String input = "-50\n"; + BudgetTrackerApp app = createApp(input); + double amount = app.readAmount(); + assertEquals(-1, amount, 0.01); + + } + + @Test + void readAmountRejectsNaN() { + String input = "NaN\n"; + BudgetTrackerApp app = createApp(input); + double amount = app.readAmount(); + assertEquals(-1, amount, 0.01); + } + + @Test + void readAmountRejectsInfinity() { + String input = "Infinity\n"; + BudgetTrackerApp app = createApp(input); + double amount = app.readAmount(); + assertEquals(-1, amount, 0.01); + } + + @Test + void filterByCategoryFlow() { + budgetService.addExpense("Food", "Lunch", 15); + budgetService.addExpense("Food", "Dinner", 30); + budgetService.addExpense("Transport", "Bus", 5); + + String input = "5\nFood\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("TRANSACTIONS: FOOD")); + assertTrue(output.contains("Lunch")); + assertTrue(output.contains("Dinner")); + assertFalse(output.contains("Bus")); + } + + @Test + void filterByCategoryNoMatch() { + budgetService.addExpense("Food", "Lunch", 15); + + String input = "5\nRent\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("No transactions found for category: Rent")); + } + + @Test + void filterByCategoryNoCategories() { + String input = "5\n6\n"; + BudgetTrackerApp app = createApp(input); + app.run(); + + String output = outputStream.toString(); + assertTrue(output.contains("No categories found")); + } + + @Test + void handleFilterByCategoryDirectly() { + budgetService.addExpense("Food", "Lunch", 15); + budgetService.addExpense("Transport", "Bus", 5); + + String input = "Food\n"; + BudgetTrackerApp app = createApp(input); + app.handleFilterByCategory(); + + String output = outputStream.toString(); + assertTrue(output.contains("Available categories: Food, Transport")); + assertTrue(output.contains("TRANSACTIONS: FOOD")); + assertTrue(output.contains("Lunch")); + } +} diff --git a/budget-tracker/src/test/java/com/budgettracker/model/TransactionTest.java b/budget-tracker/src/test/java/com/budgettracker/model/TransactionTest.java new file mode 100644 index 000000000..17bca8c46 --- /dev/null +++ b/budget-tracker/src/test/java/com/budgettracker/model/TransactionTest.java @@ -0,0 +1,56 @@ +package com.budgettracker.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class TransactionTest { + + @Test + void incomeTransactionCreation() { + Transaction t = new Transaction(TransactionType.INCOME, "", "Salary", 5000.0); + assertEquals(TransactionType.INCOME, t.getType()); + assertEquals("", t.getCategory()); + assertEquals("Salary", t.getDescription()); + assertEquals(5000.0, t.getAmount()); + assertNotNull(t.getTimestamp()); + } + + @Test + void expenseTransactionCreation() { + Transaction t = new Transaction(TransactionType.EXPENSE, "Food", "Groceries", 150.0); + assertEquals(TransactionType.EXPENSE, t.getType()); + assertEquals("Food", t.getCategory()); + assertEquals("Groceries", t.getDescription()); + assertEquals(150.0, t.getAmount()); + } + + @Test + void setTimestamp() { + Transaction t = new Transaction(TransactionType.INCOME, "", "Test", 100.0); + t.setTimestamp("2024-01-15 10:30:00"); + assertEquals("2024-01-15 10:30:00", t.getTimestamp()); + } + + @Test + void toStringForIncome() { + Transaction t = new Transaction(TransactionType.INCOME, "", "Salary", 5000.0); + t.setTimestamp("2024-01-15 10:30:00"); + String str = t.toString(); + assertTrue(str.contains("INCOME")); + assertTrue(str.contains("N/A")); + assertTrue(str.contains("Salary")); + assertTrue(str.contains("5000.00")); + } + + @Test + void toStringForExpense() { + Transaction t = new Transaction(TransactionType.EXPENSE, "Food", "Groceries", 150.0); + t.setTimestamp("2024-01-15 10:30:00"); + String str = t.toString(); + assertTrue(str.contains("EXPENSE")); + assertTrue(str.contains("Food")); + assertTrue(str.contains("Groceries")); + assertTrue(str.contains("150.00")); + } +} diff --git a/budget-tracker/src/test/java/com/budgettracker/service/BudgetServiceTest.java b/budget-tracker/src/test/java/com/budgettracker/service/BudgetServiceTest.java new file mode 100644 index 000000000..a9e560395 --- /dev/null +++ b/budget-tracker/src/test/java/com/budgettracker/service/BudgetServiceTest.java @@ -0,0 +1,293 @@ +package com.budgettracker.service; + +import com.budgettracker.model.Transaction; +import com.budgettracker.model.TransactionType; +import com.budgettracker.storage.StorageService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class BudgetServiceTest { + + @TempDir + Path tempDir; + + private BudgetService budgetService; + private PrintStream originalOut; + + @BeforeEach + void setUp() { + String filePath = tempDir.resolve("test_budget.json").toString(); + StorageService storageService = new StorageService(filePath); + budgetService = new BudgetService(storageService); + originalOut = System.out; + } + + @AfterEach + void tearDown() { + System.setOut(originalOut); + } + + @Test + void addIncomeSuccessfully() { + Transaction t = budgetService.addIncome("Salary", 5000.0); + assertEquals(TransactionType.INCOME, t.getType()); + assertEquals("Salary", t.getDescription()); + assertEquals(5000.0, t.getAmount()); + assertEquals("", t.getCategory()); + } + + @Test + void addIncomeTrimsDescription() { + Transaction t = budgetService.addIncome(" Freelance Work ", 1000.0); + assertEquals("Freelance Work", t.getDescription()); + } + + @Test + void addIncomeRejectsEmptyDescription() { + assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome("", 100.0)); + assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome(" ", 100.0)); + assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome(null, 100.0)); + } + + @Test + void addIncomeRejectsNonPositiveAmount() { + assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome("Salary", 0)); + assertThrows(IllegalArgumentException.class, () -> budgetService.addIncome("Salary", -100)); + } + + @Test + void addExpenseSuccessfully() { + Transaction t = budgetService.addExpense("Food", "Groceries", 150.0); + assertEquals(TransactionType.EXPENSE, t.getType()); + assertEquals("Food", t.getCategory()); + assertEquals("Groceries", t.getDescription()); + assertEquals(150.0, t.getAmount()); + } + + @Test + void addExpenseTrimsInputs() { + Transaction t = budgetService.addExpense(" Transport ", " Bus fare ", 50.0); + assertEquals("Transport", t.getCategory()); + assertEquals("Bus fare", t.getDescription()); + } + + @Test + void addExpenseRejectsEmptyCategory() { + assertThrows(IllegalArgumentException.class, + () -> budgetService.addExpense("", "Groceries", 100.0)); + assertThrows(IllegalArgumentException.class, + () -> budgetService.addExpense(null, "Groceries", 100.0)); + } + + @Test + void addExpenseRejectsEmptyDescription() { + assertThrows(IllegalArgumentException.class, + () -> budgetService.addExpense("Food", "", 100.0)); + assertThrows(IllegalArgumentException.class, + () -> budgetService.addExpense("Food", null, 100.0)); + } + + @Test + void addExpenseRejectsNonPositiveAmount() { + assertThrows(IllegalArgumentException.class, + () -> budgetService.addExpense("Food", "Groceries", 0)); + assertThrows(IllegalArgumentException.class, + () -> budgetService.addExpense("Food", "Groceries", -50)); + } + + @Test + void getTotalIncome() { + budgetService.addIncome("Salary", 5000.0); + budgetService.addIncome("Bonus", 1000.0); + budgetService.addExpense("Food", "Groceries", 200.0); + assertEquals(6000.0, budgetService.getTotalIncome(), 0.01); + } + + @Test + void getTotalExpenses() { + budgetService.addIncome("Salary", 5000.0); + budgetService.addExpense("Food", "Groceries", 200.0); + budgetService.addExpense("Transport", "Bus", 50.0); + assertEquals(250.0, budgetService.getTotalExpenses(), 0.01); + } + + @Test + void getNetBalance() { + budgetService.addIncome("Salary", 5000.0); + budgetService.addExpense("Rent", "Monthly rent", 1500.0); + budgetService.addExpense("Food", "Groceries", 300.0); + assertEquals(3200.0, budgetService.getNetBalance(), 0.01); + } + + @Test + void getNetBalanceIsZeroWhenNoTransactions() { + assertEquals(0.0, budgetService.getNetBalance(), 0.01); + } + + @Test + void getTransactionsReturnsUnmodifiableList() { + budgetService.addIncome("Salary", 5000.0); + List transactions = budgetService.getTransactions(); + assertThrows(UnsupportedOperationException.class, + () -> transactions.add(new Transaction(TransactionType.INCOME, "", "Hack", 1.0))); + } + + @Test + void getTransactionsInChronologicalOrder() { + budgetService.addIncome("First", 100.0); + budgetService.addExpense("Cat", "Second", 200.0); + budgetService.addIncome("Third", 300.0); + + List transactions = budgetService.getTransactions(); + assertEquals(3, transactions.size()); + assertEquals("First", transactions.get(0).getDescription()); + assertEquals("Second", transactions.get(1).getDescription()); + assertEquals("Third", transactions.get(2).getDescription()); + } + + @Test + void printSummaryOutputsCorrectFormat() { + budgetService.addIncome("Salary", 5000.0); + budgetService.addExpense("Food", "Groceries", 200.0); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + budgetService.printSummary(); + + String output = out.toString(); + assertTrue(output.contains("BUDGET SUMMARY")); + assertTrue(output.contains("5000.00")); + assertTrue(output.contains("200.00")); + assertTrue(output.contains("4800.00")); + } + + @Test + void printTransactionsWhenEmpty() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + budgetService.printTransactions(); + + assertTrue(out.toString().contains("No transactions recorded yet.")); + } + + @Test + void printTransactionsShowsAllEntries() { + budgetService.addIncome("Salary", 5000.0); + budgetService.addExpense("Food", "Groceries", 200.0); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + budgetService.printTransactions(); + + String output = out.toString(); + assertTrue(output.contains("ALL TRANSACTIONS")); + assertTrue(output.contains("Salary")); + assertTrue(output.contains("Groceries")); + assertTrue(output.contains("2 transaction(s)")); + } + + @Test + void getCategoriesReturnsDistinctSortedCategories() { + budgetService.addExpense("Food", "Lunch", 10); + budgetService.addExpense("Transport", "Bus", 5); + budgetService.addExpense("Food", "Dinner", 20); + budgetService.addIncome("Salary", 5000); + + java.util.Set categories = budgetService.getCategories(); + assertEquals(2, categories.size()); + assertTrue(categories.contains("Food")); + assertTrue(categories.contains("Transport")); + } + + @Test + void getCategoriesEmptyWhenNoExpenses() { + budgetService.addIncome("Salary", 5000); + assertTrue(budgetService.getCategories().isEmpty()); + } + + @Test + void filterByCategoryReturnMatchingTransactions() { + budgetService.addExpense("Food", "Lunch", 10); + budgetService.addExpense("Transport", "Bus", 5); + budgetService.addExpense("Food", "Dinner", 20); + + List filtered = budgetService.filterByCategory("Food"); + assertEquals(2, filtered.size()); + assertTrue(filtered.stream().allMatch(t -> "Food".equals(t.getCategory()))); + } + + @Test + void filterByCategoryIsCaseInsensitive() { + budgetService.addExpense("Food", "Lunch", 10); + List filtered = budgetService.filterByCategory("food"); + assertEquals(1, filtered.size()); + } + + @Test + void filterByCategoryReturnsEmptyForNoMatch() { + budgetService.addExpense("Food", "Lunch", 10); + List filtered = budgetService.filterByCategory("Rent"); + assertTrue(filtered.isEmpty()); + } + + @Test + void filterByCategoryThrowsOnEmptyCategory() { + assertThrows(IllegalArgumentException.class, () -> budgetService.filterByCategory("")); + assertThrows(IllegalArgumentException.class, () -> budgetService.filterByCategory(null)); + } + + @Test + void printFilteredTransactionsShowsMatchingEntries() { + budgetService.addExpense("Food", "Lunch", 10); + budgetService.addExpense("Transport", "Bus", 5); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + budgetService.printFilteredTransactions("Food"); + + String output = out.toString(); + assertTrue(output.contains("TRANSACTIONS: FOOD")); + assertTrue(output.contains("Lunch")); + assertFalse(output.contains("Bus")); + } + + @Test + void printFilteredTransactionsShowsNoMatchMessage() { + budgetService.addExpense("Food", "Lunch", 10); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + + budgetService.printFilteredTransactions("Rent"); + + assertTrue(out.toString().contains("No transactions found for category: Rent")); + } + + @Test + void dataPersistsAcrossServiceInstances() { + String filePath = tempDir.resolve("persist_test.json").toString(); + StorageService storage = new StorageService(filePath); + BudgetService service1 = new BudgetService(storage); + + service1.addIncome("Salary", 3000.0); + service1.addExpense("Food", "Lunch", 15.0); + + BudgetService service2 = new BudgetService(new StorageService(filePath)); + assertEquals(2, service2.getTransactions().size()); + assertEquals(3000.0, service2.getTotalIncome(), 0.01); + assertEquals(15.0, service2.getTotalExpenses(), 0.01); + } +} diff --git a/budget-tracker/src/test/java/com/budgettracker/storage/StorageServiceTest.java b/budget-tracker/src/test/java/com/budgettracker/storage/StorageServiceTest.java new file mode 100644 index 000000000..97106c0d1 --- /dev/null +++ b/budget-tracker/src/test/java/com/budgettracker/storage/StorageServiceTest.java @@ -0,0 +1,105 @@ +package com.budgettracker.storage; + +import com.budgettracker.model.Transaction; +import com.budgettracker.model.TransactionType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class StorageServiceTest { + + @TempDir + Path tempDir; + + private StorageService storageService; + private String filePath; + + @BeforeEach + void setUp() { + filePath = tempDir.resolve("test_budget.json").toString(); + storageService = new StorageService(filePath); + } + + @Test + void loadReturnsEmptyListWhenFileDoesNotExist() { + List result = storageService.load(); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void saveAndLoadRoundTrip() { + List transactions = new ArrayList<>(); + transactions.add(new Transaction(TransactionType.INCOME, "", "Salary", 5000.0)); + transactions.add(new Transaction(TransactionType.EXPENSE, "Food", "Groceries", 150.0)); + + assertTrue(storageService.save(transactions)); + + List loaded = storageService.load(); + assertEquals(2, loaded.size()); + assertEquals("Salary", loaded.get(0).getDescription()); + assertEquals(5000.0, loaded.get(0).getAmount()); + assertEquals(TransactionType.INCOME, loaded.get(0).getType()); + assertEquals("Groceries", loaded.get(1).getDescription()); + assertEquals(150.0, loaded.get(1).getAmount()); + assertEquals("Food", loaded.get(1).getCategory()); + } + + @Test + void saveCreatesParentDirectories() { + String nestedPath = tempDir.resolve("sub/dir/data.json").toString(); + StorageService nestedService = new StorageService(nestedPath); + + List transactions = new ArrayList<>(); + transactions.add(new Transaction(TransactionType.INCOME, "", "Test", 100.0)); + + assertTrue(nestedService.save(transactions)); + assertEquals(1, nestedService.load().size()); + } + + @Test + void loadHandlesCorruptedFile() throws IOException { + Files.writeString(Path.of(filePath), "this is not json{{{"); + List result = storageService.load(); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void loadHandlesEmptyFile() throws IOException { + Files.writeString(Path.of(filePath), ""); + List result = storageService.load(); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void loadHandlesNullJsonContent() throws IOException { + Files.writeString(Path.of(filePath), "null"); + List result = storageService.load(); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void saveEmptyList() { + assertTrue(storageService.save(new ArrayList<>())); + List loaded = storageService.load(); + assertNotNull(loaded); + assertTrue(loaded.isEmpty()); + } + + @Test + void saveToReadOnlyLocationFails() { + StorageService badService = new StorageService("/proc/invalid/path/data.json"); + assertFalse(badService.save(List.of(new Transaction(TransactionType.INCOME, "", "Test", 100.0)))); + } +}