diff --git a/.gitignore b/.gitignore index 0a15bc2..ce76a9b 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,5 @@ https-instance.config /src/main/webapp/WEB-INF/web.xml /src/main/resources/local.properties +/src/test/resources/test-db.properties +/src/main/resources/test-db.properties diff --git a/README.md b/README.md index a5efe50..ae81757 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
- CI + CI Java Servlets/JSP @@ -48,39 +48,50 @@ CodeForge’s goal is to provide a *friendlier, clarity-first alternative* to ex Basic Challenge management (Create/Edit/Delete), list pagination/sorting, filter by difficulty, validation and friendly error pages (404/500). --- -## πŸ“š Tech Stack - -The CodeForge project leverages a modern **Enterprise Java** stack alongside supporting tools for development, testing, and deployment. +## πŸ“š Tech Stack (Current) - ### Backend - **Java 17 (LTS)** β€” Core language - - **JPA / Hibernate** β€” ORM layer for database persistence + - Servlets + JSP (Tomcat 9) + - Hibernate 6.x (JPA ORM) - **Project Lombok** β€” Reduces boilerplate (getters, setters, builders, etc.) - - **Log4J** β€” Centralized logging framework (replaces `System.out.println`) + - Log4j2 for application logging + - DAO layer with SessionFactoryProvider -- ### Frontend (Server-Side) - - **JSP / Servlets** β€” Required for class demonstrations and some views - - **JSTL** β€” Tag library for dynamic rendering in JSPs +- ### Frontend (Server-Side + Client Enhancements) + - JSP + JSTL for views + - Monaco Editor (CDN) for code editing in Drill/Practice pages + - Minimal vanilla JS for editor sync, hint toggle, and flash messages + - Lightweight CSS (β€œCodeForge UI”) without a large framework - ### Database - - **H2 (local/dev)** β€” Lightweight in-memory DB for rapid testing - - **MySQL / PostgreSQL (prod)** β€” Relational databases for persistence + - MySQL (local/dev tests and prod) + - Test DB reset via `DbReset` + `cleandb.sql` + - Seed data managed in `src/test/resources/cleandb.sql` (predictable schema) - **AWS RDS** β€” Cloud-hosted DB for deployment - ### Authentication & Security - - **AWS Cognito** β€” Authentication & authorization service (user registration, login, tokens) - - **Servlet filter (MVP)** β€” `AuthGuardFilter` protects admin routes and all Drill routes (redirects to `/logIn`) + - Amazon Cognito Hosted UI (servlet-based flow) + - AuthGuardFilter gate for Drill and admin routes + - Public Practice routes (GET/POST) with evaluator feedback, no persistence - ID token validation (JWKS, RSA256) and HTTP session storage of the user +- ### Evaluator (MVP) + - Evaluator scaffold + ChallengeRunService (non-executing heuristics in MVP) + - Expected answer compare and outcome mapping (CORRECT/ACCEPTABLE/etc.) + - Timeout/memory guard planned for local runner + - ### Testing - - **JUnit 5** β€” Unit and integration testing - - **Mockito** β€” (Optional/Stretch) Mocking framework for service/DAO testing - - **Log4J Test Appenders** β€” Capture and assert logs during test runs + - JUnit 5 test suite + - Mockito for unit tests and servlet/filter behavior stubbing + - DbReset single-source DB reset strategy + - Surefire plugin 3.2.x - **JaCoCo** β€” (Optional/Stretch) Test coverage reporting - ### Build & Deployment - - **Maven** β€” Dependency management and build tool - - **GitHub Actions** β€” (Optional/Stretch) CI/CD pipeline (build, test, deploy) + - Maven (3.9.x) + - WAR packaging + - GitHub Actions for CI (build + test + artifact upload) - **AWS (Elastic Beanstalk / EC2)** β€” Hosting & deployment - **Docker** (Stretch) β€” Containerized environment @@ -154,54 +165,77 @@ CodeForge will include challenges from: - Linked Lists & Trees (basics β†’ moderate) - Introductory Dynamic Programming ---- -## πŸš€ Getting Started -### Clone the repository: +# πŸš€ Getting Started + +## 1️⃣ Clone the repository ```bash git clone https://github.com/ArchILLtect/code-forge.git cd code-forge ``` -### Set required environment variable for Cognito client secret: -- Windows cmd.exe -```bash +--- + +## πŸ” Runtime configuration (local development) + +### Cognito client secret (required for login) + +The Cognito client secret **must NOT be committed**. +Set it via environment variable or JVM system property. + +**Windows (PowerShell):** +```powershell +$env:COGNITO_CLIENT_SECRET="your_client_secret" +``` + +**Windows (cmd.exe):** +```cmd set COGNITO_CLIENT_SECRET=your_client_secret ``` -- PowerShell -```bash -$env:COGNITO_CLIENT_SECRET="your_client_secret" -``` -- bash + +**macOS / Linux (bash):** ```bash export COGNITO_CLIENT_SECRET=your_client_secret ``` -### Set up test database: -create a MySQL database--MySQL Workbench or command line: +Non-secret Cognito values (user pool ID, region, etc.) live in: +``` +src/main/resources/cognito.properties +``` + +--- + +## πŸ§ͺ Test database setup (required to run tests) + +Tests use a **local MySQL test database** and are intentionally isolated from production data. + +### 2️⃣ Create a local MySQL test database ```sql CREATE DATABASE cf_test_db; -``` +``` -Then create a new file: `src/main/resources/local.properties` with content: -```properties -DB_HOST= # e.g., localhost -DB_PORT= # default MySQL port is 3306 -DB_NAME= # e.g., cf_test_db -DB_USER= # e.g., root for local -DB_PASS= -``` - -And a file `src/test/resources/hibernate.properties` with content: +Ensure MySQL is running locally. + +--- + +### 3️⃣ Create `src/test/resources/test-db.properties` (untracked) + +Create the file: +``` +src/test/resources/test-db.properties +``` + +Example contents: ```properties -hibernate.connection.url=jdbc:mysql://:/?useSSL=false&serverTimezone=UTC +# JDBC for local test database (untracked) +hibernate.connection.url=jdbc:mysql://localhost:3306/cf_test_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC hibernate.connection.driver_class=com.mysql.cj.jdbc.Driver hibernate.connection.username= hibernate.connection.password= hibernate.dialect=org.hibernate.dialect.MySQLDialect -#pool -hibernate.c3p0.min_size=5 -hibernate.c3p0.max_size=20 +# Connection pool (local defaults) +hibernate.c3p0.min_size=1 +hibernate.c3p0.max_size=10 hibernate.c3p0.timeout=300 hibernate.c3p0.max_statements=50 hibernate.c3p0.idle_test_period=3000 @@ -210,21 +244,53 @@ hibernate.show_sql=false hibernate.hbm2ddl.auto=none ``` -Ensure you have a local MySQL instance running with a database named `cf_test_db`. +> **Important** +> - Do NOT commit this file. +> - Each contributor must create their own local test DB and properties file. + +--- + +### 4️⃣ How test DB reset works + +- `DbReset` runs before each test class. +- It calls `Database.runSQL("cleandb.sql")`. +- `cleandb.sql` lives in `src/test/resources/`. +- Tests always start from a clean, predictable state. + +--- -### Build and run the project (example with Maven): +## ▢️ Run tests ```bash -mvn clean install -mvn spring-boot:run +mvn -q -DskipTests=false test ``` -### Open in your browser at: +--- + +## πŸ›  Build & run the application + +### Build the WAR ```bash -http://localhost:5000 +mvn clean package +``` + +### Deploy to Tomcat 9 +- Copy `target/codeforge.war` into Tomcat’s `webapps/` directory +- Start Tomcat +- Open: +``` +http://localhost:8080/codeforge/ ``` --- +## ⚠️ Notes on environments + +- Production credentials are provided via environment variables (Elastic Beanstalk). +- Local development may temporarily point to production DBs for demo purposes. +- Tests always use the local test DB defined in `test-db.properties` and never touch production data. + +--- + ## Build the WAR: - Windows cmd.exe @@ -244,7 +310,7 @@ Required runtime configuration: --- -## Configuration required by QuoteService (non‑Spring) +## Configuration required by QuoteService QuoteService loads settings from `src/main/resources/application.properties` at runtime. Required keys: @@ -514,5 +580,3 @@ CodeForge stores static assets (images, JSON files, etc.) in an S3 bucket rather - ### JSP Usage `` - ---- \ No newline at end of file diff --git a/docs/reflections/TimeLog.md b/docs/reflections/TimeLog.md index 67666ea..1cd39ba 100644 --- a/docs/reflections/TimeLog.md +++ b/docs/reflections/TimeLog.md @@ -163,30 +163,30 @@ --- -// TODO: Update this template log for final week activities once the week is complete. ## Week 15 (Dec 8–Dec 14) | Date | Task | Hours | |-------------------|----------------------------------------------------------------------------|--------------| -| 12/08 | CodeForge β†’ Added user_id propagation and DAO updates. | 3 | +| 12/08 | CodeForge β†’ Added user_id propagation and DAO updates. | 2 | | 12/08 | CodeForge β†’ Added expected-answer support + ChallengeService enhancements. | 2 | -| 12/08 | CodeForge β†’ Implemented evaluator pipeline, Normalizer, telemetry logging. | 4 | -| 12/08 | CodeForge β†’ Practice Mode v2 (UI, styles, backend updates). | 3 | -| 12/09 | CodeForge β†’ Monaco editor integration + UI adjustments. | 2.5 | +| 12/08 | CodeForge β†’ Implemented evaluator pipeline, Normalizer, telemetry logging. | 3 | +| 12/08 | CodeForge β†’ Practice Mode v2 (UI, styles, backend updates). | 2 | +| 12/09 | CodeForge β†’ Monaco editor integration + UI adjustments. | 1.5 | | 12/09 | Ent Java β†’ Time Log update | 2 | -| **Total Week 15** | | **16.5 hrs** | +| 12/10 | Codeforge β†’ Peer review | 4 | +| 12/11 | Codeforge β†’ Doc/Javadoc updates/Hints/JSP upgrades | 5 | +| **Total Week 15** | | **21.5 hrs** | --- // TODO: Update this template log for final week activities once course is complete. ## Week 16 (Dec 15–Dec 21) -| Date | Task | Hours | -|-------------------|------------------------------------------------------------------------------------|------------| -| 12/09–12/15 | CodeForge β†’ Hibernate config restoration, UTF-8 filter, logout fix, model updates. | 4 | -| 12/09–12/15 | CodeForge β†’ Security polish, token redaction, client ID improvements. | 2 | -| 12/09–12/15 | CodeForge β†’ UI refinements on solve/detail pages, editor tweaks. | 3 | -| 12/09–12/15 | CodeForge β†’ Final documentation updates, deployment checks. | 3 | -| 12/15–12/21 | Ent Java β†’ Final reflections, journal updates, course wrap-up. | 3 | -| **Total Week 16** | | **15 hrs** | +| Date | Task | Hours | +|-------|--------------------------------------------------|-------| +| 12/15 | CodeForge β†’ Checkpoint 3 feedback implementation | 7 | +| 12/16 | CodeForge β†’ Presentation Outline Creation | 4 | +| 12/17 | CodeForge β†’ Presentation Script/Practice | 3 | + +| **Total Week 14** | | **-- hrs** | --- diff --git a/pom.xml b/pom.xml index 14a863d..9d5f05c 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,7 @@ 3.13.0 3.2.5 3.4.0 + 3.6.3 @@ -197,8 +198,39 @@ ${maven.war.plugin.version} false + + + ${project.build.directory}/site/apidocs + apidocs + + **/* + + false + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven.javadoc.plugin.version} + + + generate-web-apidocs + prepare-package + + javadoc + + + false + none + UTF-8 + ${maven.compiler.release} + + + + + diff --git a/src/main/java/me/nickhanson/codeforge/config/EnvConfig.java b/src/main/java/me/nickhanson/codeforge/config/EnvConfig.java index d8fd39a..1941464 100644 --- a/src/main/java/me/nickhanson/codeforge/config/EnvConfig.java +++ b/src/main/java/me/nickhanson/codeforge/config/EnvConfig.java @@ -9,7 +9,6 @@ * The environment is determined by the "APP_ENV" environment variable, * defaulting to "dev" if not set. * @author Nick Hanson - * TODO: Is this still needed, now that spring is gone? */ public final class EnvConfig { diff --git a/src/main/java/me/nickhanson/codeforge/config/LocalConfig.java b/src/main/java/me/nickhanson/codeforge/config/LocalConfig.java index b3a8951..89c6d0a 100644 --- a/src/main/java/me/nickhanson/codeforge/config/LocalConfig.java +++ b/src/main/java/me/nickhanson/codeforge/config/LocalConfig.java @@ -2,18 +2,30 @@ import java.util.Properties; +/** + * Loads local configuration properties from a file. + */ public class LocalConfig implements PropertiesLoader { private static Properties localProps; + /** + * Loads the local configuration properties from 'test-db.properties'. + * @return Properties object containing the loaded properties. + */ public static Properties load() { if (localProps == null) { LocalConfig loader = new LocalConfig(); - localProps = loader.loadProperties("/local.properties"); + localProps = loader.loadProperties("/test-db.properties"); } return localProps; } + /** + * Retrieves the value of a specific property by key. + * @param key The property key. + * @return The property value associated with the key. + */ public static String get(String key) { Properties props = load(); return props.getProperty(key); diff --git a/src/main/java/me/nickhanson/codeforge/controller/StartupServlet.java b/src/main/java/me/nickhanson/codeforge/controller/StartupServlet.java index b592574..39843a4 100644 --- a/src/main/java/me/nickhanson/codeforge/controller/StartupServlet.java +++ b/src/main/java/me/nickhanson/codeforge/controller/StartupServlet.java @@ -5,6 +5,7 @@ import javax.servlet.ServletContextEvent; import javax.servlet.ServletContextListener; import javax.servlet.annotation.WebListener; +import java.time.Year; import java.util.Properties; /** @@ -24,14 +25,7 @@ public void contextInitialized(ServletContextEvent sce) { Properties props = loadProperties("/cognito.properties"); sce.getServletContext().setAttribute("cognitoProperties", props); - } - /** - * No operation on context destruction. - * @param sce the servlet context event - */ - @Override - public void contextDestroyed(ServletContextEvent sce) { - // no-op + sce.getServletContext().setAttribute("currentYear", Year.now().getValue()); } } diff --git a/src/main/java/me/nickhanson/codeforge/entity/AuthenticatedUser.java b/src/main/java/me/nickhanson/codeforge/entity/AuthenticatedUser.java index 579dca6..b7d3813 100644 --- a/src/main/java/me/nickhanson/codeforge/entity/AuthenticatedUser.java +++ b/src/main/java/me/nickhanson/codeforge/entity/AuthenticatedUser.java @@ -1,71 +1,22 @@ package me.nickhanson.codeforge.entity; +import lombok.*; + /** * Represents an authenticated user in the system. - * Contains basic user information such as username, email, and unique identifier (sub). + *

