AdvancedJava · Lesson 9 of 9

Project: Results Portal API

A secured Spring Boot API: users and roles with Spring Security, parent-only access, validated input, rankings with SQL window functions and full MockMvc tests.

This project builds on the Spring Data JPA lesson (keep ResultsApplication, ExamResult and the V1 migration) and turns it into a results portal: teachers record results and see form rankings, while parents log in to see only their own child's results. A V2 migration adds the users table and links each student to a parent.

Spring Security protects every endpoint by default. SecurityConfig declares the rules (POST results and rankings need the TEACHER role, everything else needs a login), uses HTTP Basic authentication (fine over HTTPS for an API; a real app might use JWT or OAuth2), and stores passwords with a delegating encoder that writes {bcrypt} hashes. Users come from the database through a UserDetailsService. Object-level rules, a parent seeing only their own child, live in the service, which answers 404 so strangers can't discover which students exist.

Requests are validated with Jakarta Validation on a record DTO, errors are returned as RFC 9457 problem details, and the ranking is a native SQL query using rank() mapped to an interface projection. The MockMvc tests log in as each kind of user with httpBasic(...) and run against a separate test database, each test rolled back. A demo profile seeds sample data for trying the API by hand.

TerminalShell
createdb results_portal && createdb results_portal_test
mvn test
mvn spring-boot:run -Dspring-boot.run.profiles=demo      # loads the demo users and students

# Parent: own child works, other children are 404, rankings are 403
curl -u mama.amina:familia-yetu-2026 localhost:8080/api/students/1/results
curl -u mama.amina:familia-yetu-2026 localhost:8080/api/students/2/results
curl -u mama.amina:familia-yetu-2026 "localhost:8080/api/forms/4/ranking?term=2026-T1"

# Teacher: record a result and see the ranking
curl -u mwalimu:chalk-and-board-2026 -X POST localhost:8080/api/results -H "Content-Type: application/json" \
     -d '{"admissionNumber":"ADM-2026-0002","subject":"Maths","term":"2026-T1","score":45}'
curl -u mwalimu:chalk-and-board-2026 "localhost:8080/api/forms/4/ranking?term=2026-T1"
pom.xmlXML
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
  </parent>
  <groupId>tz.dolese</groupId>
  <artifactId>results-portal</artifactId>
  <version>1.0.0</version>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-flyway</artifactId>
    </dependency>
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>
src/main/resources/application.propertiesText
spring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/results_portal}
spring.datasource.username=${DATABASE_USER:postgres}
spring.datasource.password=${DATABASE_PASSWORD:postgres}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
src/main/resources/db/migration/V2__users_and_parents.sqlSQL
CREATE TABLE app_users (
  username      text PRIMARY KEY,
  password_hash text NOT NULL,
  role          text NOT NULL CHECK (role IN ('TEACHER', 'PARENT'))
);

ALTER TABLE students ADD COLUMN parent_username text REFERENCES app_users(username);
CREATE INDEX students_parent_idx ON students (parent_username);
src/main/java/tz/dolese/results/Student.javaJava
package tz.dolese.results;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "students")
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "admission_number", nullable = false, unique = true)
    private String admissionNumber;

    @Column(nullable = false)
    private String name;

    private int form;

    @Column(name = "parent_username")
    private String parentUsername;

    @OneToMany(mappedBy = "student", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<ExamResult> results = new ArrayList<>();

    protected Student() {}

    public Student(String admissionNumber, String name, int form, String parentUsername) {
        this.admissionNumber = admissionNumber;
        this.name = name;
        this.form = form;
        this.parentUsername = parentUsername;
    }

    public ExamResult addResult(String subject, String term, int score) {
        ExamResult result = new ExamResult(this, subject, term, score);
        results.add(result);
        return result;
    }

    public Long getId() { return id; }
    public String getAdmissionNumber() { return admissionNumber; }
    public String getName() { return name; }
    public int getForm() { return form; }
    public String getParentUsername() { return parentUsername; }
    public List<ExamResult> getResults() { return results; }
}
src/main/java/tz/dolese/results/AppUser.javaJava
package tz.dolese.results;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "app_users")
public class AppUser {
    @Id
    private String username;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(nullable = false)
    private String role;                   // "TEACHER" or "PARENT"

    protected AppUser() {}

    public AppUser(String username, String passwordHash, String role) {
        this.username = username;
        this.passwordHash = passwordHash;
        this.role = role;
    }

    public String getUsername() { return username; }
    public String getPasswordHash() { return passwordHash; }
    public String getRole() { return role; }
}
src/main/java/tz/dolese/results/Repositories.javaJava
package tz.dolese.results;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

interface AppUserRepository extends JpaRepository<AppUser, String> {}

interface StudentRepository extends JpaRepository<Student, Long> {
    Optional<Student> findByAdmissionNumber(String admissionNumber);

