diff --git a/README.md b/README.md index d20aaf9..7071577 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/339Lr3BJ) ### How the tests work (and Docker requirement) This project ships with an end‑to‑end CLI integration test suite that uses Testcontainers to spin up a temporary MySQL database. diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..0c555fe --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + com.example + jdbc + 1.0-SNAPSHOT + + + + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.release} + + + + maven-surefire-plugin + 3.2.5 + + + maven-failsafe-plugin + 3.5.4 + + + integration-test + + integration-test + verify + + + + + + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + com.example.Main + + + + + + + + + + + org.junit.jupiter + junit-jupiter + 6.0.1 + test + + + junit-jupiter-api + org.junit.jupiter + + + junit-jupiter-params + org.junit.jupiter + + + junit-jupiter-engine + org.junit.jupiter + + + + + org.assertj + assertj-core + 3.27.6 + test + + + byte-buddy + net.bytebuddy + + + + + org.mockito + mockito-junit-jupiter + 5.20.0 + test + + + mockito-core + org.mockito + + + junit-jupiter-api + org.junit.jupiter + + + + + org.testcontainers + junit-jupiter + 1.21.3 + test + + + + 25 + UTF-8 + 5.20.0 + 6.0.1 + 3.27.6 + + diff --git a/src/main/java/com/example/ConfigUtils.java b/src/main/java/com/example/ConfigUtils.java new file mode 100644 index 0000000..cde3447 --- /dev/null +++ b/src/main/java/com/example/ConfigUtils.java @@ -0,0 +1,37 @@ +package com.example; + +import java.util.Arrays; + +/** + * Utility class for reading configuration and environment variables. + */ +public class ConfigUtils { + + /** + * Determines if the application should run in development mode. + * Checks system property "devMode", environment variable "DEV_MODE", + * and command-line argument "--dev". + * + * @param args command-line arguments + * @return true if dev mode is enabled, false otherwise + */ + public static boolean isDevMode(String[] args) { + if (Boolean.getBoolean("devMode")) return true; + if ("true".equalsIgnoreCase(System.getenv("DEV_MODE"))) return true; + return Arrays.asList(args).contains("--dev"); + } + + /** + * Resolves a configuration value from system properties or environment variables. + * Returns null if neither is set. + * + * @param propertyKey system property key + * @param envKey environment variable key + * @return trimmed configuration value or null if not set + */ + public static String resolveConfig(String propertyKey, String envKey) { + String v = System.getProperty(propertyKey); + if (v == null || v.trim().isEmpty()) v = System.getenv(envKey); + return (v == null || v.trim().isEmpty()) ? null : v.trim(); + } +} diff --git a/src/main/java/com/example/DevDatabaseInitializer.java b/src/main/java/com/example/DevDatabaseInitializer.java index e8a45fe..b61fc2c 100644 --- a/src/main/java/com/example/DevDatabaseInitializer.java +++ b/src/main/java/com/example/DevDatabaseInitializer.java @@ -3,6 +3,10 @@ import org.testcontainers.containers.MySQLContainer; +/** + * Initializes a MySQL development database using Testcontainers. + * Sets system properties for JDBC URL, username, and password after startup. + */ public class DevDatabaseInitializer { private static MySQLContainer mysql; @@ -21,4 +25,4 @@ public static void start() { System.setProperty("APP_DB_PASS", mysql.getPassword()); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/Main.java b/src/main/java/com/example/Main.java index 6dc6fbd..1b929c3 100644 --- a/src/main/java/com/example/Main.java +++ b/src/main/java/com/example/Main.java @@ -1,62 +1,56 @@ package com.example; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.util.Arrays; - +import com.example.cli.*; +import com.example.repository.*; +import com.example.service.*; + +/** + * Entry point for the Moon Mission application. + * + *

+ * Initializes the development database if dev mode is enabled, sets up repositories, + * services, and CLI components, handles user login, and shows the main menu. + *

