From ec5a1de58426383267b6c485f49d2738d09effb4 Mon Sep 17 00:00:00 2001 From: Ninjaws Date: Sun, 3 Nov 2024 01:43:47 +0100 Subject: [PATCH] [Added] Nightly filling up of fresh data (and swapping), Startup filling of DB, sessionToken for bulk gathering --- .../quiz/components/StartupRunner.java | 47 ++-- ...SourceConfig.java => DataSourceConfig.txt} | 0 .../quiz/controllers/QuizController.java | 1 + .../quiz/entities/BaseQuestionEntity.java | 34 +++ .../entities/QuestionCollectingEntity.java | 10 + .../ninjaws/quiz/entities/QuestionEntity.java | 28 +-- .../com/ninjaws/quiz/models/QuizSettings.java | 57 ----- .../ninjaws/quiz/models/SessionTokenDTO.java | 15 ++ .../QuestionCollectingRepository.java | 14 ++ .../quiz/repositories/QuestionRepository.java | 37 ++-- .../com/ninjaws/quiz/services/ApiService.java | 29 ++- .../ninjaws/quiz/services/CleanerService.java | 38 +++- .../ninjaws/quiz/services/DataService.java | 48 +++-- .../quiz/services/DatabaseScheduler.java | 20 -- .../ninjaws/quiz/services/QuizService.java | 11 +- .../services/ResponseHandlingService.java | 26 ++- .../quiz/services/SchedulerService.java | 16 +- .../quiz/services/StateManagementService.java | 29 +++ .../src/main/resources/application.properties | 44 ++-- .../quiz/services/CleanerServiceTest.txt | 107 ---------- .../ninjaws/quiz/services/DataServiceTest.txt | 202 ------------------ .../quiz/services/backupApiServiceTest.txt | 114 ---------- .../quiz/services/backupQuizServiceTest.txt | 72 ------- scripts/LoadTestMultiple.sh | 6 +- scripts/LoadTestOne-Silent.sh | 38 ++++ scripts/LoadTestOne-WithParams.sh | 7 +- scripts/LoadTestOne.sh | 15 +- ...- All Params - 1000 and 10000 sessions.png | Bin 0 -> 25996 bytes 28 files changed, 365 insertions(+), 700 deletions(-) rename backend/src/main/java/com/ninjaws/quiz/config/{DataSourceConfig.java => DataSourceConfig.txt} (100%) create mode 100644 backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java delete mode 100644 backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java create mode 100644 backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt delete mode 100644 backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt create mode 100755 scripts/LoadTestOne-Silent.sh create mode 100644 scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png diff --git a/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java index 1885a81..129f975 100644 --- a/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java +++ b/backend/src/main/java/com/ninjaws/quiz/components/StartupRunner.java @@ -1,27 +1,40 @@ package com.ninjaws.quiz.components; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.CommandLineRunner; -import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; -/** - * Sets up the database, and adds the categories - * - * Fills up the primary database - * - * Then, at night, creates a new database, adds categories, and then starts filling it up - * When full, swaps the databases, and wipes the old one - */ -public class StartupRunner implements CommandLineRunner { - private final JdbcTemplate primaryJdbcTemplate; +import com.ninjaws.quiz.services.ApiService; +import com.ninjaws.quiz.services.StateManagementService; - public StartupRunner(JdbcTemplate primaryJdbcTemplate) { - this.primaryJdbcTemplate = primaryJdbcTemplate; - } + +@Component +public class StartupRunner implements CommandLineRunner { + + @Autowired + private StateManagementService stateManagementService; + @Autowired + private ApiService apiService; @Override public void run(String... args) { - // Initialization logic here - primaryJdbcTemplate.execute("CREATE TABLE IF NOT EXISTS test_table (id INT PRIMARY KEY, name VARCHAR(255))"); - primaryJdbcTemplate.update("INSERT INTO test_table (id, name) VALUES (?, ?)", 1, "Sample Data"); + + /** + * On startup: + * - Set ProcessingState to Startup + * - Store the categories + * - Get a sessiontoken + * + * * Inside the SchedulerService + * - Start filling the question table + * + * * Inside the Response Handling Service + * - No more questions? Stop + * - Set ProcessingState to Normal + */ + + stateManagementService.setCurrentProcessingState(StateManagementService.ProcessingState.STARTUP); + apiService.getCategories(); + apiService.getSessionToken(); } } diff --git a/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java b/backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt similarity index 100% rename from backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.java rename to backend/src/main/java/com/ninjaws/quiz/config/DataSourceConfig.txt diff --git a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java index b9f592e..fca6518 100644 --- a/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java +++ b/backend/src/main/java/com/ninjaws/quiz/controllers/QuizController.java @@ -23,6 +23,7 @@ public class QuizController { @Autowired private QuizService quizService; + /** * Start a session * @param amount The amount of questsions diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java new file mode 100644 index 0000000..bd4f7a7 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/BaseQuestionEntity.java @@ -0,0 +1,34 @@ +package com.ninjaws.quiz.entities; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; + +@MappedSuperclass +@Getter +@Setter +public class BaseQuestionEntity { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne + @JoinColumn(name = "category", nullable = false) + private Category category; + + private String difficulty; + private String type; + private String question; + + private String correctAnswer; + // @ElementCollection // This stores it in a separate table, it's just a set of strings so not required right now + private List incorrectAnswers; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java new file mode 100644 index 0000000..bc5370e --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionCollectingEntity.java @@ -0,0 +1,10 @@ +package com.ninjaws.quiz.entities; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "question_collecting") +public class QuestionCollectingEntity extends BaseQuestionEntity{ + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java index 753254d..24aece9 100644 --- a/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java +++ b/backend/src/main/java/com/ninjaws/quiz/entities/QuestionEntity.java @@ -1,36 +1,10 @@ package com.ninjaws.quiz.entities; -import java.util.List; - import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import lombok.Getter; -import lombok.Setter; @Entity @Table(name = "question") -@Getter -@Setter -public class QuestionEntity { +public class QuestionEntity extends BaseQuestionEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "category", nullable = false) - private Category category; - - private String difficulty; - private String type; - private String question; - - //* Hidden from the user */ - private String correctAnswer; - private List incorrectAnswers; } diff --git a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java index cb82d5e..4693f8a 100644 --- a/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java +++ b/backend/src/main/java/com/ninjaws/quiz/models/QuizSettings.java @@ -1,8 +1,5 @@ package com.ninjaws.quiz.models; -import com.ninjaws.quiz.models.QuizSettings.Category; -import com.ninjaws.quiz.models.QuizSettings.Difficulty; - import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -13,45 +10,9 @@ import lombok.Setter; @Setter public class QuizSettings { - public enum Type { - ANY, BOOLEAN, MULTIPLE; - - public String getApiValue() { - return this.name().toLowerCase(); - } - } - - public enum Difficulty { - ANY, EASY, MEDIUM, HARD; - - public String getApiValue() { - return this.name().toLowerCase(); - } - } - - public enum Category { - ANY, GEN_KNOWLEDGE, ENT_BOOKS, ENT_FILM, ENT_MUSIC, ENT_MUSICAL_THEATRE, ENT_TELEVISION, - ENT_VIDEOGAMES, ENT_BOARDGAMES, SCIENCE_NATURE, SCIENCE_COMPUTERS, SCIENCE_MATH, MYTHOLOGY, SPORTS, GEOGRAPHY, HISTORY, POLITICS, ART, CELEBRITIES, - ANIMALS, VEHICLES, ENT_COMICS, SCIENCE_GADGETS, ENT_ANIME_MANGA, ENT_CARTOON_ANIMATIONS; - - /** - * For some reason the external API starts with a value of 9 for the General Knowledge category, so we have to adjust - * ANY also gets adjusted, but it doesn't matter since that one doesn't get used as parameter anyway - * @return The corrected value - */ - public int getApiValue() { - return 8 + this.ordinal(); - } - } - @Min(1) @Max(50) private int amount = 10; - - // private Type type = Type.ANY; - - // private Difficulty difficulty = Difficulty.ANY; - private Integer category; private String difficulty; private String type; @@ -81,24 +42,6 @@ public class QuizSettings { * Converts the class to a format that can be provided as arguments for the api request * @return The resulting request arguments as String */ - // public String toApiQuery() { - // StringBuilder query = new StringBuilder("?amount=" + amount); - - // if (category != null && category != Category.ANY) { - // query.append("&category=").append(category.getApiValue()); - // } - - // if (difficulty != null && difficulty != Difficulty.ANY) { - // query.append("&difficulty=").append(difficulty.getApiValue()); - // } - - // if (type != null && type != Type.ANY) { - // query.append("&type=").append(type.getApiValue()); - // } - - // return query.toString(); - // } - public String toApiQuery() { StringBuilder query = new StringBuilder("?amount=" + amount); diff --git a/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java b/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java new file mode 100644 index 0000000..193a48e --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/models/SessionTokenDTO.java @@ -0,0 +1,15 @@ +package com.ninjaws.quiz.models; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SessionTokenDTO { + @JsonProperty("response_code") + private int responseCode; + @JsonProperty("token") + private String token; +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java new file mode 100644 index 0000000..ae6214c --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionCollectingRepository.java @@ -0,0 +1,14 @@ +package com.ninjaws.quiz.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.ninjaws.quiz.entities.QuestionCollectingEntity; + +/** + * This table is used for collecting questions from the external API + * When it finishes, the data from this repository will replace the data in the QuestionRepository, to ensure it stays fresh + * This should be scheduled once every day + */ +public interface QuestionCollectingRepository extends JpaRepository{ + +} diff --git a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java index 4ec9de2..afcc321 100644 --- a/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java +++ b/backend/src/main/java/com/ninjaws/quiz/repositories/QuestionRepository.java @@ -3,24 +3,37 @@ package com.ninjaws.quiz.repositories; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.ninjaws.quiz.entities.QuestionEntity; +import jakarta.transaction.Transactional; + public interface QuestionRepository extends JpaRepository{//, JpaSpecificationExecutor { + // @Query(value = "SELECT * FROM question AS q " + + // "WHERE (?1 IS NULL OR q.category = ?1) " + + // "AND (?2 IS NULL OR q.difficulty = ?2) " + + // "AND (?3 IS NULL OR q.type = ?3) " + + // "ORDER BY RAND() LIMIT ?4", nativeQuery = true) @Query(value = "SELECT * FROM question AS q " + - "WHERE (?1 IS NULL OR q.category = ?1) " + - "AND (?2 IS NULL OR q.difficulty = ?2) " + - "AND (?3 IS NULL OR q.type = ?3) " + - "ORDER BY RAND() LIMIT ?4", nativeQuery = true) - List lookupQuestions(Integer categoryId, String difficulty, String type, int limit); + "WHERE (:categoryId IS NULL OR q.category = :categoryId) " + + "AND (:difficulty IS NULL OR q.difficulty = :difficulty) " + + "AND (:type IS NULL OR q.type = :type) " + + "ORDER BY RAND() LIMIT :limit;", nativeQuery = true) + List lookupQuestions( + @Param("categoryId") Integer categoryId, + @Param("difficulty") String difficulty, + @Param("type") String type, + @Param("limit") int limit); - // public List getQuestionsBy(String type, String category, String difficulty, int limit) { - // Specification spec = Specification.where(QuestionSpecification.hasType(type)) - // .and(QuestionSpecification.hasCategory(category)) - // .and(QuestionSpecification.hasDifficulty(difficulty)); - - // return productRepository.findAll(spec); - // } + /** + * Moves all data from one table to another + */ + @Modifying + @Transactional + @Query(value="INSERT INTO question SELECT * FROM question_collecting;", nativeQuery=true) + void moveCollection(); } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java index 219e6fb..e4ce492 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ApiService.java @@ -12,6 +12,7 @@ import com.ninjaws.quiz.models.ApiResponse; import com.ninjaws.quiz.models.CategoryResponse; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.models.Request; +import com.ninjaws.quiz.models.SessionTokenDTO; import jakarta.validation.Valid; @@ -28,6 +29,7 @@ public class ApiService { private static final System.Logger logger = System.getLogger(ApiService.class.getName()); private final String apiUrl = "https://opentdb.com"; + private String sessionToken; public ApiService() { } @@ -55,21 +57,42 @@ public class ApiService { * Store them in the DataService */ public void collectBulk() { - // TODO: Make this happen only once, when a new database is generated - getCategories(); - String url = apiUrl + "/api.php?amount=50"; + String tokenStr = sessionToken == null ? "" : "&token="+sessionToken; + String url = apiUrl + "/api.php?amount=50" + tokenStr; try { ResponseEntity response = restTemplate.getForEntity(url, String.class); String json = response.getBody(); ApiResponse apiResponse = cleanerService.extractApiResponse(json); responseHandler.handleResponse(apiResponse); + /** + * A bit dirty, but otherwise it causes circular dependencies + * Makes sure the categories are refreshed after finishing to replace the questions, to make sure the ids match + * And resets the sessionToken, it will be remade during the next REFRESH + */ + if(apiResponse.getResponseCode() == 4) { + getCategories(); + sessionToken = null; + } + } catch (RestClientException | IOException e) { logger.log(System.Logger.Level.ERROR, "Critical failure", e); } } + public void getSessionToken() { + String url = apiUrl + "/api_token.php?command=request"; + try { + ResponseEntity response = restTemplate.getForEntity(url, String.class); + String json = response.getBody(); + SessionTokenDTO tokenDto = cleanerService.extractSessionToken(json); + sessionToken = tokenDto.getToken(); + + } catch (RestClientException | IOException e) { + logger.log(System.Logger.Level.ERROR, "Critical failure", e); + } + } public void getCategories() { String url = apiUrl + "/api_category.php"; diff --git a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java index 9c08359..7b26710 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/CleanerService.java @@ -10,12 +10,20 @@ import org.springframework.stereotype.Service; import com.fasterxml.jackson.databind.ObjectMapper; import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionCollectingEntity; import com.ninjaws.quiz.entities.QuestionEntity; import com.ninjaws.quiz.models.ApiResponse; import com.ninjaws.quiz.models.CategoryResponse; import com.ninjaws.quiz.models.QuestionDTO; +import com.ninjaws.quiz.models.SessionTokenDTO; import com.ninjaws.quiz.repositories.CategoryRepository; +/** + * Deals with conversions from one form of data to another + * Json to Object + * Entity to DTO + * etc + */ @Service public class CleanerService { @@ -44,6 +52,10 @@ public class CleanerService { return dtos; } + public SessionTokenDTO extractSessionToken(String json) throws IOException { + return objectMapper.readValue(json, SessionTokenDTO.class); + } + public QuestionDTO questionEntityToDTO(QuestionEntity entity) { QuestionDTO dto = new QuestionDTO(); dto.setQuestion(entity.getQuestion()); @@ -63,7 +75,7 @@ public class CleanerService { } return entities; } - + public QuestionEntity questionDTOtoEntity(QuestionDTO dto) { QuestionEntity entity = new QuestionEntity(); Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); @@ -76,6 +88,30 @@ public class CleanerService { return entity; } + + // TODO: Find a simpler way to convert from one table to another if the data is identical so this nonsense isn't necessary + public List questionListDTOtoCollectingEntity(List dtos) { + List entities = new ArrayList<>(); + for (int i = 0; i < dtos.size(); i++) { + QuestionDTO dto = dtos.get(i); + entities.add(questionDTOtoCollectingEntity(dto)); + } + return entities; + } + + public QuestionCollectingEntity questionDTOtoCollectingEntity(QuestionDTO dto) { + QuestionCollectingEntity entity = new QuestionCollectingEntity(); + Category category = categoryRepository.findByName(decodeString(dto.getCategory())).orElseThrow(() -> new IllegalStateException("Category not in DB!")); + entity.setCategory(category); + entity.setDifficulty(dto.getDifficulty()); + entity.setType(dto.getType()); + entity.setQuestion(dto.getQuestion()); + entity.setCorrectAnswer(dto.getCorrectAnswer()); + entity.setIncorrectAnswers(dto.getIncorrectAnswers()); + return entity; + } + + /** * For some reason, the categories come with special characters, * But the categories from the questions dont diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java index adb2469..de2f114 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/DataService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/DataService.java @@ -6,17 +6,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.ninjaws.quiz.entities.Category; +import com.ninjaws.quiz.entities.QuestionCollectingEntity; import com.ninjaws.quiz.entities.QuestionEntity; import com.ninjaws.quiz.models.QuizSettings; import com.ninjaws.quiz.repositories.CategoryRepository; +import com.ninjaws.quiz.repositories.QuestionCollectingRepository; import com.ninjaws.quiz.repositories.QuestionRepository; +import jakarta.transaction.Transactional; + @Service public class DataService { @Autowired private QuestionRepository questionRepository; - + @Autowired + private QuestionCollectingRepository collectingRepository; @Autowired private CategoryRepository categoryRepository; @@ -32,34 +37,31 @@ public class DataService { return questionRepository.saveAll(questions); } - // public List getRandomQuestions(String type, String category, String difficulty, int limit) { - // return questionRepository.findRandomQuestions(type, limit); - // } - - public List lookupQuestions(QuizSettings quizSettings) { - // Long category = Long.valueOf(quizSettings.getCategory().getApiValue() == 1 ? null : quizSettings.getCategory().getApiValue()); - // String difficulty = quizSettings.getDifficulty() == QuizSettings.Difficulty.ANY ? null : quizSettings.getDifficulty().getApiValue(); - // String type = quizSettings.getType() == QuizSettings.Type.ANY ? null : quizSettings.getType().getApiValue(); - // System.out.println(category); - // System.out.println(difficulty); - // System.out.println(type); - - return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount()); - } - /** * Tries to get questions that match the specified QuizSettings + * * @return The questions if the query was possible, and an empty array if not */ - // public List getByQuizSettings(QuizSettings quizSettings) { - // Specification spec = Specification.where(QuestionSpecification.hasType(type)) - // .and(QuestionSpecification.hasCategory(category)) - // .and(QuestionSpecification.hasDifficulty(difficulty)); - - // return productRepository.findAll(spec); - // } + public List lookupQuestions(QuizSettings quizSettings) { + return questionRepository.lookupQuestions(quizSettings.getCategory(), quizSettings.getDifficulty(), quizSettings.getType(), quizSettings.getAmount()); + } public List saveCategories(List categories) { return categoryRepository.saveAll(categories); } + + /** + * Moving the collected questions to the questions list + * + */ + @Transactional + public void migrateQuestions() { + questionRepository.deleteAll(); + questionRepository.moveCollection(); + collectingRepository.deleteAll(); + } + + public List collectQuestions(List questions) { + return collectingRepository.saveAll(questions); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java b/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java deleted file mode 100644 index 83686c1..0000000 --- a/backend/src/main/java/com/ninjaws/quiz/services/DatabaseScheduler.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ninjaws.quiz.services; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Service -public class DatabaseScheduler { - @Autowired - private JdbcTemplate secondaryJdbcTemplate; - - @Scheduled(fixedRate = 60000) // Runs every 60 seconds - public void createAndFillSecondaryDatabase() { - // Create tables and insert data into the secondary database - secondaryJdbcTemplate. - .execute("CREATE TABLE IF NOT EXISTS sample_table (id INT PRIMARY KEY, name VARCHAR(255))"); - secondaryJdbcTemplate.update("INSERT INTO sample_table (id, name) VALUES (?, ?)", 1, "Sample Data"); - } -} diff --git a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java index d511850..5bb92b3 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/QuizService.java @@ -38,7 +38,7 @@ public class QuizService { /** First, we check if there are already enough items in our db */ List questionsRetrieved = dataService.lookupQuestions(quizSettings); - if(!questionsRetrieved.isEmpty()) { + if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { session.setQuestions(cleanerService.questionListEntitytoDTO(questionsRetrieved)); } @@ -47,7 +47,7 @@ public class QuizService { /** * If the questions were retrieved from the DB, then it doesn't need to be added to the queue */ - if(!questionsRetrieved.isEmpty()) { + if(!questionsRetrieved.isEmpty() && questionsRetrieved.size() == quizSettings.getAmount()) { return Optional.of(session.getId()); } @@ -81,9 +81,10 @@ public class QuizService { } /** Anything other than statuscode 0 doesn't result in active sessions, so remove them from the cache after reporting to the frontend */ - if(session.getStatusCode() != 0) { - cacheService.removeFromCache(session.getId()); - } + // if(session.getStatusCode() != 0) { + /** Regardless of status code, this link is not reusable */ + cacheService.removeFromCache(session.getId()); + // } return Optional.of(session); } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java index 96b2dd0..4d07c9f 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/ResponseHandlingService.java @@ -10,6 +10,8 @@ import com.ninjaws.quiz.models.Session; @Service public class ResponseHandlingService { + @Autowired + private StateManagementService stateManager; @Autowired private CacheService cacheService; @Autowired @@ -33,7 +35,8 @@ public class ResponseHandlingService { case 0: // Success updateSessionInCache(session, response); - dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + /** Managing the token gets difficult since there is a timelimit, so simply dont add these, let the bulk collector do it */ + // dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); break; case 1: // Not enough questions to satisfy the request. @@ -67,11 +70,26 @@ public class ResponseHandlingService { public void handleResponse(ApiResponse response) { switch (response.getResponseCode()) { case 0: - dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + if(stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP) { + /** Initial saving during the startup */ + dataService.saveQuestions(cleanerService.questionListDTOtoEntity(response.getResults())); + } else if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + /** Background collecting during the night */ + dataService.collectQuestions(cleanerService.questionListDTOtoCollectingEntity(response.getResults())); + } break; case 4: - // Signal DataService that we have extracted all the data. Stop gathering, switch to caching-mode - // TODO: Signal DB that it's full, start db-swapping process + // Signal DataService that we have extracted all the data. Stop gathering, + // TODO: Switch to caching-mode + + /** + * If it was in the REFRESH state, move the data from collecting to the used questions + * Otherwise simply switch to the NORMAL state + */ + if (stateManager.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + dataService.migrateQuestions(); + } + stateManager.setCurrentProcessingState(StateManagementService.ProcessingState.NORMAL); break; case 5: // Should be prevented by the scheduler diff --git a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java index 41b7dc4..a8f474a 100644 --- a/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java +++ b/backend/src/main/java/com/ninjaws/quiz/services/SchedulerService.java @@ -13,6 +13,8 @@ public class SchedulerService { private QueueService queueService; @Autowired private ApiService apiService; + @Autowired + private StateManagementService stateService; public SchedulerService() {} @@ -25,11 +27,23 @@ public class SchedulerService { @Scheduled(fixedDelay = 5000) protected void processQueue() { Request request = queueService.popFromQueue(); + if (request != null) { apiService.handleRequest(request); } else { - apiService.collectBulk(); + if (stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.STARTUP || stateService.getCurrentProcessingState() == StateManagementService.ProcessingState.REFRESH) { + apiService.collectBulk(); + } } } + + /** + * Starts the renewing of the questions, to ensure they stay fresh + */ + @Scheduled(cron = "0 0 1 * * ?") + protected void StartDbRefresh() { + apiService.getSessionToken(); + stateService.setCurrentProcessingState(StateManagementService.ProcessingState.REFRESH); + } } diff --git a/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java b/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java new file mode 100644 index 0000000..b1b8c49 --- /dev/null +++ b/backend/src/main/java/com/ninjaws/quiz/services/StateManagementService.java @@ -0,0 +1,29 @@ +package com.ninjaws.quiz.services; + +import org.springframework.stereotype.Service; + +@Service +public class StateManagementService { + + /** + * STARTUP: When starting the app + * REFRESH: Scheduled at night, so the database stays fresh + * NORMAL: No special activities + */ + public enum ProcessingState { + STARTUP, + REFRESH, + NORMAL + } + + private ProcessingState currentProcessingState; + + public ProcessingState getCurrentProcessingState() { + return currentProcessingState; + } + + public void setCurrentProcessingState(ProcessingState currentProcessingState) { + this.currentProcessingState = currentProcessingState; + } + +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f7d952d..451ac85 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -6,29 +6,29 @@ spring.h2.console.enabled=true # spring.h2.console.path=/console -# spring.datasource.url=jdbc:h2:mem:testdb -# spring.datasource.driverClassName=org.h2.Driver -# spring.datasource.username=sa -# spring.datasource.password= +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= -# spring.jpa.hibernate.ddl-auto=create-drop -# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -# Primary database configuration -spring.datasource.primary.url=jdbc:h2:mem:testdb -# ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.primary.driver-class-name=org.h2.Driver -spring.datasource.primary.username=sa -spring.datasource.primary.password= -spring.jpa.primary.hibernate.ddl-auto=create-drop -spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect +# # Primary database configuration +# spring.datasource.primary.url=jdbc:h2:mem:testdb +# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# spring.datasource.primary.driver-class-name=org.h2.Driver +# spring.datasource.primary.username=sa +# spring.datasource.primary.password= +# spring.jpa.primary.hibernate.ddl-auto=create-drop +# spring.jpa.primary.database-platform=org.hibernate.dialect.H2Dialect -# Secondary database configuration -spring.datasource.secondary.url=jdbc:h2:mem:secondarydb -# ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.secondary.driver-class-name=org.h2.Driver -spring.datasource.secondary.username=sa -spring.datasource.secondary.password= -spring.jpa.secondary.hibernate.ddl-auto=create-drop -spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file +# # Secondary database configuration +# spring.datasource.secondary.url=jdbc:h2:mem:secondarydb +# # ;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +# spring.datasource.secondary.driver-class-name=org.h2.Driver +# spring.datasource.secondary.username=sa +# spring.datasource.secondary.password= +# spring.jpa.secondary.hibernate.ddl-auto=create-drop +# spring.jpa.secondary.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt deleted file mode 100644 index bdb7919..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/CleanerServiceTest.txt +++ /dev/null @@ -1,107 +0,0 @@ -package com.ninjaws.quiz.services; - -import java.io.IOException; -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.entities.Category; -import com.ninjaws.quiz.models.ApiResponse; -import com.ninjaws.quiz.models.QuestionDTO; - -// public class CleanerServiceTest { - -// @InjectMocks -// private CleanerService cleanerService; - -// @Mock -// private ObjectMapper objectMapper; - -// @BeforeEach -// public void setUp() { -// MockitoAnnotations.openMocks(this); -// } - -// @Test -// public void testExtractApiResponse() throws IOException { -// String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input - -// Question question = new Question(); -// question.setType("multiple choice"); -// question.setDifficulty("easy"); -// question.setCategory("general knowledge"); -// question.setQuestion("What is the capital of France?"); -// question.setCorrectAnswer("Paris"); -// question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - -// ApiResponse expectedResponse = new ApiResponse(); -// expectedResponse.setResponseCode(200); -// expectedResponse.setResults(Arrays.asList(question)); -// when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); - -// ApiResponse actualResponse = cleanerService.extractApiResponse(json); -// assertThat(actualResponse).isNotNull(); -// assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); -// assertThat(actualResponse.getResults()).isNotEmpty(); -// assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); -// verify(objectMapper, times(1)).readValue(json, ApiResponse.class); -// } -// } - -@ExtendWith(MockitoExtension.class) -public class CleanerServiceTest { - - @InjectMocks - private CleanerService cleanerService; - - @Mock - private ObjectMapper objectMapper; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - public void testExtractApiResponse() throws IOException { - String json = "{\"response_code\":200,\"results\":[{\"type\":\"multiple choice\",\"difficulty\":\"easy\",\"category\":\"general knowledge\",\"question\":\"What is the capital of France?\",\"correct_answer\":\"Paris\",\"incorrect_answers\":[\"Berlin\",\"Madrid\",\"Rome\"]}]}"; // Example JSON input - - // Create a Category object for the question - Category category = new Category(); - category.setId(1L); - category.setName("General Knowledge"); - - // Create the Question object with the category - QuestionDTO question = new QuestionDTO(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory(category); // Set the Category object here - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - ApiResponse expectedResponse = new ApiResponse(); - expectedResponse.setResponseCode(200); - expectedResponse.setResults(Arrays.asList(question)); - when(objectMapper.readValue(json, ApiResponse.class)).thenReturn(expectedResponse); - - ApiResponse actualResponse = cleanerService.extractApiResponse(json); - assertThat(actualResponse).isNotNull(); - assertThat(actualResponse.getResponseCode()).isEqualTo(expectedResponse.getResponseCode()); - assertThat(actualResponse.getResults()).isNotEmpty(); - assertThat(actualResponse.getResults().get(0).getQuestion()).isEqualTo(question.getQuestion()); - assertThat(actualResponse.getResults().get(0).getCategory().getName()).isEqualTo(category.getName()); // Check category name - verify(objectMapper, times(1)).readValue(json, ApiResponse.class); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt deleted file mode 100644 index faa94e2..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/DataServiceTest.txt +++ /dev/null @@ -1,202 +0,0 @@ -package com.ninjaws.quiz.services; - -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.ninjaws.quiz.entities.Category; -import com.ninjaws.quiz.models.QuestionDTO; -import com.ninjaws.quiz.repositories.QuestionRepository; - -// public class DataServiceTest { - -// @InjectMocks -// private DataService dataService; - -// @Mock -// private QuestionRepository questionRepository; - -// private Question question; - - -// @BeforeEach -// public void setUp() { -// MockitoAnnotations.openMocks(this); - -// question = new Question(); -// question.setType("multiple choice"); -// question.setDifficulty("easy"); -// question.setCategory("general knowledge"); -// question.setQuestion("What is the capital of France?"); -// question.setCorrectAnswer("Paris"); -// question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); -// } - -// @Test -// public void testSaveQuestion() { -// when(questionRepository.save(any(Question.class))).thenReturn(question); - -// Question savedQuestion = dataService.saveQuestion(question); -// assertThat(savedQuestion).isNotNull(); -// assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); -// verify(questionRepository, times(1)).save(question); -// } - -// @Test -// public void testGetAllQuestions() { -// when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); - -// List questions = dataService.getAllQuestions(); -// assertThat(questions).isNotEmpty(); -// assertThat(questions.size()).isEqualTo(1); -// assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); -// verify(questionRepository, times(1)).findAll(); -// } - -// @Test -// public void testFindQuestionById() { -// when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); - -// Optional foundQuestion = questionRepository.findById(1L); -// assertThat(foundQuestion).isPresent(); -// assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); -// verify(questionRepository, times(1)).findById(1L); -// } - -// @Test -// public void testSaveQuestions() { -// Question question1 = new Question(); -// question1.setType("multiple choice"); -// question1.setDifficulty("easy"); -// question1.setCategory("general knowledge"); -// question1.setQuestion("What is the capital of France?"); -// question1.setCorrectAnswer("Paris"); -// question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - -// Question question2 = new Question(); -// question2.setType("multiple choice"); -// question2.setDifficulty("medium"); -// question2.setCategory("science"); -// question2.setQuestion("What is the chemical symbol for water?"); -// question2.setCorrectAnswer("H2O"); -// question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); - -// when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); - -// List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); - -// assertThat(questions).hasSize(2); -// assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); -// assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); -// verify(questionRepository, times(1)).saveAll(anyList()); -// } -// } - -@ExtendWith(MockitoExtension.class) -public class DataServiceTest { - - @InjectMocks - private DataService dataService; - - @Mock - private QuestionRepository questionRepository; - - private QuestionDTO question; - private Category category; - - @BeforeEach - public void setUp() { - MockitoAnnotations.openMocks(this); - - category = new Category(); - category.setId(1L); - category.setName("General Knowledge"); - - question = new QuestionDTO(); - question.setType("multiple choice"); - question.setDifficulty("easy"); - question.setCategory(category); // Set the Category object here - question.setQuestion("What is the capital of France?"); - question.setCorrectAnswer("Paris"); - question.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - } - - @Test - public void testSaveQuestion() { - when(questionRepository.save(any(QuestionDTO.class))).thenReturn(question); - - QuestionDTO savedQuestion = dataService.saveQuestion(question); - assertThat(savedQuestion).isNotNull(); - assertThat(savedQuestion.getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(savedQuestion.getCategory().getName()).isEqualTo("General Knowledge"); // Check category name - verify(questionRepository, times(1)).save(question); - } - - @Test - public void testGetAllQuestions() { - when(questionRepository.findAll()).thenReturn(Arrays.asList(question)); - - List questions = dataService.getAllQuestions(); - assertThat(questions).isNotEmpty(); - assertThat(questions.size()).isEqualTo(1); - assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(questions.get(0).getCategory().getName()).isEqualTo("General Knowledge"); // Check category name - verify(questionRepository, times(1)).findAll(); - } - - @Test - public void testFindQuestionById() { - when(questionRepository.findById(any(Long.class))).thenReturn(Optional.of(question)); - - Optional foundQuestion = questionRepository.findById(1L); - assertThat(foundQuestion).isPresent(); - assertThat(foundQuestion.get().getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(foundQuestion.get().getCategory().getName()).isEqualTo("General Knowledge"); // Check category name - verify(questionRepository, times(1)).findById(1L); - } - - @Test - public void testSaveQuestions() { - QuestionDTO question1 = new QuestionDTO(); - question1.setType("multiple choice"); - question1.setDifficulty("easy"); - question1.setCategory(category); // Set the Category object here - question1.setQuestion("What is the capital of France?"); - question1.setCorrectAnswer("Paris"); - question1.setIncorrectAnswers(Arrays.asList("Berlin", "Madrid", "Rome")); - - QuestionDTO question2 = new QuestionDTO(); - Category category2 = new Category(); - category2.setId(2L); - category2.setName("Science"); - question2.setType("multiple choice"); - question2.setDifficulty("medium"); - question2.setCategory(category2); // Set the Category object here - question2.setQuestion("What is the chemical symbol for water?"); - question2.setCorrectAnswer("H2O"); - question2.setIncorrectAnswers(Arrays.asList("CO2", "O2", "NaCl")); - - when(questionRepository.saveAll(anyList())).thenReturn(Arrays.asList(question1, question2)); - - List questions = dataService.saveQuestions(Arrays.asList(question1, question2)); - - assertThat(questions).hasSize(2); - assertThat(questions.get(0).getQuestion()).isEqualTo("What is the capital of France?"); - assertThat(questions.get(1).getQuestion()).isEqualTo("What is the chemical symbol for water?"); - verify(questionRepository, times(1)).saveAll(anyList()); - } -} diff --git a/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt deleted file mode 100644 index 1b3f44e..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/backupApiServiceTest.txt +++ /dev/null @@ -1,114 +0,0 @@ -package com.ninjaws.quiz.services; - -import static org.assertj.core.api.Assertions.assertThat; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ninjaws.quiz.models.QuizSettings; - -@SpringBootTest -public class ApiServiceTest { - - @Autowired - private ApiService apiService; - - @Autowired - private ObjectMapper objectMapper; - - /** - * The external API only allows one request every 5 seconds - */ - @AfterEach - public void cooldown() throws InterruptedException { - Thread.sleep(5000); - } - - @Test - public void testRequestQuestions() throws Exception { - QuizSettings settings = new QuizSettings(); - String jsonResponse = apiService.handleRequest(settings); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - // Check if the response code is as expected - assertThat(jsonNode.has("response_code")).isTrue(); - assertThat(jsonNode.get("response_code").asInt()).isEqualTo(0); - - // Check the results array - assertThat(jsonNode.has("results")).isTrue(); - assertThat(jsonNode.get("results").isArray()).isTrue(); - assertThat(jsonNode.get("results").size()).isEqualTo(10); - - // Verify the first result has the expected fields - JsonNode firstResult = jsonNode.get("results").get(0); - assertThat(firstResult.has("type")).isTrue(); - assertThat(firstResult.has("difficulty")).isTrue(); - assertThat(firstResult.has("category")).isTrue(); - assertThat(firstResult.has("question")).isTrue(); - assertThat(firstResult.has("correct_answer")).isTrue(); - assertThat(firstResult.has("incorrect_answers")).isTrue(); - assertThat(firstResult.get("incorrect_answers").size()).isGreaterThan(0); - } - - @Test - public void testAllParams() throws Exception { - int amount = 10; - - QuizSettings settings = new QuizSettings(); - settings.setAmount(amount); - settings.setCategory(QuizSettings.Category.HISTORY); - settings.setType(QuizSettings.Type.MULTIPLE); - settings.setDifficulty(QuizSettings.Difficulty.MEDIUM); - - String jsonResponse = apiService.requestQuestions(settings); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - // Check if the response code is as expected - assertThat(jsonNode.has("response_code")).isTrue(); - assertThat(jsonNode.get("response_code").asInt()).isEqualTo(0); - - // Check the results array - assertThat(jsonNode.has("results")).isTrue(); - assertThat(jsonNode.get("results").isArray()).isTrue(); - assertThat(jsonNode.get("results").size()).isEqualTo(amount); - - // Verify the first result has the expected fields - JsonNode firstResult = jsonNode.get("results").get(0); - String retrievedType = firstResult.get("type").toString(); - String retrievedDifficulty = firstResult.get("difficulty").toString(); - String retrievedCategory = firstResult.get("category").toString(); - assertThat(retrievedType.equals(settings.getType().getApiValue())); - assertThat(retrievedDifficulty.equals(settings.getDifficulty().getApiValue())); - assertThat(retrievedCategory.equals("Animals")); - assertThat(firstResult.get("incorrect_answers").size()).isEqualTo(3); - } - - /** - * If the API doesn't have the requested amount of questions, it will return an empty list and respond with 1 - */ - @Test - public void testCombinationThatResultsInZeroQuestions() throws Exception { - int amount = 30; - - QuizSettings settings = new QuizSettings(); - settings.setAmount(amount); - settings.setCategory(QuizSettings.Category.ANIMALS); - settings.setType(QuizSettings.Type.MULTIPLE); - settings.setDifficulty(QuizSettings.Difficulty.MEDIUM); - - String jsonResponse = apiService.requestQuestions(settings); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - // Check if the response code is as expected - assertThat(jsonNode.has("response_code")).isTrue(); - assertThat(jsonNode.get("response_code").asInt()).isEqualTo(1); - - // Check the results array - assertThat(jsonNode.has("results")).isTrue(); - assertThat(jsonNode.get("results").isArray()).isTrue(); - assertThat(jsonNode.get("results").size()).isEqualTo(0); - } -} \ No newline at end of file diff --git a/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt b/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt deleted file mode 100644 index 130fbd0..0000000 --- a/backend/src/test/java/com/ninjaws/quiz/services/backupQuizServiceTest.txt +++ /dev/null @@ -1,72 +0,0 @@ -package com.ninjaws.quiz.services; - -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -// @EnableScheduling -// @TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class QuizServiceTest { - - // @InjectMocks - // private QuizService quizService; - - // @Mock - // private ApiService apiService; - - // @BeforeAll - // public void setUp() { - // MockitoAnnotations.openMocks(this); - // // Cache cache = Caffeine.newBuilder() - // // .expireAfterWrite(1, TimeUnit.HOURS) - // // .build(); // Create a mock cache or a real cache for tests - // // quizService = new QuizService(cache); - // } - - // @Test - // public void testCreateSession() { - // QuizSettings settings = new QuizSettings(); - // String response = quizService.createSession(settings); - // assertTrue(response instanceof String); - // } - - // /** - // * TODO: Fix the test - // * Seems to clear the cache and queue when the createSession is finished - // */ - // @Test - // public void testCheckSession() throws InterruptedException { - - // QuizSettings settings = new QuizSettings(); - // when(apiService.requestQuestions(settings)).thenReturn("{\"response_code\":0,\"results\":[{\"type\":\"multiple\",\"difficulty\":\"hard\",\"category\":\"Entertainment: Video Games\",\"question\":\"The map featured in Arma 3 named "Altis" is based off of what Greek island?\",\"correct_answer\":\"Lemnos\",\"incorrect_answers\":[\"Ithaca\",\"Naxos\",\"Anafi\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Entertainment: Music\",\"question\":\"What is the last song on the first Panic! At the Disco album?\",\"correct_answer\":\"Build God, Then We'll Talk\",\"incorrect_answers\":[\"Nails for Breakfast, Tacks for Snacks\",\"I Write Sins Not Tragedies\",\"Lying Is The Most Fun A Girl Can Have Without Taking Her Clothes Off\"]},{\"type\":\"multiple\",\"difficulty\":\"easy\",\"category\":\"Entertainment: Video Games\",\"question\":\"Which of these is NOT a playable character in the 2016 video game Overwatch?\",\"correct_answer\":\"Invoker\",\"incorrect_answers\":[\"Mercy\",\"Winston\",\"Zenyatta\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Science: Computers\",\"question\":\"What does AD stand for in relation to Windows Operating Systems? \",\"correct_answer\":\"Active Directory\",\"incorrect_answers\":[\"Alternative Drive\",\"Automated Database\",\"Active Department\"]},{\"type\":\"multiple\",\"difficulty\":\"medium\",\"category\":\"Entertainment: Japanese Anime & Manga\",\"question\":\"In "Highschool DxD", Koneko Toujou is from what race?\",\"correct_answer\":\"Nekomata\",\"incorrect_answers\":[\"Kitsune\",\"Human\",\"Kappa\"]}]}"); - // String sessionId = quizService.createSession(settings); - - - // int interval = 1000; - // int maxTime = 5000; - // long startTime = System.currentTimeMillis(); - // Session session = null; - - // while((System.currentTimeMillis() - maxTime) < startTime) { - // Optional retrieved = quizService.checkSession(sessionId); - // System.out.println(retrieved); - // if (retrieved.isPresent()) { - // System.out.print("Present!"); - // session = retrieved.get(); - // break; - // } else { - // try { - // Thread.sleep(interval); - // } catch (InterruptedException e) { - // Thread.currentThread().interrupt(); // Restore interrupted status - // fail("Thread was interrupted during sleep"); - // } - // } - // } - - // assertNotEquals(session, null); - // } - - // public void testCalculateScore(){ - - // } -} diff --git a/scripts/LoadTestMultiple.sh b/scripts/LoadTestMultiple.sh index 352f191..1bc9f85 100755 --- a/scripts/LoadTestMultiple.sh +++ b/scripts/LoadTestMultiple.sh @@ -2,9 +2,9 @@ start_time=$(date +%s) -for i in {1..6}; do - ./LoadTestOne-WithParams.sh & - sleep 0.1 +for i in {1..1000}; do + ./LoadTestOne-Silent.sh & +# sleep 0.1 done wait # Wait for all background jobs to finish diff --git a/scripts/LoadTestOne-Silent.sh b/scripts/LoadTestOne-Silent.sh new file mode 100755 index 0000000..a76f8b3 --- /dev/null +++ b/scripts/LoadTestOne-Silent.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Step 1: Get the sessionId from the questions endpoint +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=10&category=23&difficulty=medium&type=multiple") + +# Extract the status code +statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + +if [ "$statusCode" -ne 0 ]; then + exit 1 +fi + +# Extract the session ID +sessionId=$(echo "$sessionIdJson" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//') + +if [ -z "$sessionId" ]; then + exit 1 +fi + +# Step 2: Poll the status endpoint every 2 seconds +while true; do + status_response=$(curl -s "http://localhost:9090/quiz/status/$sessionId") + + if [ "$status_response" != "null" ]; then + # Extract the status code from the response + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + exit 1 + fi + + # Check if the response is JSON (starts with '{') + if [[ $status_response == \{* ]]; then + break + fi + fi + + sleep 2 +done diff --git a/scripts/LoadTestOne-WithParams.sh b/scripts/LoadTestOne-WithParams.sh index f23ce00..42d65f4 100755 --- a/scripts/LoadTestOne-WithParams.sh +++ b/scripts/LoadTestOne-WithParams.sh @@ -1,7 +1,6 @@ #!/bin/bash - # Step 1: Get the sessionId from the questions endpoint -sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=25&category=27&difficulty=medium&type=boolean") +sessionIdJson=$(curl -s "http://localhost:9090/quiz/questions?amount=20&category=27&difficulty=medium&type=multiple") statusCode=$(echo "$sessionIdJson" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') @@ -22,8 +21,7 @@ echo "Received sessionId: $sessionId" # Step 2: Poll the status endpoint every 2 seconds while true; do status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") - - if [ $? -eq 0 ]; then + if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') @@ -34,7 +32,6 @@ while true; do # Check if the response is JSON (starts with '{') if [[ $status_response == \{* ]]; then -# echo "$statusCode" echo "$sessionId: done! status: $statusCode" break fi diff --git a/scripts/LoadTestOne.sh b/scripts/LoadTestOne.sh index feb32eb..a85eab8 100755 --- a/scripts/LoadTestOne.sh +++ b/scripts/LoadTestOne.sh @@ -1,5 +1,4 @@ #!/bin/bash - # Step 1: Get the sessionId from the questions endpoint sessionIdJson=$(curl -s http://localhost:9090/quiz/questions?amount=5) @@ -22,16 +21,22 @@ echo "Received sessionId: $sessionId" # Step 2: Poll the status endpoint every 2 seconds while true; do status_response=$(curl -s http://localhost:9090/quiz/status/"$sessionId") + if [ "$status_response" != "null" ] && [ $? -eq 0 ]; then + + statusCode=$(echo "$status_response" | grep -o '"statusCode":[0-9]*' | sed 's/"statusCode"://') + + if [ "$statusCode" -ne 0 ]; then + echo "Exiting: statusCode is $statusCode" + exit 1 + fi - if [ $? -eq 0 ]; then # Check if the response is JSON (starts with '{') if [[ $status_response == \{* ]]; then - echo "$sessionId: done!" + echo "$sessionId: done! status: $statusCode" break fi else - echo "Status check failed, exiting..." - exit 1 + echo "Status check failed, retrying..." fi sleep 2 diff --git a/scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png b/scripts/benchmarks/DB and Queue - All Params - 1000 and 10000 sessions.png new file mode 100644 index 0000000000000000000000000000000000000000..e9032bbaf79e0aaeb49c2c6a5e17cc3df949650f GIT binary patch literal 25996 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�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