diff --git a/.gitignore b/.gitignore index f99d6f8..87f53ea 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,7 @@ out/ ### Firebase Service Account ### timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json -### Secrets ### -/src/main/resources/application-test-real.properties +### Secrets ### .env ### Internal usage ### diff --git a/Dockerfile b/Dockerfile index fefbc74..74c9251 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,6 @@ COPY src ./src RUN --mount=type=cache,target=/root/.gradle \ ./gradlew bootJar --no-daemon - FROM eclipse-temurin:21-jdk-alpine RUN apk add --no-cache ffmpeg curl diff --git a/README.md b/README.md index 5dd5358..7917402 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST --- +## Docker + +### Run with Docker Compose +```bash +docker compose up -d --build +``` +or use sh/ps1 scripts +--- + ## Configuration ### Essential Environment Variables @@ -151,9 +160,14 @@ Run tests with: ```bash ./gradlew test ``` -- Uses separate in-memory H2 database -- External services (OpenAI) are mocked -- Test coverage reports: `build/reports/tests` +### Notes +- Uses separate in-memory H2 database via test profiles +- Real OpenAI tests are tagged `openAI-api` and excluded from default `test` +- Run real-API tests explicitly: + ```bash + ./gradlew testOpenAI + ``` +- Real-API tests use `application-test-real.properties` and require valid `CHATGPT_API_KEY` ### Postman Collection @@ -176,3 +190,4 @@ MIT License - see [LICENSE](LICENSE.txt) file ## Contributors - [Dmitry Rusanov](https://github.com/DimaRus05) - [Mikhail Minaev](https://github.com/minmise) +- [Usatov Pavel](https://github.com/UsatovPavel) \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/controller/ChatGPTController.java b/src/main/java/com/smartcalendar/controller/ChatGPTController.java index 79b46c9..c59b061 100644 --- a/src/main/java/com/smartcalendar/controller/ChatGPTController.java +++ b/src/main/java/com/smartcalendar/controller/ChatGPTController.java @@ -1,10 +1,14 @@ package com.smartcalendar.controller; +import com.smartcalendar.model.Event; +import com.smartcalendar.service.AudioProcessingService; import com.smartcalendar.service.ChatGPTService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -14,6 +18,7 @@ public class ChatGPTController { private final ChatGPTService chatGPTService; + private final AudioProcessingService audioProcessingService; @PostMapping("/ask") public ResponseEntity askChatGPT(@RequestBody Map requestBody) { @@ -56,4 +61,38 @@ public ResponseEntity generateEntities(@RequestBody Map reque return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } } + @PostMapping("{userId}/generate/suggestions") + public ResponseEntity> generateSuggestions(@PathVariable Long userId, @RequestBody Map body) { + try { + String query = body.get("query"); + + List result = chatGPTService.generateSuggestions(userId, query); + return ResponseEntity.ok(Map.of("events", result)); + + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + @PostMapping("{userId}/generate/suggestions/audio") + public ResponseEntity> generateSuggestionsFromAudio( + @PathVariable Long userId, + @RequestParam("file") MultipartFile audioFile) { + String query; + try { + query = audioProcessingService.transcribeAudio(audioFile); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("error", "Failed to transcribe: "+e.getMessage())); + } + try{ + List result = chatGPTService.generateSuggestions(userId, query); + + return ResponseEntity.ok(Map.of( + "query", query, + "events", result + )); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Event.java b/src/main/java/com/smartcalendar/model/Event.java index 9cf3d4e..d8a6399 100644 --- a/src/main/java/com/smartcalendar/model/Event.java +++ b/src/main/java/com/smartcalendar/model/Event.java @@ -22,6 +22,15 @@ @NoArgsConstructor @AllArgsConstructor public class Event { + static public class Interval { + public final LocalDateTime start; + public final LocalDateTime end; + + public Interval(LocalDateTime start, LocalDateTime end) { + this.start = start; + this.end = end; + } + } @Id //@GeneratedValue(strategy = GenerationType.UUID) private UUID id; @@ -39,7 +48,7 @@ public class Event { private LocalDateTime end; @Column(name = "event_location") - private String location; + private String location = ""; @Enumerated(EnumType.STRING) private EventType type; @@ -80,16 +89,19 @@ public class Event { ) @JsonIgnore private List participants = new ArrayList<>(); - - public LocalDateTime getEnd() { - return end; - } - - public LocalDateTime getStart() { - return start; - } - - public List getTags() { - return tags; + public Event(Event other) { + this.id = other.id != null ? other.id : UUID.randomUUID(); + this.title = other.title; + this.description = other.description; + this.start = other.start; + this.end = other.end; + this.location = other.location != null ? other.location : ""; + this.type = other.type != null ? other.type : EventType.COMMON; + this.organizer = other.organizer; + this.creationTime = other.creationTime != null ? other.creationTime : LocalDateTime.now(); + this.completed = other.completed; + this.isShared = other.isShared; + this.invitees = other.invitees != null ? new ArrayList<>(other.invitees) : new ArrayList<>(); + this.participants = other.participants != null ? new ArrayList<>(other.participants) : new ArrayList<>(); } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/FormatterUtils.java b/src/main/java/com/smartcalendar/model/FormatterUtils.java new file mode 100644 index 0000000..98e0202 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/FormatterUtils.java @@ -0,0 +1,57 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static java.util.Map.entry; + +@Component +public class FormatterUtils { + private final ObjectMapper objectMapper; + private static final Logger logger = LoggerFactory.getLogger(FormatterUtils.class); + + public FormatterUtils(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String convertEventsToJson(List events) { + try { + List> simplifiedEvents = events.stream() + .map(e -> Map.ofEntries( + entry("title", Objects.toString(e.getTitle(), "")), + entry("description", Objects.toString(e.getDescription(), "")), + entry("start", e.getStart() != null ? e.getStart().toString() : ""), + entry("end", e.getEnd() != null ? e.getEnd().toString() : ""), + entry("location", Objects.toString(e.getLocation(), "")), + entry("type", e.getType() != null ? e.getType().toString() : "COMMON") + )) + .toList(); + return objectMapper.writeValueAsString(simplifiedEvents); + } catch (Exception ex) { + logger.error("Failed to convert events to JSON", ex); + return "[]"; + } + } + public String convertDayIntervalsToJson(LocalDate date, List intervals){ + try { + List> simplifiedIntervals = intervals.stream() + .map(e -> Map.ofEntries( + entry("date", date.toString()), + entry("start", Objects.toString(e.start, "")), + entry("end", Objects.toString(e.end, "")) + )) + .toList(); + return objectMapper.writeValueAsString(simplifiedIntervals); + } catch (Exception ex) { + logger.error("Failed to convert events to JSON", ex); + return "[]"; + } + } +} diff --git a/src/main/java/com/smartcalendar/repository/EventRepository.java b/src/main/java/com/smartcalendar/repository/EventRepository.java index 312c2d5..9f134c2 100644 --- a/src/main/java/com/smartcalendar/repository/EventRepository.java +++ b/src/main/java/com/smartcalendar/repository/EventRepository.java @@ -5,12 +5,20 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @Repository public interface EventRepository extends JpaRepository { List findByOrganizerId(Long organizerId); + @Query("SELECT e FROM Event e " + + "LEFT JOIN e.participants p " + + "WHERE (e.organizer.id = :userId OR p.id = :userId) " + + "AND e.start BETWEEN :startOfDay AND :endOfDay") + List findByUserIdAndDate(Long userId, + LocalDateTime startOfDay, + LocalDateTime endOfDay); @Query("SELECT e FROM Event e WHERE LOWER(e.location) = LOWER(:location) ORDER BY e.end ASC") List findByLocationIgnoreCase(String location); @Query("SELECT e FROM Event e WHERE LOWER(e.location) = LOWER(:location) AND :userId IS NOT NULL " + diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index 1067be8..2d2def8 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -6,8 +6,11 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.smartcalendar.model.Event; import com.smartcalendar.model.EventType; +import com.smartcalendar.model.FormatterUtils; +import com.smartcalendar.model.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -16,14 +19,23 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.*; +import java.util.stream.Collectors; @Service public class ChatGPTService { - + @Autowired + private EventDistributorService eventDistributorService; private static final Logger logger = LoggerFactory.getLogger(ChatGPTService.class); private final ObjectMapper objectMapper; + @Autowired + private FormatterUtils formatterUtils; + + private final UserService userService; + @Value("${chatgpt.api.url}") private String apiUrl; @@ -32,7 +44,8 @@ public class ChatGPTService { private final WebClient webClient = WebClient.builder().build(); - public ChatGPTService() { + public ChatGPTService(UserService userService) { + this.userService = userService; this.objectMapper = new ObjectMapper(); this.objectMapper.registerModule(new JavaTimeModule()); } @@ -83,9 +96,107 @@ public String askChatGPT(String question, String model) { } } - public Map> generateEvents(String userQuery) { + public List findDates(String userQuery) { + logger.info("Find dates for query: {}", userQuery); + + String prompt = "Today is " + LocalDate.now()+ + " Based on the user's query: \"" + userQuery + "\"," + + " create a JSON array of ISO 8601 dates (YYYY-MM-DD) representing the days the user refers to or intends. " + + "Respond STRICTLY with a JSON array, e.g. [\"2025-08-22\",\"2025-08-23\"]. " + + "If there are no dates, return JSON array of today date . Do not include any extra text."; + + String response = askChatGPT(prompt, "gpt-3.5-turbo"); + if (response == null) { + logger.warn("ChatGPT returned null response for findDates"); + return List.of(); + } + + List result = new ArrayList<>(); + try { + List dateStrings = objectMapper.readValue(response, new TypeReference<>() { + }); + for (String s : dateStrings) { + if (s == null) continue; + try { + LocalDate d = LocalDate.parse(s.trim(), DateTimeFormatter.ISO_LOCAL_DATE); + result.add(d); + } catch (DateTimeParseException ex) { + logger.warn("Skipping unparsable date string from JSON array: '{}'", s); + } + } + return result.stream().distinct().toList(); + } catch (Exception e) { + logger.info("Failed extract dates from query. Response: {}", response); + } + return List.of(); + } + public List generateSuggestions(Long userId, String query) { + List datesList = findDates(query); + Map> intervalsByDate = new HashMap<>(); + StringBuilder intervalsInfo = new StringBuilder(); + for (LocalDate date: datesList){ + List dateIntervals = eventDistributorService.getFreeSlots(userService.findEventsByUserIdAndDate(userId, date), date); + intervalsInfo.append(formatterUtils.convertDayIntervalsToJson(date, dateIntervals)); + intervalsByDate.put(date, dateIntervals); + } + + Map> chatResponse = generateEventsWithTaskInfo(query, intervalsInfo.toString()); + Map> generatedByDate = convertToEntities(chatResponse) + .stream() + .filter(e -> e instanceof Event) + .map(e -> (Event) e) + .collect(Collectors.groupingBy(e -> e.getStart().toLocalDate())); + + + List finalPlaced = new ArrayList<>(); + User organizer = userService.findUserById(userId); + + for (LocalDate date : datesList) { + List freeSlots = intervalsByDate.getOrDefault(date, List.of()); + List dayGenerated = generatedByDate.getOrDefault(date, List.of()); + + finalPlaced.addAll(eventDistributorService.placeEvents(dayGenerated, freeSlots, organizer, date)); + } + return finalPlaced; + } + + public Map> generateEventsWithTaskInfo(String userQuery, String intervalsInfo) { logger.info("Generating events for query: {}", userQuery); + String prompt = "Based on the user's query and user's free intervals in that days generate a list of events. "+ + "USER_QUERY:\n"+ userQuery+ "\n " + + "USER FREE INTERVALS: \n "+ intervalsInfo + "\n " + + "Respond strictly in JSON format with the following structure: " + + "{ \"events\": [{ " + + "\"title\": \"string\", " + + "\"description\": \"string\", " + + "\"start\": \"ISO 8601 datetime\", " + + "\"end\": \"ISO 8601 datetime\", " + + "\"location\": \"string\", " + + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + + "}], " + + "If the query does not require adding an event, return an empty list."+ + "If the user mentions a note, description, or additional information related to an event, include it in the 'description' field of the corresponding event, " + + "unless it is clearly a separate event. " + + "Do not include any additional text or explanation. The \"type\" field MUST be exactly one of: \"COMMON\", \"FITNESS\", \"STUDIES\", \"WORK\". \n" + + "If the event is about sports or training, always use \"FITNESS\". \n" + + "Do not invent other values (e.g., \"SPORT\")."+ + "Do not repeat or copy any of the provided user events. +\n" + + "Generate only new events that are required based on the user's query. "+ + "When generating new events, do not duplicate or overlap with existing events in USERS_EVENTS."; + + String response = askChatGPT(prompt, "gpt-3.5-turbo"); + + try { + return objectMapper.readValue(response, new TypeReference<>() {}); + } catch (Exception e) { + logger.error("Error parsing ChatGPT response into events", e); + throw new RuntimeException("Failed to parse ChatGPT response: " + e.getMessage()); + } + } + + public Map> generateEvents(String userQuery) { + logger.info("Generating events for query: {}", userQuery); String prompt = "Based on the user's query: \"" + userQuery + "\", generate a list of events . " + "If the user mentions a note, description, or additional information related to an event, include it in the 'description' field of the corresponding event, " + "unless it is clearly a separate task. " + diff --git a/src/main/java/com/smartcalendar/service/EventDistributorService.java b/src/main/java/com/smartcalendar/service/EventDistributorService.java new file mode 100644 index 0000000..16fae43 --- /dev/null +++ b/src/main/java/com/smartcalendar/service/EventDistributorService.java @@ -0,0 +1,125 @@ +package com.smartcalendar.service; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import com.smartcalendar.model.Event; +import com.smartcalendar.model.EventType; +import com.smartcalendar.model.User; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +@Service +public class EventDistributorService { + private final Duration dayBegin = Duration.ofHours(6); + public LocalDateTime getDayBegin(LocalDate date){ + return date.atStartOfDay().plusHours(dayBegin.toHoursPart()).plusMinutes(dayBegin.toMinutesPart()); + } + public Duration defaultDuration(EventType type) { + return //switch (String.valueOf(type)) { + switch (type) { + case STUDIES-> Duration.ofHours(2); + case FITNESS -> Duration.ofHours(1); + case WORK -> Duration.ofHours(8); + default -> Duration.ofHours(1); + }; + } + + + List getFreeSlots(List events, LocalDate date) { + LocalDateTime dayStart = getDayBegin(date); + LocalDateTime dayEnd = date.plusDays(1).atStartOfDay(); + events = new ArrayList<>(events); + events.sort(Comparator.comparing(Event::getStart)); + List free = new ArrayList<>(); + LocalDateTime current = dayStart; + + for (Event e : events) { + if (e.getStart().isAfter(current)) { + free.add(new Event.Interval(current, e.getStart())); + } + current = e.getEnd().isAfter(current) ? e.getEnd() : current; + } + + if (current.isBefore(dayEnd)) { + free.add(new Event.Interval(current, dayEnd)); + } + return free; + } + /** changes Id - don't hope Id from GPT + * */ + List normalizeEvents(List events, LocalDate day) { + List normalized = new ArrayList<>(); + for (Event e : events) { + Event normalizedEvent = new Event(e); + + LocalDateTime start = e.getStart() != null ? e.getStart() : day.atStartOfDay(); + LocalDateTime end = e.getEnd() != null ? e.getEnd() : start.plus(defaultDuration(e.getType())); + + normalizedEvent.setStart(start); + normalizedEvent.setEnd(end); + + normalized.add(normalizedEvent); + } + return normalized; + } + + /** traverse in linear, if event not in free slots set default time and place in nearest later slot + * */ + List placeEvents(List generated, List freeSlots, User organizer, LocalDate date) { + List placed = new ArrayList<>(); + generated = new ArrayList<>(normalizeEvents(generated, date)); + generated.sort(Comparator.comparing(Event::getStart)); + List mutableSlots = new ArrayList<>(freeSlots); + + int i = 0, j = 0; + while (i < generated.size() && j < mutableSlots.size()) { + Event generatedEvent = generated.get(i); + Event.Interval slot = mutableSlots.get(j); + + LocalDateTime desiredStart = generatedEvent.getStart(); + Duration duration = defaultDuration(generatedEvent.getType()); + LocalDateTime desiredEnd = generatedEvent.getEnd(); + if (desiredStart.isBefore(slot.start)) { + desiredStart = slot.start; + desiredEnd = desiredStart.plus(duration); + } + if (desiredEnd.isAfter(slot.end)) { + j++; + continue; + } + Event newEvent = new Event(generatedEvent); + newEvent.setStart(desiredStart); + newEvent.setEnd(desiredEnd); + placed.add(newEvent); + + i++; + if (desiredEnd.equals(slot.end)) { + j++; + } else { + mutableSlots.set(j, new Event.Interval(desiredEnd, slot.end)); + } + } + LocalDateTime lastEnd = mutableSlots.isEmpty() ? LocalDateTime.now() : mutableSlots.getLast().end; + while (i < generated.size()) { + Event g = generated.get(i++); + Duration duration = defaultDuration(g.getType()); + + Event newEvent = new Event(); + newEvent.setId(UUID.randomUUID()); + newEvent.setTitle(g.getTitle()); + newEvent.setDescription(g.getDescription()); + newEvent.setStart(lastEnd); + newEvent.setEnd(lastEnd.plus(duration)); + newEvent.setLocation(g.getLocation() != null ? g.getLocation() : ""); + newEvent.setType(g.getType()); + newEvent.setOrganizer(organizer); + placed.add(newEvent); + lastEnd = newEvent.getEnd(); + } + return placed; + } +} diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 0362e4f..99015a0 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -20,6 +20,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; import java.time.format.DateTimeFormatter; @@ -62,6 +64,13 @@ public List findTasksByUserId(Long userId) { return taskRepository.findByUserId(userId); } + @Transactional(readOnly = true) + public List findEventsByUserIdAndDate(Long userId, LocalDate date) { + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = date.atTime(23, 59, 59); + + return eventRepository.findByUserIdAndDate(userId, startOfDay, endOfDay); + } public Task createTask(Task task) { return taskRepository.save(task); } diff --git a/src/main/resources/application-test-real.properties b/src/main/resources/application-test-real.properties new file mode 100644 index 0000000..b0eb722 --- /dev/null +++ b/src/main/resources/application-test-real.properties @@ -0,0 +1,16 @@ +spring.main.allow-bean-definition-overriding=true +spring.jpa.hibernate.ddl-auto=create-drop +spring.datasource.url=jdbc:h2:mem:realtestdb +spring.sql.init.mode=never + +# реальные настройки OpenAI API +chatgpt.api.url=https://api.openai.com/v1/chat/completions +whisper.api.url=https://api.openai.com/v1/audio/transcriptions +chatgpt.api.key=${CHATGPT_API_KEY} +JWT_SECRET=${JWT_SECRET} + +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 636a284..21d8424 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,6 +58,7 @@ springdoc.swagger-ui.oauth.use-pkce-with-authorization-code-grant=true server.port=8080 server.address=0.0.0.0 spring.main.banner-mode=off +logging.level.org.springframework.boot.autoconfigure=ERROR chatgpt.api.url=https://api.openai.com/v1/chat/completions whisper.api.url=https://api.openai.com/v1/audio/transcriptions @@ -74,3 +75,4 @@ spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.from=noreply@ttsc.com +spring.jpa.hibernate.ddl-auto=update diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java index 89ca6d2..51a7dec 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java @@ -19,7 +19,6 @@ import static org.junit.jupiter.api.Assertions.*; @Tag("openAI-api") -//@Disabled("call real OpenAI API") @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {SmartCalendarApplication.class, TestSecurityConfig.class} // подмешиваем TestSecurityConfig diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java index 2a2b4f0..0071441 100644 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java @@ -1,5 +1,6 @@ package com.smartcalendar.controller; +import com.smartcalendar.model.Event; import com.smartcalendar.service.ChatGPTService; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -17,6 +18,7 @@ import java.util.Map; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -80,4 +82,19 @@ void testGenerateEntities_Error() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").exists()); } + @Test + @WithMockUser + void testGenerateSuggestions() throws Exception { + Event mockEvent = new Event(); + mockEvent.setTitle("Study session"); + + Mockito.when(chatGPTService.generateSuggestions(eq(1L), eq("study"))) + .thenReturn(List.of(mockEvent)); + + mockMvc.perform(post("/api/chatgpt/1/generate/suggestions") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"query\":\"study\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.events[0].title").value("Study session")); + } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTRestTemplateTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTRestTemplateTest.java new file mode 100644 index 0000000..5198edf --- /dev/null +++ b/src/test/java/com/smartcalendar/controller/ChatGPTRestTemplateTest.java @@ -0,0 +1,83 @@ +package com.smartcalendar.controller; + +import com.smartcalendar.SmartCalendarApplication; +import com.smartcalendar.config.TestSecurityConfig; +import com.smartcalendar.model.Event; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("openAI-api") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = {SmartCalendarApplication.class, TestSecurityConfig.class} +) +@ActiveProfiles("test-real") +public class ChatGPTRestTemplateTest { + + @Autowired + private org.springframework.boot.test.web.client.TestRestTemplate restTemplate; + + ResponseEntity> sendAudioForSuggestions(Long userId, String filename) throws Exception { + ClassPathResource audioFile = new ClassPathResource(filename); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new org.springframework.core.io.FileSystemResource(audioFile.getFile())); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + HttpEntity> requestEntity = + new HttpEntity<>(body, headers); + + return restTemplate.exchange( + "/api/chatgpt/" + userId + "/generate/suggestions/audio", + HttpMethod.POST, + requestEntity, + new ParameterizedTypeReference>() {} + ); + } + @Test + void testStudyEventFromAudio() throws Exception { + ResponseEntity> response = sendAudioForSuggestions(1L, "DescriptionIneedStudy.mp3"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + + Map body = response.getBody(); + assertThat(body).containsKeys("query", "events"); + + String query = body.get("query").toString(); + assertThat(query.toLowerCase()).contains("study"); + + List> events = (List>) body.get("events"); + assertThat(events).isNotEmpty(); + + Map event = events.get(0); + assertThat(event.get("title").toString().toLowerCase()).contains("study"); + } + @Test + void testEmptyAudio() throws Exception { + ResponseEntity> response = sendAudioForSuggestions(1L, "empty.mp3"); + assertThat(response.getBody()).isNotNull(); + if (response.getStatusCode()==(HttpStatus.OK)){ + Map body = response.getBody(); + List> events = (List>) body.get("events"); + assertThat(events).isEmpty(); + } else { + assertThat(response.getBody()).containsKey("error"); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java index 18d7f36..004802c 100644 --- a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java +++ b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java @@ -1,21 +1,47 @@ package com.smartcalendar.service; +import com.smartcalendar.model.Event; +import com.smartcalendar.model.EventType; +import com.smartcalendar.model.FormatterUtils; +import com.smartcalendar.model.User; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.mockito.ArgumentMatchers.anyString; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; +import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import org.springframework.test.context.ActiveProfiles; -@ActiveProfiles("h2") +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) class ChatGPTServiceTest { + private Event createEvent(String title, LocalDateTime start, LocalDateTime end, EventType type, String description) { + Event e = new Event(); + e.setId(UUID.randomUUID()); + e.setTitle(title); + e.setStart(start); + e.setEnd(end); + e.setType(type); + e.setDescription(description); + return e; + } + @Mock + private UserService userService; + @Mock + private EventDistributorService eventDistributorService; + @Mock + private FormatterUtils formatterUtils; + @InjectMocks private ChatGPTService chatGPTService; @@ -50,4 +76,82 @@ void testGenerateEvents_ValidJson() { assertTrue(result.containsKey("events")); assertTrue(result.containsKey("tasks")); } + @Test + void findDates(){ + ChatGPTService spyService = spy(chatGPTService); + doReturn("[\"2025-08-22\",\"2025-08-23\"]").when(spyService).askChatGPT(anyString(), anyString()); + List dates = spyService.findDates("I want to create tasks on 22 and 23 August 2025"); + assertNotNull(dates); + assertEquals(2, dates.size()); + assertTrue(dates.contains(LocalDate.of(2025, 8, 22))); + assertTrue(dates.contains(LocalDate.of(2025, 8, 23))); + } + + @Test + void testGenerateSuggestions() { + Long userId = 1L; + String query = "Schedule study session today"; + LocalDate today = LocalDate.of(2025, 8, 25); + + ChatGPTService spyService = Mockito.spy(chatGPTService); + + doReturn("[\"2025-08-25\"]").when(spyService).askChatGPT(anyString(), anyString()); + + Event.Interval slot = new Event.Interval( + today.atTime(20, 0), + today.atTime(23, 59) + ); + when(eventDistributorService.getFreeSlots(anyList(), eq(today))) + .thenReturn(List.of(slot)); + + when(formatterUtils.convertDayIntervalsToJson(eq(today), anyList())) + .thenReturn("[{\"date\":\"2025-08-25\",\"start\":\"2025-08-25T20:00\",\"end\":\"2025-08-25T23:59\"}]"); + + Map> chatResponse = Map.of( + "events", List.of( + Map.of( + "title", "Study Session", + "description", "Scheduled study session today", + "start", "2025-08-25T20:00", + "end", "2025-08-25T23:59", + "location", "", + "type", "STUDIES" + ) + ) + ); + doReturn(chatResponse).when(spyService).generateEventsWithTaskInfo(anyString(), anyString()); + + User mockUser = new User(); + mockUser.setId(userId); + mockUser.setEmail("test@test.com"); + + Event placedEvent = new Event(); + placedEvent.setId(UUID.randomUUID()); + placedEvent.setTitle("Study Session"); + placedEvent.setDescription("Scheduled study session today"); + placedEvent.setStart(today.atTime(20, 0)); + placedEvent.setEnd(today.atTime(23, 59)); + placedEvent.setType(EventType.STUDIES); + + when(eventDistributorService.placeEvents( + anyList(), + anyList(), + isNull(), + eq(today) + )).thenReturn(List.of(placedEvent)); + + List result = spyService.generateSuggestions(userId, query); + + assertNotNull(result); + assertEquals(1, result.size()); + Event e = result.get(0); + assertEquals("Study Session", e.getTitle()); + assertEquals(EventType.STUDIES, e.getType()); + assertEquals(today.atTime(20, 0), e.getStart()); + assertEquals(today.atTime(23, 59), e.getEnd()); + + verify(eventDistributorService).placeEvents(anyList(), + anyList(), + isNull(), eq(today)); + } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/EventDistributorServiceTest.java b/src/test/java/com/smartcalendar/service/EventDistributorServiceTest.java new file mode 100644 index 0000000..cc8738e --- /dev/null +++ b/src/test/java/com/smartcalendar/service/EventDistributorServiceTest.java @@ -0,0 +1,73 @@ +package com.smartcalendar.service; + +import com.smartcalendar.model.Event; +import com.smartcalendar.model.EventType; +import com.smartcalendar.model.User; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class EventDistributorServiceTest { + private final EventDistributorService service = new EventDistributorService(); + + private User organizer = new User(); + private Event createEvent(String title, LocalDateTime start, LocalDateTime end, EventType type) { + Event e = new Event(); + e.setId(UUID.randomUUID()); + e.setTitle(title); + e.setStart(start); + e.setEnd(end); + e.setType(type); + e.setOrganizer(organizer); + return e; + } + LocalDate date = LocalDate.now(); + Event firstEvent = createEvent("Math", date.atTime(9, 0), date.atTime(10, 0), EventType.STUDIES); + Event secondEvent = createEvent("Pool", date.atTime(11, 30), date.atTime(13, 0), EventType.FITNESS); + + @Test + void testGetFreeSlots() { + + List freeSlotsNoEvents = service.getFreeSlots(List.of(), date); + LocalDateTime dayBegin = service.getDayBegin(date); + assertEquals(1, freeSlotsNoEvents.size()); + assertEquals(dayBegin, freeSlotsNoEvents.getFirst().start); + assertEquals(date.plusDays(1).atStartOfDay(), freeSlotsNoEvents.getFirst().end); + + List freeSlots = service.getFreeSlots(List.of(secondEvent, firstEvent), date); + assertEquals(3, freeSlots.size()); + assertEquals(firstEvent.getStart(), freeSlots.getFirst().end); + assertEquals(secondEvent.getEnd(), freeSlots.getLast().start); + } + + @Test + void testPlaceEvents() { + List slots = List.of( + new Event.Interval(date.atTime(8, 0), date.atTime(12, 0)), + new Event.Interval(date.atTime(13, 0), date.atTime(15, 0)) + ); + + Event thirdEvent = createEvent("Gym", date.atTime(14, 30), date.atTime(15, 0), EventType.FITNESS); + + //first ok, second in interval, third ok + List placed = service.placeEvents(List.of(firstEvent, secondEvent, thirdEvent), slots, organizer, date); + + placed.getFirst().setId(firstEvent.getId());//id and creation time will changed + placed.getFirst().setCreationTime(firstEvent.getCreationTime()); + placed.getLast().setId(thirdEvent.getId()); + placed.getLast().setCreationTime(thirdEvent.getCreationTime()); + + assertEquals(3, placed.size()); + assertEquals(firstEvent, placed.getFirst()); + assertEquals(slots.getLast().start.plus(service.defaultDuration(secondEvent.getType())), + placed.get(1).getEnd()); + assertEquals(thirdEvent, placed.get(2)); + } +} diff --git a/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java index 7a5726e..ba3deae 100644 --- a/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java +++ b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java @@ -1,17 +1,18 @@ package com.smartcalendar.service; +import com.smartcalendar.model.Event; +import com.smartcalendar.model.FormatterUtils; +import com.smartcalendar.model.User; +import com.smartcalendar.repository.UserRepository; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; - -import static org.hibernate.validator.internal.util.Contracts.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import org.junit.jupiter.api.Disabled; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -21,6 +22,9 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.ActiveProfiles; +import static org.hibernate.validator.internal.util.Contracts.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; + @Tag("openAI-api") @Nested @SpringBootTest @@ -31,11 +35,35 @@ class ServiceRealApiTest { @Autowired private AudioProcessingService audioProcessingService; + @Autowired + private UserRepository userRepository; + + @Autowired + private FormatterUtils formatterUtils; + @Autowired private ChatGPTService chatGPTService; + private final User testUser = new User(); + private final Event eventToday = new Event(); + private final LocalDate todayDate = LocalDate.now(); + + @BeforeEach + void setup() { + testUser.setId(1L); + testUser.setUsername("testUser"); + testUser.setEmail("test@example.com"); + testUser.setPassword("12345"); + userRepository.save(testUser); + eventToday.setTitle("Morning Meeting"); + eventToday.setId(UUID.randomUUID()); + eventToday.setStart(todayDate.atTime(0, 0)); + eventToday.setEnd(todayDate.atTime(23, 0)); + eventToday.setOrganizer(testUser); + } + MockMultipartFile convertFileToMultipart(String filename) throws IOException { - File audio = new File("src/test/resources/"+filename); + File audio = new File("src/test/resources/" + filename); byte[] content = Files.readAllBytes(audio.toPath()); return new MockMultipartFile( @@ -47,7 +75,6 @@ MockMultipartFile convertFileToMultipart(String filename) throws IOException { } @Test - @Disabled("call real OpenAI API") void testRealAudioTranscription() { MockMultipartFile multipartFile = assertDoesNotThrow(() -> convertFileToMultipart("DescriptionIneedStudy.mp3")); @@ -57,7 +84,6 @@ void testRealAudioTranscription() { } @Test - @Disabled("call real OpenAI API") void testProcessTranscript_RealRequest() { String transcript = "Create event type study with exactly this description: I need to study"; @@ -71,4 +97,58 @@ void testProcessTranscript_RealRequest() { assertEquals("I need to study", firstEvent.get("description")); System.out.println("Processed events: " + result); } + + @Test + void testFindDates_RealRequest() { + String userQuery = "I want to create tasks on 22nd and 23rd August 2025"; + List dates = chatGPTService.findDates(userQuery); + assertNotNull(dates); + assertFalse(dates.isEmpty()); + assertTrue(dates.stream().anyMatch(d -> d.toString().equals("2025-08-22"))); + assertTrue(dates.stream().anyMatch(d -> d.toString().equals("2025-08-23"))); + } + + @Test + void testGenerateEventsWithTaskInfo_RealRequest() { + List slots = List.of( + new Event.Interval(todayDate.atTime(20, 0), todayDate.atTime(23, 59)) + ); + String intervalsInfo = formatterUtils.convertDayIntervalsToJson(todayDate, slots); + + String userQuery = "Schedule study session today"; + Map> result = chatGPTService.generateEventsWithTaskInfo(userQuery, intervalsInfo); + + assertNotNull(result); + assertTrue(result.containsKey("events")); + + List events = result.get("events"); + assertFalse(events.isEmpty()); + + Map firstEvent = (Map) events.get(0); + String startStr = (String) firstEvent.get("start"); + assertNotNull(startStr); + + LocalDateTime startDateTime = LocalDateTime.parse(startStr); + + assertTrue(startDateTime.getHour() >= 20, "Event should start after 20:00"); + System.out.println("Generated events: " + events); + } + + @Test + void testGenerateSuggestions_RealRequest() { + Long userId = testUser.getId(); + String query = "Schedule study session today"; + + List result = chatGPTService.generateSuggestions(userId, query); + + assertNotNull(result); + assertFalse(result.isEmpty()); + + Event first = result.getFirst(); + assertNotNull(first.getTitle()); + assertNotNull(first.getStart()); + assertNotNull(first.getEnd()); + + System.out.println("AI generated event: " + first.getTitle() + " " + first.getStart() + " - " + first.getEnd()); + } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/UserServiceIntegrationTest.java b/src/test/java/com/smartcalendar/service/UserServiceIntegrationTest.java new file mode 100644 index 0000000..8800aa1 --- /dev/null +++ b/src/test/java/com/smartcalendar/service/UserServiceIntegrationTest.java @@ -0,0 +1,104 @@ +package com.smartcalendar.service; + +import com.smartcalendar.SmartCalendarApplication; +import com.smartcalendar.config.TestSecurityConfig; +import com.smartcalendar.model.Event; +import com.smartcalendar.model.User; +import com.smartcalendar.repository.EventRepository; +import com.smartcalendar.repository.UserRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest( + classes = {SmartCalendarApplication.class, TestSecurityConfig.class} +) +@ActiveProfiles("test") +class UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EventRepository eventRepository; + + private User testUser; + private User partisipantUser; + private final Event eventToday = new Event(); + private final Event eventTodayEvening = new Event(); + private final Event eventTomorrow = new Event(); + private final LocalDate date = LocalDate.now(); + @BeforeEach + void setup() { + testUser = new User(); + testUser.setId(1L); + testUser.setUsername("testUser"); + testUser.setEmail("test@example.com"); + testUser.setPassword("12345"); + userRepository.save(testUser); + partisipantUser = new User(); + partisipantUser.setId(2L); + partisipantUser.setUsername("invited"); + partisipantUser.setEmail("invited@example.com"); + partisipantUser.setPassword("12345"); + userRepository.save(partisipantUser); + + eventToday.setTitle("Morning Meeting"); + eventToday.setId(UUID.randomUUID()); + eventToday.setStart(date.atTime(9, 0)); + eventToday.setEnd(date.atTime(10, 0)); + eventToday.setOrganizer(testUser); + + eventTodayEvening.setId(UUID.randomUUID()); + eventTodayEvening.setTitle("Workshop"); + eventTodayEvening.setStart(date.atTime(15, 0)); + eventTodayEvening.setEnd(date.atTime(17, 0)); + eventTodayEvening.setOrganizer(testUser); + + eventTomorrow.setTitle("Workshop"); + eventTomorrow.setId(UUID.randomUUID()); + eventTomorrow.setStart(date.plusDays(1).atTime(15, 0)); + eventTomorrow.setEnd(date.plusDays(1).atTime(17, 0)); + eventTomorrow.setOrganizer(testUser); + } + + @Transactional + @Test + void testSaveAndFindEventsByUserIdAndDate() { + userService.saveEvent(eventToday); + userService.saveEvent(eventTodayEvening); + userService.saveEvent(eventTomorrow); + + List events = userService.findEventsByUserIdAndDate(testUser.getId(), date); + + assertEquals(2, events.size()); + assertTrue(events.stream().anyMatch(e -> e.getTitle().equals("Morning Meeting"))); + assertTrue(events.stream().anyMatch(e -> e.getTitle().equals("Workshop"))); + List eventsNull = userService.findEventsByUserIdAndDate(testUser.getId(), date.minusDays(1)); + assertTrue(eventsNull.isEmpty()); + } + + @Transactional + @Test + void testFindEventsByUserIdAndDate_Participants() { + userService.saveEvent(eventToday); + + eventToday.getParticipants().add(partisipantUser); + userService.saveEvent(eventToday); + + List events = userService.findEventsByUserIdAndDate(partisipantUser.getId(), date); + assertTrue(events.stream().anyMatch(e -> e.getTitle().equals("Morning Meeting"))); + } +} \ No newline at end of file