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..72b65d10 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,12 @@ out/ ### VS Code ### .vscode/ +### deploy +/letsencrypt/acme.json + ### Rest Docs /src/main/resources/static/docs/openapi3.yaml ### application-local.yml /src/main/resources/application-local.yml +.serena diff --git a/build.gradle b/build.gradle index a24fcd40..4c8b6752 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" @@ -60,7 +59,8 @@ dependencies { // Monitoring implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'io.micrometer:micrometer-registry-prometheus' // Before + implementation 'io.micrometer:micrometer-registry-statsd' // After // DB schema manager implementation 'org.flywaydb:flyway-mysql' @@ -71,7 +71,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' @@ -82,6 +82,10 @@ bootJar { dependsOn("openapi3") } +jar { + enabled = false +} + jacoco { toolVersion = '0.8.9' } diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..17299ce8 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,17 @@ +FROM eclipse-temurin:17-jdk-alpine AS builder +WORKDIR /workspace +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +RUN java -Djarmode=layertools -jar app.jar extract + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /workspace + +ADD https://dtdg.co/latest-java-tracer ./dd-java-agent.jar + +COPY --from=builder /workspace/dependencies/ ./ +COPY --from=builder /workspace/spring-boot-loader/ ./ +COPY --from=builder /workspace/snapshot-dependencies/ ./ +COPY --from=builder /workspace/application/ ./ + +ENTRYPOINT ["java", "-javaagent:./dd-java-agent.jar", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/docker/application/docker-compose.application.yml b/docker/application/docker-compose.application.yml new file mode 100644 index 00000000..2e9ec098 --- /dev/null +++ b/docker/application/docker-compose.application.yml @@ -0,0 +1,54 @@ +services: + application: + image: debatetimer/debate_timer:${ENV:-dev} + container_name: application + environment: + - SERVER_FORWARD_HEADERS_STRATEGY=framework + - SPRING_PROFILES_ACTIVE=${ENV:-dev},monitor + - TZ=Asia/Seoul + + - DD_AGENT_HOST=datadog-agent + - DD_SERVICE=debate-timer + - DD_ENV=${ENV:-dev} + - DD_VERSION=${APP_VERSION:-1.0.0} + - DD_LOGS_INJECTION=true + - DD_PROFILING_ENABLED=true + - DD_PROFILING_ALLOCATION_ENABLED=true + - DD_PROFILING_HEAP_ENABLED=true + + - MANAGEMENT_STATSD_METRICS_EXPORT_ENABLED=true + - MANAGEMENT_STATSD_METRICS_EXPORT_FLAVOR=datadog + - MANAGEMENT_STATSD_METRICS_EXPORT_HOST=datadog-agent + - MANAGEMENT_STATSD_METRICS_EXPORT_PORT=8125 + - MANAGEMENT_STATSD_METRICS_EXPORT_PROTOCOL=UDP + + networks: + - debate-timer-net + + depends_on: + traefik: + condition: service_healthy + + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8083/monitoring/health" ] + interval: 60s + retries: 10 + start_period: 450s + + labels: + - "traefik.enable=true" + - "traefik.http.routers.application.rule=Host(`api.${ENV:-dev}.debate-timer.com`)" + - "traefik.http.routers.application.entrypoints=websecure" + - "traefik.http.routers.application.tls=true" + - "traefik.http.routers.application.tls.certresolver=myresolver" + - "traefik.http.routers.application.service=application" + - "traefik.http.services.application.loadbalancer.server.port=8080" + + - "traefik.http.routers.application-monitor.rule=PathPrefix(`/`)" + - "traefik.http.routers.application-monitor.entrypoints=monitoring" + - "traefik.http.routers.application-monitor.service=application-monitor-svc" + - "traefik.http.services.application-monitor-svc.loadbalancer.server.port=8083" + + - "com.datadoghq.ad.logs=[{\"source\": \"java\", \"service\": \"debate-timer\"}]" + + restart: unless-stopped diff --git a/docker/datadog/docker-compose.datadog.yml b/docker/datadog/docker-compose.datadog.yml new file mode 100644 index 00000000..a3823718 --- /dev/null +++ b/docker/datadog/docker-compose.datadog.yml @@ -0,0 +1,31 @@ +services: + datadog-agent: + image: gcr.io/datadoghq/agent:7 + container_name: datadog-agent + environment: + - DD_API_KEY=${DD_API_KEY} + - DD_SITE=us5.datadoghq.com + - DD_HOSTNAME=debate-timer-${ENV:-dev}-server + - DD_APM_ENABLED=true + - DD_APM_NON_LOCAL_TRAFFIC=true + - DD_APM_IGNORE_RESOURCES=/monitoring/health + - DD_DOGSTATSD_NON_LOCAL_TRAFFIC=true + - DD_LOGS_ENABLED=true + - DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL=true + - DD_PROCESS_AGENT_ENABLED=true + - DD_TAGS=env:${ENV:-dev},project:debate-timer + ports: + - "8125:8125/udp" # Metrics (StatsD) + - "8126:8126/tcp" # APM (Trace) + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro # 로그 수집 필수 + - /proc/:/host/proc/:ro + - /sys/fs/cgroup/:/host/sys/fs/cgroup/:ro + networks: + - debate-timer-net + healthcheck: + test: ["CMD", "agent", "health"] + interval: 60s + timeout: 30s + retries: 5 + start_period: 120s diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..7fc5b5b7 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,12 @@ +name: debate-timer-server + +include: + - ./application/docker-compose.application.yml + - ./datadog/docker-compose.datadog.yml + - ./traefik/docker-compose.traefik.yml + - ./webhook/docker-compose.webhook.yml + +networks: + debate-timer-net: + name: debate-timer-net + driver: bridge diff --git a/docker/traefik/docker-compose.traefik.yml b/docker/traefik/docker-compose.traefik.yml new file mode 100644 index 00000000..3fe979cb --- /dev/null +++ b/docker/traefik/docker-compose.traefik.yml @@ -0,0 +1,39 @@ +services: + traefik: + image: traefik:v2.11 + container_name: traefik + ports: + - "80:80" + - "443:443" + - "8080:8080" + - "8083:8083" + environment: + - DOCKER_API_VERSION=1.44 + command: + - "--api.insecure=true" + - "--ping=true" + - "--log.level=INFO" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + + - "--entrypoints.web.address=:80" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--entrypoints.websecure.address=:443" + + - "--certificatesresolvers.myresolver.acme.tlschallenge=true" + - "--certificatesresolvers.myresolver.acme.email=debatetimekeeping@gmail.com" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" + + - "--entrypoints.monitoring.address=:8083" + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/ping"] + interval: 60s + timeout: 10s + retries: 5 + start_period: 120s + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ../../letsencrypt:/letsencrypt + networks: + - debate-timer-net diff --git a/docker/webhook/docker-compose.webhook.yml b/docker/webhook/docker-compose.webhook.yml new file mode 100644 index 00000000..c9e4e0a3 --- /dev/null +++ b/docker/webhook/docker-compose.webhook.yml @@ -0,0 +1,35 @@ +services: + webhook: + build: + context: .. + dockerfile: ./webhook/webhook.Dockerfile + container_name: webhook + user: root + command: ["-verbose", "-hooks=/etc/webhook/hooks.json", "-hotreload"] + environment: + - ENV=${ENV:-dev} + - SECRET_TOKEN=${WEBHOOK_KEY} + labels: + - "traefik.enable=true" + - "traefik.http.routers.webhook.rule=Host(`webhook.${ENV:-dev}.debate-timer.com`)" + - "traefik.http.routers.webhook.entrypoints=websecure" + - "traefik.http.routers.webhook.tls=true" + - "traefik.http.routers.webhook.tls.certresolver=myresolver" + - "traefik.http.services.webhook.loadbalancer.server.port=9000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /home/ubuntu/.docker:/root/.docker:ro + - ./hooks.json:/etc/webhook/hooks.json + - ../..:/home/ubuntu/debate-timer + networks: + - debate-timer-net + depends_on: + traefik: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 9000 || exit 1"] + interval: 120s + timeout: 30s + retries: 5 + start_period: 30s + restart: always diff --git a/docker/webhook/hooks.json b/docker/webhook/hooks.json new file mode 100644 index 00000000..db3e8f9d --- /dev/null +++ b/docker/webhook/hooks.json @@ -0,0 +1,38 @@ +[ + { + "id": "deploy", + "http-methods": ["POST"], + "execute-command": "/home/ubuntu/debate-timer/scripts/deploy/deploy-docker.sh", + "command-working-directory": "/home/ubuntu/debate-timer", + "pass-environment-to-command": [ + { + "source": "header", + "name": "X-Deploy-Token", + "envname": "REQUEST_TOKEN" + } + ], + "pass-arguments-to-command": [ + { "source": "entire-payload" } + ] + }, + { + "id": "status", + "http-methods": ["GET"], + "execute-command": "/home/ubuntu/debate-timer/scripts/deploy/check-deploy-status.sh", + "command-working-directory": "/home/ubuntu/debate-timer", + "include-command-output-in-response": true, + "response-headers": [ + { + "name": "Content-Type", + "value": "application/json" + } + ], + "pass-environment-to-command": [ + { + "source": "header", + "name": "X-Deploy-Token", + "envname": "REQUEST_TOKEN" + } + ] + } +] diff --git a/docker/webhook/webhook.Dockerfile b/docker/webhook/webhook.Dockerfile new file mode 100644 index 00000000..0453da68 --- /dev/null +++ b/docker/webhook/webhook.Dockerfile @@ -0,0 +1,7 @@ +FROM almir/webhook:2.8.3 +USER root +RUN apk add --no-cache docker-cli-compose curl bash util-linux git + +WORKDIR /etc/webhook + +RUN git config --global --add safe.directory /home/ubuntu/debate-timer 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/deploy/check-deploy-status.sh b/scripts/deploy/check-deploy-status.sh new file mode 100755 index 00000000..98dc2fa0 --- /dev/null +++ b/scripts/deploy/check-deploy-status.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +if [ -z "$SECRET_TOKEN" ] || [ "$REQUEST_TOKEN" != "$SECRET_TOKEN" ]; then + echo '{"status": "error", "message": "Unauthorized request. Invalid Token."}' + exit 0 +fi + +LOCK_FILE="/tmp/deploy-application.lock" + +exec 200>"$LOCK_FILE" + +if ! flock -n 200; then + echo '{"status": "running", "message": "배포 스크립트가 현재 실행 중입니다."}' +else + flock -u 200 + echo '{"status": "idle", "message": "현재 진행 중인 배포가 없습니다. 대기 중입니다."}' +fi + +exec 200>&- diff --git a/scripts/deploy/deploy-docker.sh b/scripts/deploy/deploy-docker.sh new file mode 100755 index 00000000..746ecf3a --- /dev/null +++ b/scripts/deploy/deploy-docker.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +if [ -z "$SECRET_TOKEN" ] || [ "$REQUEST_TOKEN" != "$SECRET_TOKEN" ]; then + echo "[$(date)] 🚨 보안 경고: 유효하지 않은 배포 요청입니다. (Unauthorized)" + exit 1 +fi + +echo "스크립트 실행 여부를 확인합니다." +LOCK_FILE="/tmp/deploy-application.lock" +exec 200>"$LOCK_FILE" +flock -n 200 || { echo "⚠️ 이미 배포 스크립트가 실행 중입니다. 중복 실행을 차단합니다."; exit 1; } +echo "스크립트를 실행하고 있지 않습니다. 배포를 시작합니다." + +TARGET_ENV=${ENV:-dev} +PROJECT_DIR="/home/ubuntu/debate-timer" +DOCKER_DIR="/home/ubuntu/debate-timer/docker" +TARGET_SERVICE="application" + +echo "--- Git 저장소 최신화 시작 ---" +cd $PROJECT_DIR || { echo "프로젝트 디렉토리 이동 실패"; exit 1; } + +if [ "$TARGET_ENV" = "prod" ]; then + echo "▶ [ENV: prod] main 브랜치로 이동하여 최신 코드를 가져옵니다." + git fetch origin main || { echo "❌ git fetch(main) 실패"; exit 1; } + git switch main || { echo "❌ git switch(main) 실패"; exit 1; } + git reset --hard origin/main || { echo "❌ git reset(main) 실패"; exit 1; } +elif [ "$TARGET_ENV" = "dev" ]; then + echo "▶ [ENV: dev] develop 브랜치로 이동하여 최신 코드를 가져옵니다." + git fetch origin develop || { echo "❌ git fetch(develop) 실패"; exit 1; } + git switch develop || { echo "❌ git switch(develop) 실패"; exit 1; } + git reset --hard origin/develop || { echo "❌ git reset(develop) 실패"; exit 1; } +else + CURRENT_BRANCH=$(git branch --show-current) + if [ -z "$CURRENT_BRANCH" ]; then + echo "❌ 현재 브랜치를 확인할 수 없습니다 (detached HEAD 상태일 수 있음)" + exit 1 + fi + echo "▶ [ENV: $TARGET_ENV] 현재 브랜치($CURRENT_BRANCH)에서 최신 코드를 가져옵니다." + git fetch origin "$CURRENT_BRANCH" || { echo "❌ git fetch($CURRENT_BRANCH) 실패"; exit 1; } + git reset --hard origin/"$CURRENT_BRANCH" || { echo "❌ git reset($CURRENT_BRANCH) 실패"; exit 1; } +fi + +cd $DOCKER_DIR || { echo "디렉토리 이동 실패"; exit 1; } +echo "--- 중단 배포 시작 ENV: $TARGET_ENV at $(date) ---" +export ENV=$TARGET_ENV + +echo "최근 이미지 가져오는 중 (ENV: $TARGET_ENV)" +docker compose pull $TARGET_SERVICE || { echo "❌ 이미지 풀 실패"; exit 1; } +if docker ps --format '{{.Names}}' | grep -q "^${TARGET_SERVICE}$"; then + echo "기존 $TARGET_SERVICE 중지 및 제거 중..." + docker compose stop $TARGET_SERVICE +fi + +echo "새 컨테이너 실행 중 - $TARGET_SERVICE..." +docker compose up -d --no-deps $TARGET_SERVICE || { echo "❌ 컨테이너 실행 실패"; exit 1; } + +echo "헬스 체크 진행 중 - $TARGET_SERVICE" +MAX_RETRIES=50 +SLEEP_SECOND=10 +COUNT=0 + +while [ $COUNT -lt $MAX_RETRIES ]; do + HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$TARGET_SERVICE" 2>/dev/null) + if [ "$HEALTH_STATUS" = "healthy" ]; then + echo "헬스 체크 완료 - $TARGET_SERVICE" + break + fi + + echo "헬스체크 진행 중 ($COUNT/$MAX_RETRIES) - 현재 상태: $HEALTH_STATUS" + sleep $SLEEP_SECOND + COUNT=$((COUNT + 1)) +done + +if [ $COUNT -eq $MAX_RETRIES ]; then + echo "배포 실패: $TARGET_SERVICE가 healthy 상태가 되지 않았습니다." + docker logs --tail 50 $TARGET_SERVICE + exit 1 +fi + +docker image prune -f + +echo "--- $TARGET_ENV 배포 완료 at $(date) ---" 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/init/auto-swap.sh b/scripts/init/auto-swap.sh new file mode 100755 index 00000000..56e97a96 --- /dev/null +++ b/scripts/init/auto-swap.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -e # 스크립트 실행 중 에러 발생 시 즉시 중단 (안전 장치) + +# --- 설정 변수 --- +SWAP_FILE="/swapfile" +SWAP_SIZE=${1:-"2G"} +FSTAB_FILE="/etc/fstab" + +# 1. 루트 권한 확인 +if [ "$EUID" -ne 0 ]; then + echo "오류: 이 스크립트는 root 권한(sudo)으로 실행해야 합니다." + exit 1 +fi + +# 2. 기존 스왑 파일 존재 여부 확인 +if [ -f "$SWAP_FILE" ]; then + echo "알림: $SWAP_FILE 이 이미 존재합니다." + echo "작업을 중단합니다." + exit 0 +fi + +echo "=== Swap 메모리 생성 시작 ($SWAP_SIZE) ===" + +# 3. Swap 파일 생성 +fallocate -l $SWAP_SIZE $SWAP_FILE +echo " -> 파일 생성 완료" + +# 4. 권한 설정 (600) +chmod 600 $SWAP_FILE +echo " -> 권한 설정 완료" + +# 5. Swap 활성화 +mkswap $SWAP_FILE > /dev/null +swapon $SWAP_FILE +echo " -> Swap 활성화 완료" + +# 6. /etc/fstab 등록 (재부팅 후에도 유지) +if ! grep -q "$SWAP_FILE" "$FSTAB_FILE"; then + echo "$SWAP_FILE swap swap defaults 0 0" >> "$FSTAB_FILE" + echo " -> fstab 등록 완료 (자동 실행 설정)" +fi + +echo "=== 모든 작업이 완료되었습니다 ===" +echo "[현재 메모리 상태]" +free -h diff --git a/scripts/init/init-docker.sh b/scripts/init/init-docker.sh new file mode 100755 index 00000000..c515ffa9 --- /dev/null +++ b/scripts/init/init-docker.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -e # 스크립트 실행 중 에러 발생 시 즉시 중단 (안전 장치) + +DOCKER_USER=$1 +DOCKER_TOKEN=$2 + +echo "=== Docker 설치를 시작합니다 ===" + +echo "1. 필수 패키지 설치 및 GPG 키 설정 진행" +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg --yes +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +echo "2. 리포지토리 설정 진행" +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +echo "3. Docker Engine 설치 진행" +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +echo "4. 사용자 권한 설정 진행" +sudo usermod -aG docker $USER + +echo "5. 로그인 진행 (변수 입력했을 경우에만)" +if [ -n "$DOCKER_USER" ] && [ -n "$DOCKER_TOKEN" ]; then + echo "입력된 정보로 Docker Hub 로그인을 시도합니다..." + + if echo "$DOCKER_TOKEN" | sg docker -c "docker login -u \"$DOCKER_USER\" --password-stdin"; then + echo "✅ 로그인 성공! (config.json이 생성되었습니다)" + else + echo "❌ 로그인 실패. 아이디/토큰을 확인하거나 'newgrp docker' 후 다시 시도하세요." + fi +else + echo "로그인 정보가 입력되지 않아 로그인을 건너뜁니다." + echo "추후 'docker login' 명령어로 로그인하세요." +fi + +echo "6. Docker 권한이 적용된 새로운 쉘로 전환" +exec newgrp docker diff --git a/scripts/init/init-letsencrypt.sh b/scripts/init/init-letsencrypt.sh new file mode 100755 index 00000000..f8820a66 --- /dev/null +++ b/scripts/init/init-letsencrypt.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +cd "$(dirname "$0")" || exit 1 + +mkdir -p ../../letsencrypt +touch ../../letsencrypt/acme.json +chmod 600 ../../letsencrypt/acme.json + +echo "letsencrypt 폴더를 생성했습니다." 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/customize/TeamName.java b/src/main/java/com/debatetimer/domain/customize/TeamName.java index 352eca26..2ac47657 100644 --- a/src/main/java/com/debatetimer/domain/customize/TeamName.java +++ b/src/main/java/com/debatetimer/domain/customize/TeamName.java @@ -8,7 +8,7 @@ public class TeamName { private static final String NAME_REGEX = "^[\\p{L}\\p{M}\\p{N}\\p{P}\\p{Z}\\s]+$"; - public static final int NAME_MAX_LENGTH = 8; + public static final int NAME_MAX_LENGTH = 15; private final String value; 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-monitor.yml b/src/main/resources/application-monitor.yml index d9809cb1..682582b8 100644 --- a/src/main/resources/application-monitor.yml +++ b/src/main/resources/application-monitor.yml @@ -1,4 +1,13 @@ management: + statsd: + metrics: + export: + enabled: true + flavor: datadog + host: ${MANAGEMENT_STATSD_METRICS_EXPORT_HOST:localhost} + port: 8125 + protocol: UDP + server: port: 8083 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