    // A native SQL query when you need PostgreSQL features such as window functions.
    @Query(value = """
            SELECT rank() OVER (ORDER BY avg(r.score) DESC) AS position,
                   s.admission_number AS admissionNumber, s.name AS name,
                   round(avg(r.score), 1) AS average
              FROM students s JOIN exam_results r ON r.student_id = s.id
             WHERE s.form = :form AND r.term = :term
             GROUP BY s.id
             ORDER BY position, s.name
            """, nativeQuery = true)
    List<RankingRow> ranking(int form, String term);
}

// Interface projection: Spring maps each column alias to a getter.
interface RankingRow {
    int getPosition();
    String getAdmissionNumber();
    String getName();
    double getAverage();
}
src/main/java/tz/dolese/results/SecurityConfig.javaJava
package tz.dolese.results;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/api/results").hasRole("TEACHER")
                .requestMatchers("/api/forms/**").hasRole("TEACHER")
                .anyRequest().authenticated())                       // deny by default
            .httpBasic(Customizer.withDefaults())                    // username + password on every request (HTTPS only!)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());                           // safe for a stateless API without cookies
        return http.build();
    }

    // Stores "{bcrypt}$2a$10$..." so the hashing algorithm can be upgraded later.
    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    UserDetailsService users(AppUserRepository repository) {
        return username -> repository.findById(username)
                .map(u -> User.withUsername(u.getUsername()).password(u.getPasswordHash()).roles(u.getRole()).build())
                .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}
src/main/java/tz/dolese/results/Dtos.javaJava
package tz.dolese.results;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

record NewResult(
        @NotBlank String admissionNumber,
        @NotBlank String subject,
        @Pattern(regexp = "\\d{4}-T[123]", message = "must look like 2026-T1") String term,
        @Min(0) @Max(100) int score) {}

record ResultView(String subject, String term, int score) {}
src/main/java/tz/dolese/results/ResultService.javaJava
package tz.dolese.results;

import java.util.Comparator;
import java.util.List;
import java.util.NoSuchElementException;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ResultService {
    private final StudentRepository students;

    public ResultService(StudentRepository students) {
        this.students = students;
    }

    @Transactional(readOnly = true)
    public List<ResultView> resultsFor(long studentId, Authentication user) {
        Student student = students.findById(studentId).filter(s -> canView(user, s))
                // Same 404 for "doesn't exist" and "not yours": don't reveal which ids exist.
                .orElseThrow(() -> new NoSuchElementException("Student not found"));
        return student.getResults().stream()
                .map(r -> new ResultView(r.getSubject(), r.getTerm(), r.getScore()))
                .sorted(Comparator.comparing(ResultView::term).thenComparing(ResultView::subject))
                .toList();
    }

    @Transactional
    public ResultView record(NewResult in) {
        Student student = students.findByAdmissionNumber(in.admissionNumber())
                .orElseThrow(() -> new NoSuchElementException("No student " + in.admissionNumber()));
        ExamResult result = student.getResults().stream()
                .filter(r -> r.getSubject().equals(in.subject()) && r.getTerm().equals(in.term()))
                .findFirst()
                .orElseGet(() -> student.addResult(in.subject(), in.term(), in.score()));
        result.setScore(in.score());
        return new ResultView(result.getSubject(), result.getTerm(), result.getScore());
    }

    @Transactional(readOnly = true)
    public List<RankingRow> ranking(int form, String term) {
        return students.ranking(form, term);
    }

    private static boolean canView(Authentication user, Student student) {
        boolean teacher = user.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_TEACHER"));
        return teacher || user.getName().equals(student.getParentUsername());
    }
}
src/main/java/tz/dolese/results/ResultsController.javaJava
package tz.dolese.results;

import jakarta.validation.Valid;
import java.util.List;
import java.util.NoSuchElementException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class ResultsController {
    private final ResultService service;

    public ResultsController(ResultService service) {
        this.service = service;
    }

    @GetMapping("/students/{id}/results")
    List<ResultView> results(@PathVariable long id, Authentication user) {
        return service.resultsFor(id, user);
    }

    @PostMapping("/results")
    @ResponseStatus(HttpStatus.CREATED)
    ResultView record(@Valid @RequestBody NewResult body) {
        return service.record(body);
    }

    @GetMapping("/forms/{form}/ranking")
    List<RankingRow> ranking(@PathVariable int form, @RequestParam String term) {
        return service.ranking(form, term);
    }

    // RFC 9457 problem details: {"status":404,"detail":"Student not found",...}
    @ExceptionHandler(NoSuchElementException.class)
    ProblemDetail notFound(NoSuchElementException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }
}
src/main/java/tz/dolese/results/DemoData.javaJava
package tz.dolese.results;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

