diff --git a/.gitignore b/.gitignore index ca2bfc8..f99d6f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ - +tmp/* ### STS ### .apt_generated .classpath @@ -37,3 +37,11 @@ out/ ### Firebase Service Account ### timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json + +### Secrets ### +/src/main/resources/application-test-real.properties +.env + +### Internal usage ### +/internalDocs +out.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fefbc74 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder + +RUN apk add --no-cache bash + +WORKDIR /app + +COPY gradlew gradlew.bat settings.gradle build.gradle ./ +COPY gradle ./gradle +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 + +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar app.jar + +ENV JAVA_OPTS="" + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index c8ca27e..4923e65 100644 --- a/build.gradle +++ b/build.gradle @@ -55,9 +55,18 @@ dependencies { testImplementation 'org.mockito:mockito-junit-jupiter' testImplementation 'io.projectreactor:reactor-test' implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + excludeTags 'openAI-api' + } +} + +tasks.register('testOpenAI', Test) { + useJUnitPlatform { + includeTags 'openAI-api' + } } \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..14656f4 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:15 + container_name: smartcalendar_db + environment: + POSTGRES_DB: smartcalendar + POSTGRES_USER: smartuser + POSTGRES_PASSWORD: smartpass + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + + app: + build: + context: . + dockerfile: Dockerfile + container_name: smartcalendar_app + depends_on: + - postgres + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/smartcalendar + SPRING_DATASOURCE_USERNAME: smartuser + SPRING_DATASOURCE_PASSWORD: smartpass + JWT_SECRET: ${JWT_SECRET} + CHATGPT_API_KEY: ${CHATGPT_API_KEY} + networks: + - backend + +volumes: + postgres_data: + +networks: + backend: \ No newline at end of file diff --git a/restart-docker.ps1 b/restart-docker.ps1 new file mode 100644 index 0000000..648de7a --- /dev/null +++ b/restart-docker.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +Set-Location $PSScriptRoot + +.\gradlew.bat clean build +docker-compose down -v +docker-compose up --build -d \ No newline at end of file diff --git a/run-docker.ps1 b/run-docker.ps1 new file mode 100644 index 0000000..cda4327 --- /dev/null +++ b/run-docker.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = "Stop" + +Set-Location $PSScriptRoot + +.\gradlew.bat clean build +docker-compose up --build -d \ No newline at end of file diff --git a/run-docker.sh b/run-docker.sh new file mode 100644 index 0000000..ea26b91 --- /dev/null +++ b/run-docker.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +./gradlew clean build +docker-compose up --build -d postgres app diff --git a/src/main/java/com/smartcalendar/config/DataInitializer.java b/src/main/java/com/smartcalendar/config/DataInitializer.java new file mode 100644 index 0000000..223c74a --- /dev/null +++ b/src/main/java/com/smartcalendar/config/DataInitializer.java @@ -0,0 +1,30 @@ +package com.smartcalendar.config; + +import com.smartcalendar.model.User; +import com.smartcalendar.repository.UserRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DataInitializer { + + @Bean + CommandLineRunner initUsers(UserRepository userRepository) { + return args -> { + if (userRepository.count() == 0) { + User admin = new User(); + admin.setUsername("admin"); + admin.setEmail("admin@example.com"); + admin.setPassword("encoded_password"); // сюда поставь реальный зашифрованный пароль + userRepository.save(admin); + + User user = new User(); + user.setUsername("user"); + user.setEmail("user@example.com"); + user.setPassword("encoded_password"); + userRepository.save(user); + } + }; + } +} \ 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 3dfad32..79b46c9 100644 --- a/src/main/java/com/smartcalendar/controller/ChatGPTController.java +++ b/src/main/java/com/smartcalendar/controller/ChatGPTController.java @@ -26,7 +26,7 @@ public ResponseEntity askChatGPT(@RequestBody Map reques @PostMapping("/generate") public ResponseEntity>> generateEventsAndTasks(@RequestBody Map requestBody) { String userQuery = requestBody.get("query"); - Map> result = chatGPTService.generateEventsAndTasks(userQuery); + Map> result = chatGPTService.generateEvents(userQuery); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/smartcalendar/model/EventType.java b/src/main/java/com/smartcalendar/model/EventType.java index 5581f0f..f5917f9 100644 --- a/src/main/java/com/smartcalendar/model/EventType.java +++ b/src/main/java/com/smartcalendar/model/EventType.java @@ -1,5 +1,9 @@ package com.smartcalendar.model; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = EventTypeDeserializer.class) public enum EventType { COMMON, FITNESS, WORK, STUDIES } + diff --git a/src/main/java/com/smartcalendar/model/EventTypeDeserializer.java b/src/main/java/com/smartcalendar/model/EventTypeDeserializer.java new file mode 100644 index 0000000..9f0e692 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/EventTypeDeserializer.java @@ -0,0 +1,19 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; + +public class EventTypeDeserializer extends JsonDeserializer { + @Override + public EventType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getText().toUpperCase(); + try { + return EventType.valueOf(value); + } catch (IllegalArgumentException e) { + return EventType.COMMON; + } + } +} diff --git a/src/main/java/com/smartcalendar/service/AudioProcessingService.java b/src/main/java/com/smartcalendar/service/AudioProcessingService.java index 6139c75..a378298 100644 --- a/src/main/java/com/smartcalendar/service/AudioProcessingService.java +++ b/src/main/java/com/smartcalendar/service/AudioProcessingService.java @@ -1,41 +1,88 @@ package com.smartcalendar.service; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Service public class AudioProcessingService { @Value("${whisper.api.url}") private String whisperApiUrl; - + @Value("${gpt4o-mini-transcribe.api.url}") + private String transcribeApiUrl; @Value("${chatgpt.api.key}") private String apiKey; private final WebClient webClient = WebClient.builder().build(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private void convertToWav(Path input, Path output) throws IOException, InterruptedException { + String[] command = { + "ffmpeg", "-y", + "-i", input.toString(), + "-ar", "16000", + "-ac", "1", + "-c:a", "pcm_s16le", + output.toString() + }; + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + throw new RuntimeException("ffmpeg failed to convert audio, exit code: " + exitCode); + } + log.info("Converted file with ffmpeg: {}", output.toAbsolutePath()); + } public String transcribeAudio(MultipartFile file) { try { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", file.getResource()); - body.add("model", "whisper-1"); + Path uploadsDir = Paths.get(System.getProperty("user.dir"), "tmp", "uploads").toAbsolutePath(); + Files.createDirectories(uploadsDir); + + Path path = uploadsDir.resolve(Objects.requireNonNull(file.getOriginalFilename())); + file.transferTo(path.toFile()); + log.info("Saved uploaded file to: {}", path.toAbsolutePath()); + + //Path fixedPath = uploadsDir.resolve("fixed.wav"); + //convertToWav(path, fixedPath); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("file", new FileSystemResource(path)); + builder.part("model", "whisper-1"); String response = webClient.post() - .uri(whisperApiUrl) + .uri("https://api.openai.com/v1/audio/transcriptions") .header("Authorization", "Bearer " + apiKey) .contentType(MediaType.MULTIPART_FORM_DATA) - .bodyValue(body) + .body(BodyInserters.fromMultipartData(builder.build())) .retrieve() .bodyToMono(String.class) .block(); - return response; } catch (Exception e) { + log.info("Using API key starts with: {}", apiKey.substring(0, 10)); + log.error("Transcription request failed", e); throw new RuntimeException("Failed to transcribe audio: " + e.getMessage()); } } diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index 89a61f8..1067be8 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.smartcalendar.model.Event; import com.smartcalendar.model.EventType; -import com.smartcalendar.model.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -84,10 +83,10 @@ public String askChatGPT(String question, String model) { } } - public Map> generateEventsAndTasks(String userQuery) { - logger.info("Generating events and tasks for query: {}", userQuery); + 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 and tasks. " + + 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. " + "Respond strictly in JSON format with the following structure: " + @@ -99,21 +98,16 @@ public Map> generateEventsAndTasks(String userQuery) { "\"location\": \"string\", " + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + "}], " + - "\"tasks\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"completed\": false, " + - "\"dueDateTime\": \"ISO 8601 datetime\", " + - "\"allDay\": false " + - "}] } " + - "Do not include any additional text or explanation."; + "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\")."; 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 and tasks", e); + logger.error("Error parsing ChatGPT response into events", e); throw new RuntimeException("Failed to parse ChatGPT response: " + e.getMessage()); } } @@ -124,11 +118,17 @@ public List convertToEntities(Map> data) { List entities = new ArrayList<>(); List> events = (List>) data.get("events"); - List> tasks = (List>) data.get("tasks"); if (events != null) { for (Map eventData : events) { Event event = objectMapper.convertValue(eventData, Event.class); + event.setType(EventType.COMMON); + + if (eventData.get("type")!=null && Arrays.stream(EventType.values()).anyMatch(x->Objects.equals(x.toString(),eventData.get("type").toString()))) { + try { + event.setType(EventType.valueOf(eventData.get("type").toString())); + } catch (Exception ignored) {} + } if (event.getId() == null) { event.setId(UUID.randomUUID()); @@ -136,14 +136,10 @@ public List convertToEntities(Map> data) { if (event.getCreationTime() == null) { event.setCreationTime(LocalDateTime.now()); } - if (event.getType() == null && eventData.get("type") != null) { - try { - event.setType(EventType.valueOf(eventData.get("type").toString())); - } catch (Exception ignored) {} - } if (!event.isCompleted() && eventData.get("completed") != null) { event.setCompleted(Boolean.parseBoolean(eventData.get("completed").toString())); } + event.setShared(false); event.setInvitees(new ArrayList<>()); event.setParticipants(new ArrayList<>()); @@ -151,36 +147,14 @@ public List convertToEntities(Map> data) { } } - if (tasks != null) { - for (Map taskData : tasks) { - Task task = objectMapper.convertValue(taskData, Task.class); - - if (task.getId() == null) { - task.setId(UUID.randomUUID()); - } - if (task.getCreationTime() == null) { - task.setCreationTime(LocalDateTime.now()); - } - if (task.getAllDay() == null && taskData.get("allDay") != null) { - task.setAllDay(Boolean.parseBoolean(taskData.get("allDay").toString())); - } - if (task.getDueDateTime() == null && taskData.get("dueDate") != null) { - try { - LocalDate date = LocalDate.parse(taskData.get("dueDate").toString()); - task.setDueDateTime(date.atStartOfDay()); - } catch (Exception ignored) {} - } - entities.add(task); - } - } return entities; } public Map processTranscript(String transcript) { String today = LocalDate.now().toString(); - String prompt = "Today is " + today + ". Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events or tasks. " + - "If it is, generate a list of events and tasks strictly in JSON format with the following structure: " + + String prompt = "Today is " + today + ". Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events. " + + "If it is, generate a list of events strictly in JSON format with the following structure: " + "{ \"events\": [{ " + "\"title\": \"string\", " + "\"description\": \"string\", " + @@ -189,16 +163,9 @@ public Map processTranscript(String transcript) { "\"location\": \"string\", " + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + "}], " + - "\"tasks\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"completed\": false, " + - "\"dueDateTime\": \"ISO 8601 datetime\", " + - "\"allDay\": false " + - "}] } " + "If the transcript contains a note, description, or additional information about an event, include it in the 'description' field of the event, " + - "unless it is clearly a separate task. " + - "If the transcript is not related to events or tasks, respond with: { \"error\": \"Unrelated request\" }. " + + "If the transcript is not related to events, respond with: { \"error\": \"Unrelated request\" }. " + + "Treat any mention of a 'task' as an event. " + "Do not include any additional text or explanation."; String response = askChatGPT(prompt, "gpt-3.5-turbo"); @@ -211,9 +178,6 @@ public Map processTranscript(String transcript) { if (!result.containsKey("events")) { result.put("events", List.of()); } - if (!result.containsKey("tasks")) { - result.put("tasks", List.of()); - } return result; } catch (Exception e) { throw new RuntimeException("Failed to process ChatGPT response: " + e.getMessage()); diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 04ff572..54e44e1 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -13,4 +13,4 @@ spring.h2.console.settings.trace=true spring.h2.console.settings.web-allow-others=true spring.sql.init.mode=always spring.jpa.defer-datasource-initialization=true -spring.jpa.properties.hibernate.dialect= \ No newline at end of file +spring.jpa.properties.hibernate.dialect= diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9acbb95..636a284 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -19,7 +19,7 @@ spring.h2.console.settings.web-allow-others=true # =============================== # DATA INITIALIZATION # =============================== -spring.sql.init.mode=always +spring.sql.init.mode=never spring.jpa.defer-datasource-initialization=true spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect @@ -62,6 +62,7 @@ spring.main.banner-mode=off 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} +gpt4o-mini-transcribe.api.url = https://api.openai.com//v1/chat/completions # =============================== # SMTP @@ -72,4 +73,4 @@ spring.mail.username=${MAIL_ADDRESS} 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 \ No newline at end of file +spring.mail.from=noreply@ttsc.com diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql deleted file mode 100644 index 16d763c..0000000 --- a/src/main/resources/data.sql +++ /dev/null @@ -1,4 +0,0 @@ -INSERT INTO users (username, email, password) -VALUES -('admin', 'admin@example.com', 'encoded_password'), -('user', 'user@example.com', 'encoded_password'); \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 5818795..0000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,71 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, - username VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, - device_token VARCHAR(255) -); - -CREATE TABLE IF NOT EXISTS events ( - id UUID PRIMARY KEY, - title VARCHAR(255) NOT NULL, - description VARCHAR(255), - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - event_location VARCHAR(255) NOT NULL, - completed BOOLEAN NOT NULL, - is_shared BOOLEAN NOT NULL, - organizer_id BIGSERIAL NOT NULL, - FOREIGN KEY (organizer_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS tasks ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - description VARCHAR(255) NOT NULL, - is_completed BOOLEAN, - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS tags ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL -); - -CREATE TABLE IF NOT EXISTS group_chats ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (admin_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS group_messages ( - id BIGSERIAL PRIMARY KEY, - message_text VARCHAR(255) NOT NULL, - time_sent TIMESTAMP NOT NULL, - FOREIGN KEY (chat_id) REFERENCES group_chats (id), - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS private_chats ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id1) REFERENCES users (id), - FOREIGN KEY (user_id2) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS private_messages ( - id BIGSERIAL PRIMARY KEY, - message_text VARCHAR(255) NOT NULL, - time_sent TIMESTAMP NOT NULL, - FOREIGN KEY (chat_id) REFERENCES private_chats (id), - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS friendships ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id1) REFERENCES users (id), - FOREIGN KEY (user_id2) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS statistics ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id) REFERENCES users (id) -); \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java index 6be38ce..57e4e48 100644 --- a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java +++ b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java @@ -16,7 +16,7 @@ @TestConfiguration public class TestSecurityConfig { - @Bean + @Bean(name = "securityFilterChain") @Primary public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java index 99282cd..da5990b 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java @@ -1,32 +1,33 @@ package com.smartcalendar.controller; -import com.smartcalendar.service.AudioProcessingService; -import com.smartcalendar.service.ChatGPTService; +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; - -import java.util.List; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.smartcalendar.service.AudioProcessingService; +import com.smartcalendar.service.ChatGPTService; @SpringBootTest(properties = { "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" + "spring.security.enabled=false", + "spring.sql.init.mode=never" }) @ActiveProfiles("h2") @AutoConfigureMockMvc diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java new file mode 100644 index 0000000..89ca6d2 --- /dev/null +++ b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java @@ -0,0 +1,82 @@ +package com.smartcalendar.controller; + +import com.smartcalendar.SmartCalendarApplication; +import com.smartcalendar.config.TestSecurityConfig; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +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 java.util.function.Predicate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +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 +) +@ActiveProfiles("test-real") +public class AudioControllerRestTemplateTest { + + @Autowired + private org.springframework.boot.test.web.client.TestRestTemplate restTemplate; + + ResponseEntity testAudioSend(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); + + ResponseEntity response = restTemplate.postForEntity( + "/api/audio/process", requestEntity, Object.class + ); + return response; + } + void testSuccessfulAudioSend(String filename, Predicate> checker) throws Exception { + ResponseEntity response = testAudioSend(filename); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + ResponseEntity responseList = (ResponseEntity) response; + Map task = (Map) responseList.getBody().get(0); + assertTrue(checker.test(task)); + } + void testUnrelatedAudio(String filename) throws Exception{ + ResponseEntity response = testAudioSend(filename); + assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.OK); + Map body = (Map) response.getBody(); + Assertions.assertNotNull(body); + assertThat(body.containsKey("error")); + } + @Test + void testStudyEvent(){ + Predicate> descriptionStudy = task ->(task.get("description").toString()).contains("I need to study"); + assertDoesNotThrow(()->testSuccessfulAudioSend("DescriptionIneedStudy.mp3", descriptionStudy)); + } + @Test + void testSwimmingEvent(){ + Predicate> type = task ->(task.get("type").toString().contains("FITNESS") || task.get("type").toString().contains("COMMON")); + //если CHAT GPT выставляет некорректный(SPORT например) COMMON выставляется + Predicate> time12to14=task->task.get("start").toString().contains("12:00") && task.get("end").toString().contains("14:00"); + Predicate> predicate = task->type.test(task) && time12to14.test(task); + assertDoesNotThrow(()->testSuccessfulAudioSend("Swimming12-14Sport.mp3", predicate)); + } + @Test + void testEmptyAudio(){ + assertDoesNotThrow(()->testUnrelatedAudio("empty.mp3")); + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java index 081bc73..2a2b4f0 100644 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java @@ -24,7 +24,8 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" + "spring.security.enabled=false", + "spring.sql.init.mode=never" }) @ActiveProfiles("h2") @AutoConfigureMockMvc @@ -58,7 +59,7 @@ void testAskChatGPT() throws Exception { @Test @WithMockUser void testGenerateEventsAndTasks() throws Exception { - Mockito.when(chatGPTService.generateEventsAndTasks(any())).thenReturn( + Mockito.when(chatGPTService.generateEvents(any())).thenReturn( Map.of("events", List.of(), "tasks", List.of()) ); mockMvc.perform(post("/api/chatgpt/generate") diff --git a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java index 28f339e..18d7f36 100644 --- a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java +++ b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java @@ -1,17 +1,18 @@ package com.smartcalendar.service; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.*; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.web.reactive.function.client.WebClient; - import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.anyString; +import org.mockito.InjectMocks; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("h2") class ChatGPTServiceTest { @@ -26,13 +27,11 @@ void setUp() { @Test void testConvertToEntities() { Map> data = Map.of( - "events", List.of(Map.of("title", "Event1")), - "tasks", List.of(Map.of("title", "Task1", "completed", false)) + "events", List.of(Map.of("title", "Event1")) ); var entities = chatGPTService.convertToEntities(data); - assertEquals(2, entities.size()); + assertEquals(1, entities.size()); assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Event"))); - assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Task"))); } @Test @@ -44,10 +43,10 @@ void testProcessTranscript_Error() { } @Test - void testGenerateEventsAndTasks_ValidJson() { + void testGenerateEvents_ValidJson() { ChatGPTService spyService = spy(chatGPTService); doReturn("{\"events\":[],\"tasks\":[]}").when(spyService).askChatGPT(anyString(), anyString()); - Map> result = spyService.generateEventsAndTasks("test"); + Map> result = spyService.generateEvents("test"); assertTrue(result.containsKey("events")); assertTrue(result.containsKey("tasks")); } diff --git a/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java new file mode 100644 index 0000000..7a5726e --- /dev/null +++ b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java @@ -0,0 +1,74 @@ +package com.smartcalendar.service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +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 org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; + +@Tag("openAI-api") +@Nested +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test-real") +class ServiceRealApiTest { + + @Autowired + private AudioProcessingService audioProcessingService; + + @Autowired + private ChatGPTService chatGPTService; + + MockMultipartFile convertFileToMultipart(String filename) throws IOException { + File audio = new File("src/test/resources/"+filename); + byte[] content = Files.readAllBytes(audio.toPath()); + + return new MockMultipartFile( + "file", + audio.getName(), + "audio/mpeg", + content + ); + } + + @Test + @Disabled("call real OpenAI API") + void testRealAudioTranscription() { + MockMultipartFile multipartFile = assertDoesNotThrow(() -> convertFileToMultipart("DescriptionIneedStudy.mp3")); + + String result = audioProcessingService.transcribeAudio(multipartFile); + assertNotNull(result); + assertTrue(result.contains("need") && result.contains("description")); + } + + @Test + @Disabled("call real OpenAI API") + void testProcessTranscript_RealRequest() { + String transcript = "Create event type study with exactly this description: I need to study"; + + Map result = chatGPTService.processTranscript(transcript); + assertNotNull(result); + assertTrue(result.containsKey("events")); + + List> events = (List>) result.get("events"); + assertFalse(events.isEmpty()); + Map firstEvent = events.getFirst(); + assertEquals("I need to study", firstEvent.get("description")); + System.out.println("Processed events: " + result); + } +} \ No newline at end of file diff --git a/src/test/resources/DescriptionIneedStudy.mp3 b/src/test/resources/DescriptionIneedStudy.mp3 new file mode 100644 index 0000000..d98aa38 Binary files /dev/null and b/src/test/resources/DescriptionIneedStudy.mp3 differ diff --git a/src/test/resources/Swimming12-14Sport.mp3 b/src/test/resources/Swimming12-14Sport.mp3 new file mode 100644 index 0000000..4349074 Binary files /dev/null and b/src/test/resources/Swimming12-14Sport.mp3 differ diff --git a/src/test/resources/empty.mp3 b/src/test/resources/empty.mp3 new file mode 100644 index 0000000..e7a8740 Binary files /dev/null and b/src/test/resources/empty.mp3 differ