diff --git a/.github/workflows/Dev_CD.yml b/.github/workflows/Dev_CD.yml index e9a4e830..1aae3280 100644 --- a/.github/workflows/Dev_CD.yml +++ b/.github/workflows/Dev_CD.yml @@ -59,7 +59,7 @@ jobs: uses: actions/download-artifact@v4 with: name: app-artifact - path: ~/app + path: ~/app/staging - name: Download deploy scripts uses: actions/download-artifact@v4 @@ -67,11 +67,17 @@ jobs: name: deploy-scripts path: ~/app/scripts/ - - name: Replace application to latest - run: sudo sh ~/app/scripts/replace-new-version.sh + - name: Setup log directory + run: | + sudo mkdir -p /home/ubuntu/logs + sudo chown -R ubuntu:ubuntu /home/ubuntu/logs + chmod 755 /home/ubuntu/logs + + - name: Make deploy script executable + run: chmod +x ~/app/scripts/zero-downtime-deploy.sh - - name: Health Check - run: sh ~/app/scripts/health-check.sh + - name: Zero Downtime Deployment + run: sh ~/app/scripts/zero-downtime-deploy.sh - name: Send Discord Alert on Failure if: failure() diff --git a/.github/workflows/Prod_CD.yml b/.github/workflows/Prod_CD.yml index cb10ee58..dad56d3a 100644 --- a/.github/workflows/Prod_CD.yml +++ b/.github/workflows/Prod_CD.yml @@ -59,7 +59,7 @@ jobs: uses: actions/download-artifact@v4 with: name: app-artifact - path: ~/app + path: ~/app/staging - name: Download deploy scripts uses: actions/download-artifact@v4 @@ -67,11 +67,17 @@ jobs: name: deploy-scripts path: ~/app/scripts/ - - name: Replace application to latest - run: sudo sh ~/app/scripts/replace-new-version.sh + - name: Setup log directory + run: | + sudo mkdir -p /home/ubuntu/logs + sudo chown -R ubuntu:ubuntu /home/ubuntu/logs + chmod 755 /home/ubuntu/logs + + - name: Make deploy script executable + run: chmod +x ~/app/scripts/zero-downtime-deploy.sh - - name: Health Check - run: sh ~/app/scripts/health-check.sh + - name: Zero Downtime Deployment + run: sh ~/app/scripts/zero-downtime-deploy.sh - name: Send Discord Alert on Failure if: failure() diff --git a/.gitignore b/.gitignore index 671e0e9f..7d690374 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ out/ ### application-local.yml /src/main/resources/application-local.yml +.serena diff --git a/build.gradle b/build.gradle index a24fcd40..e0a42d5e 100644 --- a/build.gradle +++ b/build.gradle @@ -42,15 +42,14 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + // Websocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-gson:0.11.5' - // Excel Export - implementation 'org.apache.poi:poi-ooxml:5.2.3' - implementation 'org.apache.poi:poi:5.2.3' - // Logging implementation 'org.springframework.boot:spring-boot-starter-log4j2' implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" @@ -71,7 +70,7 @@ dependencies { testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter:1.1.11") // Rest Docs & Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' testImplementation 'io.rest-assured:rest-assured:5.5.0' testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2' diff --git a/nginx/api.dev.debate-timer.com b/nginx/api.dev.debate-timer.com new file mode 100644 index 00000000..5b775735 --- /dev/null +++ b/nginx/api.dev.debate-timer.com @@ -0,0 +1,34 @@ +upstream debate_timer_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +server { + server_name api.dev.debate-timer.com; + + location / { + proxy_pass http://debate_timer_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/api.dev.debate-timer.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/api.dev.debate-timer.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = api.dev.debate-timer.com) { + return 308 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name api.dev.debate-timer.com; + return 404; # managed by Certbot +} diff --git a/nginx/api.prod.debate-timer.com b/nginx/api.prod.debate-timer.com new file mode 100644 index 00000000..efa873fe --- /dev/null +++ b/nginx/api.prod.debate-timer.com @@ -0,0 +1,34 @@ +upstream debate_timer_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +server { + server_name api.prod.debate-timer.com; + + location / { + proxy_pass http://debate_timer_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/api.prod.debate-timer.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/api.prod.debate-timer.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = api.prod.debate-timer.com) { + return 308 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name api.prod.debate-timer.com; + return 404; # managed by Certbot +} diff --git a/scripts/dev/zero-downtime-deploy.sh b/scripts/dev/zero-downtime-deploy.sh new file mode 100644 index 00000000..992252c3 --- /dev/null +++ b/scripts/dev/zero-downtime-deploy.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +set -e + +APP_DIR="/home/ubuntu/app" +PORT_FILE="$APP_DIR/current_port.txt" +LOG_FILE="$APP_DIR/deploy.log" +BLUE_PORT=8080 +GREEN_PORT=8081 +BLUE_MONITOR_PORT=8083 +GREEN_MONITOR_PORT=8084 +MAX_HEALTH_CHECK_RETRIES=60 +HEALTH_CHECK_INTERVAL=2 +PROFILE="dev" +TIMEZONE="Asia/Seoul" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "${timestamp} $@" | tee -a "$LOG_FILE" +} + +error_exit() { + log "$1" + exit 1 +} + +get_current_port() { + if [ ! -f "$PORT_FILE" ]; then + log "Port file not found. Initializing with default port $BLUE_PORT" + echo "$BLUE_PORT" > "$PORT_FILE" + echo "$BLUE_PORT" + else + cat "$PORT_FILE" + fi +} + +get_inactive_port() { + local current_port=$1 + if [ "$current_port" -eq "$BLUE_PORT" ]; then + echo "$GREEN_PORT" + else + echo "$BLUE_PORT" + fi +} + +get_monitor_port() { + local app_port=$1 + if [ "$app_port" -eq "$BLUE_PORT" ]; then + echo "$BLUE_MONITOR_PORT" + else + echo "$GREEN_MONITOR_PORT" + fi +} + +is_port_in_use() { + local port=$1 + sudo lsof -t -i:$port > /dev/null 2>&1 + return $? +} + +kill_process_on_port() { + local port=$1 + local pid=$(sudo lsof -t -i:$port 2>/dev/null) + + if [ -z "$pid" ]; then + log "No process running on port $port" + return 0 + fi + + log "Sending graceful shutdown signal to process $pid on port $port" + sudo kill -15 "$pid" + + local wait_count=0 + while [ $wait_count -lt 65 ] && is_port_in_use "$port"; do + sleep 1 + wait_count=$((wait_count + 1)) + done + + if is_port_in_use "$port"; then + log "Process didn't stop gracefully, forcing shutdown" + sudo kill -9 "$pid" 2>/dev/null || true + sleep 2 + fi + + log "Process on port $port stopped successfully" +} + +health_check() { + local port=$1 + local monitor_port=$2 + local health_url="http://localhost:$monitor_port/monitoring/health" + + log "Starting health check for port $port (monitor: $monitor_port)" + + local retry=1 + while [ $retry -le $MAX_HEALTH_CHECK_RETRIES ]; do + local status=$(curl -s -o /dev/null -w "%{http_code}" "$health_url" 2>/dev/null || echo "000") + + log "Health check attempt $retry/$MAX_HEALTH_CHECK_RETRIES - Status: $status" + + if [ "$status" = "200" ]; then + log "Health check passed!" + return 0 + fi + + sleep $HEALTH_CHECK_INTERVAL + retry=$((retry + 1)) + done + + log "Health check failed after $MAX_HEALTH_CHECK_RETRIES attempts" + return 1 +} + +start_application() { + local port=$1 + local monitor_port=$2 + local staging_jar="$APP_DIR/staging/app.jar" + local jar_file="$APP_DIR/app-$port.jar" + + if [ ! -f "$staging_jar" ]; then + error_exit "No JAR file found in staging directory: $staging_jar" + fi + + log "Copying JAR from staging to $jar_file" + cp "$staging_jar" "$jar_file" + + log "Starting application on port $port with JAR: $jar_file" + + if is_port_in_use "$port"; then + log "Port $port is in use, cleaning up..." + kill_process_on_port "$port" + fi + + sudo nohup java \ + -Dspring.profiles.active=$PROFILE,monitor \ + -Duser.timezone=$TIMEZONE \ + -Dserver.port=$port \ + -Dmanagement.server.port=$monitor_port \ + -Ddd.service=debate-timer \ + -Ddd.env=$PROFILE \ + -jar "$jar_file" > "$APP_DIR/app-$port.log" 2>&1 & + + local pid=$! + log "Application started with PID: $pid" + + sleep 3 + + if ! kill -0 $pid 2>/dev/null; then + error_exit "Application process died immediately after start. Check logs at $APP_DIR/app-$port.log" + fi +} + +switch_nginx_upstream() { + local new_port=$1 + local nginx_conf="/etc/nginx/sites-available/api.dev.debate-timer.com" + local temp_conf="/tmp/api.dev.debate-timer.com.tmp" + local backup_conf="${nginx_conf}.bak" + + if [ ! -f "$nginx_conf" ]; then + error_exit "nginx configuration not found at $nginx_conf" + fi + + log "Switching nginx upstream to port $new_port" + sudo cp "$nginx_conf" "$backup_conf" + + sed "s/server 127\.0\.0\.1:[0-9]\+;/server 127.0.0.1:$new_port;/" "$nginx_conf" > "$temp_conf" + sudo cp "$temp_conf" "$nginx_conf" + + if ! sudo nginx -t 2>/dev/null; then + log "nginx configuration test failed, rolling back." + sudo cp "$backup_conf" "$nginx_conf" + sudo rm "$backup_conf" + return 1 + fi + + sudo nginx -s reload + log "nginx reloaded successfully" + + sleep 2 + local response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost/" 2>/dev/null || echo "000") + if [ "$response" = "000" ] || [ "$response" = "502" ] || [ "$response" = "503" ]; then + log "nginx health check failed after reload (status: $response). Rolling back nginx config." + sudo cp "$backup_conf" "$nginx_conf" + sudo nginx -s reload + sudo rm "$backup_conf" + return 1 + fi + + log "nginx is now routing traffic to port $new_port" + sudo rm "$backup_conf" + return 0 +} + +main() { + local current_port=$(get_current_port) + local new_port=$(get_inactive_port "$current_port") + local new_monitor_port=$(get_monitor_port "$new_port") + + log "Current active port: $current_port" + log "Deploying to port: $new_port" + log "Monitor port: $new_monitor_port" + + log "Step 1/4: Starting new version on port $new_port" + start_application "$new_port" "$new_monitor_port" + + log "Step 2/4: Performing health check" + if ! health_check "$new_port" "$new_monitor_port"; then + log "Deployment failed: Health check did not pass" + log "Rolling back: Stopping new version on port $new_port" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to health check failure" + fi + + log "Step 3/4: Switching nginx to new version" + if ! switch_nginx_upstream "$new_port"; then + log "nginx switch failed, rolling back" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to nginx switch failure" + fi + + log "Step 4/4: Stopping old version on port $current_port" + kill_process_on_port "$current_port" + + local old_jar="$APP_DIR/app-$current_port.jar" + if [ -f "$old_jar" ]; then + log "Removing old JAR file: $old_jar" + rm -f "$old_jar" + fi + + echo "$new_port" > "$PORT_FILE" + log "Updated active port file to $new_port" + + log "Deployment completed successfully!" + log "Active port: $new_port" + log "Inactive port: $current_port" +} + +main "$@" diff --git a/scripts/nginx-switch-port.sh b/scripts/nginx-switch-port.sh new file mode 100644 index 00000000..586f5c67 --- /dev/null +++ b/scripts/nginx-switch-port.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +set -e + +NGINX_CONF="/etc/nginx/sites-available/api.dev.debate-timer.com" +BACKUP_CONF="/etc/nginx/sites-available/api.dev.debate-timer.com.backup" +TEMP_CONF="/tmp/api.dev.debate-timer.com.tmp" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "${timestamp} $@" +} + +if [ -z "$1" ]; then + log "Usage: $0 " + log "Example: $0 8081" + exit 1 +fi + +NEW_PORT=$1 + +if ! [[ "$NEW_PORT" =~ ^[0-9]+$ ]] || [ "$NEW_PORT" -lt 1 ] || [ "$NEW_PORT" -gt 65535 ]; then + log "Invalid port number: $NEW_PORT" + exit 1 +fi + +if [ ! -f "$NGINX_CONF" ]; then + log "nginx configuration not found at $NGINX_CONF" + exit 1 +fi + +log "Backing up current nginx configuration" +sudo cp "$NGINX_CONF" "$BACKUP_CONF" + +log "Updating nginx upstream to port $NEW_PORT" +sed "s/server 127\.0\.0\.1:[0-9]\+;/server 127.0.0.1:$NEW_PORT;/" "$NGINX_CONF" > "$TEMP_CONF" + +log "Configuration changes:" +diff "$NGINX_CONF" "$TEMP_CONF" || true + +sudo cp "$TEMP_CONF" "$NGINX_CONF" + +log "Testing nginx configuration" +if ! sudo nginx -t 2>&1; then + log "nginx configuration test failed!" + log "Rolling back to previous configuration" + sudo cp "$BACKUP_CONF" "$NGINX_CONF" + exit 1 +fi + +log "Reloading nginx" +sudo nginx -s reload + +sleep 2 +HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost/monitoring/health" 2>/dev/null || echo "000") + +if [ "$HEALTH_STATUS" = "200" ]; then + log "nginx successfully switched to port $NEW_PORT" + log "Health check: OK (status $HEALTH_STATUS)" + rm -f "$TEMP_CONF" + exit 0 +else + log "Health check failed after nginx reload (status: $HEALTH_STATUS)" + log "nginx may not be routing to the correct backend" + exit 1 +fi diff --git a/scripts/prod/zero-downtime-deploy.sh b/scripts/prod/zero-downtime-deploy.sh new file mode 100644 index 00000000..c4b41432 --- /dev/null +++ b/scripts/prod/zero-downtime-deploy.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +set -e + +APP_DIR="/home/ubuntu/app" +PORT_FILE="$APP_DIR/current_port.txt" +LOG_FILE="$APP_DIR/deploy.log" +BLUE_PORT=8080 +GREEN_PORT=8081 +BLUE_MONITOR_PORT=8083 +GREEN_MONITOR_PORT=8084 +MAX_HEALTH_CHECK_RETRIES=60 +HEALTH_CHECK_INTERVAL=2 +PROFILE="prod" +TIMEZONE="Asia/Seoul" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "${timestamp} $@" | tee -a "$LOG_FILE" +} + +error_exit() { + log "$1" + exit 1 +} + +get_current_port() { + if [ ! -f "$PORT_FILE" ]; then + log "Port file not found. Initializing with default port $BLUE_PORT" + echo "$BLUE_PORT" > "$PORT_FILE" + echo "$BLUE_PORT" + else + cat "$PORT_FILE" + fi +} + +get_inactive_port() { + local current_port=$1 + if [ "$current_port" -eq "$BLUE_PORT" ]; then + echo "$GREEN_PORT" + else + echo "$BLUE_PORT" + fi +} + +get_monitor_port() { + local app_port=$1 + if [ "$app_port" -eq "$BLUE_PORT" ]; then + echo "$BLUE_MONITOR_PORT" + else + echo "$GREEN_MONITOR_PORT" + fi +} + +is_port_in_use() { + local port=$1 + sudo lsof -t -i:$port > /dev/null 2>&1 + return $? +} + +kill_process_on_port() { + local port=$1 + local pid=$(sudo lsof -t -i:$port 2>/dev/null) + + if [ -z "$pid" ]; then + log "No process running on port $port" + return 0 + fi + + log "Sending graceful shutdown signal to process $pid on port $port" + sudo kill -15 "$pid" + + local wait_count=0 + while [ $wait_count -lt 65 ] && is_port_in_use "$port"; do + sleep 1 + wait_count=$((wait_count + 1)) + done + + if is_port_in_use "$port"; then + log "Process didn't stop gracefully, forcing shutdown" + sudo kill -9 "$pid" 2>/dev/null || true + sleep 2 + fi + + log "Process on port $port stopped successfully" +} + +health_check() { + local port=$1 + local monitor_port=$2 + local health_url="http://localhost:$monitor_port/monitoring/health" + + log "Starting health check for port $port (monitor: $monitor_port)" + + local retry=1 + while [ $retry -le $MAX_HEALTH_CHECK_RETRIES ]; do + local status=$(curl -s -o /dev/null -w "%{http_code}" "$health_url" 2>/dev/null || echo "000") + + log "Health check attempt $retry/$MAX_HEALTH_CHECK_RETRIES - Status: $status" + + if [ "$status" = "200" ]; then + log "Health check passed!" + return 0 + fi + + sleep $HEALTH_CHECK_INTERVAL + retry=$((retry + 1)) + done + + log "Health check failed after $MAX_HEALTH_CHECK_RETRIES attempts" + return 1 +} + +start_application() { + local port=$1 + local monitor_port=$2 + local staging_jar="$APP_DIR/staging/app.jar" + local jar_file="$APP_DIR/app-$port.jar" + + if [ ! -f "$staging_jar" ]; then + error_exit "No JAR file found in staging directory: $staging_jar" + fi + + log "Copying JAR from staging to $jar_file" + cp "$staging_jar" "$jar_file" + + log "Starting application on port $port with JAR: $jar_file" + + if is_port_in_use "$port"; then + log "Port $port is in use, cleaning up..." + kill_process_on_port "$port" + fi + + sudo nohup java \ + -Dspring.profiles.active=$PROFILE,monitor \ + -Duser.timezone=$TIMEZONE \ + -Dserver.port=$port \ + -Dmanagement.server.port=$monitor_port \ + -Ddd.service=debate-timer \ + -Ddd.env=$PROFILE \ + -jar "$jar_file" > "$APP_DIR/app-$port.log" 2>&1 & + + local pid=$! + log "Application started with PID: $pid" + + sleep 3 + + if ! kill -0 $pid 2>/dev/null; then + error_exit "Application process died immediately after start. Check logs at $APP_DIR/app-$port.log" + fi +} + +switch_nginx_upstream() { + local new_port=$1 + local nginx_conf="/etc/nginx/sites-available/api.prod.debate-timer.com" + local temp_conf="/tmp/api.prod.debate-timer.com.tmp" + local backup_conf="${nginx_conf}.bak" + + if [ ! -f "$nginx_conf" ]; then + error_exit "nginx configuration not found at $nginx_conf" + fi + + log "Switching nginx upstream to port $new_port" + sudo cp "$nginx_conf" "$backup_conf" + + sed "s/server 127\.0\.0\.1:[0-9]\+;/server 127.0.0.1:$new_port;/" "$nginx_conf" > "$temp_conf" + sudo cp "$temp_conf" "$nginx_conf" + + if ! sudo nginx -t 2>/dev/null; then + log "nginx configuration test failed, rolling back." + sudo cp "$backup_conf" "$nginx_conf" + sudo rm "$backup_conf" + return 1 + fi + + sudo nginx -s reload + log "nginx reloaded successfully" + + sleep 2 + local response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost/" 2>/dev/null || echo "000") + if [ "$response" = "000" ] || [ "$response" = "502" ] || [ "$response" = "503" ]; then + log "nginx health check failed after reload (status: $response). Rolling back nginx config." + sudo cp "$backup_conf" "$nginx_conf" + sudo nginx -s reload + sudo rm "$backup_conf" + return 1 + fi + + log "nginx is now routing traffic to port $new_port" + sudo rm "$backup_conf" + return 0 +} + +main() { + local current_port=$(get_current_port) + local new_port=$(get_inactive_port "$current_port") + local new_monitor_port=$(get_monitor_port "$new_port") + + log "Current active port: $current_port" + log "Deploying to port: $new_port" + log "Monitor port: $new_monitor_port" + + log "Step 1/4: Starting new version on port $new_port" + start_application "$new_port" "$new_monitor_port" + + log "Step 2/4: Performing health check" + if ! health_check "$new_port" "$new_monitor_port"; then + log "Deployment failed: Health check did not pass" + log "Rolling back: Stopping new version on port $new_port" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to health check failure" + fi + + log "Step 3/4: Switching nginx to new version" + if ! switch_nginx_upstream "$new_port"; then + log "nginx switch failed, rolling back" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to nginx switch failure" + fi + + log "Step 4/4: Stopping old version on port $current_port" + kill_process_on_port "$current_port" + + local old_jar="$APP_DIR/app-$current_port.jar" + if [ -f "$old_jar" ]; then + log "Removing old JAR file: $old_jar" + rm -f "$old_jar" + fi + + echo "$new_port" > "$PORT_FILE" + log "Updated active port file to $new_port" + + log "Deployment completed successfully!" + log "Active port: $new_port" + log "Inactive port: $current_port" +} + +main "$@" diff --git a/src/main/java/com/debatetimer/config/CorsConfig.java b/src/main/java/com/debatetimer/config/CorsConfig.java index 216c15da..12511729 100644 --- a/src/main/java/com/debatetimer/config/CorsConfig.java +++ b/src/main/java/com/debatetimer/config/CorsConfig.java @@ -1,8 +1,7 @@ package com.debatetimer.config; -import com.debatetimer.exception.custom.DTInitializationException; -import com.debatetimer.exception.errorcode.InitializationErrorCode; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -10,30 +9,16 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration +@RequiredArgsConstructor +@EnableConfigurationProperties(CorsProperties.class) public class CorsConfig implements WebMvcConfigurer { - private final String[] corsOrigin; - - public CorsConfig(@Value("${cors.origin}") String[] corsOrigin) { - validate(corsOrigin); - this.corsOrigin = corsOrigin; - } - - private void validate(String[] corsOrigin) { - if (corsOrigin == null || corsOrigin.length == 0) { - throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_EMPTY); - } - for (String origin : corsOrigin) { - if (origin == null || origin.isBlank()) { - throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK); - } - } - } + private final CorsProperties corsProperties; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOriginPatterns(corsOrigin) + .allowedOriginPatterns(corsProperties.getCorsOrigin()) .allowedMethods( HttpMethod.GET.name(), HttpMethod.POST.name(), diff --git a/src/main/java/com/debatetimer/config/CorsProperties.java b/src/main/java/com/debatetimer/config/CorsProperties.java new file mode 100644 index 00000000..8103553b --- /dev/null +++ b/src/main/java/com/debatetimer/config/CorsProperties.java @@ -0,0 +1,31 @@ +package com.debatetimer.config; + +import com.debatetimer.exception.custom.DTInitializationException; +import com.debatetimer.exception.errorcode.InitializationErrorCode; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + + +@Getter +@ConfigurationProperties(prefix = "cors.origin") +public class CorsProperties { + + private final String[] corsOrigin; + + //TODO 머지될 때 dev, prod secret 갱신 필요 + public CorsProperties(String[] corsOrigin) { + validate(corsOrigin); + this.corsOrigin = corsOrigin; + } + + private void validate(String[] corsOrigin) { + if (corsOrigin == null || corsOrigin.length == 0) { + throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_EMPTY); + } + for (String origin : corsOrigin) { + if (origin == null || origin.isBlank()) { + throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK); + } + } + } +} diff --git a/src/main/java/com/debatetimer/config/SchedulerConfig.java b/src/main/java/com/debatetimer/config/SchedulerConfig.java new file mode 100644 index 00000000..49893796 --- /dev/null +++ b/src/main/java/com/debatetimer/config/SchedulerConfig.java @@ -0,0 +1,10 @@ +package com.debatetimer.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + +} diff --git a/src/main/java/com/debatetimer/config/WebConfig.java b/src/main/java/com/debatetimer/config/WebConfig.java index 5f3ed66f..0ce8ab1e 100644 --- a/src/main/java/com/debatetimer/config/WebConfig.java +++ b/src/main/java/com/debatetimer/config/WebConfig.java @@ -1,13 +1,11 @@ package com.debatetimer.config; -import com.debatetimer.controller.tool.export.ExcelExportInterceptor; import com.debatetimer.controller.tool.jwt.AuthManager; import com.debatetimer.service.auth.AuthService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @@ -21,9 +19,4 @@ public class WebConfig implements WebMvcConfigurer { public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(new AuthMemberArgumentResolver(authManager, authService)); } - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new ExcelExportInterceptor()); - } } diff --git a/src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java b/src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java new file mode 100644 index 00000000..de16ac9f --- /dev/null +++ b/src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java @@ -0,0 +1,41 @@ +package com.debatetimer.config.sharing; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.controller.tool.jwt.AuthManager; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.auth.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WebSocketAuthMemberResolver implements HandlerMethodArgumentResolver { + + private final AuthManager authManager; + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + String token = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + + if (token == null) { + throw new DTClientErrorException(ClientErrorCode.UNAUTHORIZED_MEMBER); + } + + String email = authManager.resolveAccessToken(token); + return authService.getMember(email); + } +} + diff --git a/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java b/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java new file mode 100644 index 00000000..b2ee31a6 --- /dev/null +++ b/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java @@ -0,0 +1,38 @@ +package com.debatetimer.config.sharing; + +import com.debatetimer.config.CorsProperties; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@RequiredArgsConstructor +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final CorsProperties corsProperties; + private final WebSocketAuthMemberResolver webSocketAuthMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(webSocketAuthMemberResolver); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/room", "/chairman"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns(corsProperties.getCorsOrigin()) + .withSockJS(); + } +} diff --git a/src/main/java/com/debatetimer/controller/organization/OrganizationController.java b/src/main/java/com/debatetimer/controller/organization/OrganizationController.java new file mode 100644 index 00000000..46efeae6 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/organization/OrganizationController.java @@ -0,0 +1,20 @@ +package com.debatetimer.controller.organization; + +import com.debatetimer.dto.organization.OrganizationResponses; +import com.debatetimer.service.organization.OrganizationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class OrganizationController { + + private final OrganizationService organizationService; + + @GetMapping("/api/organizations/templates") + public ResponseEntity getOrganizationTemplates() { + return ResponseEntity.ok(organizationService.findAll()); + } +} diff --git a/src/main/java/com/debatetimer/controller/sharing/SharingController.java b/src/main/java/com/debatetimer/controller/sharing/SharingController.java new file mode 100644 index 00000000..edacf8e6 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/sharing/SharingController.java @@ -0,0 +1,25 @@ +package com.debatetimer.controller.sharing; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.sharing.request.SharingRequest; +import com.debatetimer.dto.sharing.response.SharingResponse; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.stereotype.Controller; + +@Controller +public class SharingController { + + @MessageMapping("/event/{roomId}") + @SendTo("/room/{roomId}") + public SharingResponse share( + @AuthMember Member member, + @DestinationVariable(value = "roomId") long roomId, + @Payload SharingRequest request + ) { + return new SharingResponse(request.time()); + } +} diff --git a/src/main/java/com/debatetimer/controller/tool/export/ExcelExport.java b/src/main/java/com/debatetimer/controller/tool/export/ExcelExport.java deleted file mode 100644 index bad11bb5..00000000 --- a/src/main/java/com/debatetimer/controller/tool/export/ExcelExport.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.debatetimer.controller.tool.export; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ExcelExport { - -} diff --git a/src/main/java/com/debatetimer/controller/tool/export/ExcelExportInterceptor.java b/src/main/java/com/debatetimer/controller/tool/export/ExcelExportInterceptor.java deleted file mode 100644 index 535d21c1..00000000 --- a/src/main/java/com/debatetimer/controller/tool/export/ExcelExportInterceptor.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.debatetimer.controller.tool.export; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.servlet.HandlerInterceptor; - -@Component -public class ExcelExportInterceptor implements HandlerInterceptor { - - private static final String SPREAD_SHEET_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - private static final String EXCEL_FILE_NAME = "my_debate_template.xlsx"; - - @Override - public boolean preHandle( - HttpServletRequest request, - HttpServletResponse response, - Object handler - ) { - if (isPreflight(request)) { - return true; - } - if (isExcelExportRequest(handler)) { - setExcelHeader(response); - } - return true; - } - - private boolean isExcelExportRequest(Object handler) { - if (!(handler instanceof HandlerMethod)) { - return false; - } - - HandlerMethod handlerMethod = (HandlerMethod) handler; - return handlerMethod.hasMethodAnnotation(ExcelExport.class) - && handlerMethod.getBeanType().isAnnotationPresent(RestController.class); - } - - private boolean isPreflight(HttpServletRequest request) { - return HttpMethod.OPTIONS.toString() - .equals(request.getMethod()); - } - - private void setExcelHeader(HttpServletResponse response) { - ContentDisposition contentDisposition = ContentDisposition.attachment() - .filename(EXCEL_FILE_NAME) - .build(); - response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString()); - response.setContentType(SPREAD_SHEET_MEDIA_TYPE); - } -} diff --git a/src/main/java/com/debatetimer/domain/organization/Organization.java b/src/main/java/com/debatetimer/domain/organization/Organization.java new file mode 100644 index 00000000..23b0b219 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/organization/Organization.java @@ -0,0 +1,26 @@ +package com.debatetimer.domain.organization; + +import java.util.List; +import lombok.Getter; + +@Getter +public class Organization { + + private final Long id; + private final String name; + private final String affiliation; + private final String iconPath; + private final List templates; + + public Organization(Long id, + String name, + String affiliation, + String iconPath, + List templates) { + this.id = id; + this.name = name; + this.affiliation = affiliation; + this.iconPath = iconPath; + this.templates = templates; + } +} diff --git a/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java b/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java new file mode 100644 index 00000000..6c920f38 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java @@ -0,0 +1,17 @@ +package com.debatetimer.domain.organization; + +import lombok.Getter; + +@Getter +public class OrganizationTemplate { + + private final Long id; + private final String name; + private final String data; + + public OrganizationTemplate(Long id, String name, String data) { + this.id = id; + this.name = name; + this.data = data; + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java new file mode 100644 index 00000000..917de8cc --- /dev/null +++ b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java @@ -0,0 +1,36 @@ +package com.debatetimer.domainrepository.organization; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.organization.OrganizationTemplateEntity; +import com.debatetimer.repository.organization.OrganizationRepository; +import com.debatetimer.repository.organization.OrganizationTemplateRepository; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrganizationDomainRepository { + + private final OrganizationRepository organizationRepository; + private final OrganizationTemplateRepository organizationTemplateRepository; + + public List findAll() { + Map> idToTemplatesEntity = organizationTemplateRepository.findAll() + .stream() + .collect(Collectors.groupingBy( + OrganizationTemplateEntity::getOrganizationId, + Collectors.mapping(OrganizationTemplateEntity::toDomain, Collectors.toList()) + )); + + return organizationRepository.findAll() + .stream() + .map(entity -> entity.toDomain( + idToTemplatesEntity.getOrDefault(entity.getId(), Collections.emptyList()) + )).toList(); + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java index e35ceca4..4bf45775 100644 --- a/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java @@ -1,8 +1,10 @@ package com.debatetimer.domainrepository.poll; import com.debatetimer.domain.poll.Poll; +import com.debatetimer.domain.poll.PollStatus; import com.debatetimer.entity.poll.PollEntity; import com.debatetimer.repository.poll.PollRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -38,4 +40,9 @@ public Poll finishPoll(long pollId, long memberId) { pollEntity.updateToDone(); return pollEntity.toDomain(); } + + @Transactional + public void updateStatusToDoneForOldPolls(PollStatus pollStatus, LocalDateTime threshold) { + pollRepository.updateStatusToDoneForOldPolls(PollStatus.DONE, pollStatus, threshold); + } } diff --git a/src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java b/src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java new file mode 100644 index 00000000..59bcfa90 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java @@ -0,0 +1,28 @@ +package com.debatetimer.dto.organization; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; +import java.util.List; + +public record OrganizationResponse( + String organization, + String affiliation, + String iconPath, + List templates +) { + + public OrganizationResponse(Organization organization) { + this( + organization.getName(), + organization.getAffiliation(), + organization.getIconPath(), + toTemplatesResponse(organization.getTemplates()) + ); + } + + private static List toTemplatesResponse(List templates) { + return templates.stream() + .map(OrganizationTemplateResponse::new) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java b/src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java new file mode 100644 index 00000000..f28ca43f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java @@ -0,0 +1,17 @@ +package com.debatetimer.dto.organization; + +import com.debatetimer.domain.organization.Organization; +import java.util.List; + +public record OrganizationResponses(List organizations) { + + public static OrganizationResponses from(List organizations) { + return new OrganizationResponses(toOrganizationsResponse(organizations)); + } + + private static List toOrganizationsResponse(List organizations) { + return organizations.stream() + .map(OrganizationResponse::new) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java b/src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java new file mode 100644 index 00000000..9c6add1f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.organization; + +import com.debatetimer.domain.organization.OrganizationTemplate; + +public record OrganizationTemplateResponse(String name, String data) { + + public OrganizationTemplateResponse(OrganizationTemplate template) { + this(template.getName(), template.getData()); + } +} diff --git a/src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java b/src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java new file mode 100644 index 00000000..ecc5134f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java @@ -0,0 +1,7 @@ +package com.debatetimer.dto.sharing.request; + +public record ChairmanSharingRequest( + long roomId +) { + +} diff --git a/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java b/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java new file mode 100644 index 00000000..b6063711 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java @@ -0,0 +1,9 @@ +package com.debatetimer.dto.sharing.request; + +import java.time.LocalDateTime; + +public record SharingRequest( + LocalDateTime time +) { + +} diff --git a/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java b/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java new file mode 100644 index 00000000..704384d1 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java @@ -0,0 +1,9 @@ +package com.debatetimer.dto.sharing.response; + +import java.time.LocalDateTime; + +public record SharingResponse( + LocalDateTime time +) { + +} diff --git a/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java b/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java index ff24334e..e10a9307 100644 --- a/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java +++ b/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java @@ -60,7 +60,7 @@ public CustomizeTableEntity(CustomizeTable customizeTable) { this.consTeamName = customizeTable.getConsTeamName(); this.warningBell = customizeTable.isWarningBell(); this.finishBell = customizeTable.isFinishBell(); - this.usedAt = LocalDateTime.now(); + this.usedAt = customizeTable.getUsedAt(); } public CustomizeTable toDomain() { diff --git a/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java b/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java new file mode 100644 index 00000000..326e289e --- /dev/null +++ b/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java @@ -0,0 +1,46 @@ +package com.debatetimer.entity.organization; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "organization") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrganizationEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String name; + + @NotNull + private String affiliation; + + @NotBlank + private String iconPath; + + public OrganizationEntity(String name, String affiliation, String iconPath) { + this.name = name; + this.affiliation = affiliation; + this.iconPath = iconPath; + } + + public Organization toDomain(List templates) { + return new Organization(this.id, this.name, this.affiliation, this.iconPath, templates); + } +} diff --git a/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java b/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java new file mode 100644 index 00000000..96753279 --- /dev/null +++ b/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java @@ -0,0 +1,56 @@ +package com.debatetimer.entity.organization; + + +import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +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 jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "organization_template") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrganizationTemplateEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id") + private OrganizationEntity organization; + + @NotBlank + private String name; + + @NotBlank + @Column(length = 8191) + private String data; + + public OrganizationTemplateEntity(OrganizationEntity organization, String name, String data) { + this.organization = organization; + this.name = name; + this.data = data; + } + + public OrganizationTemplate toDomain() { + return new OrganizationTemplate(this.id, this.name, this.data); + } + + public Long getOrganizationId() { + return this.organization.getId(); + } +} diff --git a/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java new file mode 100644 index 00000000..f2e4b47a --- /dev/null +++ b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java @@ -0,0 +1,47 @@ +package com.debatetimer.event.sharing; + +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RoomSubscribeListener { + + private static final String AUDIENCE_SUBSCRIBE_PREFIX = "/room/"; + private static final String CHAIRMAN_CHANNEL_PREFIX = "/chairman/"; + + private final SimpMessagingTemplate messagingTemplate; + + @EventListener + public void handleSubscribeEvent(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + if (destination == null) { + return; + } + + if (destination.startsWith(AUDIENCE_SUBSCRIBE_PREFIX)) { + long roomId = parseRoomId(destination); + messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId)); + } + } + + private long parseRoomId(String destination) { + try { + String parsedRoomId = destination.substring(AUDIENCE_SUBSCRIBE_PREFIX.length()); + return Long.parseLong(parsedRoomId); + } catch (NumberFormatException exception) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ROOM_ID); + } + } +} + diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index 695cbb0c..5bf7d05b 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -53,6 +53,8 @@ public enum ClientErrorCode implements ResponseErrorCode { ALREADY_DONE_POLL(HttpStatus.BAD_REQUEST, "이미 완료된 투표 입니다"), ALREADY_VOTED_PARTICIPANT(HttpStatus.BAD_REQUEST, "이미 참여한 투표자 입니다"), + INVALID_ROOM_ID(HttpStatus.BAD_REQUEST, "잘못된 roomId 값입니다"), + TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 테이블을 찾을 수 없습니다."), NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."), POLL_NOT_FOUND(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다."), diff --git a/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java b/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java new file mode 100644 index 00000000..5c6ef65a --- /dev/null +++ b/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java @@ -0,0 +1,12 @@ +package com.debatetimer.repository.organization; + +import com.debatetimer.entity.organization.OrganizationEntity; +import java.util.List; +import org.springframework.data.repository.Repository; + +public interface OrganizationRepository extends Repository { + + List findAll(); + + OrganizationEntity save(OrganizationEntity organizationEntity); +} diff --git a/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java b/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java new file mode 100644 index 00000000..4c5b7472 --- /dev/null +++ b/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java @@ -0,0 +1,12 @@ +package com.debatetimer.repository.organization; + +import com.debatetimer.entity.organization.OrganizationTemplateEntity; +import java.util.List; +import org.springframework.data.repository.Repository; + +public interface OrganizationTemplateRepository extends Repository { + + List findAll(); + + OrganizationTemplateEntity save(OrganizationTemplateEntity entity); +} diff --git a/src/main/java/com/debatetimer/repository/poll/PollRepository.java b/src/main/java/com/debatetimer/repository/poll/PollRepository.java index 22c6db56..80b6624f 100644 --- a/src/main/java/com/debatetimer/repository/poll/PollRepository.java +++ b/src/main/java/com/debatetimer/repository/poll/PollRepository.java @@ -1,12 +1,21 @@ package com.debatetimer.repository.poll; +import com.debatetimer.domain.poll.PollStatus; import com.debatetimer.entity.poll.PollEntity; import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDateTime; import java.util.Optional; -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.Repository; +import org.springframework.data.repository.query.Param; -public interface PollRepository extends JpaRepository { +public interface PollRepository extends Repository { + + PollEntity save(PollEntity pollEntity); + + Optional findById(long id); Optional findByIdAndMemberId(long id, long memberId); @@ -19,4 +28,9 @@ default PollEntity getByIdAndMemberId(long id, long memberId) { return findByIdAndMemberId(id, memberId) .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.POLL_NOT_FOUND)); } + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE PollEntity p SET p.status = :doneStatus WHERE p.status = :status AND p.createdAt <= :threshold") + void updateStatusToDoneForOldPolls(@Param("doneStatus") PollStatus doneStatus, @Param("status") PollStatus status, + @Param("threshold") LocalDateTime threshold); } diff --git a/src/main/java/com/debatetimer/repository/poll/VoteRepository.java b/src/main/java/com/debatetimer/repository/poll/VoteRepository.java index 5f211efd..9cf73a36 100644 --- a/src/main/java/com/debatetimer/repository/poll/VoteRepository.java +++ b/src/main/java/com/debatetimer/repository/poll/VoteRepository.java @@ -2,11 +2,15 @@ import com.debatetimer.entity.poll.VoteEntity; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface VoteRepository extends JpaRepository { +public interface VoteRepository extends Repository { + + VoteEntity save(VoteEntity voteEntity); List findAllByPollId(long pollId); boolean existsByPollIdAndParticipateCode(long pollId, String participateCode); + + long count(); } diff --git a/src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java b/src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java new file mode 100644 index 00000000..9a88ba38 --- /dev/null +++ b/src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java @@ -0,0 +1,27 @@ +package com.debatetimer.scheduler; + +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domainrepository.poll.PollDomainRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class PollCleanupScheduler { + + private static final int INTERVAL_HOURS = 12; + private static final long INTERVAL_MILLIS = INTERVAL_HOURS * 60 * 60 * 1000L; + static final int TIMEOUT_HOURS = 3; + + private final PollDomainRepository pollDomainRepository; + + @Scheduled(fixedRate = INTERVAL_MILLIS, zone = "Asia/Seoul") + @Transactional + public void cleanupStalePolls() { + LocalDateTime threshold = LocalDateTime.now().minusHours(TIMEOUT_HOURS); + pollDomainRepository.updateStatusToDoneForOldPolls(PollStatus.PROGRESS, threshold); + } +} diff --git a/src/main/java/com/debatetimer/service/organization/OrganizationService.java b/src/main/java/com/debatetimer/service/organization/OrganizationService.java new file mode 100644 index 00000000..ab07e4b5 --- /dev/null +++ b/src/main/java/com/debatetimer/service/organization/OrganizationService.java @@ -0,0 +1,17 @@ +package com.debatetimer.service.organization; + +import com.debatetimer.domainrepository.organization.OrganizationDomainRepository; +import com.debatetimer.dto.organization.OrganizationResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrganizationService { + + private final OrganizationDomainRepository organizationDomainRepository; + + public OrganizationResponses findAll() { + return OrganizationResponses.from(organizationDomainRepository.findAll()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b60259e8..46ebc8ed 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -21,7 +21,9 @@ spring: baseline-version: 1 cors: - origin: ${secret.cors.origin} + origin: + cors-origin: + - ${secret.cors.origin} oauth: client_id: ${secret.oauth.client_id} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 208ac91b..cc4de59d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -20,7 +20,9 @@ spring: baseline-version: 1 cors: - origin: ${secret.cors.origin} + origin: + cors-origin: + - ${secret.cors.origin} oauth: client_id: ${secret.oauth.client_id} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 40e92645..8196d857 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: profiles: default: local + lifecycle: + timeout-per-shutdown-phase: 60s + +server: + shutdown: graceful springdoc: swagger-ui: diff --git a/src/main/resources/db/migration/V15__create_organization_template.sql b/src/main/resources/db/migration/V15__create_organization_template.sql new file mode 100644 index 00000000..42ea295c --- /dev/null +++ b/src/main/resources/db/migration/V15__create_organization_template.sql @@ -0,0 +1,26 @@ +create table organization +( + id bigint auto_increment, + name varchar(255) not null, + affiliation varchar(255) not null, + icon_path varchar(255) not null, + created_at timestamp not null DEFAULT CURRENT_TIMESTAMP, + modified_at timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + primary key (id) +); + +create table organization_template +( + id bigint auto_increment, + name varchar(255) not null, + data varchar(8191) not null, + organization_id bigint not null, + created_at timestamp not null DEFAULT CURRENT_TIMESTAMP, + modified_at timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + primary key (id) +); + +alter table organization_template + add constraint organization_template_to_organization + foreign key (organization_id) + references organization (id); diff --git a/src/main/resources/static/icon/debate_commission_icon.png b/src/main/resources/static/icon/debate_commission_icon.png new file mode 100644 index 00000000..a16e8e8a Binary files /dev/null and b/src/main/resources/static/icon/debate_commission_icon.png differ diff --git a/src/main/resources/static/icon/government_icon.png b/src/main/resources/static/icon/government_icon.png new file mode 100644 index 00000000..27c93db6 Binary files /dev/null and b/src/main/resources/static/icon/government_icon.png differ diff --git a/src/main/resources/static/icon/han_alm_icon.png b/src/main/resources/static/icon/han_alm_icon.png new file mode 100644 index 00000000..1167f15d Binary files /dev/null and b/src/main/resources/static/icon/han_alm_icon.png differ diff --git a/src/main/resources/static/icon/hantomak_icon.png b/src/main/resources/static/icon/hantomak_icon.png new file mode 100644 index 00000000..9e4faee5 Binary files /dev/null and b/src/main/resources/static/icon/hantomak_icon.png differ diff --git a/src/main/resources/static/icon/igam_icon.png b/src/main/resources/static/icon/igam_icon.png new file mode 100644 index 00000000..65f8a591 Binary files /dev/null and b/src/main/resources/static/icon/igam_icon.png differ diff --git a/src/main/resources/static/icon/kogito_icon.png b/src/main/resources/static/icon/kogito_icon.png new file mode 100644 index 00000000..36292c6c Binary files /dev/null and b/src/main/resources/static/icon/kogito_icon.png differ diff --git a/src/main/resources/static/icon/kondae_time_icon.png b/src/main/resources/static/icon/kondae_time_icon.png new file mode 100644 index 00000000..f75c2b84 Binary files /dev/null and b/src/main/resources/static/icon/kondae_time_icon.png differ diff --git a/src/main/resources/static/icon/mcu_icon.png b/src/main/resources/static/icon/mcu_icon.png new file mode 100644 index 00000000..9ac43528 Binary files /dev/null and b/src/main/resources/static/icon/mcu_icon.png differ diff --git a/src/main/resources/static/icon/nogotte_icon.png b/src/main/resources/static/icon/nogotte_icon.png new file mode 100644 index 00000000..56ac3066 Binary files /dev/null and b/src/main/resources/static/icon/nogotte_icon.png differ diff --git a/src/main/resources/static/icon/osansi_icon.png b/src/main/resources/static/icon/osansi_icon.png new file mode 100644 index 00000000..5eba3fbb Binary files /dev/null and b/src/main/resources/static/icon/osansi_icon.png differ diff --git a/src/main/resources/static/icon/seobangjeongto_icon.png b/src/main/resources/static/icon/seobangjeongto_icon.png new file mode 100644 index 00000000..95c4daed Binary files /dev/null and b/src/main/resources/static/icon/seobangjeongto_icon.png differ diff --git a/src/main/resources/static/icon/todallae_icon.png b/src/main/resources/static/icon/todallae_icon.png new file mode 100644 index 00000000..45250602 Binary files /dev/null and b/src/main/resources/static/icon/todallae_icon.png differ diff --git a/src/main/resources/static/icon/visual_icon.png b/src/main/resources/static/icon/visual_icon.png new file mode 100644 index 00000000..1a74153f Binary files /dev/null and b/src/main/resources/static/icon/visual_icon.png differ diff --git a/src/main/resources/static/icon/yuppm_law_track_icon.png b/src/main/resources/static/icon/yuppm_law_track_icon.png new file mode 100644 index 00000000..ab1558a4 Binary files /dev/null and b/src/main/resources/static/icon/yuppm_law_track_icon.png differ diff --git a/src/test/java/com/debatetimer/BaseStompTest.java b/src/test/java/com/debatetimer/BaseStompTest.java new file mode 100644 index 00000000..4f0410c0 --- /dev/null +++ b/src/test/java/com/debatetimer/BaseStompTest.java @@ -0,0 +1,76 @@ +package com.debatetimer; + +import com.debatetimer.fixture.HeaderGenerator; +import com.debatetimer.fixture.entity.MemberGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.sockjs.client.SockJsClient; +import org.springframework.web.socket.sockjs.client.Transport; +import org.springframework.web.socket.sockjs.client.WebSocketTransport; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseStompTest { + + private static final String SOCKET_ENDPOINT = "/ws"; + + protected StompSession stompSession; + + @LocalServerPort + private int port; + + private final String url; + + private final WebSocketStompClient websocketClient; + + @Autowired + protected MemberGenerator memberGenerator; + + @Autowired + protected HeaderGenerator headerGenerator; + + public BaseStompTest() { + List transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); + this.websocketClient = new WebSocketStompClient(new SockJsClient(transports)); + this.websocketClient.setMessageConverter(buildMessageConverter()); + this.url = "ws://localhost:"; + } + + private MessageConverter buildMessageConverter() { + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.findAndRegisterModules(); + converter.setObjectMapper(objectMapper); + return converter; + } + + @BeforeEach + public void connect() throws ExecutionException, InterruptedException, TimeoutException { + this.stompSession = this.websocketClient + .connectAsync(url + port + SOCKET_ENDPOINT, new StompSessionHandlerAdapter() { + }) + .get(3, TimeUnit.SECONDS); + } + + @AfterEach + public void disconnect() { + if (this.stompSession.isConnected()) { + this.stompSession.disconnect(); + } + } +} diff --git a/src/test/java/com/debatetimer/MessageFrameHandler.java b/src/test/java/com/debatetimer/MessageFrameHandler.java new file mode 100644 index 00000000..d0e60346 --- /dev/null +++ b/src/test/java/com/debatetimer/MessageFrameHandler.java @@ -0,0 +1,31 @@ +package com.debatetimer; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; + +public class MessageFrameHandler implements StompFrameHandler { + + private final CompletableFuture completableFuture = new CompletableFuture<>(); + private final Class tClass; + + public MessageFrameHandler(Class tClass) { + this.tClass = tClass; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + if (completableFuture.complete((T) payload)) { + } + } + + @Override + public Type getPayloadType(StompHeaders headers) { + return this.tClass; + } + + public CompletableFuture getCompletableFuture() { + return completableFuture; + } +} diff --git a/src/test/java/com/debatetimer/config/CorsConfigTest.java b/src/test/java/com/debatetimer/config/CorsPropertiesTest.java similarity index 84% rename from src/test/java/com/debatetimer/config/CorsConfigTest.java rename to src/test/java/com/debatetimer/config/CorsPropertiesTest.java index 0011729a..b4889839 100644 --- a/src/test/java/com/debatetimer/config/CorsConfigTest.java +++ b/src/test/java/com/debatetimer/config/CorsPropertiesTest.java @@ -9,21 +9,21 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; -class CorsConfigTest { +class CorsPropertiesTest { @Nested class Validate { @Test void 허용된_도메인이_null_일_경우_예외를_발생시칸다() { - assertThatThrownBy(() -> new CorsConfig(null)) + assertThatThrownBy(() -> new CorsProperties(null)) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.CORS_ORIGIN_EMPTY.getMessage()); } @Test void 허용된_도메인이_빈_배열일_경우_예외를_발생시칸다() { - assertThatThrownBy(() -> new CorsConfig(new String[0])) + assertThatThrownBy(() -> new CorsProperties(new String[0])) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.CORS_ORIGIN_EMPTY.getMessage()); } @@ -31,10 +31,9 @@ class Validate { @ParameterizedTest @NullAndEmptySource void 허용된_도메인_중에_빈_값이_있을_경우_예외를_발생시킨다(String empty) { - assertThatThrownBy(() -> new CorsConfig(new String[]{empty})) + assertThatThrownBy(() -> new CorsProperties(new String[]{empty})) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK.getMessage()); - } } } diff --git a/src/test/java/com/debatetimer/controller/BaseControllerTest.java b/src/test/java/com/debatetimer/controller/BaseControllerTest.java index 504c5dcd..2c43b995 100644 --- a/src/test/java/com/debatetimer/controller/BaseControllerTest.java +++ b/src/test/java/com/debatetimer/controller/BaseControllerTest.java @@ -16,6 +16,8 @@ import com.debatetimer.fixture.entity.CustomizeTableEntityGenerator; import com.debatetimer.fixture.entity.CustomizeTimeBoxEntityGenerator; import com.debatetimer.fixture.entity.MemberGenerator; +import com.debatetimer.fixture.entity.OrganizationEntityGenerator; +import com.debatetimer.fixture.entity.OrganizationTemplateEntityGenerator; import com.debatetimer.fixture.entity.PollEntityGenerator; import com.debatetimer.fixture.entity.VoteEntityGenerator; import com.debatetimer.repository.customize.CustomizeTableRepository; @@ -57,6 +59,12 @@ public abstract class BaseControllerTest { @Autowired protected VoteEntityGenerator voteEntityGenerator; + @Autowired + protected OrganizationEntityGenerator organizationEntityGenerator; + + @Autowired + protected OrganizationTemplateEntityGenerator organizationTemplateEntityGenerator; + @Autowired protected HeaderGenerator headerGenerator; diff --git a/src/test/java/com/debatetimer/controller/BaseDocumentTest.java b/src/test/java/com/debatetimer/controller/BaseDocumentTest.java index 0a74a3cc..bbac2b81 100644 --- a/src/test/java/com/debatetimer/controller/BaseDocumentTest.java +++ b/src/test/java/com/debatetimer/controller/BaseDocumentTest.java @@ -12,6 +12,7 @@ import com.debatetimer.service.auth.AuthService; import com.debatetimer.service.customize.CustomizeService; import com.debatetimer.service.member.MemberService; +import com.debatetimer.service.organization.OrganizationService; import com.debatetimer.service.poll.PollService; import com.debatetimer.service.poll.VoteService; import io.restassured.RestAssured; @@ -70,6 +71,9 @@ public abstract class BaseDocumentTest { @MockitoBean protected VoteService voteService; + @MockitoBean + protected OrganizationService organizationService; + @MockitoBean protected AuthManager authManager; diff --git a/src/test/java/com/debatetimer/controller/GlobalControllerTest.java b/src/test/java/com/debatetimer/controller/GlobalControllerTest.java index 6c9c041f..6de1f29a 100644 --- a/src/test/java/com/debatetimer/controller/GlobalControllerTest.java +++ b/src/test/java/com/debatetimer/controller/GlobalControllerTest.java @@ -4,11 +4,13 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Value; public class GlobalControllerTest extends BaseControllerTest { - @Value("${cors.origin}") + @Value("${cors.origin.cors-origin[0]}") private String corsOrigin; @Nested @@ -42,4 +44,17 @@ class CorsConfigTest { .then().statusCode(403); } } + + @Nested + class StaticFileTest { + + @ValueSource(strings = {"/icon/government_icon.png", "/icon/han_alm_icon.png"}) + @ParameterizedTest + void 정적_파일을_정상적으로_조회할_수_있다(String filePath) { + given() + .when().get(filePath) + .then().statusCode(200) + .contentType("image/png"); + } + } } diff --git a/src/test/java/com/debatetimer/controller/Tag.java b/src/test/java/com/debatetimer/controller/Tag.java index 39a3c588..5da09bb2 100644 --- a/src/test/java/com/debatetimer/controller/Tag.java +++ b/src/test/java/com/debatetimer/controller/Tag.java @@ -7,7 +7,7 @@ public enum Tag { TIME_BASED_API("Time Based Table API"), CUSTOMIZE_API("Customize Table API"), POLL_API("Poll API"), - ; + ORGANIZATION_API("Organization API"); private final String displayName; diff --git a/src/test/java/com/debatetimer/controller/organization/OrganizationControllerTest.java b/src/test/java/com/debatetimer/controller/organization/OrganizationControllerTest.java new file mode 100644 index 00000000..1c6cbd25 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/organization/OrganizationControllerTest.java @@ -0,0 +1,40 @@ +package com.debatetimer.controller.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.dto.organization.OrganizationResponses; +import com.debatetimer.entity.organization.OrganizationEntity; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class OrganizationControllerTest extends BaseControllerTest { + + @Nested + class GetOrganizationTemplates { + + @Test + void 모든_기관의_토론_템플릿을_조회할_수_있다() { + OrganizationEntity organization1 = organizationEntityGenerator.generate("한앎", "한양대"); + OrganizationEntity organization2 = organizationEntityGenerator.generate("한모름", "양한대"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿1"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿2"); + organizationTemplateEntityGenerator.generate(organization2, "릿플템1"); + + OrganizationResponses response = given() + .contentType(ContentType.JSON) + .when().get("/api/organizations/templates") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(OrganizationResponses.class); + + assertAll( + () -> assertThat(response.organizations()).hasSize(2), + () -> assertThat(response.organizations().get(0).templates()).hasSize(2), + () -> assertThat(response.organizations().get(1).templates()).hasSize(1) + ); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/organization/OrganizationDocumentTest.java b/src/test/java/com/debatetimer/controller/organization/OrganizationDocumentTest.java new file mode 100644 index 00000000..1933b7db --- /dev/null +++ b/src/test/java/com/debatetimer/controller/organization/OrganizationDocumentTest.java @@ -0,0 +1,67 @@ +package com.debatetimer.controller.organization; + +import static org.mockito.Mockito.doReturn; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; + +import com.debatetimer.controller.BaseDocumentTest; +import com.debatetimer.controller.RestDocumentationRequest; +import com.debatetimer.controller.RestDocumentationResponse; +import com.debatetimer.controller.Tag; +import com.debatetimer.dto.organization.OrganizationResponse; +import com.debatetimer.dto.organization.OrganizationResponses; +import com.debatetimer.dto.organization.OrganizationTemplateResponse; +import io.restassured.http.ContentType; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class OrganizationDocumentTest extends BaseDocumentTest { + + @Nested + class GetOrganizationTemplates { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.ORGANIZATION_API) + .summary("기관별 템플릿 조회"); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("organizations").type(ARRAY).description("기관 정보"), + fieldWithPath("organizations[].organization").type(STRING).description("기관 명"), + fieldWithPath("organizations[].affiliation").type(STRING).description("소속"), + fieldWithPath("organizations[].iconPath").type(STRING).description("아이콘 경로 (해당 경로로 서버 요청)"), + fieldWithPath("organizations[].templates").type(ARRAY).description("기관 템플릿 목록"), + fieldWithPath("organizations[].templates[].name").type(STRING).description("템플릿 명"), + fieldWithPath("organizations[].templates[].data").type(STRING).description("템플릿 데이터") + ); + + private static final String DEFAULT_TEMPLATE_CONTENT = "eJyrVspMUbIytjDXUcrMS8tXsqpWykvMTVWyUjJWKCtWMFZ427b1TXPj27YFrxcueN3T8HZWj8LbGVPfdM9V0lEqqSwAqXQODQ7x9%2FWMcgUKJaan5qUkAgWB7IKi%2FOKQ1MRcP4iBbzasedOyESienJ%2BHLP56wwygwUDx8sSivMy8dKfUnBwlq7TEnOJUHaW0zLzM4gwkoVqgtYlJOUCN0dVKxSWJeckgMwKC%2FIOBJhQXpKYmZ4RAnPVmXivQzUDRpPwKqJCff5Cvow%2FI5Zkgqw2NDICyYLPzSnNyIMIBqUUgx6EJBRekJmYDHQcTLgbxU4sg3FodJKc4%2B%2FsNFqf4uYaGBIEtQXPNhDdzFkCiFMVNIZ6%2BrvFOjsGuLnB3QazA6TBzkLMx3AX2DIkhtHXOm0WtCq83zHkzbQfdAwpr8qG7i2JrAbdLRw0%3D"; + + @Test + void 기관_템플릿_조회_성공() { + OrganizationResponses response = new OrganizationResponses(List.of( + new OrganizationResponse("한앎", "한양대", "/icons/icon1.png", List.of( + new OrganizationTemplateResponse("템플릿1", DEFAULT_TEMPLATE_CONTENT), + new OrganizationTemplateResponse("템플릿2", DEFAULT_TEMPLATE_CONTENT) + )), + new OrganizationResponse("한모름", "양한대", "/icons/icon2.png", List.of( + new OrganizationTemplateResponse("템플릿1", DEFAULT_TEMPLATE_CONTENT), + new OrganizationTemplateResponse("템플릿2", DEFAULT_TEMPLATE_CONTENT) + )) + )); + doReturn(response).when(organizationService).findAll(); + + var document = document("organization/get-templates", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .when().get("/api/organizations/templates") + .then().statusCode(200); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java b/src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java new file mode 100644 index 00000000..c1362bc5 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java @@ -0,0 +1,39 @@ +package com.debatetimer.controller.sharing; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.BaseStompTest; +import com.debatetimer.MessageFrameHandler; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.sharing.request.SharingRequest; +import com.debatetimer.dto.sharing.response.SharingResponse; +import java.time.LocalDateTime; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.simp.stomp.StompHeaders; + +class SharingControllerTest extends BaseStompTest { + + @Nested + class Share { + + @Test + void 사회자가_발생시킨_이벤트를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { + long roomId = 1L; + LocalDateTime time = LocalDateTime.now(); + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateAccessTokenHeader("/app/event/" + roomId, member); + stompSession.subscribe("/room/" + roomId, handler); //청중의 구독 + + stompSession.send(headers, new SharingRequest(time)); //사회자의 이벤트 발생 + + SharingResponse response = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(response.time()).isEqualTo(time); + } + } +} diff --git a/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java index 485a7f25..16b60f98 100644 --- a/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java +++ b/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java @@ -8,6 +8,8 @@ import com.debatetimer.fixture.entity.CustomizeTableEntityGenerator; import com.debatetimer.fixture.entity.CustomizeTimeBoxEntityGenerator; import com.debatetimer.fixture.entity.MemberGenerator; +import com.debatetimer.fixture.entity.OrganizationEntityGenerator; +import com.debatetimer.fixture.entity.OrganizationTemplateEntityGenerator; import com.debatetimer.fixture.entity.PollEntityGenerator; import com.debatetimer.fixture.entity.VoteEntityGenerator; import com.debatetimer.repository.customize.BellRepository; @@ -49,6 +51,12 @@ public abstract class BaseDomainRepositoryTest { @Autowired protected VoteEntityGenerator voteEntityGenerator; + @Autowired + protected OrganizationEntityGenerator organizationEntityGenerator; + + @Autowired + protected OrganizationTemplateEntityGenerator organizationTemplateEntityGenerator; + @Autowired protected PollRepository pollRepository; diff --git a/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java new file mode 100644 index 00000000..8e28eb07 --- /dev/null +++ b/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java @@ -0,0 +1,39 @@ +package com.debatetimer.domainrepository.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domainrepository.BaseDomainRepositoryTest; +import com.debatetimer.entity.organization.OrganizationEntity; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class OrganizationDomainRepositoryTest extends BaseDomainRepositoryTest { + + @Autowired + private OrganizationDomainRepository organizationDomainRepository; + + @Nested + class FindAll { + + @Test + void 모든_조직_템플릿을_가져온다() { + OrganizationEntity organization1 = organizationEntityGenerator.generate("한앎", "한양대"); + OrganizationEntity organization2 = organizationEntityGenerator.generate("한모름", "양한대"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿1"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿2"); + organizationTemplateEntityGenerator.generate(organization2, "릿플템1"); + + List organizations = organizationDomainRepository.findAll(); + + assertAll( + () -> assertThat(organizations).hasSize(2), + () -> assertThat(organizations.get(0).getTemplates()).hasSize(2), + () -> assertThat(organizations.get(1).getTemplates()).hasSize(1) + ); + } + } +} diff --git a/src/test/java/com/debatetimer/entity/customize/CustomizeTableEntityTest.java b/src/test/java/com/debatetimer/entity/customize/CustomizeTableEntityTest.java index 6dbfa1c5..ab8dc36c 100644 --- a/src/test/java/com/debatetimer/entity/customize/CustomizeTableEntityTest.java +++ b/src/test/java/com/debatetimer/entity/customize/CustomizeTableEntityTest.java @@ -30,7 +30,7 @@ class UpdateUsedAt { void 테이블의_사용_시각을_업데이트한다() { Member member = new Member("default@gmail.com"); CustomizeTable table = new CustomizeTable(member, "tableName", "agenda", "찬성", "반대", - true, true, LocalDateTime.now().minusNanos(1L)); + true, true, LocalDateTime.now().minusSeconds(1L)); CustomizeTableEntity customizeTableEntity = new CustomizeTableEntity(table); LocalDateTime beforeUsedAt = customizeTableEntity.getUsedAt(); @@ -68,7 +68,7 @@ class Update { void 테이블_업데이트_할_때_사용_시간을_변경한다() { Member member = new Member("default@gmail.com"); CustomizeTable table = new CustomizeTable(member, "tableName", "agenda", "찬성", "반대", - true, true, LocalDateTime.now().minusNanos(1L)); + true, true, LocalDateTime.now().minusSeconds(1L)); CustomizeTableEntity customizeTableEntity = new CustomizeTableEntity(table); CustomizeTable renewTable = new CustomizeTable(member, "newName", "newAgenda", "newPros", "newCons", false, false, LocalDateTime.now()); diff --git a/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java new file mode 100644 index 00000000..b8e2bba7 --- /dev/null +++ b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java @@ -0,0 +1,33 @@ +package com.debatetimer.event.sharing; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.BaseStompTest; +import com.debatetimer.MessageFrameHandler; +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class RoomSubscribeListenerTest extends BaseStompTest { + + @Nested + class SubscribeListener { + + @Test + void 새로운_청중이_공유되면_사회자에게_정보공유_트리거를_발송한다() throws ExecutionException, InterruptedException, TimeoutException { + long roomId = 1L; + MessageFrameHandler handler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + stompSession.subscribe("/chairman/" + roomId, handler); + + stompSession.subscribe("/room/" + roomId, handler); + + ChairmanSharingRequest sharingRequest = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(sharingRequest).isNotNull(); + } + } +} diff --git a/src/test/java/com/debatetimer/fixture/HeaderGenerator.java b/src/test/java/com/debatetimer/fixture/HeaderGenerator.java index a8a6b8bb..70362980 100644 --- a/src/test/java/com/debatetimer/fixture/HeaderGenerator.java +++ b/src/test/java/com/debatetimer/fixture/HeaderGenerator.java @@ -6,6 +6,7 @@ import io.restassured.http.Header; import io.restassured.http.Headers; import org.springframework.http.HttpHeaders; +import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.stereotype.Component; @Component @@ -21,4 +22,12 @@ public Headers generateAccessTokenHeader(Member member) { String accessToken = jwtTokenProvider.createAccessToken(new MemberInfo(member)); return new Headers(new Header(HttpHeaders.AUTHORIZATION, accessToken)); } + + public StompHeaders generateAccessTokenHeader(String destination, Member member) { + String accessToken = jwtTokenProvider.createAccessToken(new MemberInfo(member)); + StompHeaders stompHeaders = new StompHeaders(); + stompHeaders.setDestination(destination); + stompHeaders.add(HttpHeaders.AUTHORIZATION, accessToken); + return stompHeaders; + } } diff --git a/src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java b/src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java new file mode 100644 index 00000000..f193aeca --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java @@ -0,0 +1,22 @@ +package com.debatetimer.fixture.entity; + +import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.repository.organization.OrganizationRepository; +import org.springframework.stereotype.Component; + +@Component +public class OrganizationEntityGenerator { + + private static final String DEFAULT_ICON_PATH = "/static/icons/default_icon.png"; + + private final OrganizationRepository organizationRepository; + + public OrganizationEntityGenerator(OrganizationRepository organizationRepository) { + this.organizationRepository = organizationRepository; + } + + public OrganizationEntity generate(String name, String affiliation) { + OrganizationEntity organization = new OrganizationEntity(name, affiliation, DEFAULT_ICON_PATH); + return organizationRepository.save(organization); + } +} diff --git a/src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java b/src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java new file mode 100644 index 00000000..45242cd7 --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java @@ -0,0 +1,24 @@ +package com.debatetimer.fixture.entity; + +import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.entity.organization.OrganizationTemplateEntity; +import com.debatetimer.repository.organization.OrganizationTemplateRepository; +import org.springframework.stereotype.Component; + +@Component +public class OrganizationTemplateEntityGenerator { + + private static final String DEFAULT_TEMPLATE_CONTENT = "eJyrVspMUbIytjDXUcrMS8tXsqpWykvMTVWyUjJWKCtWMFZ427b1TXPj27YFrxcueN3T8HZWj8LbGVPfdM9V0lEqqSwAqXQODQ7x9%2FWMcgUKJaan5qUkAgWB7IKi%2FOKQ1MRcP4iBbzasedOyESienJ%2BHLP56wwygwUDx8sSivMy8dKfUnBwlq7TEnOJUHaW0zLzM4gwkoVqgtYlJOUCN0dVKxSWJeckgMwKC%2FIOBJhQXpKYmZ4RAnPVmXivQzUDRpPwKqJCff5Cvow%2FI5Zkgqw2NDICyYLPzSnNyIMIBqUUgx6EJBRekJmYDHQcTLgbxU4sg3FodJKc4%2B%2FsNFqf4uYaGBIEtQXPNhDdzFkCiFMVNIZ6%2BrvFOjsGuLnB3QazA6TBzkLMx3AX2DIkhtHXOm0WtCq83zHkzbQfdAwpr8qG7i2JrAbdLRw0%3D"; + + private final OrganizationTemplateRepository organizationTemplateRepository; + + public OrganizationTemplateEntityGenerator(OrganizationTemplateRepository organizationTemplateRepository) { + this.organizationTemplateRepository = organizationTemplateRepository; + } + + public OrganizationTemplateEntity generate(OrganizationEntity organization, String name) { + OrganizationTemplateEntity template = + new OrganizationTemplateEntity(organization, name, DEFAULT_TEMPLATE_CONTENT); + return organizationTemplateRepository.save(template); + } +} diff --git a/src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java b/src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java new file mode 100644 index 00000000..cea12f4d --- /dev/null +++ b/src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java @@ -0,0 +1,74 @@ +package com.debatetimer.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.entity.poll.PollEntity; +import com.debatetimer.repository.poll.PollRepository; +import com.debatetimer.service.BaseServiceTest; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +class PollCleanupSchedulerTest extends BaseServiceTest { + + @Autowired + private PollRepository pollRepository; + + @Autowired + private PollCleanupScheduler pollCleanupScheduler; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Nested + class CleanupStalePolls { + + @Test + void 생성_후_일정_시간_이상_경과한_진행_상태인_투표를_완료_상태로_변경한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + PollEntity poll = pollEntityGenerator.generate(table, PollStatus.PROGRESS); + updateCreatedAt(poll.getId(), LocalDateTime.now().minusHours(PollCleanupScheduler.TIMEOUT_HOURS + 1)); + + pollCleanupScheduler.cleanupStalePolls(); + + PollStatus status = pollRepository.getById(poll.getId()).getStatus(); + assertThat(status).isEqualTo(PollStatus.DONE); + } + + @Test + void 생성_후_일정_시간_미만_경과한_진행_상태인_투표는_그대로_유지한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + PollEntity poll = pollEntityGenerator.generate(table, PollStatus.PROGRESS); + updateCreatedAt(poll.getId(), LocalDateTime.now().minusHours(PollCleanupScheduler.TIMEOUT_HOURS - 1)); + + pollCleanupScheduler.cleanupStalePolls(); + + PollStatus status = pollRepository.getById(poll.getId()).getStatus(); + assertThat(status).isEqualTo(PollStatus.PROGRESS); + } + + @Test + void 이미_완료_상태인_투표는_영향받지_않는다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + PollEntity poll = pollEntityGenerator.generate(table, PollStatus.DONE); + updateCreatedAt(poll.getId(), LocalDateTime.now().minusHours(PollCleanupScheduler.TIMEOUT_HOURS + 1)); + + pollCleanupScheduler.cleanupStalePolls(); + + PollStatus status = pollRepository.getById(poll.getId()).getStatus(); + assertThat(status).isEqualTo(PollStatus.DONE); + } + + private void updateCreatedAt(Long pollId, LocalDateTime createdAt) { + jdbcTemplate.update("UPDATE poll SET created_at = ? WHERE id = ?", createdAt, pollId); + } + } +} diff --git a/src/test/java/com/debatetimer/service/BaseServiceTest.java b/src/test/java/com/debatetimer/service/BaseServiceTest.java index 3bd0a8c1..f7ba2145 100644 --- a/src/test/java/com/debatetimer/service/BaseServiceTest.java +++ b/src/test/java/com/debatetimer/service/BaseServiceTest.java @@ -5,6 +5,8 @@ import com.debatetimer.fixture.entity.CustomizeTableEntityGenerator; import com.debatetimer.fixture.entity.CustomizeTimeBoxEntityGenerator; import com.debatetimer.fixture.entity.MemberGenerator; +import com.debatetimer.fixture.entity.OrganizationEntityGenerator; +import com.debatetimer.fixture.entity.OrganizationTemplateEntityGenerator; import com.debatetimer.fixture.entity.PollEntityGenerator; import com.debatetimer.fixture.entity.VoteEntityGenerator; import com.debatetimer.repository.customize.BellRepository; @@ -55,6 +57,12 @@ public abstract class BaseServiceTest { @Autowired protected VoteEntityGenerator voteEntityGenerator; + @Autowired + protected OrganizationEntityGenerator organizationEntityGenerator; + + @Autowired + protected OrganizationTemplateEntityGenerator organizationTemplateEntityGenerator; + protected void runAtSameTime(int count, Runnable task) throws InterruptedException { List threads = IntStream.range(0, count) .mapToObj(i -> new Thread(task)) diff --git a/src/test/java/com/debatetimer/service/organization/OrganizationServiceTest.java b/src/test/java/com/debatetimer/service/organization/OrganizationServiceTest.java new file mode 100644 index 00000000..86e0b251 --- /dev/null +++ b/src/test/java/com/debatetimer/service/organization/OrganizationServiceTest.java @@ -0,0 +1,45 @@ +package com.debatetimer.service.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.dto.organization.OrganizationResponses; +import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.service.BaseServiceTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class OrganizationServiceTest extends BaseServiceTest { + + @Autowired + private OrganizationService organizationService; + + @Nested + class FindAll { + + @Test + void 저장한_모든_기관_템플릿을_반환한다() { + OrganizationEntity organization1 = organizationEntityGenerator.generate("한앎", "한양대"); + OrganizationEntity organization2 = organizationEntityGenerator.generate("한모름", "양한대"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿1"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿2"); + organizationTemplateEntityGenerator.generate(organization2, "릿플템1"); + + OrganizationResponses response = organizationService.findAll(); + + assertAll( + () -> assertThat(response.organizations()).hasSize(2), + () -> assertThat(response.organizations().get(0).templates()).hasSize(2), + () -> assertThat(response.organizations().get(1).templates()).hasSize(1) + ); + } + + @Test + void 비어있을_경우_빈_객체를_반환한다() { + OrganizationResponses response = organizationService.findAll(); + + assertThat(response.organizations()).isEmpty(); + } + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b844ae6c..fa923caa 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -3,7 +3,9 @@ spring: active: test cors: - origin: http://test.debate-timer.com + origin: + cors-origin: + - http://test.debate-timer.com oauth: client_id: oauth_client_id