// Runs only with --spring.profiles.active=demo, and only into an empty database.
@Component
@Profile("demo")
class DemoData implements CommandLineRunner {
    private final AppUserRepository users;
    private final StudentRepository students;
    private final PasswordEncoder encoder;

    DemoData(AppUserRepository users, StudentRepository students, PasswordEncoder encoder) {
        this.users = users;
        this.students = students;
        this.encoder = encoder;
    }

    @Override
    public void run(String... args) {
        if (users.count() > 0) return;
        users.save(new AppUser("mwalimu", encoder.encode("chalk-and-board-2026"), "TEACHER"));
        users.save(new AppUser("mama.amina", encoder.encode("familia-yetu-2026"), "PARENT"));

        Student amina = new Student("ADM-2026-0001", "Amina Hassan", 4, "mama.amina");
        amina.addResult("Maths", "2026-T1", 88);
        amina.addResult("Biology", "2026-T1", 79);
        Student juma = new Student("ADM-2026-0002", "Juma Said", 4, null);
        juma.addResult("Maths", "2026-T1", 29);
        juma.addResult("Biology", "2026-T1", 48);
        students.save(amina);
        students.save(juma);
    }
}
src/test/resources/application.propertiesText
# Tests use their own database, so they never touch (or depend on) development data.
spring.datasource.url=${TEST_DATABASE_URL:jdbc:postgresql://localhost:5432/results_portal_test}
spring.datasource.username=${DATABASE_USER:postgres}
spring.datasource.password=${DATABASE_PASSWORD:postgres}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
src/test/java/tz/dolese/results/ResultsApiTest.javaJava
package tz.dolese.results;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ResultsApiTest {
    @Autowired MockMvc mvc;
    @Autowired AppUserRepository users;
    @Autowired StudentRepository students;
    @Autowired PasswordEncoder encoder;

    Long aminaId;
    Long jumaId;

    @BeforeEach
    void seed() {
        users.save(new AppUser("mwalimu", encoder.encode("chalk"), "TEACHER"));
        users.save(new AppUser("mama.amina", encoder.encode("familia"), "PARENT"));
        Student amina = new Student("ADM-2026-0001", "Amina Hassan", 4, "mama.amina");
        amina.addResult("Maths", "2026-T1", 88);
        amina.addResult("Biology", "2026-T1", 79);
        Student juma = new Student("ADM-2026-0002", "Juma Said", 4, null);
        juma.addResult("Maths", "2026-T1", 29);
        Student ali = new Student("ADM-2026-0003", "Ali Mohamed", 4, null);
        ali.addResult("Maths", "2026-T1", 77);
        ali.addResult("Biology", "2026-T1", 90);
        aminaId = students.save(amina).getId();
        jumaId = students.save(juma).getId();
        students.save(ali);
    }

    @Test
    void requiresLogin() throws Exception {
        mvc.perform(get("/api/students/" + aminaId + "/results")).andExpect(status().isUnauthorized());
        mvc.perform(get("/api/students/" + aminaId + "/results").with(httpBasic("mwalimu", "wrong")))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void parentSeesOnlyTheirOwnChild() throws Exception {
        mvc.perform(get("/api/students/" + aminaId + "/results").with(httpBasic("mama.amina", "familia")))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.length()").value(2))
                .andExpect(jsonPath("$[0].subject").value("Biology"));
        mvc.perform(get("/api/students/" + jumaId + "/results").with(httpBasic("mama.amina", "familia")))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.detail").value("Student not found"));
        mvc.perform(get("/api/forms/4/ranking?term=2026-T1").with(httpBasic("mama.amina", "familia")))
                .andExpect(status().isForbidden());
    }

    @Test
    void teacherRecordsAndRanks() throws Exception {
        mvc.perform(post("/api/results").with(httpBasic("mwalimu", "chalk"))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"admissionNumber":"ADM-2026-0002","subject":"Maths","term":"2026-T1","score":45}"""))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.score").value(45));

        mvc.perform(get("/api/forms/4/ranking?term=2026-T1").with(httpBasic("mwalimu", "chalk")))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].name").value("Ali Mohamed"))
                .andExpect(jsonPath("$[0].average").value(83.5))
                .andExpect(jsonPath("$[1].position").value(1))
                .andExpect(jsonPath("$[2].name").value("Juma Said"))
                .andExpect(jsonPath("$[2].average").value(45.0));
    }

    @Test
    void rejectsInvalidResults() throws Exception {
        mvc.perform(post("/api/results").with(httpBasic("mwalimu", "chalk"))
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"admissionNumber":"ADM-2026-0001","subject":"Maths","term":"Term 1","score":120}"""))
                .andExpect(status().isBadRequest());
    }
}

Key points

  • Spring Security: deny by default, role rules in one place, hashed passwords from the database.
  • Put ownership checks in the service and answer 404 to outsiders.
  • Validated record DTOs, problem-detail errors and MockMvc tests for every role.