+ */ public class Main { - static void main(String[] args) { - if (isDevMode(args)) { + /** + * Starts the application. + * + * @param args Command-line arguments. Supports "--dev" to enable dev mode. + */ + public static void main(String[] args) { + if (ConfigUtils.isDevMode(args)) { DevDatabaseInitializer.start(); } - new Main().run(); - } - public void run() { - // Resolve DB settings with precedence: System properties -> Environment variables - String jdbcUrl = resolveConfig("APP_JDBC_URL", "APP_JDBC_URL"); - String dbUser = resolveConfig("APP_DB_USER", "APP_DB_USER"); - String dbPass = resolveConfig("APP_DB_PASS", "APP_DB_PASS"); + String jdbcUrl = ConfigUtils.resolveConfig("APP_JDBC_URL", "APP_JDBC_URL"); + String dbUser = ConfigUtils.resolveConfig("APP_DB_USER", "APP_DB_USER"); + String dbPass = ConfigUtils.resolveConfig("APP_DB_PASS", "APP_DB_PASS"); if (jdbcUrl == null || dbUser == null || dbPass == null) { - throw new IllegalStateException( - "Missing DB configuration. Provide APP_JDBC_URL, APP_DB_USER, APP_DB_PASS " + - "as system properties (-Dkey=value) or environment variables."); + throw new IllegalStateException("Missing DB configuration."); } - try (Connection connection = DriverManager.getConnection(jdbcUrl, dbUser, dbPass)) { - } catch (SQLException e) { - throw new RuntimeException(e); - } - //Todo: Starting point for your code - } + SimpleDriverManagerDataSource dataSource = new SimpleDriverManagerDataSource(jdbcUrl, dbUser, dbPass); + boolean devMode = ConfigUtils.isDevMode(args); - /** - * Determines if the application is running in development mode based on system properties, - * environment variables, or command-line arguments. - * - * @param args an array of command-line arguments - * @return {@code true} if the application is in development mode; {@code false} otherwise - */ - private static boolean isDevMode(String[] args) { - if (Boolean.getBoolean("devMode")) //Add VM option -DdevMode=true - return true; - if ("true".equalsIgnoreCase(System.getenv("DEV_MODE"))) //Environment variable DEV_MODE=true - return true; - return Arrays.asList(args).contains("--dev"); //Argument --dev - } + AccountRepositoryJdbc accountRepo = new AccountRepositoryJdbc(dataSource, devMode); + MoonMissionRepositoryJdbc missionRepo = new MoonMissionRepositoryJdbc(dataSource, devMode); - /** - * Reads configuration with precedence: Java system property first, then environment variable. - * Returns trimmed value or null if neither source provides a non-empty value. - */ - private static String resolveConfig(String propertyKey, String envKey) { - String v = System.getProperty(propertyKey); - if (v == null || v.trim().isEmpty()) { - v = System.getenv(envKey); + AccountService accountService = new AccountService(accountRepo); + MoonMissionService missionService = new MoonMissionService(missionRepo); + + InputReader input = new InputReader(); + + AccountCLI accountCLI = new AccountCLI(accountService, input); + MoonMissionCLI missionCLI = new MoonMissionCLI(missionService, input); + MenuCLI menu = new MenuCLI(accountCLI, missionCLI, input); + + LoginManager loginManager = new LoginManager(accountService, input); + + if (loginManager.login()) { + menu.showMainMenu(); } - return (v == null || v.trim().isEmpty()) ? null : v.trim(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/SimpleDriverManagerDataSource.java b/src/main/java/com/example/SimpleDriverManagerDataSource.java new file mode 100644 index 0000000..2109297 --- /dev/null +++ b/src/main/java/com/example/SimpleDriverManagerDataSource.java @@ -0,0 +1,48 @@ +package com.example; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * Simple DataSource implementation using DriverManager. + * + *

+ * Provides basic JDBC connections using a URL, username, and password. + * Only getConnection methods are supported; other DataSource features throw + * UnsupportedOperationException. + *

+ */ +public class SimpleDriverManagerDataSource implements DataSource { + private final String url; + private final String username; + private final String password; + + /** + * Creates a new DataSource with the given JDBC parameters. + * + * @param url the JDBC URL + * @param username the database username + * @param password the database password + */ + public SimpleDriverManagerDataSource(String url, String username, String password) { + this.url = url; + this.username = username; + this.password = password; + } + + @Override + public Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } + + @Override + public Connection getConnection(String username, String password) throws SQLException { return DriverManager.getConnection(url, username, password); } + + @Override public T unwrap(Class iface) { throw new UnsupportedOperationException(); } + @Override public boolean isWrapperFor(Class iface) { return false; } + @Override public java.io.PrintWriter getLogWriter() { throw new UnsupportedOperationException(); } + @Override public void setLogWriter(java.io.PrintWriter out) { throw new UnsupportedOperationException(); } + @Override public void setLoginTimeout(int seconds) { throw new UnsupportedOperationException(); } + @Override public int getLoginTimeout() { return 0; } + @Override public java.util.logging.Logger getParentLogger() { throw new UnsupportedOperationException(); } +} \ No newline at end of file diff --git a/src/main/java/com/example/cli/AccountCLI.java b/src/main/java/com/example/cli/AccountCLI.java new file mode 100644 index 0000000..bdc1311 --- /dev/null +++ b/src/main/java/com/example/cli/AccountCLI.java @@ -0,0 +1,117 @@ +package com.example.cli; + +import com.example.model.Account; +import com.example.service.AccountService; +import com.example.repository.RepositoryException; + +import java.util.List; + +/** + * CLI for managing accounts. + * Handles creating, updating, and deleting accounts via AccountService. + */ +public class AccountCLI implements ExitMenuHandler { + + private final AccountService service; + private final InputReader input; + + public AccountCLI(AccountService service, InputReader input) { + this.service = service; + this.input = input; + } + + /** Prompts the user and creates a new account. */ + public void createAccount() { + try { + var first = input.readName("First name"); + if (handleExitOrMenu(first.result())) return; + + var last = input.readName("Last name"); + if (handleExitOrMenu(last.result())) return; + + var ssn = input.readSSN("SSN"); + if (handleExitOrMenu(ssn.result())) return; + + var pass = input.readPassword("Password"); + if (handleExitOrMenu(pass.result())) return; + + long id = service.createAccount(first.value(), last.value(), ssn.value(), pass.value()); + System.out.println("\n✅ Account created with ID: " + id + " ✅\n"); + + } catch (RepositoryException e) { + System.out.println("❌ Error creating account: " + e.getMessage()); + } + } + + /** Updates the password of an existing account after selecting by user ID. */ + public void updatePassword() { + try { + List accounts = service.listAccounts(); + if (accounts.isEmpty()) { + System.out.println("❌ No accounts found ❌"); + return; + } + + System.out.println("\n📋 Existing accounts:"); + accounts.forEach(System.out::println); + + var idWrapper = input.readValidUserId("User ID"); + if (handleExitOrMenu(idWrapper.result())) return; + + if (service.getById(idWrapper.value()).isEmpty()) { + System.out.println("❌ Account with this ID does not exist ❌"); + return; + } + + var passWrapper = input.readPassword("New Password"); + if (handleExitOrMenu(passWrapper.result())) return; + + service.updatePassword(idWrapper.value(), passWrapper.value()); + System.out.println("\n✅ Password updated ✅\n"); + + } catch (RepositoryException e) { + System.out.println("❌ Error updating password: " + e.getMessage()); + } + } + + /** Deletes an account after confirming with the user. */ + public void deleteAccount() { + try { + List accounts = service.listAccounts(); + if (accounts.isEmpty()) { + System.out.println("❌ No accounts found ❌"); + return; + } + + System.out.println("\n📋 Existing accounts:"); + accounts.forEach(System.out::println); + + var idWrapper = input.readValidUserId("User ID"); + if (handleExitOrMenu(idWrapper.result())) return; + + if (service.getById(idWrapper.value()).isEmpty()) { + System.out.println("❌ Account with this ID does not exist ❌"); + return; + } + + while (true) { + var confirmWrapper = input.readString("Are you sure you want to delete this account? (yes/no)"); + if (handleExitOrMenu(confirmWrapper.result())) return; + + String confirm = confirmWrapper.value(); + if (confirm.equalsIgnoreCase("yes") || confirm.equalsIgnoreCase("y")) { + service.deleteAccount(idWrapper.value()); + System.out.println("\n✅ Account deleted ✅\n"); + break; + } else if (confirm.equalsIgnoreCase("no") || confirm.equalsIgnoreCase("n")) { + System.out.println("❌ Account deletion cancelled ❌\n"); + break; + } else { + System.out.println("❌ Invalid input, type yes, no, or menu ❌"); + } + } + } catch (RepositoryException e) { + System.out.println("❌ Error deleting account: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cli/ExitMenuHandler.java b/src/main/java/com/example/cli/ExitMenuHandler.java new file mode 100644 index 0000000..235126c --- /dev/null +++ b/src/main/java/com/example/cli/ExitMenuHandler.java @@ -0,0 +1,22 @@ +package com.example.cli; + +/** + * Provides a default method to handle 'exit' or 'menu' commands from the user. + * Classes implementing this interface can easily check if the user wants to exit or return to the menu. + */ +public interface ExitMenuHandler { + + /** + * Handles input results indicating exit or menu commands. + * + * @param result the result from InputReader (CONTINUE, EXIT, MENU) + * @return true if the user wants to exit, false if continue or menu + */ + default boolean handleExitOrMenu(InputReader.InputResult result) { + if (result == InputReader.InputResult.EXIT) { + System.out.println("Exiting..."); + return true; + } + return result == InputReader.InputResult.MENU; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cli/InputReader.java b/src/main/java/com/example/cli/InputReader.java new file mode 100644 index 0000000..e3327da --- /dev/null +++ b/src/main/java/com/example/cli/InputReader.java @@ -0,0 +1,109 @@ +package com.example.cli; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + +/** + * Handles user input from the console with validation and special commands. + * Supports reading strings, integers, user IDs, names, SSNs, and passwords. + */ +public class InputReader { + + private final BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + + /** + * Represents the result of reading input. + * CONTINUE = normal input, MENU = user wants to go back, EXIT = user wants to exit. + */ + public enum InputResult { CONTINUE, MENU, EXIT } + + /** + * Wraps a value read from the user along with the input result status. + * + * @param the type of the input value + * @param value the actual input value + * @param result the input result (CONTINUE, MENU, EXIT) + */ + public record InputWrapper(T value, InputResult result) {} + + // Reads a line from the console + private String readLine() { + try { + String line = reader.readLine(); + return line != null ? line.trim() : ""; + } catch (Exception e) { + throw new RuntimeException("Error reading input", e); + } + } + + // Checks if input is '0' (exit) or 'menu' + private InputResult checkExitOrMenu(String input) { + if (input.equals("0")) return InputResult.EXIT; + if (input.equalsIgnoreCase("menu")) return InputResult.MENU; + return InputResult.CONTINUE; + } + + /** Reads a string from the user with exit/menu handling. */ + public InputWrapper readString(String label) { + System.out.print(label + ": "); + System.out.flush(); + String input = readLine(); + InputResult result = checkExitOrMenu(input); + return new InputWrapper<>(input, result); + } + + /** Reads an integer from the user with validation and exit/menu handling. */ + public InputWrapper readInt(String label) { + while (true) { + InputWrapper wrapper = readString(label); + if (wrapper.result() != InputResult.CONTINUE) return new InputWrapper<>(0, wrapper.result()); + + try { + return new InputWrapper<>(Integer.parseInt(wrapper.value()), InputResult.CONTINUE); + } catch (NumberFormatException e) { + System.out.println("❌ Please enter a valid number ❌"); + } + } + } + + /** Reads a valid user ID (long) from the user with validation and exit/menu handling. */ + public InputWrapper readValidUserId(String label) { + while (true) { + InputWrapper wrapper = readString(label); + if (wrapper.result() != InputResult.CONTINUE) return new InputWrapper<>(0L, wrapper.result()); + + try { + return new InputWrapper<>(Long.parseLong(wrapper.value()), InputResult.CONTINUE); + } catch (NumberFormatException e) { + System.out.println("❌ Please enter a valid number ❌"); + } + } + } + + /** Reads a valid name from the user (capitalized, at least 3 letters). */ + public InputWrapper readName(String label) { + while (true) { + InputWrapper wrapper = readString(label); + if (wrapper.result() != InputResult.CONTINUE) return wrapper; + + if (wrapper.value().matches("[A-Z][a-zA-Z]{2,}")) return wrapper; + System.out.println("❌ Must start with a capital letter and be at least 3 letters ❌"); + } + } + + /** Reads a valid Swedish SSN from the user (######-####). */ + public InputWrapper readSSN(String label) { + while (true) { + InputWrapper wrapper = readString(label); + if (wrapper.result() != InputResult.CONTINUE) return wrapper; + + if (wrapper.value().matches("\\d{6}-\\d{4}")) return wrapper; + System.out.println("❌ SSN must match format ######-#### ❌"); + } + } + + /** Reads a password from the user. */ + public InputWrapper readPassword(String label) { + return readString(label); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cli/LoginManager.java b/src/main/java/com/example/cli/LoginManager.java new file mode 100644 index 0000000..7657055 --- /dev/null +++ b/src/main/java/com/example/cli/LoginManager.java @@ -0,0 +1,66 @@ +package com.example.cli; + +import com.example.service.AccountService; +import com.example.repository.RepositoryException; + +/** + * Handles user login attempts, including input reading, validation, and retry limits. + */ +public class LoginManager implements ExitMenuHandler { + + private final AccountService service; + private final int maxAttempts; + private final InputReader input; + + /** + * Creates a LoginManager with default max attempts (5). + */ + public LoginManager(AccountService service, InputReader input) { + this(service, input, 5); + } + + /** + * Creates a LoginManager with a specified maximum number of login attempts. + * + * @param maxAttempts maximum allowed attempts before login fails + */ + public LoginManager(AccountService service, InputReader input, int maxAttempts) { + this.service = service; + this.input = input; + this.maxAttempts = maxAttempts; + } + + /** + * Performs the login process, asking for username and password. + * Returns true if login succeeds, false if attempts are exhausted or user exits. + */ + public boolean login() { + System.out.println("Type 0 to exit anytime."); + + int attempts = 0; + while (attempts < maxAttempts) { + var usernameWrapper = input.readString("Username"); + if (handleExitOrMenu(usernameWrapper.result())) return false; + + var passwordWrapper = input.readPassword("Password"); + if (handleExitOrMenu(passwordWrapper.result())) return false; + + try { + if (service.validateLogin(usernameWrapper.value(), passwordWrapper.value())) { + System.out.println("\n✅ Login successful! Welcome, " + usernameWrapper.value() + "!\n"); + return true; + } else { + System.out.println("❌ Invalid username or password ❌"); + } + } catch (RepositoryException e) { + System.out.println("❌ Error validating login: " + e.getMessage()); + } + + attempts++; + if (attempts < maxAttempts) { + System.out.println("Attempts left: " + (maxAttempts - attempts)); + } + } + return false; + } +} diff --git a/src/main/java/com/example/cli/MenuCLI.java b/src/main/java/com/example/cli/MenuCLI.java new file mode 100644 index 0000000..db94c82 --- /dev/null +++ b/src/main/java/com/example/cli/MenuCLI.java @@ -0,0 +1,67 @@ +package com.example.cli; + +/** + * Handles the main menu display and routes user selections + * to the appropriate CLI handlers for accounts or moon missions. + */ +public class MenuCLI { + + private final AccountCLI accountCLI; + private final MoonMissionCLI missionCLI; + private final InputReader input; + + /** + * Creates a MenuCLI with the required CLI handlers and input reader. + * + * @param accountCLI CLI handler for account-related actions + * @param missionCLI CLI handler for moon mission-related actions + * @param input input reader for user interaction + */ + public MenuCLI(AccountCLI accountCLI, MoonMissionCLI missionCLI, InputReader input) { + this.accountCLI = accountCLI; + this.missionCLI = missionCLI; + this.input = input; + } + + /** + * Displays the main menu, handles user input, and routes commands + * to the appropriate CLI methods until the user exits. + */ + public void showMainMenu() { + while (true) { + printHeader(); + var choiceWrapper = input.readInt("Choose option"); + + if (choiceWrapper.result() == InputReader.InputResult.EXIT) { + System.out.println("Exiting..."); + return; + } + + if (choiceWrapper.result() == InputReader.InputResult.MENU) continue; + + switch (choiceWrapper.value()) { + case 1 -> missionCLI.listMissions(); + case 2 -> missionCLI.getMissionById(); + case 3 -> missionCLI.countMissionsByYear(); + case 4 -> accountCLI.createAccount(); + case 5 -> accountCLI.updatePassword(); + case 6 -> accountCLI.deleteAccount(); + default -> System.out.println("Invalid option, try again."); + } + } + } + + /** Prints the menu header and available options. */ + private void printHeader() { + System.out.println("\n 🌕 MOON MISSION HUB 🌕 "); + System.out.println("----------------------------------------"); + System.out.println("Type 0 to exit from this menu, or 'menu' to go back to the main menu."); + System.out.println(" 1) List moon missions"); + System.out.println(" 2) Get mission by ID"); + System.out.println(" 3) Count missions by year"); + System.out.println(" 4) Create account"); + System.out.println(" 5) Update password"); + System.out.println(" 6) Delete account"); + System.out.println(" 0) Exit\n"); + } +} diff --git a/src/main/java/com/example/cli/MoonMissionCLI.java b/src/main/java/com/example/cli/MoonMissionCLI.java new file mode 100644 index 0000000..4559324 --- /dev/null +++ b/src/main/java/com/example/cli/MoonMissionCLI.java @@ -0,0 +1,97 @@ +package com.example.cli; + +import com.example.model.MoonMission; +import com.example.service.MoonMissionService; +import com.example.repository.RepositoryException; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * CLI handler for moon mission operations. + * Provides methods for listing, fetching by ID, and counting missions by year. + */ +public class MoonMissionCLI implements ExitMenuHandler { + + private final MoonMissionService service; + private final InputReader input; + + /** + * Creates a MoonMissionCLI with the provided service and input reader. + * + * @param service service handling moon mission data + * @param input input reader for user interaction + */ + public MoonMissionCLI(MoonMissionService service, InputReader input) { + this.service = service; + this.input = input; + } + + /** Lists all moon missions by spacecraft name. */ + public void listMissions() { + try { + List missions = service.listMissions(); + System.out.println("\n-- All Moon Missions --"); + missions.forEach(m -> System.out.println(m.spacecraft())); + System.out.println("----------------------\n"); + } catch (RepositoryException e) { + System.out.println("❌ Error listing missions: " + e.getMessage()); + } + } + + /** Fetches and displays a mission by its ID. */ + public void getMissionById() { + var idWrapper = input.readInt("Mission ID"); + if (handleExitOrMenu(idWrapper.result())) return; + + try { + Optional mission = service.getMissionById(idWrapper.value()); + mission.ifPresentOrElse( + m -> { + System.out.println("\n-- Mission Details --"); + System.out.println(m); + System.out.println("-------------------\n"); + }, + () -> System.out.println("❌ No mission with that ID ❌") + ); + } catch (RepositoryException e) { + System.out.println("❌ Error fetching mission: " + e.getMessage()); + } + } + + /** + * Counts missions for a given year, prints total and detailed summaries + * of missions sorted by launch date (most recent first). + */ + public void countMissionsByYear() { + var yearWrapper = input.readInt("Year"); + if (handleExitOrMenu(yearWrapper.result())) return; + + int year = yearWrapper.value(); + + try { + int count = service.countMissionsByYear(year); + + System.out.println("\n---- Missions for year " + year + " ----"); + System.out.println("Total missions: " + count); + + List missions = service.listMissions(); + missions.stream() + .filter(m -> m.launchDate().toLocalDate().getYear() == year) + .sorted(Comparator.comparing(MoonMission::launchDate).reversed()) + .forEach(this::printMissionSummary); + + System.out.println("----------------------------------------\n"); + + } catch (RepositoryException e) { + System.out.println("❌ Error counting/listing missions: " + e.getMessage()); + } + } + + /** Prints a short summary for a single moon mission. */ + private void printMissionSummary(MoonMission m) { + System.out.printf("Spacecraft: %s | Launch Date: %s | Rocket: %s | Operator: %s%n", + m.spacecraft(), m.launchDate(), m.carrierRocket(), m.operator()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/model/Account.java b/src/main/java/com/example/model/Account.java new file mode 100644 index 0000000..dd40376 --- /dev/null +++ b/src/main/java/com/example/model/Account.java @@ -0,0 +1,35 @@ +package com.example.model; + +/** + * Represents a user account in the system. + * + * @param userId unique identifier for the account + * @param firstName first name of the account holder + * @param lastName last name of the account holder + * @param ssn social security number + * @param password account password + * @param name full display name of the account holder + */ +public record Account( + long userId, + String firstName, + String lastName, + String ssn, + String password, + String name +) { + + /** + * Returns a readable string representation of the account, + * excluding the password for security reasons. + */ + @Override + public String toString() { + return "Account: " + + "ID: " + userId + + ", First: " + firstName + + ", Last: " + lastName + + ", SSN: " + ssn + + ", Name: " + name; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/model/MoonMission.java b/src/main/java/com/example/model/MoonMission.java new file mode 100644 index 0000000..5eafb84 --- /dev/null +++ b/src/main/java/com/example/model/MoonMission.java @@ -0,0 +1,40 @@ +package com.example.model; + +import java.sql.Date; + +/** + * Represents a moon mission with its main details. + * + * @param missionId unique identifier for the mission + * @param spacecraft name of the spacecraft + * @param launchDate launch date of the mission + * @param carrierRocket rocket used to carry the spacecraft + * @param operator organization responsible for the mission + * @param missionType type of mission (e.g., manned, unmanned) + * @param outcome result or outcome of the mission + */ +public record MoonMission( + int missionId, + String spacecraft, + Date launchDate, + String carrierRocket, + String operator, + String missionType, + String outcome +) { + + /** + * Returns a readable string representation of the moon mission. + */ + @Override + public String toString() { + return "MoonMission: " + + "missionId: " + missionId + + ", spacecraft: " + spacecraft + + ", launchDate: " + launchDate + + ", carrierRocket: " + carrierRocket + + ", operator: " + operator + + ", missionType: " + missionType + + ", outcome: " + outcome; + } +} diff --git a/src/main/java/com/example/repository/AccountRepository.java b/src/main/java/com/example/repository/AccountRepository.java new file mode 100644 index 0000000..fc5e062 --- /dev/null +++ b/src/main/java/com/example/repository/AccountRepository.java @@ -0,0 +1,19 @@ +package com.example.repository; + +import com.example.model.Account; + +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; + +/** + * Interface defining the operations for managing accounts in the repository. + */ +public interface AccountRepository { + boolean validateLogin(String username, String password); + long createAccount(String firstName, String lastName, String ssn, String password); + void updatePassword(long userId, String newPassword); + void deleteAccount(long userId); + List listAccounts(); + Optional getById(long userId); +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/AccountRepositoryJdbc.java b/src/main/java/com/example/repository/AccountRepositoryJdbc.java new file mode 100644 index 0000000..ea0a268 --- /dev/null +++ b/src/main/java/com/example/repository/AccountRepositoryJdbc.java @@ -0,0 +1,117 @@ +package com.example.repository; + +import com.example.model.Account; + +import javax.sql.DataSource; +import java.util.List; +import java.util.Optional; + +/** + * JDBC implementation of the {@link AccountRepository}. + * Handles database operations for Account objects. + */ +public class AccountRepositoryJdbc extends BaseRepository implements AccountRepository { + + /** + * Constructs the repository with a given DataSource. + * + * @param dataSource the database source + * @param devMode if true, enables debug logging + */ + public AccountRepositoryJdbc(DataSource dataSource, boolean devMode) { + super(dataSource, devMode); + } + + /** + * Maps a ResultSet row to an Account object. + * + * @param rs the ResultSet to map + * @return the Account object + */ + @Override + protected Account map(java.sql.ResultSet rs) throws java.sql.SQLException { + return new Account( + rs.getLong("user_id"), + rs.getString("first_name"), + rs.getString("last_name"), + rs.getString("ssn"), + rs.getString("password"), + rs.getString("name") + ); + } + + /** + * Retrieves a list of all accounts in the database. + * + * @return a {@link List} of {@link Account} objects + */ + @Override + public List listAccounts() { + return queryList("SELECT * FROM account"); + } + + /** + * Retrieves an account by its unique user ID. + * + * @param userId the ID of the account to retrieve + * @return an {@link Optional} containing the {@link Account} if found, otherwise empty + */ + @Override + public Optional getById(long userId) { + return querySingle("SELECT * FROM account WHERE user_id=?", userId); + } + + /** + * Validates login credentials by checking if an account with the given + * username and password exists in the database. + * + * @param username the account's username + * @param password the account's password + * @return true if credentials match an account, false otherwise + */ + @Override + public boolean validateLogin(String username, String password) { + return executeQuery( + "SELECT COUNT(*) FROM account WHERE name=? AND password=?", + rs -> { rs.next(); return rs.getInt(1) > 0; }, + username, password + ); + } + + /** + * Creates a new account with the given personal information. + * + * @param firstName the first name of the user + * @param lastName the last name of the user + * @param ssn the social security number of the user + * @param password the password for the account + * @return the generated user ID of the newly created account + */ + @Override + public long createAccount(String firstName, String lastName, String ssn, String password) { + String name = firstName.substring(0, 3) + lastName.substring(0, 3); + String sql = "INSERT INTO account (first_name, last_name, ssn, password, name) VALUES (?,?,?,?,?)"; + return executeUpdateReturnId(sql, firstName, lastName, ssn, password, name); + } + + /** + * Updates the password for an existing account. + * + * @param userId the ID of the account to update + * @param newPassword the new password to set + */ + @Override + public void updatePassword(long userId, String newPassword) { + executeUpdate("UPDATE account SET password=? WHERE user_id=?", newPassword, userId); + } + + /** + * Deletes an account from the database. + * + * @param userId the ID of the account to delete + */ + @Override + public void deleteAccount(long userId) { + executeUpdate("DELETE FROM account WHERE user_id=?", userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/BaseRepository.java b/src/main/java/com/example/repository/BaseRepository.java new file mode 100644 index 0000000..c6bbeb7 --- /dev/null +++ b/src/main/java/com/example/repository/BaseRepository.java @@ -0,0 +1,140 @@ +package com.example.repository; + +import javax.sql.DataSource; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + + +/** + * Abstract base class for JDBC repositories. + * Provides common database operations such as query, update, and mapping. + * + * @param the type of entity this repository handles + */ +public abstract class BaseRepository { + protected final DataSource dataSource; + protected final boolean devMode; + + /** + * Constructs the repository with a DataSource. + * + * @param dataSource the database source + * @param devMode enables debug logging if true + */ + protected BaseRepository(DataSource dataSource, boolean devMode) { + this.dataSource = dataSource; + this.devMode = devMode; + if (devMode) { + System.out.println("[DEV] Repository " + this.getClass().getSimpleName() + " initialized"); + } + } + + /** + * Gets a database connection from the DataSource. + */ + protected Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + /** + * Logs messages if devMode is enabled. + */ + protected void log(String msg) { + if (devMode) System.out.println("[DEV] " + msg); + } + + /** + * Wraps database exceptions into a RepositoryException. + */ + protected RepositoryException dbError(String action, Exception e) { + log("ERROR during: " + action + " -> " + e.getMessage()); + return new RepositoryException("Database error during: " + action, e); + } + + /** + * Executes a query and applies a handler function to the ResultSet. + */ + protected R executeQuery(String sql, SQLFunction handler, Object... params) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + for (int i = 0; i < params.length; i++) stmt.setObject(i + 1, params[i]); + try (ResultSet rs = stmt.executeQuery()) { + return handler.apply(rs); + } + } catch (Exception e) { + throw dbError("executeQuery: " + sql, e); + } + } + + /** + * Executes an update/insert/delete SQL statement. + */ + protected void executeUpdate(String sql, Object... params) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + for (int i = 0; i < params.length; i++) stmt.setObject(i + 1, params[i]); + stmt.executeUpdate(); + } catch (SQLException e) { + throw dbError("executeUpdate: " + sql, e); + } + } + + /** + * Executes an insert and returns the generated key. + */ + protected long executeUpdateReturnId(String sql, Object... params) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + + for (int i = 0; i < params.length; i++) stmt.setObject(i + 1, params[i]); + stmt.executeUpdate(); + + try (ResultSet keys = stmt.getGeneratedKeys()) { + return keys.next() ? keys.getLong(1) : 0; + } + } catch (SQLException e) { + throw dbError("executeUpdateReturnId: " + sql, e); + } + } + + /** + * Functional interface for processing ResultSets. + */ + @FunctionalInterface + protected interface SQLFunction { T apply(R result) throws Exception; } + + /** + * Maps a ResultSet row to an entity. + * + * @param rs the ResultSet row + * @return the mapped entity + */ + protected abstract T map(ResultSet rs) throws SQLException; + + /** + * Executes a query and returns a list of entities. + */ + protected List queryList(String sql, Object... params) { + return executeQuery(sql, rs -> { + List list = new ArrayList<>(); + while (rs.next()) { + list.add(map(rs)); + } + return list; + }, params); + } + + /** + * Executes a query and returns a single entity wrapped in Optional. + */ + protected Optional querySingle(String sql, Object... params) { + return executeQuery(sql, rs -> { + if (rs.next()) return Optional.of(map(rs)); + return Optional.empty(); + }, params); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/MoonMissionRepository.java b/src/main/java/com/example/repository/MoonMissionRepository.java new file mode 100644 index 0000000..f5bf1b8 --- /dev/null +++ b/src/main/java/com/example/repository/MoonMissionRepository.java @@ -0,0 +1,15 @@ +package com.example.repository; + +import com.example.model.MoonMission; + +import java.util.List; +import java.util.Optional; + +/** + * Interface defining the operations for managing MoonMissions in the repository. + */ +public interface MoonMissionRepository { + List listMissions(); + Optional getMissionById(int missionId); + int countMissionsByYear(int year); +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/MoonMissionRepositoryJdbc.java b/src/main/java/com/example/repository/MoonMissionRepositoryJdbc.java new file mode 100644 index 0000000..845349a --- /dev/null +++ b/src/main/java/com/example/repository/MoonMissionRepositoryJdbc.java @@ -0,0 +1,79 @@ +package com.example.repository; + +import com.example.model.MoonMission; + +import javax.sql.DataSource; +import java.util.List; +import java.util.Optional; + +/** + * JDBC implementation of MoonMissionRepository using a DataSource. + * Handles mapping of ResultSet to MoonMission objects and executing SQL queries. + */ +public class MoonMissionRepositoryJdbc extends BaseRepository implements MoonMissionRepository { + + /** + * Creates a new repository with the given DataSource and devMode flag. + * + * @param dataSource the DataSource to use for database connections + * @param devMode if true, prints debug information + */ + public MoonMissionRepositoryJdbc(DataSource dataSource, boolean devMode) { + super(dataSource, devMode); + } + + /** + * Maps a ResultSet row to a MoonMission object. + * + * @param rs the ResultSet to map + * @return a MoonMission object + */ + @Override + protected MoonMission map(java.sql.ResultSet rs) throws java.sql.SQLException { + return new MoonMission( + rs.getInt("mission_id"), + rs.getString("spacecraft"), + rs.getDate("launch_date"), + rs.getString("carrier_rocket"), + rs.getString("operator"), + rs.getString("mission_type"), + rs.getString("outcome") + ); + } + + /** + * Retrieves a list of all moon missions in the database. + * + * @return a {@link List} of {@link MoonMission} objects + */ + @Override + public List listMissions() { + return queryList("SELECT * FROM moon_mission"); + } + + /** + * Retrieves a moon mission by its unique mission ID. + * + * @param missionId the ID of the mission to retrieve + * @return an {@link Optional} containing the {@link MoonMission} if found, otherwise empty + */ + @Override + public Optional getMissionById(int missionId) { + return querySingle("SELECT * FROM moon_mission WHERE mission_id=?", missionId); + } + + /** + * Counts the number of moon missions launched in a specific year. + * + * @param year the year to count missions for + * @return the number of missions launched in the given year + */ + @Override + public int countMissionsByYear(int year) { + return executeQuery( + "SELECT COUNT(*) FROM moon_mission WHERE YEAR(launch_date)=?", + rs -> { rs.next(); return rs.getInt(1); }, + year + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/repository/RepositoryException.java b/src/main/java/com/example/repository/RepositoryException.java new file mode 100644 index 0000000..9cd416f --- /dev/null +++ b/src/main/java/com/example/repository/RepositoryException.java @@ -0,0 +1,17 @@ +package com.example.repository; + +/** + * Custom unchecked exception for repository/database errors. + */ +public class RepositoryException extends RuntimeException { + + /** + * Creates a new RepositoryException with a message and a cause. + * + * @param message descriptive error message + * @param cause the underlying exception that caused this error + */ + public RepositoryException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/service/AccountService.java b/src/main/java/com/example/service/AccountService.java new file mode 100644 index 0000000..ba0d6a7 --- /dev/null +++ b/src/main/java/com/example/service/AccountService.java @@ -0,0 +1,117 @@ +package com.example.service; + +import com.example.model.Account; +import com.example.repository.AccountRepository; +import com.example.repository.RepositoryException; + +import java.util.List; +import java.util.Optional; + +/** + * Service layer for managing accounts. Wraps AccountRepository + * and handles exceptions by throwing RepositoryException. + */ +public class AccountService { + private final AccountRepository repo; + + /** + * Constructs the service with a given repository. + * + * @param repo the {@link AccountRepository} to use for data access + */ + public AccountService(AccountRepository repo) { + this.repo = repo; + } + + /** + * Validates a user's login credentials. + * + * @param username the username to validate + * @param password the password to validate + * @return true if the credentials are valid, false otherwise + * @throws RepositoryException if an error occurs while accessing the repository + */ + public boolean validateLogin(String username, String password) { + try { + return repo.validateLogin(username, password); + } catch (Exception e) { + throw new RepositoryException("Error validating login", e); + } + } + + /** + * Creates a new account in the system. + * + * @param firstName the first name of the user + * @param lastName the last name of the user + * @param ssn the Swedish personal identity number (######-####) + * @param password the password for the account + * @return the generated user ID of the new account + * @throws RepositoryException if an error occurs while creating the account + */ + public long createAccount(String firstName, String lastName, String ssn, String password) { + try { + return repo.createAccount(firstName, lastName, ssn, password); + } catch (Exception e) { + throw new RepositoryException("Error creating account", e); + } + } + + /** + * Updates the password for an existing account. + * + * @param userId the ID of the account to update + * @param newPassword the new password to set + * @throws RepositoryException if an error occurs while updating the password + */ + public void updatePassword(long userId, String newPassword) { + try { + repo.updatePassword(userId, newPassword); + } catch (Exception e) { + throw new RepositoryException("Error updating password", e); + } + } + + /** + * Deletes an account by its user ID. + * + * @param userId the ID of the account to delete + * @throws RepositoryException if an error occurs while deleting the account + */ + public void deleteAccount(long userId) { + try { + repo.deleteAccount(userId); + } catch (Exception e) { + throw new RepositoryException("Error deleting account", e); + } + } + + /** + * Lists all accounts in the system. + * + * @return a {@link List} of {@link Account} objects + * @throws RepositoryException if an error occurs while retrieving accounts + */ + public List listAccounts() { + try { + return repo.listAccounts(); + } catch (Exception e) { + throw new RepositoryException("Error listing accounts", e); + } + } + + /** + * Retrieves an account by its user ID. + * + * @param userId the ID of the account to retrieve + * @return an {@link Optional} containing the {@link Account} if found, otherwise empty + * @throws RepositoryException if an error occurs while fetching the account + */ + public Optional getById(long userId) { + try { + return repo.getById(userId); + } catch (Exception e) { + throw new RepositoryException("Error fetching account by ID", e); + } + } +} diff --git a/src/main/java/com/example/service/MoonMissionService.java b/src/main/java/com/example/service/MoonMissionService.java new file mode 100644 index 0000000..66538d4 --- /dev/null +++ b/src/main/java/com/example/service/MoonMissionService.java @@ -0,0 +1,69 @@ +package com.example.service; + +import com.example.model.MoonMission; +import com.example.repository.MoonMissionRepository; +import com.example.repository.RepositoryException; + +import java.util.List; +import java.util.Optional; + +/** + * Service layer for managing moon missions. Wraps MoonMissionRepository + * and handles exceptions by throwing RepositoryException. + */ +public class MoonMissionService { + private final MoonMissionRepository repo; + + /** + * Constructs the service with a given repository. + * + * @param repo the {@link MoonMissionRepository} to use for data access + */ + public MoonMissionService(MoonMissionRepository repo) { + this.repo = repo; + } + + /** + * Lists all moon missions in the repository. + * + * @return a {@link List} of {@link MoonMission} objects + * @throws RepositoryException if an error occurs while retrieving missions + */ + public List listMissions() { + try { + return repo.listMissions(); + } catch (Exception e) { + throw new RepositoryException("Error listing missions", e); + } + } + + /** + * Retrieves a moon mission by its ID. + * + * @param id the ID of the mission to fetch + * @return an {@link Optional} containing the {@link MoonMission} if found, otherwise empty + * @throws RepositoryException if an error occurs while fetching the mission + */ + public Optional getMissionById(int id) { + try { + return repo.getMissionById(id); + } catch (Exception e) { + throw new RepositoryException("Error fetching mission by ID", e); + } + } + + /** + * Counts the number of moon missions that occurred in a specific year. + * + * @param year the year to count missions for + * @return the number of missions in the given year + * @throws RepositoryException if an error occurs while counting missions + */ + public int countMissionsByYear(int year) { + try { + return repo.countMissionsByYear(year); + } catch (Exception e) { + throw new RepositoryException("Error counting missions by year", e); + } + } +} \ No newline at end of file diff --git a/src/test/com/example/CliAppIT.java b/src/test/java/com/example/CliAppIT.java similarity index 99% rename from src/test/com/example/CliAppIT.java rename to src/test/java/com/example/CliAppIT.java index 7d1eafb..984707d 100644 --- a/src/test/com/example/CliAppIT.java +++ b/src/test/java/com/example/CliAppIT.java @@ -179,6 +179,7 @@ void deleteAccount_thenRowIsGone_andPrintsConfirmation() throws Exception { "MB=V4cbAqPz4vqmQ", "6", // delete account (menu option 6 after reordering) Long.toString(userId),// user_id + "yes", // confirm delete ("y" eller "yes") "0" // exit ) + System.lineSeparator();