diff --git a/backend/pom.xml b/backend/pom.xml
index faa27b2..a1e6ad3 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -151,6 +151,14 @@
org.springframework
spring-web
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
diff --git a/backend/src/main/java/programming/tutorial/BackendApplication.java b/backend/src/main/java/programming/tutorial/BackendApplication.java
index 2459e8a..0b51fa9 100644
--- a/backend/src/main/java/programming/tutorial/BackendApplication.java
+++ b/backend/src/main/java/programming/tutorial/BackendApplication.java
@@ -2,8 +2,10 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
+@EnableCaching
public class BackendApplication {
public static void main(String[] args) {
diff --git a/backend/src/main/java/programming/tutorial/config/CacheConfig.java b/backend/src/main/java/programming/tutorial/config/CacheConfig.java
new file mode 100644
index 0000000..91e9971
--- /dev/null
+++ b/backend/src/main/java/programming/tutorial/config/CacheConfig.java
@@ -0,0 +1,36 @@
+package programming.tutorial.config;
+
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.cache.RedisCacheConfiguration;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+
+import java.time.Duration;
+
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ @Bean
+ public CacheManager cacheManager() {
+ try {
+ LettuceConnectionFactory redisConnectionFactory = new LettuceConnectionFactory("localhost", 6379);
+ redisConnectionFactory.afterPropertiesSet();
+ redisConnectionFactory.getConnection().ping();
+
+ RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
+ .entryTtl(Duration.ofMinutes(10));
+ return RedisCacheManager.builder(redisConnectionFactory)
+ .cacheDefaults(config)
+ .build();
+ } catch (Exception e) {
+ System.out.println("Redis not available. Using in-memory cache.");
+ return new ConcurrentMapCacheManager();
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/programming/tutorial/controller/CacheController.java b/backend/src/main/java/programming/tutorial/controller/CacheController.java
new file mode 100644
index 0000000..ea9fdbc
--- /dev/null
+++ b/backend/src/main/java/programming/tutorial/controller/CacheController.java
@@ -0,0 +1,29 @@
+package programming.tutorial.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import programming.tutorial.services.impl.CacheServiceJpa;
+
+@RestController
+@RequestMapping("/admin/cache")
+public class CacheController {
+
+ @Autowired
+ private CacheServiceJpa cacheService;
+
+ @DeleteMapping("/courses")
+ public ResponseEntity clearAllCoursesCache() {
+ cacheService.evictAllCoursesCache();
+ System.out.println("Called");
+ return ResponseEntity.ok("All course caches cleared!");
+ }
+
+ @DeleteMapping("/all")
+ public ResponseEntity clearAllCaches() {
+ cacheService.evictAllCaches();
+ return ResponseEntity.ok("All caches cleared");
+ }
+}
diff --git a/backend/src/main/java/programming/tutorial/controller/CourseController.java b/backend/src/main/java/programming/tutorial/controller/CourseController.java
index c9b569b..d56f96e 100644
--- a/backend/src/main/java/programming/tutorial/controller/CourseController.java
+++ b/backend/src/main/java/programming/tutorial/controller/CourseController.java
@@ -66,6 +66,10 @@ public List getAllCourses() {
}
return courses;
}
+ @GetMapping("/uncached")
+ public List getAllCoursesUncached() {
+ return courseService.getAllCoursesUncached();
+ }
@PostMapping("/create-with-lessons")
public ResponseEntity> createCourseWithLessons(@RequestBody CourseWithLessonsDTO dto) {
diff --git a/backend/src/main/java/programming/tutorial/dto/CourseDTO.java b/backend/src/main/java/programming/tutorial/dto/CourseDTO.java
index ca3da71..c889419 100644
--- a/backend/src/main/java/programming/tutorial/dto/CourseDTO.java
+++ b/backend/src/main/java/programming/tutorial/dto/CourseDTO.java
@@ -2,15 +2,19 @@
import programming.tutorial.domain.User;
-public class CourseDTO {
+import java.io.Serial;
+import java.io.Serializable;
+public class CourseDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
private Integer courseId;
private String courseName;
private int courseLength;
private String description;
private String category;
private String creatorId;
- private User creator;
+ private UserDTO creator;
private boolean systemCourse;
public CourseDTO() {
@@ -85,11 +89,11 @@ public void setCreatorId(String creatorId) {
this.creatorId = creatorId;
}
- public User getCreator() {
+ public UserDTO getCreator() {
return creator;
}
- public void setCreator(User creator) {
+ public void setCreator(UserDTO creator) {
this.creator = creator;
}
diff --git a/backend/src/main/java/programming/tutorial/dto/UserDTO.java b/backend/src/main/java/programming/tutorial/dto/UserDTO.java
index 5932824..86f9724 100644
--- a/backend/src/main/java/programming/tutorial/dto/UserDTO.java
+++ b/backend/src/main/java/programming/tutorial/dto/UserDTO.java
@@ -3,9 +3,13 @@
import programming.tutorial.domain.Role;
import programming.tutorial.domain.Tier;
+import java.io.Serial;
+import java.io.Serializable;
import java.time.LocalDateTime;
-public class UserDTO {
+public class UserDTO implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
public Integer id;
public String name;
public String surname;
diff --git a/backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java b/backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java
new file mode 100644
index 0000000..19b4ab6
--- /dev/null
+++ b/backend/src/main/java/programming/tutorial/monitoring/CacheMetrics.java
@@ -0,0 +1,25 @@
+package programming.tutorial.monitoring;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.springframework.stereotype.Component;
+
+@Component
+public class CacheMetrics {
+
+ private final Counter cacheHit;
+ private final Counter cacheMiss;
+
+ public CacheMetrics(MeterRegistry registry) {
+ this.cacheHit = Counter.builder("redis_cache_hit_total")
+ .description("Cache hits")
+ .register(registry);
+
+ this.cacheMiss = Counter.builder("redis_cache_miss_total")
+ .description("Cache misses")
+ .register(registry);
+ }
+
+ public void hit() { cacheHit.increment(); }
+ public void miss() { cacheMiss.increment(); }
+}
diff --git a/backend/src/main/java/programming/tutorial/services/CourseService.java b/backend/src/main/java/programming/tutorial/services/CourseService.java
index dbfb1ca..9f8aef4 100644
--- a/backend/src/main/java/programming/tutorial/services/CourseService.java
+++ b/backend/src/main/java/programming/tutorial/services/CourseService.java
@@ -27,4 +27,6 @@ public interface CourseService {
boolean isCourseOwner(String userId, Integer courseId);
List getLessonsForCourse(Integer courseId);
+
+ List getAllCoursesUncached();
}
diff --git a/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java
new file mode 100644
index 0000000..b64a341
--- /dev/null
+++ b/backend/src/main/java/programming/tutorial/services/impl/CacheServiceJpa.java
@@ -0,0 +1,35 @@
+package programming.tutorial.services.impl;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+
+@Service
+public class CacheServiceJpa {
+
+ @Autowired
+ private CacheManager cacheManager;
+
+ public void evictAllCoursesCache() {
+ var cache = cacheManager.getCache("all_courses");
+ if (cache != null) {
+ cache.clear();
+ }
+ }
+
+ public void evictCoursesByUser(String auth0UserId) {
+ var cache = cacheManager.getCache("courses_by_user");
+ if (cache != null) {
+ cache.evict(auth0UserId);
+ }
+ }
+
+ public void evictAllCaches() {
+ cacheManager.getCacheNames().forEach(name -> {
+ var cache = cacheManager.getCache(name);
+ if (cache != null) {
+ cache.clear();
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java
index fe48a7c..794ccbc 100644
--- a/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java
+++ b/backend/src/main/java/programming/tutorial/services/impl/CourseServiceJpa.java
@@ -1,6 +1,10 @@
package programming.tutorial.services.impl;
+import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import programming.tutorial.dao.CourseRepository;
import programming.tutorial.dao.LessonRepository;
@@ -9,7 +13,10 @@
import programming.tutorial.dto.CourseDTO;
import programming.tutorial.dto.CourseWithLessonsDTO;
import programming.tutorial.dto.LessonDTO;
+import programming.tutorial.dto.UserDTO;
+import programming.tutorial.monitoring.CacheMetrics;
import programming.tutorial.services.CourseService;
+import io.micrometer.core.instrument.Timer;
import java.util.*;
import java.util.stream.Collectors;
@@ -25,18 +32,32 @@ public class CourseServiceJpa implements CourseService {
@Autowired
private LessonRepository lessonRepository;
+ @Autowired
+ private CacheServiceJpa cacheService;
+ @Autowired
+ private CacheMetrics cacheMetrics;
+ @Autowired
+ private MeterRegistry meterRegistry;
@Override
+ @Cacheable(value = "course_by_name", key = "#name")
public Optional findByName(CourseDTO courseDTO) {
return Optional.ofNullable(courseRepository.findByCourseName(courseDTO.getCourseName()));
}
@Override
+ @Cacheable(value = "course_by_id", key = "#courseDTO.courseId")
public Optional findById(CourseDTO courseDTO) {
return courseRepository.findById(courseDTO.getCourseId());
}
@Override
+ @Caching(evict = {
+ @CacheEvict(value = "all_courses", allEntries = true),
+ @CacheEvict(value = "course_by_id", key = "#courseDTO.courseId"),
+ @CacheEvict(value = "course_by_name", key = "#courseDTO.courseName"),
+ @CacheEvict(value = "courses_by_user", key = "#courseDTO.creator.auth0UserId")
+ })
public Course saveCourse(CourseDTO courseDTO) {
Course course = new Course();
course.setId(courseDTO.getCourseId());
@@ -48,23 +69,86 @@ public Course saveCourse(CourseDTO courseDTO) {
}
@Override
+ @CacheEvict(value = "all_courses", allEntries = true)
public void deleteCourse(Integer courseId) {
courseRepository.deleteById(courseId);
}
+ private UserDTO mapUser(User user) {
+ if (user == null) return null;
+ UserDTO dto = new UserDTO();
+ dto.setId(user.getId());
+ dto.setName(user.getName());
+ dto.setSurname(user.getSurname());
+ dto.setUsername(user.getUsername());
+ dto.setAuth0UserId(user.getAuth0UserId());
+ dto.setRole(user.getRole());
+ dto.setActive(user.isActive());
+ dto.setDateCreated(user.getDateCreated());
+ dto.setTier(user.getTier());
+ return dto;
+ }
+
@Override
+ @Cacheable(value = "all_courses")
public List getAllCourses() {
- List courses = courseRepository.findAll();
- System.out.println("Retrieved courses from database:");
- for (Course course : courses) {
- System.out.println("Course ID: " + course.getId() + ", Name: " + course.getCourseName());
- }
- return courses.stream()
- .map(course -> new CourseDTO(course.getId(), course.getCourseName(), course.getLength(), course.getDescription(), course.getCategory()))
+ /* First run will print this if cache isn't evicted previously
+ * Other runs will not print this unless cache is invalidated
+ * */
+ System.out.println(">>> DB QUERY EXECUTED — NO CACHE HIT <<<");
+ List courses = courseRepository.findAll().stream()
+ .map(course -> {
+ CourseDTO dto = new CourseDTO(
+ course.getId(),
+ course.getCourseName(),
+ course.getLength(),
+ course.getDescription(),
+ course.getCategory(),
+ course.getCreator() != null ? course.getCreator().getId() : null,
+ course.isSystemCourse()
+ );
+ dto.setCreator(mapUser(course.getCreator()));
+ return dto;
+ })
.collect(Collectors.toList());
+
+ return courses;
}
+ /**
+ * Method to check how caching improves/behaves versus regular method.
+ * This bypasses the cache entirely and records execution time for comparison.
+ */
+ public List getAllCoursesUncached() {
+ Timer.Sample sample = Timer.start(meterRegistry);
+ long start = System.currentTimeMillis();
+ System.out.println("Uncached getAllCourses method called");
+ List courses = courseRepository.findAll().stream()
+ .map(course -> new CourseDTO(
+ course.getId(),
+ course.getCourseName(),
+ course.getLength(),
+ course.getDescription(),
+ course.getCategory()
+ ))
+ .collect(Collectors.toList());
+
+ sample.stop(Timer.builder("app.cache.getAllCourses_uncached.time")
+ .description("Execution time for getAllCourses WITHOUT cache")
+ .register(meterRegistry));
+
+ long duration = System.currentTimeMillis() - start;
+ System.out.println("getAllCoursesUncached execution time: " + duration + " ms");
+ return courses;
+ }
+
+
@Override
+ @Caching(evict = {
+ @CacheEvict(value = "all_courses", allEntries = true),
+ @CacheEvict(value = "courses_by_user", key = "#dto.auth0UserId"),
+ @CacheEvict(value = "lessons_for_course", key = "#result.id")
+ })
public Course createCourseWithLessons(CourseWithLessonsDTO dto) {
User creator = userRepository.findByAuth0UserId(dto.getAuth0UserId())
.orElseThrow(() -> new IllegalArgumentException("User not found for Auth0 ID: " + dto.getAuth0UserId()));
@@ -143,6 +227,7 @@ private static int getRequestedLessonCount(CourseWithLessonsDTO dto, User creato
}
@Override
+ @Cacheable(value = "courses_by_user", key = "#auth0UserId")
public List getCoursesByUserAuth0Id(String auth0UserId) {
User user = userRepository.findByAuth0UserId(auth0UserId)
.orElseThrow(() -> new RuntimeException("User not found"));
@@ -162,6 +247,7 @@ public boolean isCourseOwner(String userId, Integer courseId) {
}
@Override
+ @Cacheable(value = "lessons_for_course", key = "#courseId")
public List getLessonsForCourse(Integer courseId) {
List lessons = lessonRepository.findByCourseIdOrderByIdAsc(courseId);
return lessons.stream()
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 67d6ac2..2e1ca8a 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -24,7 +24,13 @@ management.metrics.export.prometheus.enabled=true
management.endpoints.web.exposure.include=*
management.endpoints.web.base-path=/actuator
management.endpoint.health.show-details=always
+management.metrics.enable-all=true
+management.endpoint.metrics.enabled=true
+logging.level.org.springframework.cache=DEBUG
+#spring.cache.type=redis
+spring.cache.type=simple
+spring.cache.redis.time-to-live=600000
#Mock data for payment
# email: fudansfudans@gmail.com
diff --git a/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java b/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java
index 7a6fbd4..db1ca23 100644
--- a/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java
+++ b/backend/src/test/java/programming/tutorial/controller/UserProgressControllerTest.java
@@ -1,7 +1,9 @@
package programming.tutorial.controller;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -24,10 +26,13 @@
import java.util.Optional;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.doReturn;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
@WebMvcTest(UserProgressController.class)
@@ -36,7 +41,6 @@ class UserProgressControllerTest {
@Autowired
private MockMvc mockMvc;
-
@MockBean
private UserProgressService userProgressService;
@MockBean
@@ -47,6 +51,8 @@ class UserProgressControllerTest {
private UserService userService;
@Mock
private Authentication authentication;
+ @InjectMocks
+ private UserProgressController progressController;
private final String auth0Id = "auth0|123";
private final Integer courseId = 1;
@@ -59,6 +65,81 @@ void setup() {
when(userService.getUserId(auth0Id)).thenReturn(userId);
}
+ @Test
+ void updateProgress_userNotEnrolledAndNotOwner_returnsForbidden() throws Exception {
+ UserProgressController.ProgressUpdateRequest request = new UserProgressController.ProgressUpdateRequest();
+ request.courseId = 1;
+ request.lessonId = 1;
+
+
+ Jwt jwt = Jwt.withTokenValue("token")
+ .header("alg", "none")
+ .claim("sub", auth0Id)
+ .build();
+
+ when(authentication.getPrincipal()).thenReturn(jwt);
+ when(userProgressService.isUserEnrolled(anyString(), eq(request.courseId))).thenReturn(false);
+ when(courseService.isCourseOwner(anyString(), eq(request.courseId))).thenReturn(false);
+
+ mockMvc.perform(post("/api/progress/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(new ObjectMapper().writeValueAsString(request))
+ .principal(authentication))
+ .andExpect(status().isForbidden())
+ .andExpect(content().string(""));
+
+ verify(userProgressService).isUserEnrolled(anyString(), eq(request.courseId));
+ verify(courseService).isCourseOwner(anyString(), eq(request.courseId));
+ verifyNoMoreInteractions(userProgressService, courseService);
+ }
+
+ @Test
+ void updateProgress_userEnrolled_returnsOkWithMessage() throws Exception {
+ UserProgressController.ProgressUpdateRequest request = new UserProgressController.ProgressUpdateRequest();
+ request.courseId = 2;
+ request.lessonId = 2;
+
+ Jwt jwt = Jwt.withTokenValue("token")
+ .header("alg", "none")
+ .claim("sub", auth0Id)
+ .build();
+
+ when(authentication.getPrincipal()).thenReturn(jwt);
+ when(userProgressService.isUserEnrolled(anyString(), eq(request.courseId))).thenReturn(true);
+ when(courseService.isCourseOwner(anyString(), eq(request.courseId))).thenReturn(false);
+ when(userProgressService.updateProgress(eq("auth0|123"), eq(request.courseId), eq(request.lessonId)))
+ .thenReturn("Progress updated");
+
+ mockMvc.perform(post("/api/progress/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(new ObjectMapper().writeValueAsString(request))
+ .principal(authentication))
+ .andDo(print())
+ .andExpect(status().isOk());
+
+ verify(userProgressService).updateProgress(eq("auth0|123"), eq(request.courseId), eq(request.lessonId));
+ }
+
+ @Test
+ void updateProgress_userIsOwner_returnsOkWithMessage() throws Exception {
+ UserProgressController.ProgressUpdateRequest request = new UserProgressController.ProgressUpdateRequest();
+ request.courseId = 3;
+ request.lessonId = 3;
+
+ Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").claim("sub", auth0Id).build();
+ when(authentication.getPrincipal()).thenReturn(jwt);
+ when(userProgressService.isUserEnrolled(anyString(), eq(request.courseId))).thenReturn(false);
+ when(courseService.isCourseOwner(anyString(), eq(request.courseId))).thenReturn(true);
+ when(userProgressService.updateProgress(eq("auth0|123"), eq(request.courseId), eq(request.lessonId)))
+ .thenReturn("Progress updated");
+
+ mockMvc.perform(post("/api/progress/update")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(new ObjectMapper().writeValueAsString(request))
+ .principal(authentication))
+ .andExpect(status().isOk());
+ }
+
@Test
void getCurrentLessonNumber_shouldReturnLessonNumberIfExists() throws Exception {
LessonDTO lessonDTO = new LessonDTO();
diff --git a/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java b/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java
index 7cd6fa3..44203a8 100644
--- a/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java
+++ b/backend/src/test/java/programming/tutorial/services/CourseServiceTest.java
@@ -17,11 +17,11 @@
import programming.tutorial.dto.CourseWithLessonsDTO;
import programming.tutorial.dto.LessonDTO;
import programming.tutorial.services.impl.CourseServiceJpa;
-
+import io.micrometer.core.instrument.Timer;
import java.util.*;
+import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CourseServiceTest {
diff --git a/docker-compose.yml b/docker-compose.yml
index 49834b5..e9b5461 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -91,7 +91,11 @@ services:
- backend
prometheus:
+ container_name: prometheus
image: prom/prometheus:latest
+ command:
+ - '--config.file=/etc/prometheus/prometheus.yml'
+ - '--web.enable-remote-write-receiver'
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
@@ -100,6 +104,7 @@ services:
- backend
grafana:
+ container_name: grafana
image: grafana/grafana:latest
environment:
GF_SECURITY_ADMIN_USER: admin
@@ -113,6 +118,12 @@ services:
volumes:
- grafana-data:/var/lib/grafana
+ pushgateway:
+ container_name: pushgateway
+ image: prom/pushgateway:latest
+ ports:
+ - "9091:9091"
+
volumes:
pgdata:
kafka-data:
diff --git a/microservice-moderation b/microservice-moderation
index 2ff6319..e354deb 160000
--- a/microservice-moderation
+++ b/microservice-moderation
@@ -1 +1 @@
-Subproject commit 2ff63199ace1728bd19cea3c47174c8a2e51fcb5
+Subproject commit e354deb44a2771cc3d0a97da408e77502499978b
diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml
index c000335..735cf42 100644
--- a/monitoring/prometheus.yml
+++ b/monitoring/prometheus.yml
@@ -1,8 +1,15 @@
global:
scrape_interval: 5s
+remote_write:
+ - url: http://prometheus:9090/api/v1/write
+
scrape_configs:
- job_name: 'springboot-app'
metrics_path: '/actuator/prometheus'
static_configs:
- - targets: ['backend:8080']
\ No newline at end of file
+ - targets: ['backend:8080']
+
+ - job_name: 'k6'
+ static_configs:
+ - targets: ['pushgateway:9091']
diff --git a/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js b/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js
index 2e674ea..060d93f 100644
--- a/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js
+++ b/tests/performance/controllers/course_controller/delete_course_by_id/load_delete_course_by_id.js
@@ -16,3 +16,10 @@ export default function () {
check(res, { 'DELETE load': (r) => [200, 204, 404].includes(r.status) });
sleep(Math.random() * 5 + 2)
}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_load_delete_course_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
+}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js b/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js
index 4ee9836..47db2f0 100644
--- a/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js
+++ b/tests/performance/controllers/course_controller/delete_course_by_id/spike_delete_course_by_id.js
@@ -17,4 +17,11 @@ export default function () {
const res = http.del(`${BASE_URL}/${courseId}`, null, { headers: defaultHeaders });
check(res, { 'DELETE spike': (r) => [200, 204, 404].includes(r.status) });
sleep(Math.random() * 5 + 2)
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_spike_delete_course_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js b/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js
index 10fb7c5..7e18c8d 100644
--- a/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js
+++ b/tests/performance/controllers/course_controller/delete_course_by_id/stress_delete_course_by_id.js
@@ -12,4 +12,11 @@ export default function () {
const res = http.del(`${BASE_URL}/${courseId}`, null, { headers: defaultHeaders });
check(res, { 'DELETE stress': (r) => [200, 204, 404].includes(r.status) });
sleep(Math.random() * 5 + 2);
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_stress_delete_course_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js b/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js
index effdcb4..10da7a0 100644
--- a/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js
+++ b/tests/performance/controllers/course_controller/get_all_courses/load_get_all_courses.js
@@ -19,3 +19,10 @@ export default function () {
check(res, { 'GET /api/courses status 200': (r) => checkResponse(r, 200, 'GET /api/courses') });
sleep(Math.random() * 2 + 1);
}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_load_get_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
+}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js b/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js
index 806018a..d3797d0 100644
--- a/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js
+++ b/tests/performance/controllers/course_controller/get_all_courses/spike_get_all_courses.js
@@ -19,3 +19,10 @@ export default function () {
const res = http.get(BASE_URL, { headers: defaultHeaders });
check(res, { 'GET /api/courses status 200': (r) => checkResponse(r, 200, 'GET /api/courses') });
}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_spike_get_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
+}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js b/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js
index 3fd8b9f..190a06b 100644
--- a/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js
+++ b/tests/performance/controllers/course_controller/get_all_courses/stress_get_all_courses.js
@@ -22,3 +22,10 @@ export default function () {
const res = http.get(BASE_URL, { headers: defaultHeaders });
check(res, { 'GET /api/courses status 200': (r) => checkResponse(r, 200, 'GET /api/courses') });
}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_stress_get_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
+}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js b/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js
index bba9cf9..185be0a 100644
--- a/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js
+++ b/tests/performance/controllers/course_controller/get_course_by_id/load_get_course_by_id.js
@@ -19,4 +19,11 @@ const COURSE_ID = __ENV.COURSE_ID || '1';
export default function () {
const res = http.get(`${BASE_URL}/${COURSE_ID}`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/{id} status 200': (r) => checkResponse(r, 200, 'GET /api/courses/{id}') });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_load_get_courses_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js b/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js
index 4fa0e57..73a69f0 100644
--- a/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js
+++ b/tests/performance/controllers/course_controller/get_course_by_id/spike_get_course_by_id.js
@@ -15,4 +15,11 @@ const COURSE_ID = __ENV.COURSE_ID || '1';
export default function () {
const res = http.get(`${BASE_URL}/${COURSE_ID}`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/{id} status 200': (r) => checkResponse(r, 200, 'GET /api/courses/{id}') });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_spike_get_courses_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js b/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js
index a2baebf..ff3ebaa 100644
--- a/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js
+++ b/tests/performance/controllers/course_controller/get_course_by_id/stress_get_course_by_id.js
@@ -23,4 +23,11 @@ const COURSE_ID = __ENV.COURSE_ID || '1';
export default function () {
const res = http.get(`${BASE_URL}/${COURSE_ID}`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/{id} status 200': (r) => checkResponse(r, 200, 'GET /api/courses/{id}') });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_stress_get_courses_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js b/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js
index 240a532..c56c964 100644
--- a/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js
+++ b/tests/performance/controllers/course_controller/get_course_by_name/load_get_course_by_name.js
@@ -17,3 +17,10 @@ export default function () {
check(res, { 'GET /api/courses/name/{name} returns 200': (r) => checkResponse(r, 200) });
sleep(0.5);
}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_load_get_courses_by_name_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
+}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js b/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js
index 07692d0..f0efdad 100644
--- a/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js
+++ b/tests/performance/controllers/course_controller/get_course_by_name/spike_get_course_by_name.js
@@ -20,4 +20,11 @@ export default function () {
const res = http.get(`${BASE_URL}/name/${courseName}`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/name/{name} returns 200': (r) => checkResponse(r, 200) });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_spike_get_courses_by_name_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js b/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js
index 04bed14..18e058f 100644
--- a/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js
+++ b/tests/performance/controllers/course_controller/get_course_by_name/stress_get_course_by_name.js
@@ -23,4 +23,11 @@ export default function () {
const res = http.get(`${BASE_URL}/name/${courseName}`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/name/{name} returns 200': (r) => checkResponse(r, 200) });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_stress_get_courses_by_name_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js b/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js
index e8eee45..9ad07d5 100644
--- a/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js
+++ b/tests/performance/controllers/course_controller/get_course_lessons_by_id/load_get_course_lessons_by_id.js
@@ -15,4 +15,11 @@ export default function () {
const res = http.get(`${BASE_URL}/1/lessons`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/1/lessons returns 200': (r) => checkResponse(r, 200) });
sleep(0.5);
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_load_get_course_lessons_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js b/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js
index 697635f..21eb91b 100644
--- a/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js
+++ b/tests/performance/controllers/course_controller/get_course_lessons_by_id/spike_get_course_lessons_by_id.js
@@ -18,4 +18,11 @@ export let options = {
export default function () {
const res = http.get(`${BASE_URL}/1/lessons`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/1/lessons returns 200': (r) => checkResponse(r, 200) });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_spike_get_course_lessons_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js b/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js
index 374ffad..20649b6 100644
--- a/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js
+++ b/tests/performance/controllers/course_controller/get_course_lessons_by_id/stress_get_course_lessons_by_id.js
@@ -22,4 +22,11 @@ export default function () {
const res = http.get(`${BASE_URL}/1/lessons`, { headers: defaultHeaders });
check(res, { 'GET /api/courses/1/lessons returns 200': (r) => checkResponse(r, 200) });
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_stress_get_course_lessons_by_id_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js b/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js
index a0892cf..6a03427 100644
--- a/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js
+++ b/tests/performance/controllers/course_controller/post_all_courses/load_post_all_courses.js
@@ -18,4 +18,11 @@ export default function () {
const res = http.post(BASE_URL, { headers: defaultHeaders });
check(res, { 'POST /api/courses status 200': (r) => checkResponse(r, 200, 'POST /api/courses') });
sleep(Math.random() * 2 + 1);
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_load_post_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js b/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js
index d9b5d4f..c22512b 100644
--- a/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js
+++ b/tests/performance/controllers/course_controller/post_all_courses/spike_post_all_courses.js
@@ -31,4 +31,11 @@ export default function () {
'POST /api/courses → status 201': (r) =>
checkResponse(r, 201, 'POST /api/courses'),
});
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_spike_post_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js b/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js
index 19efa26..0388eed 100644
--- a/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js
+++ b/tests/performance/controllers/course_controller/post_all_courses/stress_post_all_courses.js
@@ -39,4 +39,11 @@ export default function () {
checkResponse(r, [200, 201], 'POST /api/courses'),
});
sleep(Math.random() * 5 + 2);
+}
+
+export function handleSummary(data) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ return {
+ [`./tests/performance/results/summary_stress_post_all_courses_${timestamp}.json`]: JSON.stringify(data, null, 2),
+ };
}
\ No newline at end of file
diff --git a/tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json b/tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json
new file mode 100644
index 0000000..a2a1243
--- /dev/null
+++ b/tests/performance/results/summary_load_get_courses_by_name_2025-12-08T17-45-06-298Z.json
@@ -0,0 +1,212 @@
+{
+ "options": {
+ "summaryTrendStats": [
+ "avg",
+ "min",
+ "med",
+ "max",
+ "p(90)",
+ "p(95)"
+ ],
+ "summaryTimeUnit": "",
+ "noColor": false
+ },
+ "state": {
+ "isStdOutTTY": true,
+ "isStdErrTTY": true,
+ "testRunDurationMs": 50271.384
+ },
+ "metrics": {
+ "http_req_tls_handshaking": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 0,
+ "min": 0,
+ "med": 0,
+ "max": 0,
+ "p(90)": 0,
+ "p(95)": 0
+ }
+ },
+ "http_req_receiving": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 0.2379822510822513,
+ "min": 0.007,
+ "med": 0.076,
+ "max": 16.035,
+ "p(90)": 0.20810000000000003,
+ "p(95)": 0.3476499999999997
+ }
+ },
+ "data_sent": {
+ "type": "counter",
+ "contains": "data",
+ "values": {
+ "count": 2605680,
+ "rate": 51832.27101923432
+ }
+ },
+ "http_req_waiting": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "max": 118.755,
+ "p(90)": 57.7513,
+ "p(95)": 64.247,
+ "avg": 40.99053030303033,
+ "min": 9.778,
+ "med": 40.321
+ }
+ },
+ "checks": {
+ "contains": "default",
+ "values": {
+ "rate": 1,
+ "passes": 2310,
+ "fails": 0
+ },
+ "type": "rate"
+ },
+ "iterations": {
+ "type": "counter",
+ "contains": "default",
+ "values": {
+ "rate": 45.950594875207734,
+ "count": 2310
+ }
+ },
+ "http_reqs": {
+ "type": "counter",
+ "contains": "default",
+ "values": {
+ "count": 2310,
+ "rate": 45.950594875207734
+ }
+ },
+ "iteration_duration": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "p(95)": 566.4199189,
+ "avg": 542.7420781367962,
+ "min": 510.738083,
+ "med": 541.99,
+ "max": 618.941333,
+ "p(90)": 559.6669331
+ }
+ },
+ "http_req_failed": {
+ "contains": "default",
+ "values": {
+ "rate": 0,
+ "passes": 0,
+ "fails": 2310
+ },
+ "type": "rate"
+ },
+ "http_req_blocked": {
+ "contains": "time",
+ "values": {
+ "avg": 0.028522077922078178,
+ "min": 0.001,
+ "med": 0.006,
+ "max": 3.266,
+ "p(90)": 0.015,
+ "p(95)": 0.025
+ },
+ "type": "trend"
+ },
+ "http_req_sending": {
+ "values": {
+ "max": 8.574,
+ "p(90)": 0.05110000000000005,
+ "p(95)": 0.0775499999999999,
+ "avg": 0.04051471861471894,
+ "min": 0.004,
+ "med": 0.019
+ },
+ "type": "trend",
+ "contains": "time"
+ },
+ "http_req_duration": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 41.269027272727314,
+ "min": 9.816,
+ "med": 40.524,
+ "max": 118.789,
+ "p(90)": 58.074400000000004,
+ "p(95)": 64.67054999999999
+ }
+ },
+ "data_received": {
+ "type": "counter",
+ "contains": "data",
+ "values": {
+ "count": 1275120,
+ "rate": 25364.72837111467
+ }
+ },
+ "vus_max": {
+ "contains": "default",
+ "values": {
+ "value": 50,
+ "min": 50,
+ "max": 50
+ },
+ "type": "gauge"
+ },
+ "vus": {
+ "type": "gauge",
+ "contains": "default",
+ "values": {
+ "value": 12,
+ "min": 1,
+ "max": 49
+ }
+ },
+ "http_req_connecting": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "min": 0,
+ "med": 0,
+ "max": 2.895,
+ "p(90)": 0,
+ "p(95)": 0,
+ "avg": 0.016248484848484852
+ }
+ },
+ "http_req_duration{expected_response:true}": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 41.269027272727314,
+ "min": 9.816,
+ "med": 40.524,
+ "max": 118.789,
+ "p(90)": 58.074400000000004,
+ "p(95)": 64.67054999999999
+ }
+ }
+ },
+ "root_group": {
+ "name": "",
+ "path": "",
+ "id": "d41d8cd98f00b204e9800998ecf8427e",
+ "groups": [],
+ "checks": [
+ {
+ "id": "4900f98b03ff58bb73677daf299fc44a",
+ "passes": 2310,
+ "fails": 0,
+ "name": "GET /api/courses/name/{name} returns 200",
+ "path": "::GET /api/courses/name/{name} returns 200"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json b/tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json
new file mode 100644
index 0000000..3a89f63
--- /dev/null
+++ b/tests/performance/results/summary_spike_get_all_courses_2025-12-09T15-40-48-531Z.json
@@ -0,0 +1,222 @@
+{
+ "root_group": {
+ "groups": [],
+ "checks": [
+ {
+ "name": "GET /api/courses status 200",
+ "path": "::GET /api/courses status 200",
+ "id": "f02acc4eeef4e7a41d7801aaf3fd8a9d",
+ "passes": 88957,
+ "fails": 0
+ }
+ ],
+ "name": "",
+ "path": "",
+ "id": "d41d8cd98f00b204e9800998ecf8427e"
+ },
+ "options": {
+ "summaryTrendStats": [
+ "avg",
+ "min",
+ "med",
+ "max",
+ "p(90)",
+ "p(95)"
+ ],
+ "summaryTimeUnit": "",
+ "noColor": false
+ },
+ "state": {
+ "testRunDurationMs": 17001.441,
+ "isStdOutTTY": true,
+ "isStdErrTTY": true
+ },
+ "metrics": {
+ "http_reqs": {
+ "type": "counter",
+ "contains": "default",
+ "values": {
+ "rate": 5232.321189715624,
+ "count": 88957
+ }
+ },
+ "http_req_blocked": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "min": 0,
+ "med": 0.001,
+ "max": 77.459,
+ "p(90)": 0.002,
+ "p(95)": 0.004,
+ "avg": 0.08780094877315953
+ }
+ },
+ "iterations": {
+ "values": {
+ "rate": 5232.321189715624,
+ "count": 88957
+ },
+ "type": "counter",
+ "contains": "default"
+ },
+ "http_req_failed": {
+ "type": "rate",
+ "contains": "default",
+ "values": {
+ "fails": 88957,
+ "rate": 0,
+ "passes": 0
+ },
+ "thresholds": {
+ "rate<0.2": {
+ "ok": true
+ }
+ }
+ },
+ "iteration_duration": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 283.06402668955275,
+ "min": 0.892459,
+ "med": 296.839375,
+ "max": 912.909875,
+ "p(90)": 387.19262480000003,
+ "p(95)": 425.50211659999997
+ }
+ },
+ "vus_max": {
+ "type": "gauge",
+ "contains": "default",
+ "values": {
+ "min": 2000,
+ "max": 2000,
+ "value": 2000
+ }
+ },
+ "http_req_waiting": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 281.9709629034241,
+ "min": 0.818,
+ "med": 296.393,
+ "max": 912.853,
+ "p(90)": 384.4694,
+ "p(95)": 422.2887999999999
+ }
+ },
+ "http_req_receiving": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 0.8390701237676371,
+ "min": 0.005,
+ "med": 0.014,
+ "max": 105.463,
+ "p(90)": 0.157,
+ "p(95)": 0.288
+ }
+ },
+ "http_req_connecting": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 0.08498199129916698,
+ "min": 0,
+ "med": 0,
+ "max": 77.434,
+ "p(90)": 0,
+ "p(95)": 0
+ }
+ },
+ "vus": {
+ "values": {
+ "value": 105,
+ "min": 1,
+ "max": 2000
+ },
+ "type": "gauge",
+ "contains": "default"
+ },
+ "data_sent": {
+ "type": "counter",
+ "contains": "data",
+ "values": {
+ "count": 97763743,
+ "rate": 5750320.987497471
+ }
+ },
+ "data_received": {
+ "type": "counter",
+ "contains": "data",
+ "values": {
+ "count": 106036934,
+ "rate": 6236938.033664323
+ }
+ },
+ "http_req_duration{expected_response:true}": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "med": 296.696,
+ "max": 912.867,
+ "p(90)": 387.0536,
+ "p(95)": 425.3459999999999,
+ "avg": 282.8300397270564,
+ "min": 0.862
+ }
+ },
+ "http_req_tls_handshaking": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "med": 0,
+ "max": 0,
+ "p(90)": 0,
+ "p(95)": 0,
+ "avg": 0,
+ "min": 0
+ }
+ },
+ "http_req_sending": {
+ "contains": "time",
+ "values": {
+ "p(90)": 0.008,
+ "p(95)": 0.015,
+ "avg": 0.02000669986619526,
+ "min": 0.002,
+ "med": 0.003,
+ "max": 107.079
+ },
+ "type": "trend"
+ },
+ "checks": {
+ "type": "rate",
+ "contains": "default",
+ "values": {
+ "passes": 88957,
+ "fails": 0,
+ "rate": 1
+ }
+ },
+ "http_req_duration": {
+ "contains": "time",
+ "values": {
+ "avg": 282.8300397270564,
+ "min": 0.862,
+ "med": 296.696,
+ "max": 912.867,
+ "p(90)": 387.0536,
+ "p(95)": 425.3459999999999
+ },
+ "thresholds": {
+ "p(95)<1500": {
+ "ok": true
+ }
+ },
+ "type": "trend"
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json b/tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json
new file mode 100644
index 0000000..715d20a
--- /dev/null
+++ b/tests/performance/results/summary_stress_get_all_courses_2025-12-08T17-52-37-938Z.json
@@ -0,0 +1,210 @@
+{
+ "root_group": {
+ "id": "d41d8cd98f00b204e9800998ecf8427e",
+ "groups": [],
+ "checks": [
+ {
+ "id": "f02acc4eeef4e7a41d7801aaf3fd8a9d",
+ "passes": 0,
+ "fails": 2616135,
+ "name": "GET /api/courses status 200",
+ "path": "::GET /api/courses status 200"
+ }
+ ],
+ "name": "",
+ "path": ""
+ },
+ "options": {
+ "summaryTimeUnit": "",
+ "noColor": false,
+ "summaryTrendStats": [
+ "avg",
+ "min",
+ "med",
+ "max",
+ "p(90)",
+ "p(95)"
+ ]
+ },
+ "state": {
+ "isStdOutTTY": true,
+ "isStdErrTTY": true,
+ "testRunDurationMs": 85001.406
+ },
+ "metrics": {
+ "vus_max": {
+ "type": "gauge",
+ "contains": "default",
+ "values": {
+ "value": 2000,
+ "min": 2000,
+ "max": 2000
+ }
+ },
+ "iterations": {
+ "contains": "default",
+ "values": {
+ "count": 2616135,
+ "rate": 30777.54972664805
+ },
+ "type": "counter"
+ },
+ "http_req_sending": {
+ "values": {
+ "avg": 0.05843801600354774,
+ "min": 0.001,
+ "med": 0.003,
+ "max": 133.096,
+ "p(90)": 0.008,
+ "p(95)": 0.016
+ },
+ "type": "trend",
+ "contains": "time"
+ },
+ "data_received": {
+ "contains": "data",
+ "values": {
+ "count": 1444585301,
+ "rate": 16994840.073586546
+ },
+ "type": "counter"
+ },
+ "checks": {
+ "type": "rate",
+ "contains": "default",
+ "values": {
+ "rate": 0,
+ "passes": 0,
+ "fails": 2616135
+ }
+ },
+ "iteration_duration": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 26.15504649736695,
+ "min": 0.198542,
+ "med": 21.873667,
+ "max": 461.378875,
+ "p(90)": 53.584950000000006,
+ "p(95)": 64.65959559999999
+ }
+ },
+ "http_req_duration": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 18.474802442916353,
+ "min": 0.18,
+ "med": 13.522,
+ "max": 460.526,
+ "p(90)": 40.894,
+ "p(95)": 51.54
+ },
+ "thresholds": {
+ "p(95)<1000": {
+ "ok": true
+ }
+ }
+ },
+ "http_req_tls_handshaking": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "med": 0,
+ "max": 0,
+ "p(90)": 0,
+ "p(95)": 0,
+ "avg": 0,
+ "min": 0
+ }
+ },
+ "http_reqs": {
+ "contains": "default",
+ "values": {
+ "count": 2616135,
+ "rate": 30777.54972664805
+ },
+ "type": "counter"
+ },
+ "http_req_blocked": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "p(95)": 0.003,
+ "avg": 0.1207288645241876,
+ "min": 0,
+ "med": 0.001,
+ "max": 124.99,
+ "p(90)": 0.002
+ }
+ },
+ "http_req_connecting": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 0.11568039264028825,
+ "min": 0,
+ "med": 0,
+ "max": 124.938,
+ "p(90)": 0,
+ "p(95)": 0
+ }
+ },
+ "http_req_receiving": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 0.10442196255082742,
+ "min": 0.003,
+ "med": 0.007,
+ "max": 167.395,
+ "p(90)": 0.017,
+ "p(95)": 0.034
+ }
+ },
+ "data_sent": {
+ "values": {
+ "rate": 33824527.14958621,
+ "count": 2875132365
+ },
+ "type": "counter",
+ "contains": "data"
+ },
+ "http_req_waiting": {
+ "type": "trend",
+ "contains": "time",
+ "values": {
+ "avg": 18.31194246436071,
+ "min": 0.171,
+ "med": 13.431,
+ "max": 460.468,
+ "p(90)": 40.676,
+ "p(95)": 51.21
+ }
+ },
+ "http_req_failed": {
+ "type": "rate",
+ "contains": "default",
+ "values": {
+ "passes": 2616135,
+ "fails": 0,
+ "rate": 1
+ },
+ "thresholds": {
+ "rate<0.2": {
+ "ok": false
+ }
+ }
+ },
+ "vus": {
+ "contains": "default",
+ "values": {
+ "value": 18,
+ "min": 18,
+ "max": 1997
+ },
+ "type": "gauge"
+ }
+ }
+}
\ No newline at end of file