Exercise

Add GET /api/students/{id}/report?term=2026-T1: the student's name, each subject with score and grade (A ≥ 75, B ≥ 65, C ≥ 45, D ≥ 30, otherwise F), their average and position such as "2 of 4" from the ranking query. Use the same parent rule, keep the report building in a pure method, and test it with MockMvc.

Show solution

Try the exercise yourself first — then compare your approach with this one.

TermReport.of is a pure static method that builds the report from the student and the ranking rows, so it is easy to test, and grade is checked at every boundary. The service applies the same canView rule as the results endpoint before building anything, so parents get 404 for other children. The ranking is the same native rank() query the teachers' endpoint uses, so positions always agree.

src/main/java/tz/dolese/results/TermReport.javaJava
package tz.dolese.results;

import java.util.List;

record SubjectLine(String subject, int score, String grade) {}

record TermReport(String name, String term, List<SubjectLine> subjects, Double average, String position) {

    static String grade(int score) {
        if (score >= 75) return "A";
        if (score >= 65) return "B";
        if (score >= 45) return "C";
        if (score >= 30) return "D";
        return "F";
    }

    // Pure: builds the report from data already loaded, so it is easy to unit-test.
    static TermReport of(Student student, String term, List<RankingRow> ranking) {
        List<SubjectLine> subjects = student.getResults().stream()
                .filter(r -> r.getTerm().equals(term))
                .map(r -> new SubjectLine(r.getSubject(), r.getScore(), grade(r.getScore())))
                .sorted((a, b) -> a.subject().compareTo(b.subject()))
                .toList();
        return ranking.stream()
                .filter(row -> row.getAdmissionNumber().equals(student.getAdmissionNumber()))
                .findFirst()
                .map(row -> new TermReport(student.getName(), term, subjects, row.getAverage(),
                        row.getPosition() + " of " + ranking.size()))
                .orElse(new TermReport(student.getName(), term, subjects, null, null));
    }
}
ResultService.java and ResultsController.java (additions)Java
// In ResultService:
    @Transactional(readOnly = true)
    public TermReport report(long studentId, String term, Authentication user) {
        Student student = students.findById(studentId).filter(s -> canView(user, s))
                .orElseThrow(() -> new NoSuchElementException("Student not found"));
        return TermReport.of(student, term, students.ranking(student.getForm(), term));
    }

// In ResultsController:
    @GetMapping("/students/{id}/report")
    TermReport report(@PathVariable long id, @RequestParam String term, Authentication user) {
        return service.report(id, term, user);
    }
src/test/java/tz/dolese/results/TermReportTest.javaJava
package tz.dolese.results;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class TermReportTest {
    @Autowired MockMvc mvc;
    @Autowired AppUserRepository users;
    @Autowired StudentRepository students;
    @Autowired PasswordEncoder encoder;

    @Test
    void gradesFollowTheSchoolScale() {
        assertThat(java.util.stream.IntStream.of(75, 74, 65, 64, 45, 44, 30, 29).mapToObj(TermReport::grade))
                .containsExactly("A", "B", "B", "C", "C", "D", "D", "F");
    }

    @Test
    void parentGetsTheirChildsReportWithPosition() throws Exception {
        users.save(new AppUser("mama.amina", encoder.encode("familia"), "PARENT"));
        Student amina = new Student("ADM-2026-0001", "Amina Hassan", 4, "mama.amina");
        amina.addResult("Maths", "2026-T1", 88);
        amina.addResult("Biology", "2026-T1", 60);
        amina.addResult("Maths", "2025-T3", 40);                 // another term: not in the report
        Student ali = new Student("ADM-2026-0003", "Ali Mohamed", 4, null);
        ali.addResult("Maths", "2026-T1", 95);
        Long aminaId = students.save(amina).getId();
        Long aliId = students.save(ali).getId();

        mvc.perform(get("/api/students/" + aminaId + "/report?term=2026-T1").with(httpBasic("mama.amina", "familia")))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.subjects.length()").value(2))
                .andExpect(jsonPath("$.subjects[0].grade").value("C"))
                .andExpect(jsonPath("$.subjects[1].grade").value("A"))
                .andExpect(jsonPath("$.average").value(74.0))
                .andExpect(jsonPath("$.position").value("2 of 2"));
        mvc.perform(get("/api/students/" + aliId + "/report?term=2026-T1").with(httpBasic("mama.amina", "familia")))
                .andExpect(status().isNotFound());
    }
}

Check your understanding

  1. Why is CSRF protection disabled in this API?

  2. A parent requests another family's student. Why answer 404 instead of 403?

  3. What does a stored password look like with the delegating PasswordEncoder?

  4. Why do the tests use results_portal_test instead of the development database?

Ask AI