Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.
Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.
Week 3 - MVC, Repository interfaces, Thymeleaf, Internationalization, Controller Unit Tests,
Week 4 - Use Case Narrative, Sequence Diagram, State Chart Diagram, Create new objects.
Lesson 5 - Users, roles, and permissions setup from Java 2. User Registration.
Lesson 6 - Update and delete existing objects.
Lesson 7 - Homepage with content from the database. Custom not found and error pages.
Lesson 8 - Login and logout. Cookies. User permission access.
Lesson 9 - Edit and delete user profile. Password reset.
Lesson 10 - Filtering and limiting records by category.
Lesson 11 - Web Sockets. Date and currency formatting.
Lesson 12 - Email and SMS messaging. Failed login attempts.
Lesson 13 - Shopping Cart. Payment processing.
Title: Register New Student with Auto-School Detection
Actor: Anonymous User (to become Student by default)
Precondition: User is not logged in.
Main Success Scenario:
User clicks "Register" in the top navigation bar.
System displays the Registration Form (Fields: Email, Password).
User enters valid data (e.g., alex.student@kirkwood.edu, StrongPass1!).
System validates:
Email format is valid.
Email is not already registered.
Password meets strength requirements (8+ chars, mix of types). (continued)
Main Success Scenario (continued):
System parses the email domain (kirkwood.edu) to find the associated School.
System saves the new User with the STUDENT role.
System automatically logs the user in (establishing a session).
System redirects the user to the School Details Page for that domain (e.g., /schools/{id}).
Alternative Flows:
School Not Found: If the email domain (e.g., @gmail.com) does not match any registered school, the system saves the user but redirects them to a generic "Welcome" or "Select School" page.
Validation Fail: System re-displays the form with error messages.
This diagram includes logic specific for my Athleagues project: Domain Parsing.
When a user enters their email address, we need to extract the part after the @ symbol and look it up in the SchoolRepository.
sequenceDiagram
actor User
participant Controller as AuthController
participant Service as UserService
participant Security as HttpServletRequest
participant Repo as SchoolRepository
participant DB as Database
User->>Controller: POST /register (email, password)
%% Phase 1: Validation
Controller->>Controller: Validate Input (@Valid)
alt Validation Failed
Controller-->>User: Return "auth/registerForm" (Display Errors)
else Validation Passed
%% Phase 2: Registration
Note right of Controller: 1. Create Account
Controller->>Service: registerNewUser(user)
Service->>Service: Hash Password (BCrypt)
Service->>DB: Save User & Roles
DB-->>Service: Saved User
Service-->>Controller: Return User
%% Phase 3: Auto-Login
Note right of Controller: 2. Auto-Login
Controller->>Security: login(email, rawPassword)
Security->>Security: Authenticate & Create Session
Security-->>Controller: Session Established (JSESSIONID)
%% Phase 4: School Detection
Note right of Controller: 3. Domain Resolution
loop Recursive Domain Check
Controller->>Repo: findByDomain(currentDomain)
Repo-->>Controller: Optional<School>
opt School Not Found
Controller->>Controller: Strip subdomain (student.kirkwood.edu -> kirkwood.edu)
end
end
%% Phase 5: Redirect
alt School Found
Controller->>Controller: Add Flash Attribute (messageSuccess)
Controller-->>User: Redirect to /schools/{id}
else No School Found
Controller->>Controller: Add Flash Attribute (messageWarning)
Controller-->>User: Redirect to /
end
endWe will create additional unit tests after creating an AuthController class.
Create a "user" package.
Add the following to the dependencies block in your build.gradle file:
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
Create UserServiceImplTest and UserDetailsServiceImplTest classes.
These tests should pass when run. Other unit tests may fail.
My computer ran slowly on day 9. More could have been accomplished.
Use a tool like Insomnia, a simple browser extension, or Postman to send POST requests with a JSON body.
Make a new HTTP request. Change GET to POST.
Enter "http://localhost:8080/api/auth/register" as the URL.
Enter the following data as the body. Change the firstName and email as necessary.
{ "firstName": "Alex",
"lastName": "Student",
"email": "alex.student@kirkwood.edu",
"password": "StrongPassword123" }
Go to the Headers tab. Ensure that a header named "Content-Type" with the value "application/json" is present.
Click Send. If a 201 status displays.
Check your database. A new user and user_role are created.
If you send a POST request with valid credentials to http://localhost:8080/api/auth/login, you will get a 200 OK response.
Use this as the JSON body.
{ "email": "alex.student@kirkwood.edu",
"password": "StrongPassword123" }
If the password is wrong, Spring Security will throw a BadCredentialsException.
When we run the unit tests, it currently ignores MySqlIntegrationTests and PostgresIntegrationTests. Since we're using MySQL, let's enable MySqlIntegrationTests.
Open MySqlIntegrationTests.java.
Replace these lines:
@Testcontainers(disabledWithoutDocker = true)
@DisabledInNativeImage
@DisabledInAotMode
With this: @Testcontainers
Ensure Docker Desktop is running on your machine. The tests use "Testcontainers" to spin up a real MySQL instance on the fly.
Add an assertion to the testFindAll method to verify it worked
assertThat(vets.findAll()).isNotEmpty();
Add additional assertions to the MySqlIntegrationTests.findAll method.
The MySqlIntegrationTests class uses the @ActiveProfiles("mysql") annotation.
In application-mysql.properties, the configuration points specifically to schema.sql and data.sql
@Autowired
private VetRepository vets;
@Autowired
private UserRepository users;
@Autowired
private SchoolRepository schools;
@Test
void testFindAll() {
vets.findAll();
vets.findAll(); // served from cache
assertThat(vets.findAll()).isNotEmpty();
users.findAll();
users.findAll(); // served from cache
assertThat(users.findAll()).isNotEmpty();
schools.findAll();
schools.findAll(); // served from cache
assertThat(schools.findAll()).isNotEmpty();
}CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
first_name VARCHAR(50),
last_name VARCHAR(50),
nickname VARCHAR(50),
nickname_is_flagged TINYINT DEFAULT 0,
email VARCHAR(255) NOT NULL,
public_email TINYINT DEFAULT 0,
phone VARCHAR(255),
public_phone TINYINT DEFAULT 0,
password_hash VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME,
UNIQUE INDEX idx_users_email (email),
INDEX idx_users_name (last_name, first_name)
);
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS permission_role (
permission_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (permission_id, role_id),
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schools (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
domain VARCHAR(255) NOT NULL,
status_id ENUM('ACTIVE', 'INACTIVE', 'SUSPENDED') DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
UNIQUE INDEX idx_schools_domain (domain)
);
CREATE TABLE IF NOT EXISTS locations (
id INT AUTO_INCREMENT PRIMARY KEY,
school_id INT NOT NULL,
parent_location_id INT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
address VARCHAR(255),
latitude DECIMAL(8,4),
longitude DECIMAL(8,4),
status_id ENUM('DRAFT', 'ACTIVE', 'CLOSED', 'COMING_SOON') DEFAULT 'ACTIVE',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
CONSTRAINT fk_locations_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
CONSTRAINT fk_locations_parent FOREIGN KEY (parent_location_id) REFERENCES locations(id) ON DELETE SET NULL
);mysql profile relies strictly on SQL files found in src/main/resources/db/mysql/.schema.sql and add table definitions for your entities at the bottom.INSERT IGNORE INTO roles (name, description) VALUES
('SCHOOL_ADMIN', 'Rec Center Admin: Can manage facilities, leagues, scores, and users.'),
('STUDENT', 'Student: Can join leagues, create teams, and view schedules.');
INSERT IGNORE INTO permissions (name, description) VALUES
('MANAGE_OWN_PROFILE', 'Allows user to update their personal info and password.'),
('USE_MESSAGING', 'Allows user to send/receive messages with other participants.'),
('VIEW_LEAGUES', 'Allows user to browse and search available leagues and activities.'),
('REGISTER_FOR_LEAGUE', 'Allows user to register as an individual for a league.'),
('CREATE_TEAM', 'Allows user to create a new team as a captain.'),
('MANAGE_TEAM_INVITATIONS', 'Allows user to accept or decline invitations to a team.'),
('VIEW_OWN_SCHEDULE', 'Allows user to view their personal and team game schedule.'),
('VIEW_STANDINGS', 'Allows user to view league standings and team statistics.'),
('MANAGE_FACILITIES', 'Allows user to C/R/U/D locations, fields, and courts.'),
('MANAGE_SCHEDULES', 'Allows user to C/R/U/D leagues, activities, and games.'),
('MANAGE_REGISTRATIONS', 'Allows user to view and approve team registrations.'),
('MANAGE_SCORES', 'Allows user to enter and confirm game scores.'),
('SEND_ANNOUNCEMENTS', 'Allows user to send messages to individuals, teams, and leagues.');
INSERT IGNORE INTO permission_role (role_id, permission_id) VALUES
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'MANAGE_OWN_PROFILE')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'USE_MESSAGING')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'VIEW_LEAGUES')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'REGISTER_FOR_LEAGUE')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'CREATE_TEAM')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'MANAGE_TEAM_INVITATIONS')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'VIEW_OWN_SCHEDULE')),
((SELECT id FROM roles WHERE name = 'STUDENT'), (SELECT id FROM permissions WHERE name = 'VIEW_STANDINGS'));
INSERT IGNORE INTO permission_role (role_id, permission_id) VALUES
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_OWN_PROFILE')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'USE_MESSAGING')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'VIEW_LEAGUES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'REGISTER_FOR_LEAGUE')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'CREATE_TEAM')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_TEAM_INVITATIONS')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'VIEW_OWN_SCHEDULE')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'VIEW_STANDINGS')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_FACILITIES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_SCHEDULES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_REGISTRATIONS')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'MANAGE_SCORES')),
((SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN'), (SELECT id FROM permissions WHERE name = 'SEND_ANNOUNCEMENTS'));
INSERT IGNORE INTO users (first_name, last_name, email, password_hash) VALUES
('Brett', 'School Admin', 'brett.baumgart@kirkwood.edu', 'hashed_password_for_brett'),
('Alex', 'Student', 'alex.student@student.kirkwood.edu', 'hashed_password_for_alex');
INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
((SELECT id FROM users WHERE email = 'brett.baumgart@kirkwood.edu'), (SELECT id FROM roles WHERE name = 'SCHOOL_ADMIN')),
((SELECT id FROM users WHERE email = 'alex.student@student.kirkwood.edu'), (SELECT id FROM roles WHERE name = 'STUDENT'));
INSERT IGNORE INTO schools (name, domain, status_id) VALUES
('Kirkwood Community College', 'kirkwood.edu', 'active'),
('University of Iowa', 'uiowa.edu', 'active'),
('Iowa State University', 'iastate.edu', 'active'),
('University of Northern Iowa', 'uni.edu', 'active'),
('Coe College', 'coe.edu', 'active'),
('Mount Mercy University', 'mtmercy.edu', 'active'),
('Drake University', 'drake.edu', 'active'),
('Grinnell College', 'grinnell.edu', 'active'),
('Luther College', 'luther.edu', 'active'),
('Simpson College', 'simpson.edu', 'inactive'), -- Testing status
('Wartburg College', 'wartburg.edu', 'active'),
('Cornell College', 'cornellcollege.edu', 'active'),
('Loras College', 'loras.edu', 'active'),
('Clarke University', 'clarke.edu', 'suspended'), -- Testing status
('St. Ambrose University', 'sau.edu', 'active');
INSERT IGNORE INTO locations (school_id, name, description, address, status_id) VALUES
(1, 'Main Campus', 'The primary campus in Cedar Rapids', '6301 Kirkwood Blvd SW, Cedar Rapids, IA', 'active');
INSERT IGNORE INTO locations (school_id, name, description, address, status_id) VALUES
(2, 'Carver-Hawkeye Arena', 'Main sports arena', '1 Elliott Dr, Iowa City, IA', 'active');
INSERT IGNORE INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(1, 1, 'Michael J Gould Rec Center', 'Student recreation facility', 'active'),
(1, 1, 'Johnson Hall', 'Athletics building and gymnasium', 'active');
INSERT IGNORE INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(2, 2, 'Main Court', 'The primary basketball court', 'active'),
(2, 2, 'Weight Room', 'Athlete training facility', 'coming_soon');
INSERT IGNORE INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(1, 3, 'Basketball Court 1', 'North court', 'active'),
(1, 3, 'Basketball Court 2', 'South court', 'active');
data.sql and add this to the bottom.INSERT IGNORE is a MySQL-specific command that tells the database, "Try to insert this row. If a unique key (like the role name) already exists, just ignore this command and move on without throwing an error."
When you run the tests, the Docker container used by your test will initialize with the correct tables and data. Your tests will pass.
@SpringBootTest(webEnvironment = RANDOM_PORT)
This tells Spring to start the Tomcat server, but instead of using the default port 8080 (which might be busy), it picks a random available port (e.g., 60982).
@LocalServerPort int port;
Spring injects that random port number into this variable so your test knows where to send requests.
builder.rootUri("http://localhost:" + port):
This constructs the full URL (e.g., http://localhost:60982).
template.exchange(...)
This sends a GET request to the path /owners/1 and waits for the server to process the request (query DB, render HTML)
assertThat(...):
It verifies that the server responded with "200 OK". The test fails if the ID didn't exist (404) or the server crashed (500).
Add this method inside your MySqlIntegrationTests class:
@Test
void testSchoolDetails() {
RestTemplate template = builder.rootUri("http://localhost:" + port).build();
ResponseEntity<String> result = template.exchange(RequestEntity.get("/schools/1").build(), String.class);
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(result.getBody()).contains("Kirkwood Community College");
}/schools/{id}" mapping in the SchoolController class.Instead of returning a plain School object (which might be null), have the findById method return the following:
@Transactional(readOnly = true)
Optional<School> findById(Integer id);
Add a @GetMapping method to handle requests to "/schools/{schoolId}"
The @PathVariable annotation tells Spring to look at the URL itself to find a value after /schools/.
{schoolId} defines a "wildcard" in your URL pattern
How it works: When a user visits /schools/1, Spring sees the {schoolId} placeholder, grabs the value 1, and forces it into your method parameter int schoolId.
@GetMapping("/schools/{schoolId}")
public ModelAndView showSchool(@PathVariable("schoolId") int schoolId) {
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
return mav;
}In Spring MVC, you need to return two things to the user:
The View: Which HTML file to display (schoolDetails.html).
The Model: The actual data to populate that file (The "School" object).
ModelAndView is a container that holds both of these in one object.
You instantiate the object with the view.
You call the .addObject() method to add the model.
The .orElseThrow() method only exists on Optional objects.
You can use Spring's built-in ResponseStatusException to handle requests for schoolId's that don't exist.
Create a new view file at: templates/schools/schoolDetails.html
It uses the standard PetClinic layout, so it will have the navigation bar and footer automatically. I have added a dynamic header that pulls the school name from your database.
th:text="${school.name}": This line ensures that when your test runs assertThat(result.getBody()).contains("Kirkwood Community College"), it will pass because the HTML renders the actual name from the DB.
layout: Keeping the th:replace at the top ensures all your CSS/JS assets load, preventing 404s on static resources during browser testing.
<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org"
th:replace="~{fragments/layout :: layout (~{::body},'schools')}">
<body>
<h2 class="text-center">Welcome to <span th:text="${school.name}">School Name</span></h2>
<div class="container mt-4">
<div class="alert alert-info">
<h4 class="alert-heading">Coming Soon!</h4>
<p>Upcoming leagues, intramural events, and registration for
<strong th:text="${school.name}">this school</strong> will appear here shortly.
</p>
</div>
</div>
</body>
</html>
Hopefully you understand the importance of internationalization.
But if you are in a prototyping phase or just want to move forward without translating every single string immediately, disabling the test is a valid strategy.
Open your I18nPropertiesSyncTest.java and add the annotation to disable the whole class.
@Disabled("Temporarily disabled until MVP is complete")
If only one specific test method is annoying you, you can disable just that one.
@Test
@Disabled
void testToDisable() { /* ... */ }
Turn on Docker Desktop before running unit tests.
When we run unit tests, CrashControllerIntegrationTests fails because of a conflict between the original test design and our new Security implementation.
Our new SecurityConfig (and AuthController) requires a database connection to load (to verify users). This conflict can cause the test context to fail or behave unpredictably.
We need to remove the "No-Database" optimization. Since our application now has complex security dependencies (UserService, Roles, Database).
Remove lines 95-99—the inner TestConfiguration class and the @SpringBootApplication annotation entirely.
It is possible for a URL like `localhost:8080/schools/kirkwood` to be mapped to `localhost:8080/schools/1`.
Schools can be looked up by their id, or by the domain, minus the `.edu` part
Not recommended:
If you map `/{slug}` at the root level, `localhost:8080/{slug}`, Spring will try to match everything against it.
For example, if a user goes to /login, Spring thinks "login" is a school slug and checks the DB for login.edu it, which returns 404.
If a user goes to `/css/style.css`, Spring thinks "css" is a school slug and returns 404.
If you absolutely require root URLs (like linkedin.com/username), you have to write complex exclusion rules.
Using `/schools/kirkwood` is much safer and cleaner.
Here is how to implement "Smart URLs" that accept both IDs (/schools/1) and Slugs (/schools/kirkwood) in the same Controller.
Update the @GetMapping to use Regular Expressions to tell Spring: "If the URL is a number, call the ID method. If it is text, call the Slug method."
// Matches ONLY numbers (e.g., /schools/1)
@GetMapping("/schools/{schoolId:\\d+}")
public ModelAndView showSchoolById(@PathVariable("schoolId") int schoolId) {
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findById(schoolId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
return mav;
}
// Matches text (e.g., /schools/kirkwood)
@GetMapping("/schools/{slug:[a-zA-Z-]+}")
public ModelAndView showSchoolBySlug(@PathVariable("slug") String slug) {
// Reconstruct the domain (User asked to assume ".edu")
String fullDomain = slug + ".edu";
ModelAndView mav = new ModelAndView("schools/schoolDetails");
School school = schoolRepository.findByDomain(fullDomain)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "School not found"));
mav.addObject(school);
return mav;
}By Marc Hauschildt
Web Technologies and Computer Software Development Instructor at Kirkwood Community College in Cedar Rapids, IA.