+ * Session-scoped authenticated user derived from Cognito JWT claims. + * This class is not a persisted entity and is not managed by Hibernate. + * * @author Nick Hanson - * TODO: needs Lombok implementation */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString public class AuthenticatedUser { private String userName; private String email; private String sub; - - // Default constructor - public AuthenticatedUser() {} - - // Parameterized constructor - public AuthenticatedUser(String userName, String email, String sub) { - this.userName = userName; - this.email = email; - this.sub = sub; - } - - /** - * Gets the username of the authenticated user. - * @return the username - */ - public String getUserName() { - return userName; - } - - /** - * Sets the username of the authenticated user. - * @param userName the username to set - */ - public void setUserName(String userName) { - this.userName = userName; - } - - /** - * Gets the email of the authenticated user. - * @return the email - */ - public String getEmail() { - return email; - } - - /** - * Sets the email of the authenticated user. - * @param email the email to set - */ - public void setEmail(String email) { - this.email = email; - } - - /** - * Gets the unique identifier (sub) of the authenticated user. - * @return the sub - */ - public String getSub() { - return sub; - } - - /** - * Sets the unique identifier (sub) of the authenticated user. - * @param sub the sub to set - */ - public void setSub(String sub) { - this.sub = sub; - } } \ No newline at end of file diff --git a/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluation.java b/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluation.java index 450233c..c62ee5c 100644 --- a/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluation.java +++ b/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluation.java @@ -2,14 +2,24 @@ import me.nickhanson.codeforge.entity.Outcome; -// TODO: Add javadoc - +/** + * Represents the evaluation result of a submitted answer. + * Contains the outcome, feedback, and normalized forms of expected and submitted answers. + * @author Nick Hanson + */ public class AnswerEvaluation { private final Outcome outcome; private final String feedback; private final String normalizedExpected; private final String normalizedSubmitted; + /** + * Constructs an AnswerEvaluation with the specified details. + * @param outcome The outcome of the evaluation. + * @param feedback Feedback regarding the evaluation. + * @param normalizedExpected The normalized expected answer. + * @param normalizedSubmitted The normalized submitted answer. + */ public AnswerEvaluation(Outcome outcome, String feedback, String normalizedExpected, String normalizedSubmitted) { this.outcome = outcome; this.feedback = feedback; @@ -17,6 +27,10 @@ public AnswerEvaluation(Outcome outcome, String feedback, String normalizedExpec this.normalizedSubmitted = normalizedSubmitted; } + /** + * Gets the outcome of the evaluation. + * @return The outcome. + */ public Outcome getOutcome() { return outcome; } public String getFeedback() { return feedback; } public String getNormalizedExpected() { return normalizedExpected; } diff --git a/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluator.java b/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluator.java index 90d5e57..9b5494e 100644 --- a/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluator.java +++ b/src/main/java/me/nickhanson/codeforge/evaluator/AnswerEvaluator.java @@ -2,7 +2,18 @@ import me.nickhanson.codeforge.entity.Challenge; +/** + * Interface for evaluating submitted answers against challenges. + * @author Nick Hanson + */ public interface AnswerEvaluator { + + /** + * Evaluates a submitted answer for a given challenge. + * @param challenge The challenge to evaluate against. + * @param submission The submitted answer. + * @return An AnswerEvaluation containing the evaluation results. + */ AnswerEvaluation evaluate(Challenge challenge, String submission); } diff --git a/src/main/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorService.java b/src/main/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorService.java index 7556e47..67149e0 100644 --- a/src/main/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorService.java +++ b/src/main/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorService.java @@ -3,9 +3,20 @@ import me.nickhanson.codeforge.entity.Challenge; import me.nickhanson.codeforge.entity.Outcome; +/** + * Basic implementation of the AnswerEvaluator interface. + * Compares submitted answers to expected answers with normalization. + * @author Nick Hanson + */ public class BasicEvaluatorService implements AnswerEvaluator { private static final int MAX_SOURCE_LENGTH = 10000; // simple guard for MVP + /** + * Evaluates a submitted answer for a given challenge. + * @param challenge The challenge to evaluate against. + * @param submission The submitted answer. + * @return An AnswerEvaluation containing the evaluation results. + */ @Override public AnswerEvaluation evaluate(Challenge challenge, String submission) { String expected = challenge.getExpectedAnswer(); diff --git a/src/main/java/me/nickhanson/codeforge/evaluator/Normalizer.java b/src/main/java/me/nickhanson/codeforge/evaluator/Normalizer.java index f12a970..67608a7 100644 --- a/src/main/java/me/nickhanson/codeforge/evaluator/Normalizer.java +++ b/src/main/java/me/nickhanson/codeforge/evaluator/Normalizer.java @@ -2,19 +2,43 @@ import java.util.Locale; +/** + * Utility class for normalizing strings for evaluation. + * Provides methods to standardize strings by trimming whitespace, + * converting to lowercase, collapsing multiple spaces, and stripping punctuation. + * @author Nick Hanson + */ public final class Normalizer { private Normalizer() {} + /** + * Performs basic normalization on the input string: + * - Trims leading and trailing whitespace + * - Converts to lowercase + * - Collapses multiple whitespace characters into a single space + * @param s The input string to normalize + * @return The normalized string + */ public static String basic(String s) { if (s == null) return null; String trimmed = s.trim().toLowerCase(Locale.ROOT); return collapseWhitespace(trimmed); } + /** + * Collapses multiple consecutive whitespace characters into a single space. + * @param s The input string + * @return The string with collapsed whitespace + */ public static String collapseWhitespace(String s) { return s.replaceAll("\\s+", " "); } + /** + * Strips all punctuation characters from the input string. + * @param s The input string + * @return The string without punctuation + */ public static String stripPunctuation(String s) { if (s == null) return null; return s.replaceAll("[\\p{Punct}]", ""); diff --git a/src/main/java/me/nickhanson/codeforge/persistence/ChallengeDao.java b/src/main/java/me/nickhanson/codeforge/persistence/ChallengeDao.java index ef1bebe..99403ba 100644 --- a/src/main/java/me/nickhanson/codeforge/persistence/ChallengeDao.java +++ b/src/main/java/me/nickhanson/codeforge/persistence/ChallengeDao.java @@ -6,6 +6,11 @@ import org.apache.logging.log4j.Logger; import org.hibernate.Session; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + import java.util.List; /** @@ -49,6 +54,7 @@ public List findByDifficulty(Difficulty difficulty) { return data.findByPropertyEqual("difficulty", difficulty); } + /** * Checks if a challenge with the given title exists, ignoring case. * @param title The title to check for existence. @@ -56,10 +62,16 @@ public List findByDifficulty(Difficulty difficulty) { */ public boolean existsTitleIgnoreCase(String title) { try (Session s = SessionFactoryProvider.getSessionFactory().openSession()) { - Long count = s.createQuery( - "select count(c) from Challenge c where lower(c.title) = lower(:t)", Long.class) - .setParameter("t", title) - .getSingleResult(); + CriteriaBuilder cb = s.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(Challenge.class); + + Predicate sameTitleIgnoreCase = + cb.equal(cb.lower(root.get("title")), title.toLowerCase()); + + cq.select(cb.count(root)).where(sameTitleIgnoreCase); + + Long count = s.createQuery(cq).getSingleResult(); return count != null && count > 0; } } @@ -73,12 +85,18 @@ public boolean existsTitleIgnoreCase(String title) { */ public boolean existsTitleForOtherIgnoreCase(String title, Long excludeId) { try (Session s = SessionFactoryProvider.getSessionFactory().openSession()) { - Long count = s.createQuery( - "select count(c) from Challenge c where lower(c.title) = lower(:t) and c.id <> :id", - Long.class) - .setParameter("t", title) - .setParameter("id", excludeId) - .getSingleResult(); + CriteriaBuilder cb = s.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(Challenge.class); + + Predicate sameTitleIgnoreCase = + cb.equal(cb.lower(root.get("title")), title.toLowerCase()); + Predicate notSameId = + cb.notEqual(root.get("id"), excludeId); + + cq.select(cb.count(root)).where(cb.and(sameTitleIgnoreCase, notSameId)); + + Long count = s.createQuery(cq).getSingleResult(); return count != null && count > 0; } } diff --git a/src/main/java/me/nickhanson/codeforge/service/Week9ChallengeService.java b/src/main/java/me/nickhanson/codeforge/service/Week9ChallengeService.java deleted file mode 100644 index ea71b55..0000000 --- a/src/main/java/me/nickhanson/codeforge/service/Week9ChallengeService.java +++ /dev/null @@ -1,34 +0,0 @@ -package me.nickhanson.codeforge.service; - -import me.nickhanson.codeforge.entity.Challenge; - -import java.util.List; -import java.util.Optional; - -// TODO: Remove this class ASAP as it is not needed anymore. - -/** - * A temporary service class for Week 9 challenges. - * This class is intended to be removed as soon as possible. - * It provides methods to find all challenges and find a challenge by its ID. - * @author Nick Hanson - */ -public class Week9ChallengeService { - private final ChallengeService svc; - - public Week9ChallengeService() { - this.svc = new ChallengeService(); - } - - public Week9ChallengeService(ChallengeService svc) { - this.svc = svc; - } - - public List findAll() { - return svc.listChallenges(null); - } - - public Optional findById(Long id) { - return svc.getById(id); - } -} \ No newline at end of file diff --git a/src/main/java/me/nickhanson/codeforge/web/AppBootstrap.java b/src/main/java/me/nickhanson/codeforge/web/AppBootstrap.java index cfb22a5..bf7f428 100644 --- a/src/main/java/me/nickhanson/codeforge/web/AppBootstrap.java +++ b/src/main/java/me/nickhanson/codeforge/web/AppBootstrap.java @@ -69,13 +69,4 @@ public void contextInitialized(ServletContextEvent sce) { ctx.log("Seeding failed: " + e.getMessage(), e); } } - - /** - * No operation on context destruction. - * @param sce the servlet context event - */ - @Override - public void contextDestroyed(ServletContextEvent sce) { - // no-op - } } diff --git a/src/main/java/me/nickhanson/codeforge/web/ErrorServlet.java b/src/main/java/me/nickhanson/codeforge/web/ErrorServlet.java new file mode 100644 index 0000000..1d6fc31 --- /dev/null +++ b/src/main/java/me/nickhanson/codeforge/web/ErrorServlet.java @@ -0,0 +1,100 @@ +package me.nickhanson.codeforge.web; + +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; + +/** + * Servlet to handle errors and display appropriate error pages. + * Supports handling of 403, 404, and 500 errors with custom JSP views. + * Extracts error details from request attributes and forwards to the relevant JSP. + * @author Nick Hanson + */ +@WebServlet("/error") +public class ErrorServlet extends HttpServlet { + + /** + * Handles GET requests by delegating to the common error handling method. + */ + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + handle(req, resp); + } + + /** + * Handles POST requests by delegating to the common error handling method. + */ + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + handle(req, resp); + } + + /** + * Common error handling logic for both GET and POST requests. + * Extracts error details and forwards to the appropriate JSP view. + */ + private void handle(HttpServletRequest req, HttpServletResponse resp) + throws ServletException, IOException { + // Extract standard servlet error attributes + Integer status = (Integer) req.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + Throwable throwable = (Throwable) req.getAttribute(RequestDispatcher.ERROR_EXCEPTION); + String requestUri = (String) req.getAttribute(RequestDispatcher.ERROR_REQUEST_URI); + String message = (String) req.getAttribute(RequestDispatcher.ERROR_MESSAGE); + + if (status == null) { + status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + } + if (requestUri == null || requestUri.isEmpty()) { + requestUri = req.getRequestURI(); + } + + // Provide a friendly, concise errorMessage for the JSP (500.jsp expects this optionally) + String errorMessage; + if (message != null && !message.isBlank()) { + errorMessage = message; + } else if (throwable != null) { + errorMessage = Objects.toString(throwable.getMessage(), throwable.getClass().getSimpleName()); + } else { + errorMessage = "Unexpected error"; + } + + // Common extra context useful in views or logs + req.setAttribute("timestamp", Instant.now().toString()); + req.setAttribute("referer", req.getHeader("Referer")); + + // Align with existing JSPs: they read errorMessage and javax.servlet.error.request_uri + req.setAttribute("errorMessage", errorMessage); + req.setAttribute("javax.servlet.error.request_uri", requestUri); + req.setAttribute("javax.servlet.error.status_code", status); + + // Choose view based on status code + String view; + switch (status) { + case HttpServletResponse.SC_FORBIDDEN: // 403 + view = "/WEB-INF/jsp/error/403.jsp"; + break; + case HttpServletResponse.SC_NOT_FOUND: // 404 + view = "/WEB-INF/jsp/error/404.jsp"; + break; + case HttpServletResponse.SC_INTERNAL_SERVER_ERROR: // 500 + case HttpServletResponse.SC_BAD_REQUEST: // 400 -> treat as 500 view for now + default: + view = "/WEB-INF/jsp/error/500.jsp"; + break; + } + + // Set the response status code + resp.setStatus(status); + + // Forward to the chosen error page + req.getRequestDispatcher(view).forward(req, resp); + } +} diff --git a/src/main/java/me/nickhanson/codeforge/web/HomeServlet.java b/src/main/java/me/nickhanson/codeforge/web/HomeServlet.java index abda303..def4b79 100644 --- a/src/main/java/me/nickhanson/codeforge/web/HomeServlet.java +++ b/src/main/java/me/nickhanson/codeforge/web/HomeServlet.java @@ -15,7 +15,7 @@ * * @author Nick Hanson */ -@WebServlet(urlPatterns = {"/home"}) +@WebServlet(urlPatterns = {"/", "/home"}) public class HomeServlet extends HttpServlet { private final QuoteService quotes = new QuoteService(); @@ -33,5 +33,4 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se req.setAttribute("quote", quote); req.getRequestDispatcher("/WEB-INF/jsp/home.jsp").forward(req, resp); } -} - +} \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/about.jsp b/src/main/webapp/WEB-INF/jsp/about.jsp index 476fd7b..6858fdc 100644 --- a/src/main/webapp/WEB-INF/jsp/about.jsp +++ b/src/main/webapp/WEB-INF/jsp/about.jsp @@ -4,23 +4,19 @@ Date: 11/14/2025 Time: 11:52 PM --%> -<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + - +

@@ -83,6 +79,16 @@
  • Custom JSP layout + basic design system (CodeForge UI)
  • +

    Project Javadocs

    +

    + The Java API documentation for this project is packaged with the app. + You can browse it here: +
    + + View CodeForge Javadocs + +

    +

    Future enhancements

      @@ -96,7 +102,7 @@
    - + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/auth/me.jsp b/src/main/webapp/WEB-INF/jsp/auth/me.jsp index 17b1f00..311a7f8 100644 --- a/src/main/webapp/WEB-INF/jsp/auth/me.jsp +++ b/src/main/webapp/WEB-INF/jsp/auth/me.jsp @@ -6,23 +6,17 @@ --%> <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - - -<%-- Fallback: if "user" isn't on the request, pull it from the session --%> - - - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> - + + -<%@ include file="/WEB-INF/jsp/header.jsp" %> + +
    @@ -79,6 +73,7 @@
    -<%@ include file="/WEB-INF/jsp/footer.jsp" %> + + diff --git a/src/main/webapp/WEB-INF/jsp/challenges/detail.jsp b/src/main/webapp/WEB-INF/jsp/challenges/detail.jsp index ecc410f..94879e8 100644 --- a/src/main/webapp/WEB-INF/jsp/challenges/detail.jsp +++ b/src/main/webapp/WEB-INF/jsp/challenges/detail.jsp @@ -6,19 +6,17 @@ --%> <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + - + +
    @@ -89,7 +87,7 @@
    - + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/challenges/edit.jsp b/src/main/webapp/WEB-INF/jsp/challenges/edit.jsp index 51dbea7..74383b4 100644 --- a/src/main/webapp/WEB-INF/jsp/challenges/edit.jsp +++ b/src/main/webapp/WEB-INF/jsp/challenges/edit.jsp @@ -4,20 +4,18 @@ Date: 10/24/2025 Time: 2:30 PM --%> -<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + - +
    @@ -103,7 +101,7 @@
    - + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/challenges/list.jsp b/src/main/webapp/WEB-INF/jsp/challenges/list.jsp index 5af4128..b4feee8 100644 --- a/src/main/webapp/WEB-INF/jsp/challenges/list.jsp +++ b/src/main/webapp/WEB-INF/jsp/challenges/list.jsp @@ -6,19 +6,16 @@ --%> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> -<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + - +
    @@ -109,6 +106,7 @@
    - + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/challenges/new.jsp b/src/main/webapp/WEB-INF/jsp/challenges/new.jsp index a8149de..58137de 100644 --- a/src/main/webapp/WEB-INF/jsp/challenges/new.jsp +++ b/src/main/webapp/WEB-INF/jsp/challenges/new.jsp @@ -4,20 +4,18 @@ Date: 10/24/2025 Time: 2:30 PM --%> -<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + - +
    @@ -101,7 +99,7 @@
    - + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/drill/queue.jsp b/src/main/webapp/WEB-INF/jsp/drill/queue.jsp index 7b36825..b31b4f7 100644 --- a/src/main/webapp/WEB-INF/jsp/drill/queue.jsp +++ b/src/main/webapp/WEB-INF/jsp/drill/queue.jsp @@ -7,20 +7,18 @@ <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + - +
    @@ -121,5 +119,7 @@
    + + diff --git a/src/main/webapp/WEB-INF/jsp/drill/solve.jsp b/src/main/webapp/WEB-INF/jsp/drill/solve.jsp index b5963ef..cf6d124 100644 --- a/src/main/webapp/WEB-INF/jsp/drill/solve.jsp +++ b/src/main/webapp/WEB-INF/jsp/drill/solve.jsp @@ -7,21 +7,20 @@ <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> - + - - - <%@ include file="/WEB-INF/jsp/head-meta.jspf" %> + + - +
    @@ -144,7 +143,7 @@
    - + - +
    @@ -107,6 +105,9 @@
    + + + + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index b2d67d4..50282d9 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -17,13 +17,19 @@ + + 403 + /error + + 404 - /WEB-INF/jsp/error/404.jsp + /error + 500 - /WEB-INF/jsp/error/500.jsp + /error diff --git a/src/test/java/me/nickhanson/codeforge/auth/CognitoJWTParserTest.java b/src/test/java/me/nickhanson/codeforge/auth/CognitoJWTParserTest.java index 1f8ac6e..9651894 100644 --- a/src/test/java/me/nickhanson/codeforge/auth/CognitoJWTParserTest.java +++ b/src/test/java/me/nickhanson/codeforge/auth/CognitoJWTParserTest.java @@ -8,12 +8,18 @@ class CognitoJWTParserTest { + /** + * Verifies that validateJWT throws an exception on a malformed JWT token. + */ @Test void validateJWT_throwsOnBadToken() { // CognitoJWTParser.validateJWT throws java.security.InvalidParameterException on malformed JWT assertThrows(java.security.InvalidParameterException.class, () -> CognitoJWTParser.validateJWT("notajwt")); } + /** + * Verifies that getPayload and getClaim return the correct claim values from a JWT token. + */ @Test void getPayload_and_getClaim_returnsClaimValue() { // create a simple jwt-like string: header.payload.signature where payload has {"sub":"123","email":"a@b"} diff --git a/src/test/java/me/nickhanson/codeforge/auth/TokenResponseMappingTest.java b/src/test/java/me/nickhanson/codeforge/auth/TokenResponseMappingTest.java index 9a8aee6..be8c8c8 100644 --- a/src/test/java/me/nickhanson/codeforge/auth/TokenResponseMappingTest.java +++ b/src/test/java/me/nickhanson/codeforge/auth/TokenResponseMappingTest.java @@ -7,6 +7,10 @@ class TokenResponseMappingTest { + /** + * Verifies that Jackson correctly maps a JSON string to a TokenResponse object. + * @throws Exception if mapping fails + */ @Test void jackson_maps_json_to_tokenResponse() throws Exception { String json = "{\"access_token\":\"a\",\"refresh_token\":\"r\",\"id_token\":\"i\",\"token_type\":\"t\",\"expires_in\":3600}"; diff --git a/src/test/java/me/nickhanson/codeforge/controller/AuthTest.java b/src/test/java/me/nickhanson/codeforge/controller/AuthTest.java index 14e772a..1765f05 100644 --- a/src/test/java/me/nickhanson/codeforge/controller/AuthTest.java +++ b/src/test/java/me/nickhanson/codeforge/controller/AuthTest.java @@ -25,6 +25,10 @@ class AuthTest { @Mock HttpServletResponse resp; @Mock RequestDispatcher rd; + /** + * Sets up the Auth servlet and provides a mock ServletConfig before each test. + * Initializes the servlet while ignoring ServletException during initialization. + */ @BeforeEach void setup() throws Exception { servlet = new Auth(); @@ -45,6 +49,9 @@ void setup() throws Exception { } } + /** + * Verifies that doGet with a valid code parameter forwards to the callback JSP. + */ @Test void doGet_missingCode_forwardsToError() throws Exception { when(req.getParameter("code")).thenReturn(null); diff --git a/src/test/java/me/nickhanson/codeforge/controller/LogInTest.java b/src/test/java/me/nickhanson/codeforge/controller/LogInTest.java index 283c8d1..a4a5fcb 100644 --- a/src/test/java/me/nickhanson/codeforge/controller/LogInTest.java +++ b/src/test/java/me/nickhanson/codeforge/controller/LogInTest.java @@ -31,6 +31,10 @@ class LogInTest { ServletConfig config; + /** + * Sets up the LogIn servlet and provides a mock ServletConfig before each test. + * Initializes the servlet. + */ @BeforeEach void setup() throws Exception { lenient().when(req.getServletContext()).thenReturn(ctx); @@ -47,6 +51,9 @@ void setup() throws Exception { servlet.init(config); } + /** + * Verifies that doGet redirects to login when Cognito properties are present. + */ @Test void doGet_redirectsToLogin_whenConfigPresent() throws Exception { servlet.doGet(req, resp); diff --git a/src/test/java/me/nickhanson/codeforge/controller/LogOutTest.java b/src/test/java/me/nickhanson/codeforge/controller/LogOutTest.java index 0b3c712..00578b7 100644 --- a/src/test/java/me/nickhanson/codeforge/controller/LogOutTest.java +++ b/src/test/java/me/nickhanson/codeforge/controller/LogOutTest.java @@ -31,6 +31,10 @@ class LogOutTest { ServletConfig config; + /** + * Sets up the LogOut servlet and provides a mock ServletConfig before each test. + * Initializes the servlet. + */ @BeforeEach void setup() throws Exception { lenient().when(req.getServletContext()).thenReturn(ctx); @@ -46,6 +50,9 @@ void setup() throws Exception { servlet.init(); } + /** + * Verifies that doGet invalidates the session and redirects. + */ @Test void doGet_invalidatesSession_andRedirects() throws Exception { servlet.doGet(req, resp); diff --git a/src/test/java/me/nickhanson/codeforge/controller/StartupServletTest.java b/src/test/java/me/nickhanson/codeforge/controller/StartupServletTest.java index cef70c8..2b1e29b 100644 --- a/src/test/java/me/nickhanson/codeforge/controller/StartupServletTest.java +++ b/src/test/java/me/nickhanson/codeforge/controller/StartupServletTest.java @@ -9,6 +9,9 @@ class StartupServletTest { + /** + * Verifies that contextInitialized sets the "cognitoProperties" attribute in the ServletContext. + */ @Test void contextInitialized_setsCognitoPropertiesAttribute() { ServletContext ctx = mock(ServletContext.class); diff --git a/src/test/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorServiceTest.java b/src/test/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorServiceTest.java index 13fa5c8..0c421f4 100644 --- a/src/test/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorServiceTest.java +++ b/src/test/java/me/nickhanson/codeforge/evaluator/BasicEvaluatorServiceTest.java @@ -10,12 +10,18 @@ public class BasicEvaluatorServiceTest { private final BasicEvaluatorService evaluator = new BasicEvaluatorService(); + /** + * Helper method to create a Challenge with a specified expected answer. + */ private Challenge challengeWithExpected(String expected) { Challenge ch = new Challenge("Test", Difficulty.EASY, "", ""); ch.setExpectedAnswer(expected); return ch; } + /** + * Verifies that an exact match returns CORRECT outcome. + */ @Test void exactMatch_isCorrect() { Challenge ch = challengeWithExpected("pivotIndex"); @@ -23,6 +29,9 @@ void exactMatch_isCorrect() { assertEquals(Outcome.CORRECT, eval.getOutcome()); } + /** + * Verifies that a match ignoring punctuation and spacing returns ACCEPTABLE outcome. + */ @Test void punctuationOrSpacing_only_isAcceptable() { Challenge ch = challengeWithExpected("pivot index"); @@ -30,6 +39,9 @@ void punctuationOrSpacing_only_isAcceptable() { assertEquals(Outcome.ACCEPTABLE, eval.getOutcome()); } + /** + * Verifies that a mismatched answer returns INCORRECT outcome. + */ @Test void mismatch_isIncorrect() { Challenge ch = challengeWithExpected("pivotIndex"); @@ -37,6 +49,9 @@ void mismatch_isIncorrect() { assertEquals(Outcome.INCORRECT, eval.getOutcome()); } + /** + * Verifies that an empty expected answer results in INCORRECT outcome. + */ @Test void missingExpected_marksIncorrect() { Challenge ch = challengeWithExpected(""); @@ -44,6 +59,9 @@ void missingExpected_marksIncorrect() { assertEquals(Outcome.INCORRECT, eval.getOutcome()); } + /** + * Verifies that a submission exceeding the length limit is SKIPPED by the guard. + */ @Test void longSubmission_isSkippedByGuard() { Challenge ch = challengeWithExpected("pivotIndex"); diff --git a/src/test/java/me/nickhanson/codeforge/persistence/ChallengeDaoTest.java b/src/test/java/me/nickhanson/codeforge/persistence/ChallengeDaoTest.java index 5b822d1..e46793c 100644 --- a/src/test/java/me/nickhanson/codeforge/persistence/ChallengeDaoTest.java +++ b/src/test/java/me/nickhanson/codeforge/persistence/ChallengeDaoTest.java @@ -18,6 +18,9 @@ class ChallengeDaoTest extends DbReset { private final DrillItemDao drillDao = new DrillItemDao(); private final SubmissionDao submissionDao = new SubmissionDao(); + /** + * Verifies that saving a new Challenge assigns it an ID. + */ @Test void create_assignsId() { Challenge c1 = new Challenge("Unique Alpha", Difficulty.EASY, "Find two numbers sum to target", "..."); @@ -25,6 +28,9 @@ void create_assignsId() { assertNotNull(c1.getId()); } + /** + * Verifies that retrieving a Challenge by ID returns the saved entity. + */ @Test void read_getById_returnsSavedEntity() { Challenge c1 = new Challenge("Unique Beta", Difficulty.EASY, "Find two numbers sum to target", "..."); @@ -35,6 +41,9 @@ void read_getById_returnsSavedEntity() { assertEquals(Difficulty.EASY, found.getDifficulty()); } + /** + * Verifies that updating a Challenge persists the changes. + */ @Test void update_persistsChanges() { Challenge c1 = new Challenge("Unique Gamma", Difficulty.EASY, "Find two numbers sum to target", "..."); @@ -45,6 +54,9 @@ void update_persistsChanges() { assertEquals("Unique Gamma v2", updated.getTitle()); } + /** + * Verifies that getAll returns all saved Challenges. + */ @Test void getAll_returnsAll() { int before = dao.getAll().size(); @@ -54,12 +66,18 @@ void getAll_returnsAll() { assertEquals(before + 2, all.size()); } + /** + * Verifies that existsTitleIgnoreCase returns false for non-existent title. + */ @Test void existsTitleIgnoreCase_trueAfterInsert() { dao.saveOrUpdate(new Challenge("Unique Zeta", Difficulty.EASY, "", "...")); assertTrue(dao.existsTitleIgnoreCase("unique zeta")); } + /** + * Verifies that existsTitleForOtherIgnoreCase returns false when excluding the same entity. + */ @Test void existsTitleForOtherIgnoreCase_falseForSameEntity() { Challenge c1 = new Challenge("Unique Eta", Difficulty.EASY, "", "..."); @@ -67,6 +85,9 @@ void existsTitleForOtherIgnoreCase_falseForSameEntity() { assertFalse(dao.existsTitleForOtherIgnoreCase("unique eta", c1.getId())); } + /** + * Verifies that findByDifficulty returns only challenges matching the specified difficulty. + */ @Test void findByDifficulty_returnsOnlyMatching() { int beforeEasy = dao.findByDifficulty(Difficulty.EASY).size(); @@ -77,6 +98,9 @@ void findByDifficulty_returnsOnlyMatching() { assertEquals(beforeMedium + 1, dao.findByDifficulty(Difficulty.MEDIUM).size()); } + /** + * Verifies that deleting a Challenge removes it from the database. + */ @Test void delete_removesRow() { int before = dao.getAll().size(); @@ -89,6 +113,9 @@ void delete_removesRow() { assertEquals(before, afterDelete); } + /** + * Verifies that deleting a Challenge cascades to dependent DrillItems and Submissions. + */ @Test void delete_withDependents_cascadesToChildren() { Challenge ch = new Challenge("Dependent Test", Difficulty.EASY, "", "..."); diff --git a/src/test/java/me/nickhanson/codeforge/persistence/DaoTestBase.java b/src/test/java/me/nickhanson/codeforge/persistence/DaoTestBase.java deleted file mode 100644 index 7c0e724..0000000 --- a/src/test/java/me/nickhanson/codeforge/persistence/DaoTestBase.java +++ /dev/null @@ -1,36 +0,0 @@ -package me.nickhanson.codeforge.persistence; - -import org.hibernate.Session; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; - -import java.sql.Statement; - -/** - * Base class for DAO integration tests. Resets MySQL tables between tests for isolation. - * MySQL-only for MVP (no H2). - */ -public abstract class DaoTestBase { - - @BeforeEach - void resetDatabase() { - // Delete in FK-safe way for MySQL - try (Session session = SessionFactoryProvider.getSessionFactory().openSession()) { - session.doWork(conn -> { - try (Statement st = conn.createStatement()) { - st.execute("SET FOREIGN_KEY_CHECKS = 0"); - // Use physical table names as created by Hibernate on MySQL - st.execute("TRUNCATE TABLE SUBMISSIONS"); - st.execute("TRUNCATE TABLE drill_items"); - st.execute("TRUNCATE TABLE CHALLENGES"); - st.execute("SET FOREIGN_KEY_CHECKS = 1"); - } - }); - } - } - - @AfterEach - void cleanUp() { - // No-op for now; tables are already clean. Hook reserved for future use. - } -} \ No newline at end of file diff --git a/src/test/java/me/nickhanson/codeforge/persistence/DrillItemDaoTest.java b/src/test/java/me/nickhanson/codeforge/persistence/DrillItemDaoTest.java index 543cbf5..11df460 100644 --- a/src/test/java/me/nickhanson/codeforge/persistence/DrillItemDaoTest.java +++ b/src/test/java/me/nickhanson/codeforge/persistence/DrillItemDaoTest.java @@ -16,12 +16,18 @@ class DrillItemDaoTest extends DbReset { private static final String USER = "unit-test-user"; + /** + * Helper method to create and persist a Challenge with the given title. + */ private Challenge seedChallenge(String title) { Challenge ch = new Challenge(title, Difficulty.EASY, "", "..."); challengeDao.saveOrUpdate(ch); return ch; } + /** + * Verifies that creating a DrillItem assigns it an ID and relates it to the Challenge. + */ @Test void create_assignsId_and_relatesToChallenge() { Challenge ch = seedChallenge("FizzBuzz"); @@ -32,6 +38,9 @@ void create_assignsId_and_relatesToChallenge() { assertEquals(ch.getId(), di.getChallenge().getId()); } + /** + * Verifies that listing DrillItems by Challenge ID returns the correct items. + */ @Test void listByChallenge_returnsItems() { Challenge ch = seedChallenge("FizzBuzz"); @@ -43,6 +52,9 @@ void listByChallenge_returnsItems() { assertEquals(ch.getId(), items.get(0).getChallenge().getId()); } + /** + * Verifies that dueQueue orders items with null nextDueAt first, then by time. + */ @Test void dueQueue_ordersNullFirst_thenByTime() { // Ensure we control the data set @@ -86,6 +98,9 @@ void dueQueue_ordersNullFirst_thenByTime() { assertFalse(challengeIds.contains(chFuture.getId())); } + /** + * Verifies that deleting a DrillItem does not delete the associated Challenge. + */ @Test void delete_item_keepsChallenge() { int beforeChallengeCount = challengeDao.getAll().size(); @@ -102,6 +117,9 @@ void delete_item_keepsChallenge() { assertEquals(beforeItemCount, drillDao.getAll().size()); } + /** + * Verifies that deleting a Challenge with dependent DrillItems requires cleaning up the DrillItems first. + */ @Test void delete_challenge_requiresCleaningDependents() { int beforeChallengeCount = challengeDao.getAll().size(); diff --git a/src/test/java/me/nickhanson/codeforge/persistence/SubmissionDaoTest.java b/src/test/java/me/nickhanson/codeforge/persistence/SubmissionDaoTest.java index 0b91138..e9a464f 100644 --- a/src/test/java/me/nickhanson/codeforge/persistence/SubmissionDaoTest.java +++ b/src/test/java/me/nickhanson/codeforge/persistence/SubmissionDaoTest.java @@ -16,12 +16,18 @@ class SubmissionDaoTest extends DbReset { private static final String USER = "unit-test-user"; + /** + * Helper method to create and persist a Challenge with the given title. + */ private Challenge seedChallenge(String title) { Challenge ch = new Challenge(title, Difficulty.EASY, "", "..."); challengeDao.saveOrUpdate(ch); return ch; } + /** + * Verifies that creating a Submission assigns it an ID. + */ @Test void create_assignsId() { Challenge ch = seedChallenge("Reverse String"); @@ -31,6 +37,9 @@ void create_assignsId() { assertNotNull(s.getId()); } + /** + * Verifies that retrieving a Submission by ID returns the saved entity. + */ @Test void read_getById_returnsSaved() { Challenge ch = seedChallenge("Reverse String"); @@ -43,6 +52,9 @@ void read_getById_returnsSaved() { assertEquals(ch.getId(), found.getChallenge().getId()); } + /** + * Verifies that listing Submissions by Challenge ID returns only matching Submissions. + */ @Test void listByChallenge_returnsOnlyMatching() { Challenge ch = seedChallenge("Reverse String"); @@ -58,6 +70,9 @@ void listByChallenge_returnsOnlyMatching() { assertEquals(ch.getId(), list.get(0).getChallenge().getId()); } + /** + * Verifies that deleting a Submission does not delete its associated Challenge. + */ @Test void delete_submission_keepsChallenge() { int beforeChallengeCount = challengeDao.getAll().size(); @@ -73,6 +88,9 @@ void delete_submission_keepsChallenge() { assertEquals(beforeSubmissionCount, submissionDao.getAll().size()); } + /** + * Verifies that deleting a Challenge requires cleaning up dependent DrillItems and Submissions first. + */ @Test void delete_challenge_requiresCleaningDependents() { int beforeChallengeCount = challengeDao.getAll().size(); diff --git a/src/test/java/me/nickhanson/codeforge/service/ChallengeRunServiceTest.java b/src/test/java/me/nickhanson/codeforge/service/ChallengeRunServiceTest.java index c168594..c78e1d6 100644 --- a/src/test/java/me/nickhanson/codeforge/service/ChallengeRunServiceTest.java +++ b/src/test/java/me/nickhanson/codeforge/service/ChallengeRunServiceTest.java @@ -9,6 +9,9 @@ class ChallengeRunServiceTest { private final ChallengeRunService svc = new ChallengeRunService(); + /** + * Verifies that running with unsupported language or blank language results in SKIPPED outcome. + */ @Test void run_skippedForUnsupportedLanguageOrBlank() { assertEquals(Outcome.SKIPPED, svc.run(1L, null, "code").getOutcome()); diff --git a/src/test/java/me/nickhanson/codeforge/service/ChallengeServiceTest.java b/src/test/java/me/nickhanson/codeforge/service/ChallengeServiceTest.java index d313278..ba21fdb 100644 --- a/src/test/java/me/nickhanson/codeforge/service/ChallengeServiceTest.java +++ b/src/test/java/me/nickhanson/codeforge/service/ChallengeServiceTest.java @@ -24,6 +24,9 @@ class ChallengeServiceTest extends DbReset { @Mock ChallengeDao dao; @InjectMocks ChallengeService svc; + /** + * Verifies that listing all challenges returns what the DAO provides. + */ @Test void findAll() { List all = List.of( @@ -39,6 +42,9 @@ void findAll() { verifyNoMoreInteractions(dao); } + /** + * Verifies that listing challenges by difficulty returns what the DAO provides. + */ @Test void findById_returnsOne_whenPresent() { Challenge c = new Challenge("Two Sum", Difficulty.EASY, "b","p"); @@ -49,6 +55,9 @@ void findById_returnsOne_whenPresent() { verify(dao, times(2)).getById(42L); // or call once then store the Optional } + /** + * Verifies that creating a challenge sets fields from the form and saves it. + */ @Test void create_setsFields_and_saves() { ChallengeForm form = new ChallengeForm(); diff --git a/src/test/java/me/nickhanson/codeforge/service/DrillServiceEnrollmentTest.java b/src/test/java/me/nickhanson/codeforge/service/DrillServiceEnrollmentTest.java index 432d53b..b3a9bc6 100644 --- a/src/test/java/me/nickhanson/codeforge/service/DrillServiceEnrollmentTest.java +++ b/src/test/java/me/nickhanson/codeforge/service/DrillServiceEnrollmentTest.java @@ -27,6 +27,10 @@ class DrillServiceEnrollmentTest { @InjectMocks DrillService service; + /** + * Ensures that when enrolling a user in challenges, only missing DrillItems are created. + * @throws Exception if reflection fails + */ @Test void ensureEnrollmentForUser_createsOnlyMissing() throws Exception { String userId = "enroll-user"; diff --git a/src/test/java/me/nickhanson/codeforge/service/DrillServicePersistenceTest.java b/src/test/java/me/nickhanson/codeforge/service/DrillServicePersistenceTest.java index 668fb76..7089a29 100644 --- a/src/test/java/me/nickhanson/codeforge/service/DrillServicePersistenceTest.java +++ b/src/test/java/me/nickhanson/codeforge/service/DrillServicePersistenceTest.java @@ -27,6 +27,9 @@ void setup() { drillItemDao = new DrillItemDao(); } + /** + * Tests that recording an outcome updates the DrillItem fields appropriately. + */ @Test void recordOutcome_updatesDrillItemFields() { // Seed a challenge diff --git a/src/test/java/me/nickhanson/codeforge/service/DrillServiceTest.java b/src/test/java/me/nickhanson/codeforge/service/DrillServiceTest.java index 727dae7..af808f1 100644 --- a/src/test/java/me/nickhanson/codeforge/service/DrillServiceTest.java +++ b/src/test/java/me/nickhanson/codeforge/service/DrillServiceTest.java @@ -27,12 +27,19 @@ class DrillServiceTest { @InjectMocks DrillService service; + /** + * Utility to create a Challenge with a specific ID via reflection. + */ private static Challenge challengeWithId(long id) { Challenge c = new Challenge("Two Sum", Difficulty.EASY, "b", "p"); try { var f = Challenge.class.getDeclaredField("id"); f.setAccessible(true); f.set(c, id); } catch (Exception ignored) {} return c; } + /** + * Tests that recording an outcome creates a Submission, + * updates the corresponding DrillItem, and persists both. + */ @Test void recordOutcome_createsSubmission_updatesDrillItem_andPersistsBoth() { long id = 42L; @@ -52,6 +59,9 @@ void recordOutcome_createsSubmission_updatesDrillItem_andPersistsBoth() { assertEquals(Outcome.CORRECT, s.getOutcome()); } + /** + * Tests that the nextDueAt is computed correctly based on the current streak. + */ @Test void computeNextDueAt_advancesAccordingToStreak() { long id = 7L; @@ -74,6 +84,9 @@ void computeNextDueAt_advancesAccordingToStreak() { verify(drillDao, atLeast(2)).saveOrUpdate(any()); } + /** + * Tests that getDueQueue returns due items, or the soonest upcoming if none are due. + */ @Test void getDueQueue_returnsSoonestWhenNoneDue() { String userId = "demo-user"; @@ -87,6 +100,9 @@ void getDueQueue_returnsSoonestWhenNoneDue() { assertSame(soonest, queue.get(0)); } + /** + * Tests that ensureDrillItem creates a DrillItem if missing. + */ @Test void ensureDrillItem_createsWhenMissing() { long id = 9L; diff --git a/src/test/java/me/nickhanson/codeforge/service/QuoteServiceTest.java b/src/test/java/me/nickhanson/codeforge/service/QuoteServiceTest.java index 37e2ac9..ac91ab5 100644 --- a/src/test/java/me/nickhanson/codeforge/service/QuoteServiceTest.java +++ b/src/test/java/me/nickhanson/codeforge/service/QuoteServiceTest.java @@ -1,6 +1,5 @@ package me.nickhanson.codeforge.service; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,6 +23,9 @@ class QuoteServiceTest { private QuoteService service; + /** + * Setup before each test: create QuoteService and inject mocked HttpClient. + */ @BeforeEach void setup() throws Exception { service = new QuoteService(); diff --git a/src/test/java/me/nickhanson/codeforge/service/Week9ChallengeServiceTest.java b/src/test/java/me/nickhanson/codeforge/service/Week9ChallengeServiceTest.java deleted file mode 100644 index ab21c3e..0000000 --- a/src/test/java/me/nickhanson/codeforge/service/Week9ChallengeServiceTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package me.nickhanson.codeforge.service; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.*; -import org.mockito.junit.jupiter.MockitoExtension; - - -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -public class Week9ChallengeServiceTest { - - @Mock ChallengeService svc; - Week9ChallengeService w9; - - @BeforeEach - void setUp() { w9 = new Week9ChallengeService(svc); } - - @Test - void findAll() { - w9.findAll(); - verify(svc).listChallenges(null); - } - - @Test - void findById() { - Long id = 42L; - w9.findById(id); - verify(svc).getById(id); - } -} diff --git a/src/test/java/me/nickhanson/codeforge/testutil/Database.java b/src/test/java/me/nickhanson/codeforge/testutil/Database.java index 425b1d6..0cb0220 100644 --- a/src/test/java/me/nickhanson/codeforge/testutil/Database.java +++ b/src/test/java/me/nickhanson/codeforge/testutil/Database.java @@ -30,7 +30,7 @@ public class Database implements PropertiesLoader { /** private constructor prevents instantiating this class anywhere else */ private Database() { - properties = loadProperties("/hibernate.properties"); + properties = loadProperties("/test-db.properties"); } /** get the single database object */ diff --git a/src/test/java/me/nickhanson/codeforge/testutil/DbReset.java b/src/test/java/me/nickhanson/codeforge/testutil/DbReset.java index 87f028e..6ed067e 100644 --- a/src/test/java/me/nickhanson/codeforge/testutil/DbReset.java +++ b/src/test/java/me/nickhanson/codeforge/testutil/DbReset.java @@ -10,13 +10,7 @@ public abstract class DbReset { @BeforeEach public void setUp() { - try { - TestDbCleaner.purgeCoreTables(); - } catch (Exception e) { - // Fallback to full SQL reset if purge fails - Database database = Database.getInstance(); - database.runSQL("cleandb.sql"); - } + Database.getInstance().runSQL("cleandb.sql"); } /** diff --git a/src/test/java/me/nickhanson/codeforge/testutil/TestDbCleaner.java b/src/test/java/me/nickhanson/codeforge/testutil/TestDbCleaner.java deleted file mode 100644 index 49054f3..0000000 --- a/src/test/java/me/nickhanson/codeforge/testutil/TestDbCleaner.java +++ /dev/null @@ -1,25 +0,0 @@ -package me.nickhanson.codeforge.testutil; - -import me.nickhanson.codeforge.persistence.SessionFactoryProvider; -import org.hibernate.Session; -import org.hibernate.Transaction; - -/** - * Utility to purge core tables before tests to avoid unique constraint collisions - * when using a persistent MySQL test database. - */ -public final class TestDbCleaner { - private TestDbCleaner() {} - - public static void purgeCoreTables() { - try (Session session = SessionFactoryProvider.getSessionFactory().openSession()) { - Transaction tx = session.beginTransaction(); - // Order matters due to FKs - session.createNativeQuery("DELETE FROM submissions").executeUpdate(); - session.createNativeQuery("DELETE FROM drill_items").executeUpdate(); - session.createNativeQuery("DELETE FROM challenges").executeUpdate(); - tx.commit(); - } - } -} - diff --git a/src/test/java/me/nickhanson/codeforge/web/ChallengesServletTest.java b/src/test/java/me/nickhanson/codeforge/web/ChallengesServletTest.java index 166e321..3f67346 100644 --- a/src/test/java/me/nickhanson/codeforge/web/ChallengesServletTest.java +++ b/src/test/java/me/nickhanson/codeforge/web/ChallengesServletTest.java @@ -37,6 +37,9 @@ class ChallengesServletTest { ServletConfig config; + /** + * Setup before each test: initialize servlet with mocked context and service. + */ @BeforeEach void setup() throws Exception { lenient().when(req.getServletContext()).thenReturn(ctx); @@ -53,6 +56,9 @@ void setup() throws Exception { servlet.init(config); } + /** + * Tests that a GET to the challenges list forwards to the list JSP with challenges. + */ @Test void get_list_forwardsToListJsp_withChallenges() throws Exception { when(req.getPathInfo()).thenReturn(null); @@ -68,6 +74,9 @@ void get_list_forwardsToListJsp_withChallenges() throws Exception { verify(rd).forward(req, resp); } + /** + * Tests that a GET to a challenge detail forwards to the detail JSP with the challenge. + */ @Test void get_detail_nonexistent_returns404() throws Exception { when(req.getPathInfo()).thenReturn("/999"); @@ -78,6 +87,9 @@ void get_detail_nonexistent_returns404() throws Exception { verify(resp).sendError(404); } + /** + * Tests that a POST to create a challenge redirects to the new challenge on success. + */ @Test void post_create_redirectsOnSuccess() throws Exception { when(req.getPathInfo()).thenReturn(null); @@ -95,6 +107,9 @@ void post_create_redirectsOnSuccess() throws Exception { verify(resp).sendRedirect(contains("/challenges/123")); } + /** + * Tests that a POST to update a challenge redirects to the challenge on success. + */ @Test void post_update_malformedPath_returns400() throws Exception { // choose a path that parses to a valid id but no known action -> final sendError(400) @@ -105,6 +120,9 @@ void post_update_malformedPath_returns400() throws Exception { verify(resp).sendError(400); } + /** + * Tests that a POST to delete a challenge redirects to the challenges list on success. + */ @Test void get_detail_invalidId_returns400() throws Exception { when(req.getPathInfo()).thenReturn("/abc"); @@ -112,6 +130,9 @@ void get_detail_invalidId_returns400() throws Exception { verify(resp).sendError(400); } + /** + * Tests that a GET to detail with an invalid id returns 400. + */ @Test void post_update_invalidId_returns400() throws Exception { when(req.getPathInfo()).thenReturn("/xyz"); @@ -119,6 +140,9 @@ void post_update_invalidId_returns400() throws Exception { verify(resp).sendError(400); } + /** + * Tests that a POST to delete with an invalid id returns 400. + */ @Test void post_delete_invalidId_returns400() throws Exception { when(req.getPathInfo()).thenReturn("/xyz/delete"); diff --git a/src/test/java/me/nickhanson/codeforge/web/DrillServletTest.java b/src/test/java/me/nickhanson/codeforge/web/DrillServletTest.java index 14b091b..5897550 100644 --- a/src/test/java/me/nickhanson/codeforge/web/DrillServletTest.java +++ b/src/test/java/me/nickhanson/codeforge/web/DrillServletTest.java @@ -44,6 +44,9 @@ class DrillServletTest { ServletConfig config; + /** + * Setup before each test: initialize servlet with mocked context and services. + */ @BeforeEach void setup() throws Exception { lenient().when(req.getServletContext()).thenReturn(ctx); @@ -66,6 +69,9 @@ void setup() throws Exception { servlet.init(config); } + /** + * Tests that a GET to the drill endpoint forwards with due drill items. + */ @Test void get_queue_forwardsWithRows() throws Exception { when(req.getPathInfo()).thenReturn(null); @@ -78,6 +84,9 @@ void get_queue_forwardsWithRows() throws Exception { verify(rd).forward(req, resp); } + /** + * Tests that a POST to submit code records the outcome and redirects. + */ @Test void post_submit_recordsOutcome_andRedirects() throws Exception { when(req.getPathInfo()).thenReturn("/42/submit"); @@ -92,6 +101,9 @@ void post_submit_recordsOutcome_andRedirects() throws Exception { verify(resp).sendRedirect(contains("/drill/next")); } + /** + * Tests that a POST to add a challenge to the drill creates the DrillItem and redirects. + */ @Test void get_invalidId_returns400() throws Exception { when(req.getPathInfo()).thenReturn("/abc"); @@ -99,6 +111,9 @@ void get_invalidId_returns400() throws Exception { verify(resp).sendError(400); } + /** + * Tests that a POST to submit with an invalid ID returns 400. + */ @Test void post_submit_invalidId_returns400() throws Exception { when(req.getPathInfo()).thenReturn("/xyz/submit"); @@ -106,6 +121,9 @@ void post_submit_invalidId_returns400() throws Exception { verify(resp).sendError(400); } + /** + * Tests that a POST to add with an invalid ID returns 400. + */ @Test void post_add_invalidId_returns400() throws Exception { when(req.getPathInfo()).thenReturn("/bad/add"); diff --git a/src/test/java/me/nickhanson/codeforge/web/HomeServletTest.java b/src/test/java/me/nickhanson/codeforge/web/HomeServletTest.java index 02b5560..84fba0a 100644 --- a/src/test/java/me/nickhanson/codeforge/web/HomeServletTest.java +++ b/src/test/java/me/nickhanson/codeforge/web/HomeServletTest.java @@ -32,6 +32,9 @@ class HomeServletTest { ServletConfig config; + /** + * Setup before each test: initialize servlet with mocked context and service. + */ @BeforeEach void setup() throws Exception { lenient().when(req.getRequestDispatcher(any())).thenReturn(rd); @@ -51,6 +54,9 @@ void setup() throws Exception { servlet.init(config); } + /** + * Tests that a GET to the home servlet forwards to the home JSP and sets a quote. + */ @Test void get_forwards_andSetsQuote() throws Exception { when(quoteService.getRandomQuote()).thenReturn("hello");