[
  {
    "path": ".github/ISSUE_TEMPLATE/bug-template.md",
    "content": "---\nname: Bug Template\nabout: 버그를 이슈에 등록한다.\ntitle: '이슈의 제목을 입력해주세요!'\nlabels: ''\nassignees: ''\n---\n\n## 🤷 버그 내용\n\n## ⚠ 버그 재현 방법\n1.\n2.\n3.\n\n## 📸 스크린샷\n\n## 👄 참고 사항\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature-template.md",
    "content": "---\nname: Feature Template\nabout: 구현할 기능을 이슈에 등록한다.\ntitle: '이슈의 제목을 입력해주세요!'\nlabels: ''\nassignees: ''\n---\n\n## 🤷 구현할 기능\n\n## 🔨 상세 작업 내용\n\n- [ ] To-do 1\n- [ ] To-do 2\n- [ ] To-do 3\n\n## 📄 참고 사항\n\n## ⏰ 예상 소요 기간\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "- [ ] 🔀 PR 제목의 형식을 잘 작성했나요? e.g. `[feat] PR을 등록한다.` \n- [ ] 💯 테스트는 잘 통과했나요?\n- [ ] 🏗️ 빌드는 성공했나요?\n- [ ] 🧹 불필요한 코드는 제거했나요?\n- [ ] 💭 이슈는 등록했나요?\n- [ ] 🏷️ 라벨은 등록했나요?\n- [ ] 💻 git rebase를 사용했나요?\n- [ ] 🌈 알록달록한가요?\n\n## 작업 내용\n\n## 스크린샷\n\n## 주의사항\n\nCloses #{이슈 번호}\n"
  },
  {
    "path": ".github/workflows/backend-cd.yml",
    "content": "name: Backend CD\n\non:\n  push:\n    branches:\n      - main\n      - develop\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    defaults:\n      run:\n        working-directory: ./backend\n\n    steps:\n      - uses: actions/checkout@v2\n        with:\n          token: ${{secrets.SUBMODULE_TOKEN}}\n          submodules: true\n\n      - name: Set up JDK 11\n        uses: actions/setup-java@v2\n        with:\n          java-version: \"11\"\n          distribution: \"adopt\"\n\n      - name: Build with Gradle For RestDocs\n        run: ./gradlew bootJar\n\n      - name: Build with Gradle\n        run: ./gradlew bootJar\n\n      - name: Deploy use SCP\n        uses: appleboy/scp-action@master\n        with:\n          host: ${{secrets.LINODE_HOST}}\n          username: ${{secrets.LINODE_USERNAME}}\n          password: ${{secrets.LINODE_PASSWORD}}\n          source: \"./backend/build/libs/*.jar\"\n          target: \"/root/cd_application\"\n          strip_components: 3\n\n      - name: Run Application use SSH\n        uses: appleboy/ssh-action@master\n        with:\n          host: ${{secrets.LINODE_HOST}}\n          username: ${{secrets.LINODE_USERNAME}}\n          password: ${{secrets.LINODE_PASSWORD}}\n          script_stop: true\n          script: sh /root/cd_application/run.sh\n"
  },
  {
    "path": ".github/workflows/backend-ci.yml",
    "content": "name: Backend CI\n\non:\n  pull_request:\n    branches:\n      - main\n      - develop\n    paths:\n      - backend/**\n      - .github/** # Github Actions 작업을 위한 포함\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    defaults:\n      run:\n        working-directory: ./backend\n\n    steps:\n      - uses: actions/checkout@v3\n\n      - name: Set up JDK 11\n        uses: actions/setup-java@v3\n        with:\n          java-version: \"11\"\n          distribution: \"temurin\"\n\n      - name: Grant execute permission for gradlew\n        run: chmod +x gradlew\n\n      - name: Build with Gradle\n        run: ./gradlew build\n\n      - name: Publish Unit Test Results\n        uses: EnricoMi/publish-unit-test-result-action@v2\n        if: always()\n        with:\n          junit_files: ${{ github.workspace }}/backend/build/test-results/**/*.xml\n"
  },
  {
    "path": ".github/workflows/frontend-ci.yml",
    "content": "name: Frontend CI\n\non:\n  pull_request:\n    branches:\n      - main\n      - develop\n    paths:\n      - frontend/**\n      - .github/** # Github Actions 작업을 위한 포함\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: ./frontend\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-node@v3\n        with:\n          node-version: '16'\n          cache: 'yarn'\n          cache-dependency-path: '**/yarn.lock'\n\n      - name: Install node packages\n        run: yarn --frozen-lockfile\n\n      - name: Check lint\n        run: yarn check-lint\n\n      - name: Check prettier\n        run: yarn check-prettier\n\n      - name: Build\n        run: yarn dev-build\n\n      - name: Component test\n        run: yarn test\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea\n.DS_Store\n\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"backend/src/main/resources/config\"]\n\tpath = backend/src/main/resources/config\n\turl = https://github.com/dallog/config.git\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 dallog\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<img src=\"https://user-images.githubusercontent.com/11745691/185735071-5eb23eaa-745b-4d69-a336-b64e5a6f011e.png\" />\n\n### 달력이 기록을 공유할 때, 달록 🌙\n\n[<img src=\"https://img.shields.io/badge/-dallog.me-important?style=flat&logo=google-chrome&logoColor=white\" />](https://dallog.me) [<img src=\"https://img.shields.io/badge/-tech blog-blue?style=flat&logo=google-chrome&logoColor=white\" />](https://dallog.github.io) [<img src=\"https://img.shields.io/badge/release-v1.1.6-critical?style=flat&logo=google-chrome&logoColor=white\" />](https://github.com/woowacourse-teams/2022-dallog/releases/tag/v1.1.6)\n\n[](https://dallog.me)\n\n</div>\n\n## 🌙 소개\n\n달록은 우아한테크코스 공유 캘린더입니다. 우아한테크코스 공식 일정, 데일리 팀, 스터디 등 파편화된 여러 일정을 모아 달록에서 관리할 수 있습니다. 사용자는 관심있는 일정 카테고리를 구독하여 개인화된 캘린더를 사용할 수 있습니다.\n\n**[달록을 더 자세히 알아보고 싶다면, 여기로!](https://sites.google.com/woowahan.com/woowacourse-demo-4th/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8/%EB%8B%AC%EB%A1%9D)**\n\n## 🖥 서비스 화면\n\n![](https://user-images.githubusercontent.com/11745691/194251748-1a5f5819-7ae8-4648-a45e-6c02399af812.png)\n\n## 🛠 Tech Stacks\n\n### Front-end\n\n![](https://user-images.githubusercontent.com/11745691/197112888-c634aecc-fe5b-4087-94f9-cd4d0c4ab553.png)\n\n### Back-end\n\n![](https://user-images.githubusercontent.com/11745691/197112828-fd63411d-f7be-4501-b13e-5b450ccf0c40.png)\n\n## ⚙️ Infrastructure\n\n![](https://user-images.githubusercontent.com/11745691/197112936-d3b80ed4-f0fb-477a-8099-2600f36e9061.png)\n\n## 🔀 CI/CD Pipeline\n\n![](https://user-images.githubusercontent.com/11745691/197113000-dc562bfa-c1ad-4500-91d9-908b2d7c7014.png)\n\n## 🌈 알록달록하게 일을 더 잘하는 9가지 방법\n\n![](https://user-images.githubusercontent.com/11745691/185748153-bf170c7a-99cd-49ee-9420-397af9c7f35e.png)\n\n## 👥 Members\n\n|                   Backend                    |                      Backend                       |                     Backend                      |                   Backend                    |                    Frontend                    |                  Frontend                   |\n| :------------------------------------------: | :------------------------------------------------: | :----------------------------------------------: | :------------------------------------------: | :--------------------------------------------: | :-----------------------------------------: |\n| ![](https://github.com/hyeonic.png?size=120) | ![](https://github.com/gudonghee2000.png?size=120) | ![](https://github.com/summerlunaa.png?size=120) | ![](https://github.com/devHudi.png?size=120) | ![](https://github.com/daaaayeah.png?size=120) | ![](https://github.com/jhy979.png?size=120) |\n|  [매트(최기현)](https://github.com/hyeonic)  |  [리버(구동희)](https://github.com/gudonghee2000)  |  [파랑(이하은)](https://github.com/summerlunaa)  |  [후디(조동현)](https://github.com/devHudi)  |  [티거(이다예)](https://github.com/daaaayeah)  |  [나인(장호영)](https://github.com/jhy979)  |\n"
  },
  {
    "path": "backend/.gitignore",
    "content": "HELP.md\n.gradle\nbuild/\n!gradle/wrapper/gradle-wrapper.jar\n!**/src/main/**/build/\n!**/src/test/**/build/\n\n### STS ###\n.apt_generated\n.classpath\n.factorypath\n.project\n.settings\n.springBeans\n.sts4-cache\nbin/\n!**/src/main/**/bin/\n!**/src/test/**/bin/\n\n### IntelliJ IDEA ###\n.idea\n*.iws\n*.iml\n*.ipr\nout/\n!**/src/main/**/out/\n!**/src/test/**/out/\n\n### NetBeans ###\n/nbproject/private/\n/nbbuild/\n/dist/\n/nbdist/\n/.nb-gradle/\n\n### VS Code ###\n.vscode/\n\n### resources ###\n/src/main/resources/static/docs\n\n### logs ###\nlogs/\n"
  },
  {
    "path": "backend/build.gradle",
    "content": "plugins {\n    id 'org.springframework.boot' version '2.7.1'\n    id 'io.spring.dependency-management' version '1.0.11.RELEASE'\n    id 'org.asciidoctor.jvm.convert' version '3.3.2'\n    id 'org.sonarqube' version '3.3'\n    id 'java'\n    id 'jacoco'\n}\n\ngroup = 'com.allog'\nversion = '0.0.1-SNAPSHOT'\nsourceCompatibility = '11'\n\next {\n    snippetsDir = file('build/generated-snippets')\n}\n\nconfigurations {\n    asciidoctorExtensions\n}\n\nrepositories {\n    mavenCentral()\n}\n\ndependencies {\n    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'\n    implementation 'org.springframework.boot:spring-boot-starter-validation'\n    implementation 'org.springframework.boot:spring-boot-starter-web'\n    implementation 'org.springframework.boot:spring-boot-starter-cache'\n    \n    runtimeOnly 'mysql:mysql-connector-java'\n    runtimeOnly 'com.h2database:h2'\n\n    testImplementation 'org.springframework.boot:spring-boot-starter-test'\n    testImplementation 'io.rest-assured:rest-assured:4.4.0'\n\n    // JWT를 위한 의존성\n    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'\n    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'\n    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'\n\n    // Restdocs를 위한 의존성\n    asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'\n    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'\n\n    // Sonarqube를 위한 의존성\n    implementation 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.3'\n}\n\ntest {\n    outputs.dir snippetsDir\n    useJUnitPlatform()\n    finalizedBy 'jacocoTestReport'\n}\n\njacoco {\n    toolVersion = \"0.8.8\"\n}\n\njacocoTestReport {\n    reports {\n        xml.enabled true\n        csv.enabled true\n        html.enabled true\n\n        xml.destination file(\"${buildDir}/jacoco/index.xml\")\n        csv.destination file(\"${buildDir}/jacoco/index.csv\")\n        html.destination file(\"${buildDir}/jacoco/index.html\")\n    }\n\n    afterEvaluate {\n        classDirectories.setFrom(\n                files(classDirectories.files.collect {\n                    fileTree(dir: it, excludes: [\n                            '**/*Application*',\n                            '**/*Exception*',\n                            '**/dto/**',\n                            '**/infrastructure/**',\n                            '**/global/config/**',\n                            '**/BaseEntity*',\n                            '**/ControllerAdvice*',\n                            '**/AuthorizationExtractor*'\n                    ])\n                })\n        )\n    }\n\n    finalizedBy 'jacocoTestCoverageVerification'\n}\n\njacocoTestCoverageVerification {\n    violationRules {\n        rule {\n            enabled = true\n            element = \"CLASS\"\n\n            // 모든 클래스 각각 라인 커버리지 75% 만족시 빌드 성공\n            limit {\n                counter = 'LINE'\n                value = 'COVEREDRATIO'\n                minimum = 0.75\n            }\n\n            excludes = [\n                    '*.*Application',\n                    '*.*Exception',\n                    '*.dto.*',\n                    '*.infrastructure.*',\n                    '*.global.config.*',\n                    '*.BaseEntity',\n                    '*.ControllerAdvice',\n                    '*.AuthorizationExtractor'\n            ]\n        }\n    }\n}\n\nsonarqube {\n    properties {\n        property 'sonar.projectKey', 'dallog'\n        property \"sonar.sources\", \"src\"\n        property \"sonar.language\", \"java\"\n        property \"sonar.sourceEncoding\", \"UTF-8\"\n        property \"sonar.profile\", \"Dallog Custom Java Ruleset\"\n        property \"sonar.java.binaries\", \"${buildDir}/classes\"\n        property \"sonar.test.inclusions\", \"**/*Test.java\"\n        property 'sonar.exclusions', '**/jacoco/**'\n        property 'sonar.coverage.exclusions', '**/test/**/*, **/*Application*, **/global/config/**, **/dto/**, **/*Exception*, **/infrastructure/**, **/BaseEntity*, **/ControllerAdvice*, **/AuthorizationExtractor*'\n        property \"sonar.coverage.jacoco.xmlReportPaths\", \"${buildDir}/jacoco/index.xml\"\n    }\n}\n\nasciidoctor {\n    configurations 'asciidoctorExtensions'\n    baseDirFollowsSourceFile()\n    inputs.dir snippetsDir\n    dependsOn test\n}\n\nasciidoctor.doFirst {\n    delete file('src/main/resources/static/docs')\n}\n\ntask copyDocument(type: Copy) {\n    dependsOn asciidoctor\n\n    from \"${asciidoctor.outputDir}\"\n    into file(\"src/main/resources/static/docs\")\n}\n\nbootJar {\n    dependsOn copyDocument\n}\n"
  },
  {
    "path": "backend/gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-7.4.1-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "backend/gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\nAPP_HOME=$( cd \"${APP_HOME:-./}\" && pwd -P ) || exit\n\nAPP_NAME=\"Gradle\"\nAPP_BASE_NAME=${0##*/}\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    which java >/dev/null 2>&1 || die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n    CLASSPATH=$( cygpath --path --mixed \"$CLASSPATH\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n# Collect all arguments for the java command;\n#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of\n#     shell script including quotes and variable substitutions, so put them in\n#     double quotes to make sure that they get re-expanded; and\n#   * put everything else in single quotes, so that it's not re-expanded.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -classpath \"$CLASSPATH\" \\\n        org.gradle.wrapper.GradleWrapperMain \\\n        \"$@\"\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "backend/gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n\r\n@if \"%DEBUG%\" == \"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\" == \"\" set DIRNAME=.\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif \"%ERRORLEVEL%\" == \"0\" goto execute\r\n\r\necho.\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\r\necho.\r\necho Please set the JAVA_HOME variable in your environment to match the\r\necho location of your Java installation.\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho.\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\r\necho.\r\necho Please set the JAVA_HOME variable in your environment to match the\r\necho location of your Java installation.\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif \"%ERRORLEVEL%\"==\"0\" goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nif  not \"\" == \"%GRADLE_EXIT_CONSOLE%\" exit 1\r\nexit /b 1\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "backend/src/docs/asciidoc/auth.adoc",
    "content": "== Auth(인증)\n\n=== OAuth 로그인 링크 생성\n\n==== HTTP Request\n\ninclude::{snippets}/auth/generateLink/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/auth/generateLink/path-parameters.adoc[]\n\n==== Request Parameters\n\ninclude::{snippets}/auth/generateLink/request-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/auth/generateLink/http-response.adoc[]\n\n==== Response Fields\n\ninclude::{snippets}/auth/generateLink/response-fields.adoc[]\n\n=== OAuth 로그인\n\n==== HTTP Request\n\ninclude::{snippets}/auth/generateTokens/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/auth/generateTokens/path-parameters.adoc[]\n\n==== Request Fields\n\ninclude::{snippets}/auth/generateTokens/request-fields.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/auth/generateTokens/http-response.adoc[]\n\n==== Response Fields\n\ninclude::{snippets}/auth/generateTokens/response-fields.adoc[]\n\n=== OAuth 로그인 (Resource Server 에러)\n\n==== HTTP Response\n\ninclude::{snippets}/auth/generateTokens/failByResourceServerError/http-response.adoc[]\n\n=== RefreshToken을 통한 자동 로그인\n\n==== HTTP Request\n\ninclude::{snippets}/auth/generateRenewalToken/http-request.adoc[]\n\n==== Request Fields\n\ninclude::{snippets}/auth/generateRenewalToken/request-fields.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/auth/generateRenewalToken/http-response.adoc[]\n\n==== Response Fields\n\ninclude::{snippets}/auth/generateRenewalToken/response-fields.adoc[]\n\n=== RefreshToken을 통한 자동 로그인 (Invalid Token 에러)\n\n==== HTTP Response\n\ninclude::{snippets}/auth/generateRenewalToken/invalidTokenError/http-response.adoc[]\n"
  },
  {
    "path": "backend/src/docs/asciidoc/category.adoc",
    "content": "== Category(카테고리)\n\n=== 카테고리 생성\n\n==== HTTP Request\n\ninclude::{snippets}/category/save/http-request.adoc[]\n\n==== Request Fields\n\ninclude::{snippets}/category/save/request-fields.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/save/http-response.adoc[]\n\n==== Response Fields\n\ninclude::{snippets}/category/save/response-fields.adoc[]\n\n=== 카테고리 생성 (유효하지 않은 카테고리 이름)\n\n==== HTTP Response\n\ninclude::{snippets}/category/save/failByInvalidNameFormat/http-response.adoc[]\n\n=== 전체 카테고리 조회\n\n==== Request\n\ninclude::{snippets}/category/findAllByName/allByNoName/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/findAllByName/allByNoName/http-response.adoc[]\n\n=== 전체 카테고리 목록 이름으로 필터링\n\n==== HTTP Request\n\ninclude::{snippets}/category/findAllByName/filterByName/http-request.adoc[]\n\n==== Request Parameters\n\ninclude::{snippets}/category/findAllByName/filterByName/request-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/findAllByName/filterByName/http-response.adoc[]\n\n=== 자신이 일정을 추가/수정/삭제할 수 있는 카테고리 목록 조회\n\n==== HTTP Request\n\ninclude::{snippets}/category/findScheduleEditableCategories/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/findScheduleEditableCategories/http-response.adoc[]\n\n=== 자신이 ADMIN으로 있는 카테고리 목록 조회\n\n==== HTTP Request\n\ninclude::{snippets}/category/findAdminCategories/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/findAdminCategories/http-response.adoc[]\n\n=== ID를 통한 카테고리 단건 조회\n\n==== HTTP Request\n\ninclude::{snippets}/category/findDetailCategoryById/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/category/findDetailCategoryById/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/findDetailCategoryById/http-response.adoc[]\n\n=== ID를 통한 카테고리 단건 조회 (존재하지 않는 경우)\n\n==== HTTP Response\n\ninclude::{snippets}/category/findDetailCategoryById/failByNoCategory/http-response.adoc[]\n\n=== 카테고리 수정\n\n==== HTTP Request\n\ninclude::{snippets}/category/update/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/category/update/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/update/http-response.adoc[]\n\n=== 카테고리 수정 (존재하지 않는 경우)\n\n==== HTTP Response\n\ninclude::{snippets}/category/update/failByNoCategory/http-response.adoc[]\n\n=== 카테고리 수정 (유효하지 않은 카테고리 이름)\n\n==== HTTP Response\n\ninclude::{snippets}/category/update/failByInvalidNameFormat/http-response.adoc[]\n\n=== 카테고리 삭제\n\n==== HTTP Request\n\ninclude::{snippets}/category/delete/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/category/delete/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/delete/http-response.adoc[]\n\n=== 카테고리 삭제 (존재하지 않는 경우)\n\n==== HTTP Response\n\ninclude::{snippets}/category/delete/failByNoCategory/http-response.adoc[]\n\n=== 카테고리 역할 수정\n\n역할이 ADMIN인 회원은 카테고리 구독자(일반 구독자, 관리자, 자기자신 모두 포함)의 역할을 수정할 수 있습니다.\n\n==== HTTP Request\n\ninclude::{snippets}/category/updateRole/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/category/updateRole/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/updateRole/http-response.adoc[]\n\n=== 카테고리 역할 수정 (권한이 없는 경우)\n\n역할이 ADMIN인 경우만 구독자의 역할을 수정할 수 있습니다.\n\n==== HTTP Response\n\ninclude::{snippets}/category/updateRole/failByNoPermission/http-response.adoc[]\n\n=== 카테고리 역할 수정 (구독을 하지 않은 경우)\n\n카테고리 역할 수정 대상의 회원이 해당 카테고리를 구독하지 않은 상태인경우 역할 또한 존재하지 않으므로 역할을 찾을 수 없습니다.\n\n==== HTTP Response\n\ninclude::{snippets}/category/updateRole/failByCategoryRoleNotFound/http-response.adoc[]\n\n=== 카테고리 역할 수정 (자신의 역할 수정시, 자신이 유일한 ADMIN인 경우)\n\n자기자신의 카테고리 역할 수정시 자신이 해당 카테고리의 유일한 ADMIN인 경우 역할을 변경할 수 없습니다.\n\n==== HTTP Response\n\ninclude::{snippets}/category/updateRole/failBySoleAdmin/http-response.adoc[]\n\n=== 카테고리 구독자 목록 조회\n\n==== Request\n\ninclude::{snippets}/category/findSubscribers/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/category/findSubscribers/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/category/findSubscribers/http-response.adoc[]\n\n=== 카테고리 구독자 목록 조회 (호출자가 ADMIN이 아닌 경우)\n\n==== HTTP Response\n\ninclude::{snippets}/category/findSubscribers/failByNoAuthority/http-response.adoc[]\n"
  },
  {
    "path": "backend/src/docs/asciidoc/external-calendar.adoc",
    "content": "== External Calendar (외부 캘린더)\n\n=== 자신의 외부 캘린더 조회\n\n==== HTTP Request\n\ninclude::{snippets}/externalCalendar/get/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/externalCalendar/get/http-response.adoc[]\n\n=== 자신의 외부 캘린더 저장\n\n==== HTTP Request\n\ninclude::{snippets}/externalCalendar/save/http-request.adoc[]\n\n==== Request Fields\n\ninclude::{snippets}/externalCalendar/save/request-fields.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/externalCalendar/save/http-response.adoc[]\n\n=== 자신의 외부 캘린더 저장 (중복 저장할 경우)\n\n==== HTTP Response\n\ninclude::{snippets}/externalCalendar/duplicated-save/http-response.adoc[]\n"
  },
  {
    "path": "backend/src/docs/asciidoc/index.adoc",
    "content": "= 달록 API 문서\n:doctype: book\n:icons: font\n:source-highlighter: highlightjs\n:toc: left\n:toclevels: 3\n\ninclude::auth.adoc[]\ninclude::member.adoc[]\ninclude::category.adoc[]\ninclude::schedule.adoc[]\ninclude::subscription.adoc[]\ninclude::external-calendar.adoc[]\n"
  },
  {
    "path": "backend/src/docs/asciidoc/member.adoc",
    "content": "== Member(회원)\n\n=== 내 정보 조회\n\n==== HTTP Request\n\ninclude::{snippets}/member/findMe/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/member/findMe/http-response.adoc[]\n\n==== Response Fields\n\ninclude::{snippets}/member/findMe/response-fields.adoc[]\n\n=== 내 정보 조회 (존재하지 않는 회원 조회 시)\n\n==== HTTP Response\n\ninclude::{snippets}/member/findMe/failNoMember/http-response.adoc[]\n\n=== 내 정보 수정\n\n==== HTTP Request\n\ninclude::{snippets}/member/update/http-request.adoc[]\n\n==== Request Fields\n\ninclude::{snippets}/member/update/request-fields.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/member/update/http-response.adoc[]\n"
  },
  {
    "path": "backend/src/docs/asciidoc/schedule.adoc",
    "content": "== Schedule(일정)\n\n=== 회원 일정 목록 조회\n\n==== HTTP Request\n\ninclude::{snippets}/schedule/findSchedulesByMemberId/http-request.adoc[]\n\n==== Request Parameters\n\ninclude::{snippets}/schedule/findSchedulesByMemberId/request-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/findSchedulesByMemberId/http-response.adoc[]\n\n=== 카테고리 별 일정 목록 조회\n\n==== HTTP Request\n\ninclude::{snippets}/schedule/findSchedulesByCategoryId/http-request.adoc[]\n\n==== Request Parameters\n\ninclude::{snippets}/schedule/findSchedulesByCategoryId/request-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/findSchedulesByCategoryId/http-response.adoc[]\n\n=== 일정 등록\n\n==== HTTP Request\n\ninclude::{snippets}/schedule/save/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/save/http-response.adoc[]\n\n=== 일정 등록 (카테고리 권한이 없을 때)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/save/failByNoPermission/http-response.adoc[]\n\n=== 일정 생성 (카테고리가 존재하지 않음)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/save/failByNoCategory/http-response.adoc[]\n\n=== 일정 단건 조회\n\n==== HTTP Request\n\ninclude::{snippets}/schedule/findById/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/findById/http-response.adoc[]\n\n=== 일정 단건 조회 (일정이 존재하지 않을 때)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/findById/failByNoSchedule/http-response.adoc[]\n\n=== 일정 수정\n\n==== HTTP Request\n\ninclude::{snippets}/schedule/update/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/schedule/update/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/update/http-response.adoc[]\n\n=== 일정 수정 (카테고리 권한이 없을 때)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/update/failByNoPermission/http-response.adoc[]\n\n=== 일정 수정 (카테고리가 존재하지 않을 때)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/update/failByNoSchedule/http-response.adoc[]\n\n=== 일정 제거\n\n==== HTTP Request\n\ninclude::{snippets}/schedule/delete/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/schedule/delete/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/delete/http-response.adoc[]\n\n=== 일정 제거 (카테고리 권한이 없을 때)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/delete/failByNoPermission/http-response.adoc[]\n\n=== 일정 제거 (카테고리가 존재하지 않음)\n\n==== HTTP Response\n\ninclude::{snippets}/schedule/delete/failByNoSchedule/http-response.adoc[]\n"
  },
  {
    "path": "backend/src/docs/asciidoc/subscription.adoc",
    "content": "== Subscription(구독)\n\n=== 구독 등록\n\n==== HTTP Request\n\ninclude::{snippets}/subscription/save/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/subscription/save/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/save/http-response.adoc[]\n\n=== 구독 등록 (중복된 구독을 등록할 때)\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/save/failByAlreadyExisting/http-response.adoc[]\n\n=== 구독 등록 (3자의 개인 카테고리 구독 요청시)\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/save/failBySubscribingPrivateCategoryOfOther/http-response.adoc[]\n\n=== 자신의 구독 목록 조회\n\n==== HTTP Request\n\ninclude::{snippets}/subscription/findMine/http-request.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/findMine/http-response.adoc[]\n\n=== 내 구독 정보 수정\n\n==== HTTP Request\n\ninclude::{snippets}/subscription/update/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/subscription/update/path-parameters.adoc[]\n\n==== Request Headers\n\ninclude::{snippets}/subscription/update/request-headers.adoc[]\n\n==== Request Parameters\n\ninclude::{snippets}/subscription/update/request-body.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/update/http-response.adoc[]\n\n=== 구독 삭제\n\n==== HTTP Request\n\ninclude::{snippets}/subscription/delete/http-request.adoc[]\n\n==== Path Parameters\n\ninclude::{snippets}/subscription/delete/path-parameters.adoc[]\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/delete/http-response.adoc[]\n\n=== 구독 삭제 (내 구독이 아닐 때)\n\n==== HTTP Response\n\ninclude::{snippets}/subscription/delete/failByNoPermission/http-response.adoc[]\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/DallogApplication.java",
    "content": "package com.allog.dallog;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\n\n@SpringBootApplication\npublic class DallogApplication {\n\n    public static void main(String[] args) {\n        SpringApplication.run(DallogApplication.class, args);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/AuthService.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport com.allog.dallog.auth.domain.AuthToken;\nimport com.allog.dallog.auth.domain.OAuthToken;\nimport com.allog.dallog.auth.domain.OAuthTokenRepository;\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport com.allog.dallog.auth.dto.request.TokenRenewalRequest;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport com.allog.dallog.auth.dto.response.AccessTokenResponse;\nimport com.allog.dallog.auth.event.MemberSavedEvent;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport org.springframework.context.ApplicationEventPublisher;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class AuthService {\n\n    private final MemberRepository memberRepository;\n    private final OAuthTokenRepository oAuthTokenRepository;\n    private final TokenCreator tokenCreator;\n    private final ApplicationEventPublisher eventPublisher;\n\n    public AuthService(final MemberRepository memberRepository, final OAuthTokenRepository oAuthTokenRepository,\n                       final TokenCreator tokenCreator, final ApplicationEventPublisher eventPublisher) {\n        this.memberRepository = memberRepository;\n        this.oAuthTokenRepository = oAuthTokenRepository;\n        this.tokenCreator = tokenCreator;\n        this.eventPublisher = eventPublisher;\n    }\n\n    @Transactional\n    public AccessAndRefreshTokenResponse generateAccessAndRefreshToken(final OAuthMember oAuthMember) {\n        Member foundMember = findMember(oAuthMember);\n\n        OAuthToken oAuthToken = getOAuthToken(oAuthMember, foundMember);\n        oAuthToken.change(oAuthMember.getRefreshToken());\n\n        AuthToken authToken = tokenCreator.createAuthToken(foundMember.getId());\n        return new AccessAndRefreshTokenResponse(authToken.getAccessToken(), authToken.getRefreshToken());\n    }\n\n    private Member findMember(final OAuthMember oAuthMember) {\n        String email = oAuthMember.getEmail();\n        if (memberRepository.existsByEmail(email)) {\n            return memberRepository.getByEmail(email);\n        }\n        return saveMember(oAuthMember);\n    }\n\n    private Member saveMember(final OAuthMember oAuthMember) {\n        Member savedMember = memberRepository.save(oAuthMember.toMember());\n        eventPublisher.publishEvent(new MemberSavedEvent(savedMember.getId()));\n        return savedMember;\n    }\n\n    private OAuthToken getOAuthToken(final OAuthMember oAuthMember, final Member member) {\n        Long memberId = member.getId();\n        if (oAuthTokenRepository.existsByMemberId(memberId)) {\n            return oAuthTokenRepository.getByMemberId(memberId);\n        }\n        return oAuthTokenRepository.save(new OAuthToken(member, oAuthMember.getRefreshToken()));\n    }\n\n    public AccessTokenResponse generateAccessToken(final TokenRenewalRequest tokenRenewalRequest) {\n        String refreshToken = tokenRenewalRequest.getRefreshToken();\n        AuthToken authToken = tokenCreator.renewAuthToken(refreshToken);\n        return new AccessTokenResponse(authToken.getAccessToken());\n    }\n\n    public Long extractMemberId(final String accessToken) {\n        Long memberId = tokenCreator.extractPayload(accessToken);\n        memberRepository.validateExistsById(memberId);\n        return memberId;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/AuthTokenCreator.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport com.allog.dallog.auth.domain.AuthToken;\nimport com.allog.dallog.auth.domain.TokenRepository;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class AuthTokenCreator implements TokenCreator {\n\n    private final TokenProvider tokenProvider;\n    private final TokenRepository tokenRepository;\n\n    public AuthTokenCreator(final TokenProvider tokenProvider, final TokenRepository tokenRepository) {\n        this.tokenProvider = tokenProvider;\n        this.tokenRepository = tokenRepository;\n    }\n\n    public AuthToken createAuthToken(final Long memberId) {\n        String accessToken = tokenProvider.createAccessToken(String.valueOf(memberId));\n        String refreshToken = createRefreshToken(memberId);\n        return new AuthToken(accessToken, refreshToken);\n    }\n\n    private String createRefreshToken(final Long memberId) {\n        if (tokenRepository.exist(memberId)) {\n            return tokenRepository.getToken(memberId);\n        }\n        String refreshToken = tokenProvider.createRefreshToken(String.valueOf(memberId));\n        return tokenRepository.save(memberId, refreshToken);\n    }\n\n    public AuthToken renewAuthToken(final String refreshToken) {\n        tokenProvider.validateToken(refreshToken);\n        Long memberId = Long.valueOf(tokenProvider.getPayload(refreshToken));\n\n        String accessTokenForRenew = tokenProvider.createAccessToken(String.valueOf(memberId));\n        String refreshTokenForRenew = tokenRepository.getToken(memberId);\n\n        AuthToken renewalAuthToken = new AuthToken(accessTokenForRenew, refreshTokenForRenew);\n        renewalAuthToken.validateHasSameRefreshToken(refreshToken);\n        return renewalAuthToken;\n    }\n\n    public Long extractPayload(final String accessToken) {\n        tokenProvider.validateToken(accessToken);\n        return Long.valueOf(tokenProvider.getPayload(accessToken));\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/JwtTokenProvider.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport io.jsonwebtoken.Claims;\nimport io.jsonwebtoken.Jws;\nimport io.jsonwebtoken.JwtException;\nimport io.jsonwebtoken.Jwts;\nimport io.jsonwebtoken.SignatureAlgorithm;\nimport io.jsonwebtoken.security.Keys;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Date;\nimport javax.crypto.SecretKey;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class JwtTokenProvider implements TokenProvider {\n\n    private final SecretKey key;\n    private final long accessTokenValidityInMilliseconds;\n    private final long refreshTokenValidityInMilliseconds;\n\n    public JwtTokenProvider(@Value(\"${security.jwt.token.secret-key}\") final String secretKey,\n                            @Value(\"${security.jwt.token.access.expire-length}\") final long accessTokenValidityInMilliseconds,\n                            @Value(\"${security.jwt.token.refresh.expire-length}\") final long refreshTokenValidityInMilliseconds) {\n        this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));\n        this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds;\n        this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;\n    }\n\n    @Override\n    public String createAccessToken(final String payload) {\n        return createToken(payload, accessTokenValidityInMilliseconds);\n    }\n\n    @Override\n    public String createRefreshToken(final String payload) {\n        return createToken(payload, refreshTokenValidityInMilliseconds);\n    }\n\n    private String createToken(final String payload, final Long validityInMilliseconds) {\n        Date now = new Date();\n        Date validity = new Date(now.getTime() + validityInMilliseconds);\n\n        return Jwts.builder()\n                .setSubject(payload)\n                .setIssuedAt(now)\n                .setExpiration(validity)\n                .signWith(key, SignatureAlgorithm.HS256)\n                .compact();\n    }\n\n    @Override\n    public String getPayload(final String token) {\n        return Jwts.parserBuilder()\n                .setSigningKey(key)\n                .build()\n                .parseClaimsJws(token)\n                .getBody()\n                .getSubject();\n    }\n\n    @Override\n    public void validateToken(final String token) {\n        try {\n            Jws<Claims> claims = Jwts.parserBuilder()\n                    .setSigningKey(key)\n                    .build()\n                    .parseClaimsJws(token);\n\n            claims.getBody()\n                    .getExpiration()\n                    .before(new Date());\n        } catch (final JwtException | IllegalArgumentException e) {\n            throw new InvalidTokenException(\"권한이 없습니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/OAuthClient.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse;\n\npublic interface OAuthClient {\n\n    OAuthMember getOAuthMember(final String code, final String redirectUri);\n\n    OAuthAccessTokenResponse getAccessToken(final String refreshToken);\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/OAuthUri.java",
    "content": "package com.allog.dallog.auth.application;\n\n@FunctionalInterface\npublic interface OAuthUri {\n\n    String generate(final String redirectUri);\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/TokenCreator.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport com.allog.dallog.auth.domain.AuthToken;\n\npublic interface TokenCreator {\n\n    AuthToken createAuthToken(final Long memberId);\n\n    AuthToken renewAuthToken(final String outRefreshToken);\n\n    Long extractPayload(final String accessToken);\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/application/TokenProvider.java",
    "content": "package com.allog.dallog.auth.application;\n\npublic interface TokenProvider {\n\n    String createAccessToken(final String payload);\n\n    String createRefreshToken(final String payload);\n\n    String getPayload(final String token);\n\n    void validateToken(final String token);\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/domain/AuthToken.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport com.allog.dallog.auth.exception.NoSuchTokenException;\n\npublic class AuthToken {\n\n    private String accessToken;\n    private String refreshToken;\n\n    public AuthToken(final String accessToken, final String refreshToken) {\n        this.accessToken = accessToken;\n        this.refreshToken = refreshToken;\n    }\n\n    public String getAccessToken() {\n        return accessToken;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n\n    public void validateHasSameRefreshToken(final String otherRefreshToken) {\n        if (!refreshToken.equals(otherRefreshToken)) {\n            throw new NoSuchTokenException(\"회원의 리프레시 토큰이 아닙니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/domain/InMemoryAuthTokenRepository.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport com.allog.dallog.auth.exception.NoSuchTokenException;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ConcurrentHashMap;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class InMemoryAuthTokenRepository implements TokenRepository {\n\n    private static final Map<Long, String> TOKEN_REPOSITORY = new ConcurrentHashMap<>();\n\n    @Override\n    public String save(final Long memberId, final String refreshToken) {\n        TOKEN_REPOSITORY.put(memberId, refreshToken);\n        return TOKEN_REPOSITORY.get(memberId);\n    }\n\n    @Override\n    public void deleteAll() {\n        TOKEN_REPOSITORY.clear();\n    }\n\n    @Override\n    public void deleteByMemberId(final Long memberId) {\n        TOKEN_REPOSITORY.remove(memberId);\n    }\n\n    @Override\n    public boolean exist(final Long memberId) {\n        return TOKEN_REPOSITORY.containsKey(memberId);\n    }\n\n    @Override\n    public String getToken(final Long memberId) {\n        Optional<String> token = Optional.ofNullable(TOKEN_REPOSITORY.get(memberId));\n        return token.orElseThrow(() -> new NoSuchTokenException(\"일치하는 토큰이 존재하지 않습니다.\"));\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/domain/OAuthToken.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport com.allog.dallog.global.entity.BaseEntity;\nimport com.allog.dallog.member.domain.Member;\nimport java.util.Objects;\nimport javax.persistence.Column;\nimport javax.persistence.Entity;\nimport javax.persistence.FetchType;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.JoinColumn;\nimport javax.persistence.OneToOne;\nimport javax.persistence.Table;\n\n@Table(name = \"oauth_tokens\")\n@Entity\npublic class OAuthToken extends BaseEntity {\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    private Long id;\n\n    @OneToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"members_id\", nullable = false)\n    private Member member;\n\n    @Column(name = \"refresh_token\", nullable = false)\n    private String refreshToken;\n\n    protected OAuthToken() {\n    }\n\n    public OAuthToken(final Member member, final String refreshToken) {\n        this.member = member;\n        this.refreshToken = refreshToken;\n    }\n\n    public void change(final String refreshToken) {\n        if (!Objects.isNull(refreshToken)) {\n            this.refreshToken = refreshToken;\n        }\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public Member getMember() {\n        return member;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/domain/OAuthTokenRepository.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport com.allog.dallog.auth.exception.NoSuchOAuthTokenException;\nimport java.util.Optional;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\n\npublic interface OAuthTokenRepository extends JpaRepository<OAuthToken, Long> {\n\n    boolean existsByMemberId(final Long memberId);\n\n    @Query(\"SELECT o \"\n            + \"FROM OAuthToken o \"\n            + \"WHERE o.member.id = :memberId\")\n    Optional<OAuthToken> findByMemberId(final Long memberId);\n\n    default OAuthToken getByMemberId(final Long memberId) {\n        return findByMemberId(memberId)\n                .orElseThrow(NoSuchOAuthTokenException::new);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/domain/TokenRepository.java",
    "content": "package com.allog.dallog.auth.domain;\n\npublic interface TokenRepository {\n\n    String save(final Long memberId, final String refreshToken);\n\n    void deleteAll();\n\n    void deleteByMemberId(final Long memberId);\n\n    boolean exist(final Long memberId);\n\n    String getToken(final Long memberId);\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/LoginMember.java",
    "content": "package com.allog.dallog.auth.dto;\n\npublic class LoginMember {\n\n    private Long id;\n\n    private LoginMember() {\n    }\n\n    public LoginMember(final Long id) {\n        this.id = id;\n    }\n\n    public Long getId() {\n        return id;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/OAuthMember.java",
    "content": "package com.allog.dallog.auth.dto;\n\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.SocialType;\n\npublic class OAuthMember {\n\n    private final String email;\n    private final String displayName;\n    private final String profileImageUrl;\n    private final String refreshToken;\n\n    public OAuthMember(final String email, final String displayName, final String profileImageUrl,\n                       final String refreshToken) {\n        this.email = email;\n        this.displayName = displayName;\n        this.profileImageUrl = profileImageUrl;\n        this.refreshToken = refreshToken;\n    }\n\n    public String getEmail() {\n        return email;\n    }\n\n    public String getDisplayName() {\n        return displayName;\n    }\n\n    public String getProfileImageUrl() {\n        return profileImageUrl;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n\n    public Member toMember() {\n        return new Member(email, displayName, profileImageUrl, SocialType.GOOGLE);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/request/TokenRenewalRequest.java",
    "content": "package com.allog.dallog.auth.dto.request;\n\nimport javax.validation.constraints.NotNull;\n\npublic class TokenRenewalRequest {\n\n    @NotNull(message = \"리프레시 토큰은 공백일 수 없습니다.\")\n    private String refreshToken;\n\n    private TokenRenewalRequest() {\n    }\n\n    public TokenRenewalRequest(final String refreshToken) {\n        this.refreshToken = refreshToken;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/request/TokenRequest.java",
    "content": "package com.allog.dallog.auth.dto.request;\n\nimport javax.validation.constraints.NotBlank;\nimport javax.validation.constraints.NotNull;\n\npublic class TokenRequest {\n\n    @NotBlank(message = \"인가 코드는 공백일 수 없습니다.\")\n    private String code;\n\n    @NotNull(message = \"Null일 수 없습니다.\")\n    private String redirectUri;\n\n    private TokenRequest() {\n    }\n\n    public TokenRequest(final String code, final String redirectUri) {\n        this.code = code;\n        this.redirectUri = redirectUri;\n    }\n\n    public String getCode() {\n        return code;\n    }\n\n    public String getRedirectUri() {\n        return redirectUri;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/response/AccessAndRefreshTokenResponse.java",
    "content": "package com.allog.dallog.auth.dto.response;\n\npublic class AccessAndRefreshTokenResponse {\n\n    private String accessToken;\n    private String refreshToken;\n\n    private AccessAndRefreshTokenResponse() {\n    }\n\n    public AccessAndRefreshTokenResponse(final String accessToken, final String refreshToken) {\n        this.accessToken = accessToken;\n        this.refreshToken = refreshToken;\n    }\n\n    public String getAccessToken() {\n        return accessToken;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/response/AccessTokenResponse.java",
    "content": "package com.allog.dallog.auth.dto.response;\n\npublic class AccessTokenResponse {\n\n    private String accessToken;\n\n    private AccessTokenResponse() {\n    }\n\n    public AccessTokenResponse(final String accessToken) {\n        this.accessToken = accessToken;\n    }\n\n    public String getAccessToken() {\n        return accessToken;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/response/OAuthAccessTokenResponse.java",
    "content": "package com.allog.dallog.auth.dto.response;\n\nimport com.fasterxml.jackson.databind.PropertyNamingStrategies;\nimport com.fasterxml.jackson.databind.annotation.JsonNaming;\n\n@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)\npublic class OAuthAccessTokenResponse {\n\n    private String accessToken;\n\n    private OAuthAccessTokenResponse() {\n    }\n\n    public OAuthAccessTokenResponse(final String accessToken) {\n        this.accessToken = accessToken;\n    }\n\n    public String getAccessToken() {\n        return accessToken;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/dto/response/OAuthUriResponse.java",
    "content": "package com.allog.dallog.auth.dto.response;\n\n// OAuth 인증 URI(소셜 로그인 링크)를 전달하는 DTO\npublic class OAuthUriResponse {\n\n    private String oAuthUri;\n\n    private OAuthUriResponse() {\n    }\n\n    public OAuthUriResponse(final String oAuthUri) {\n        this.oAuthUri = oAuthUri;\n    }\n\n    public String getoAuthUri() {\n        return oAuthUri;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/event/MemberSavedEvent.java",
    "content": "package com.allog.dallog.auth.event;\n\npublic class MemberSavedEvent {\n\n    private final Long memberId;\n\n    public MemberSavedEvent(final Long memberId) {\n        this.memberId = memberId;\n    }\n\n    public Long getMemberId() {\n        return memberId;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/exception/EmptyAuthorizationHeaderException.java",
    "content": "package com.allog.dallog.auth.exception;\n\npublic class EmptyAuthorizationHeaderException extends RuntimeException {\n\n    public EmptyAuthorizationHeaderException(final String message) {\n        super(message);\n    }\n\n    public EmptyAuthorizationHeaderException() {\n        this(\"header에 Authorization이 존재하지 않습니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/exception/InvalidTokenException.java",
    "content": "package com.allog.dallog.auth.exception;\n\npublic class InvalidTokenException extends RuntimeException {\n\n    public InvalidTokenException(final String message) {\n        super(message);\n    }\n\n    public InvalidTokenException() {\n        this(\"유효하지 않은 토큰입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/exception/NoPermissionException.java",
    "content": "package com.allog.dallog.auth.exception;\n\npublic class NoPermissionException extends RuntimeException {\n\n    public NoPermissionException(final String message) {\n        super(message);\n    }\n\n    public NoPermissionException() {\n        this(\"권한이 없는 요청 입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/exception/NoSuchOAuthTokenException.java",
    "content": "package com.allog.dallog.auth.exception;\n\npublic class NoSuchOAuthTokenException extends RuntimeException {\n\n    public NoSuchOAuthTokenException(final String message) {\n        super(message);\n    }\n\n    public NoSuchOAuthTokenException() {\n        this(\"존재하지 않는 OAuthToken 입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/exception/NoSuchTokenException.java",
    "content": "package com.allog.dallog.auth.exception;\n\npublic class NoSuchTokenException extends RuntimeException {\n\n    public NoSuchTokenException(final String message) {\n        super(message);\n    }\n\n    public NoSuchTokenException() {\n        this(\"존재하지 않는 Token 입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/presentation/AuthController.java",
    "content": "package com.allog.dallog.auth.presentation;\n\nimport com.allog.dallog.auth.application.OAuthClient;\nimport com.allog.dallog.auth.application.OAuthUri;\nimport com.allog.dallog.auth.application.AuthService;\nimport com.allog.dallog.auth.dto.LoginMember;\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport com.allog.dallog.auth.dto.request.TokenRenewalRequest;\nimport com.allog.dallog.auth.dto.request.TokenRequest;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport com.allog.dallog.auth.dto.response.AccessTokenResponse;\nimport com.allog.dallog.auth.dto.response.OAuthUriResponse;\nimport javax.validation.Valid;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RequestMapping(\"/api/auth\")\n@RestController\npublic class AuthController {\n\n    private final OAuthUri oAuthUri;\n    private final OAuthClient oAuthClient;\n    private final AuthService authService;\n\n    public AuthController(final OAuthUri oAuthUri, final OAuthClient oAuthClient, final AuthService authService) {\n        this.oAuthUri = oAuthUri;\n        this.oAuthClient = oAuthClient;\n        this.authService = authService;\n    }\n\n    @GetMapping(\"/{oauthProvider}/oauth-uri\")\n    public ResponseEntity<OAuthUriResponse> generateLink(@PathVariable final String oauthProvider,\n                                                         @RequestParam final String redirectUri) {\n        OAuthUriResponse oAuthUriResponse = new OAuthUriResponse(oAuthUri.generate(redirectUri));\n        return ResponseEntity.ok(oAuthUriResponse);\n    }\n\n    @PostMapping(\"/{oauthProvider}/token\")\n    public ResponseEntity<AccessAndRefreshTokenResponse> generateAccessAndRefreshToken(\n            @PathVariable final String oauthProvider, @Valid @RequestBody final TokenRequest tokenRequest) {\n        OAuthMember oAuthMember = oAuthClient.getOAuthMember(tokenRequest.getCode(), tokenRequest.getRedirectUri());\n        AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(oAuthMember);\n        return ResponseEntity.ok(response);\n    }\n\n    @PostMapping(\"/token/access\")\n    public ResponseEntity<AccessTokenResponse> generateAccessToken(\n            @Valid @RequestBody final TokenRenewalRequest tokenRenewalRequest) {\n        AccessTokenResponse response = authService.generateAccessToken(tokenRenewalRequest);\n        return ResponseEntity.ok(response);\n    }\n\n    @GetMapping(\"/validate/token\")\n    public ResponseEntity<Void> validateToken(@AuthenticationPrincipal final LoginMember loginMember) {\n        return ResponseEntity.ok().build();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/presentation/AuthenticationPrincipal.java",
    "content": "package com.allog.dallog.auth.presentation;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n@Target(ElementType.PARAMETER)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface AuthenticationPrincipal {\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/presentation/AuthenticationPrincipalArgumentResolver.java",
    "content": "package com.allog.dallog.auth.presentation;\n\nimport com.allog.dallog.auth.application.AuthService;\nimport com.allog.dallog.auth.dto.LoginMember;\nimport javax.servlet.http.HttpServletRequest;\nimport org.springframework.core.MethodParameter;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.bind.support.WebDataBinderFactory;\nimport org.springframework.web.context.request.NativeWebRequest;\nimport org.springframework.web.method.support.HandlerMethodArgumentResolver;\nimport org.springframework.web.method.support.ModelAndViewContainer;\n\n@Component\npublic class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {\n\n    private final AuthService authService;\n\n    public AuthenticationPrincipalArgumentResolver(final AuthService authService) {\n        this.authService = authService;\n    }\n\n    @Override\n    public boolean supportsParameter(final MethodParameter parameter) {\n        return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);\n    }\n\n    @Override\n    public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,\n                                  final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {\n        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);\n        String accessToken = AuthorizationExtractor.extract(request);\n        Long id = authService.extractMemberId(accessToken);\n        return new LoginMember(id);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/auth/presentation/AuthorizationExtractor.java",
    "content": "package com.allog.dallog.auth.presentation;\n\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport com.allog.dallog.auth.exception.EmptyAuthorizationHeaderException;\nimport java.util.Objects;\nimport javax.servlet.http.HttpServletRequest;\nimport org.springframework.http.HttpHeaders;\n\npublic class AuthorizationExtractor {\n\n    private static final String BEARER_TYPE = \"Bearer \";\n\n    public static String extract(final HttpServletRequest request) {\n        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);\n        if (Objects.isNull(authorizationHeader)) {\n            throw new EmptyAuthorizationHeaderException();\n        }\n\n        validateAuthorizationFormat(authorizationHeader);\n        return authorizationHeader.substring(BEARER_TYPE.length()).trim();\n    }\n\n    private static void validateAuthorizationFormat(final String authorizationHeader) {\n        if (!authorizationHeader.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) {\n            throw new InvalidTokenException(\"token 형식이 잘못 되었습니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/application/CategoryService.java",
    "content": "package com.allog.dallog.category.application;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.category.domain.CategoryType.PERSONAL;\n\nimport com.allog.dallog.auth.event.MemberSavedEvent;\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.request.CategoryUpdateRequest;\nimport com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;\nimport com.allog.dallog.category.dto.response.CategoriesResponse;\nimport com.allog.dallog.category.dto.response.CategoryDetailResponse;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.category.exception.InvalidCategoryException;\nimport com.allog.dallog.categoryrole.domain.CategoryAuthority;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleType;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.domain.CategoryType;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.category.domain.ExternalCategoryDetailRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.schedule.domain.ScheduleRepository;\nimport com.allog.dallog.subscription.application.ColorPicker;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.domain.Subscription;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport org.springframework.context.event.EventListener;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class CategoryService {\n\n    private static final String PERSONAL_CATEGORY_NAME = \"내 일정\";\n\n    private final CategoryRepository categoryRepository;\n    private final ExternalCategoryDetailRepository externalCategoryDetailRepository;\n    private final MemberRepository memberRepository;\n    private final SubscriptionRepository subscriptionRepository;\n    private final ScheduleRepository scheduleRepository;\n    private final CategoryRoleRepository categoryRoleRepository;\n    private final ColorPicker colorPicker;\n\n    public CategoryService(final CategoryRepository categoryRepository,\n                           final ExternalCategoryDetailRepository externalCategoryDetailRepository,\n                           final MemberRepository memberRepository, final SubscriptionRepository subscriptionRepository,\n                           final ScheduleRepository scheduleRepository,\n                           final CategoryRoleRepository categoryRoleRepository, final ColorPicker colorPicker) {\n        this.categoryRepository = categoryRepository;\n        this.externalCategoryDetailRepository = externalCategoryDetailRepository;\n        this.memberRepository = memberRepository;\n        this.subscriptionRepository = subscriptionRepository;\n        this.scheduleRepository = scheduleRepository;\n        this.categoryRoleRepository = categoryRoleRepository;\n        this.colorPicker = colorPicker;\n    }\n\n    @Transactional\n    public CategoryResponse save(final Long memberId, final CategoryCreateRequest request) {\n        categoryRoleRepository.validateManagingCategoryLimit(memberId, CategoryRoleType.ADMIN);\n\n        Member member = memberRepository.getById(memberId);\n        Category category = request.toEntity(member);\n        Category savedCategory = categoryRepository.save(category);\n\n        subscribeCategory(member, category);\n        createCategoryRoleAsAdminToCreator(member, category);\n        return new CategoryResponse(savedCategory);\n    }\n\n    @Transactional\n    public CategoryResponse save(final Long memberId, final ExternalCategoryCreateRequest request) {\n        List<Category> externalCategories = categoryRepository\n                .findByMemberIdAndCategoryType(memberId, CategoryType.GOOGLE);\n        externalCategoryDetailRepository\n                .validateExistByExternalIdAndCategoryIn(request.getExternalId(), externalCategories);\n\n        CategoryResponse response = save(memberId, new CategoryCreateRequest(request.getName(), CategoryType.GOOGLE));\n        Category category = categoryRepository.getById(response.getId());\n        externalCategoryDetailRepository.save(new ExternalCategoryDetail(category, request.getExternalId()));\n\n        return response;\n    }\n\n    @Transactional\n    @EventListener\n    public void savePersonalCategory(final MemberSavedEvent event) {\n        Member member = memberRepository.getById(event.getMemberId());\n        Category category = categoryRepository.save(new Category(PERSONAL_CATEGORY_NAME, member, PERSONAL));\n\n        subscribeCategory(member, category);\n        createCategoryRoleAsAdminToCreator(member, category);\n    }\n\n    private void subscribeCategory(final Member member, final Category category) {\n        Color color = Color.pick(colorPicker.pickNumber());\n        subscriptionRepository.save(new Subscription(member, category, color));\n    }\n\n    private void createCategoryRoleAsAdminToCreator(final Member member, final Category category) {\n        CategoryRole categoryRole = new CategoryRole(category, member, CategoryRoleType.ADMIN);\n        categoryRoleRepository.save(categoryRole);\n    }\n\n    public CategoriesResponse findNormalByName(final String name) {\n        List<Category> categories = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, name);\n        return new CategoriesResponse(categories);\n    }\n\n    // 회원이 ADMIN이 아니어도 일정 추가/제거/수정이 가능하므로, findAdminCategories와 별도의 메소드로 분리해야함\n    public CategoriesResponse findScheduleEditableCategories(final Long memberId) {\n        List<CategoryRole> categoryRoles = categoryRoleRepository.findByMemberId(memberId);\n        Set<CategoryRoleType> roleTypes = CategoryRoleType.getHavingAuthorities(Set.of(CategoryAuthority.ADD_SCHEDULE, CategoryAuthority.UPDATE_SCHEDULE));\n        return new CategoriesResponse(toCategories(categoryRoles, roleTypes));\n    }\n\n    public CategoriesResponse findAdminCategories(final Long memberId) {\n        List<CategoryRole> categoryRoles = categoryRoleRepository.findByMemberId(memberId);\n        return new CategoriesResponse(toCategories(categoryRoles, Set.of(CategoryRoleType.ADMIN)));\n    }\n\n    private List<Category> toCategories(final List<CategoryRole> categoryRoles, final Set<CategoryRoleType> roleTypes) {\n        return categoryRoles.stream()\n                .filter(categoryRole -> roleTypes.contains(categoryRole.getCategoryRoleType()))\n                .map(CategoryRole::getCategory)\n                .collect(Collectors.toList());\n    }\n\n    public CategoryDetailResponse findDetailCategoryById(final Long id) {\n        Category category = categoryRepository.getById(id);\n        List<Subscription> subscriptions = subscriptionRepository.findByCategoryId(id);\n        return new CategoryDetailResponse(category, subscriptions.size());\n    }\n\n    @Transactional\n    public void update(final Long memberId, final Long id, final CategoryUpdateRequest request) {\n        Category category = categoryRepository.getById(id);\n\n        CategoryRole role = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId());\n        role.validateAuthority(CategoryAuthority.UPDATE_CATEGORY);\n\n        category.changeName(request.getName());\n    }\n\n    @Transactional\n    public void delete(final Long memberId, final Long id) {\n        Category category = categoryRepository.getById(id);\n\n        validateNotPersonalCategory(category);\n\n        CategoryRole role = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId());\n        role.validateAuthority(CategoryAuthority.DELETE_CATEGORY);\n\n        scheduleRepository.deleteByCategoryIdIn(List.of(id));\n        subscriptionRepository.deleteByCategoryIdIn(List.of(id));\n        externalCategoryDetailRepository.deleteByCategoryId(id);\n        categoryRoleRepository.deleteByCategoryId(id);\n        categoryRepository.deleteById(id);\n    }\n\n    private void validateNotPersonalCategory(final Category category) {\n        if (category.isPersonal()) {\n            throw new InvalidCategoryException(\"내 일정 카테고리는 삭제할 수 없습니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/application/ExternalCategoryDetailService.java",
    "content": "package com.allog.dallog.category.application;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.category.domain.ExternalCategoryDetailRepository;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport com.allog.dallog.subscription.domain.Subscriptions;\nimport java.util.List;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class ExternalCategoryDetailService {\n\n    private final ExternalCategoryDetailRepository externalCategoryDetailRepository;\n    private final SubscriptionRepository subscriptionRepository;\n\n    public ExternalCategoryDetailService(final ExternalCategoryDetailRepository externalCategoryDetailRepository,\n                                         final SubscriptionRepository subscriptionRepository) {\n        this.externalCategoryDetailRepository = externalCategoryDetailRepository;\n        this.subscriptionRepository = subscriptionRepository;\n    }\n\n    public List<ExternalCategoryDetail> findByMemberId(final Long memberId) {\n        Subscriptions subscriptions = new Subscriptions(subscriptionRepository.findByMemberId(memberId));\n        List<Category> categories = subscriptions.findExternalCategory();\n        return externalCategoryDetailRepository.findByCategoryIn(categories);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/domain/Category.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.category.exception.InvalidCategoryException;\nimport com.allog.dallog.global.entity.BaseEntity;\nimport com.allog.dallog.member.domain.Member;\nimport javax.persistence.Column;\nimport javax.persistence.Entity;\nimport javax.persistence.EnumType;\nimport javax.persistence.Enumerated;\nimport javax.persistence.FetchType;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.JoinColumn;\nimport javax.persistence.ManyToOne;\nimport javax.persistence.Table;\n\n@Table(name = \"categories\")\n@Entity\npublic class Category extends BaseEntity {\n\n    public static final int MAX_NAME_LENGTH = 20;\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    @Column(name = \"id\")\n    private Long id;\n\n    @Column(name = \"name\", nullable = false)\n    private String name;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"members_id\")\n    private Member member;\n\n    @Enumerated(value = EnumType.STRING)\n    @Column(name = \"category_type\", nullable = false)\n    private CategoryType categoryType;\n\n    protected Category() {\n    }\n\n    public Category(final String name, final Member member) {\n        validateNameLength(name);\n        this.name = name;\n        this.member = member;\n        this.categoryType = CategoryType.NORMAL;\n    }\n\n    public Category(final String name, final Member member, final CategoryType categoryType) {\n        validateNameLength(name);\n        this.name = name;\n        this.member = member;\n        this.categoryType = categoryType;\n    }\n\n    public void changeName(final String name) {\n        validatePersonal();\n        validateNameLength(name);\n        this.name = name;\n    }\n\n    private void validatePersonal() {\n        if (isPersonal()) {\n            throw new InvalidCategoryException(\"'내 일정' 카테고리는 수정할 수 없습니다.\");\n        }\n    }\n\n    private void validateNameLength(final String name) {\n        if (name.isBlank()) {\n            throw new InvalidCategoryException(\"카테고리 이름은 공백일 수 없습니다.\");\n        }\n        if (name.length() > MAX_NAME_LENGTH) {\n            throw new InvalidCategoryException(String.format(\"카테고리 이름의 길이는 %d을 초과할 수 없습니다.\", MAX_NAME_LENGTH));\n        }\n    }\n\n    public void validateSubscriptionPossible(final Member member) {\n        if (this.categoryType == CategoryType.PERSONAL && !isCreatorId(member.getId())) {\n            throw new NoPermissionException(\"구독 권한이 없는 카테고리입니다.\");\n        }\n    }\n\n    public void validateNotExternalCategory() {\n        if (categoryType == CategoryType.GOOGLE) {\n            throw new NoPermissionException(\"외부 연동 카테고리에는 일정을 추가할 수 없습니다.\");\n        }\n    }\n\n    public boolean isCreatorId(final Long creatorId) {\n        return member.hasSameId(creatorId);\n    }\n\n    public boolean isNormal() {\n        return categoryType == CategoryType.NORMAL;\n    }\n\n    public boolean isPersonal() {\n        return categoryType == CategoryType.PERSONAL;\n    }\n\n    public boolean isInternal() {\n        return categoryType != CategoryType.GOOGLE;\n    }\n\n    public boolean isExternal() {\n        return categoryType == CategoryType.GOOGLE;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public Member getMember() {\n        return member;\n    }\n\n    public void setMember(final Member member) {\n        this.member = member;\n    }\n\n    public CategoryType getCategoryType() {\n        return categoryType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/domain/CategoryRepository.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport java.util.List;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\n\npublic interface CategoryRepository extends JpaRepository<Category, Long> {\n\n    @Query(\"SELECT c \"\n            + \"FROM Subscription s \"\n            + \"JOIN s.category c \"\n            + \"WHERE c.categoryType = :categoryType AND c.name LIKE %:name% \"\n            + \"GROUP BY c.id \"\n            + \"ORDER BY COUNT(c.id) DESC\")\n    List<Category> findByCategoryTypeAndNameContaining(final CategoryType categoryType, final String name);\n\n    @Query(\"SELECT c \"\n            + \"FROM Category c \"\n            + \"WHERE c.member.id = :memberId AND c.categoryType = :categoryType\")\n    List<Category> findByMemberIdAndCategoryType(final Long memberId, final CategoryType categoryType);\n\n    @Query(\"SELECT c \"\n            + \"FROM Category c \"\n            + \"WHERE c.member.id = :memberId\")\n    List<Category> findByMemberId(final Long memberId);\n\n    default Category getById(final Long id) {\n        return this.findById(id)\n                .orElseThrow(NoSuchCategoryException::new);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/domain/CategoryType.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\n\npublic enum CategoryType {\n\n    NORMAL, PERSONAL, GOOGLE;\n\n    public static CategoryType from(final String value) {\n        try {\n            return CategoryType.valueOf(value.toUpperCase());\n        } catch (final IllegalArgumentException e) {\n            throw new NoSuchCategoryException(\"(\" + value + \")는 존재하지 않는 카테고리 타입입니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/domain/ExternalCategoryDetail.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport com.allog.dallog.global.entity.BaseEntity;\nimport javax.persistence.Column;\nimport javax.persistence.Entity;\nimport javax.persistence.FetchType;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.JoinColumn;\nimport javax.persistence.OneToOne;\nimport javax.persistence.Table;\n\n@Table(name = \"external_category_details\")\n@Entity\npublic class ExternalCategoryDetail extends BaseEntity {\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    @Column(name = \"id\")\n    private Long id;\n\n    @OneToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"categories_id\", nullable = false)\n    private Category category;\n\n    @Column(name = \"external_id\", nullable = false)\n    private String externalId;\n\n    protected ExternalCategoryDetail() {\n    }\n\n    public ExternalCategoryDetail(final Category category, final String externalId) {\n        this.category = category;\n        this.externalId = externalId;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public Category getCategory() {\n        return category;\n    }\n\n    public String getExternalId() {\n        return externalId;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/domain/ExternalCategoryDetailRepository.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport com.allog.dallog.category.exception.ExistExternalCategoryException;\nimport com.allog.dallog.category.exception.NoSuchExternalCategoryDetailException;\nimport java.util.List;\nimport java.util.Optional;\nimport org.springframework.data.jpa.repository.JpaRepository;\n\npublic interface ExternalCategoryDetailRepository extends JpaRepository<ExternalCategoryDetail, Long> {\n\n    Optional<ExternalCategoryDetail> findByCategory(final Category category);\n\n    List<ExternalCategoryDetail> findByCategoryIn(final List<Category> categories);\n\n    boolean existsByExternalIdAndCategoryIn(final String externalId, final List<Category> categories);\n\n    void deleteByCategoryId(final Long categoryId);\n\n    void deleteByCategoryIdIn(final List<Long> categoryIds);\n\n    default ExternalCategoryDetail getByCategory(final Category category) {\n        return this.findByCategory(category)\n                .orElseThrow(NoSuchExternalCategoryDetailException::new);\n    }\n\n    default void validateExistByExternalIdAndCategoryIn(final String externalId,\n                                                        final List<Category> externalCategories) {\n        if (existsByExternalIdAndCategoryIn(externalId, externalCategories)) {\n            throw new ExistExternalCategoryException();\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/dto/request/CategoryCreateRequest.java",
    "content": "package com.allog.dallog.category.dto.request;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryType;\nimport com.allog.dallog.member.domain.Member;\nimport javax.validation.constraints.NotBlank;\n\npublic class CategoryCreateRequest {\n\n    @NotBlank(message = \"공백일 수 없습니다.\")\n    private String name;\n\n    @NotBlank(message = \"공백일 수 없습니다.\")\n    private String categoryType;\n\n    private CategoryCreateRequest() {\n    }\n\n    public CategoryCreateRequest(final String name, final CategoryType categoryType) {\n        this.name = name;\n        this.categoryType = categoryType.name();\n    }\n\n    public Category toEntity(final Member member) {\n        return new Category(name, member, CategoryType.from(categoryType));\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getCategoryType() {\n        return categoryType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/dto/request/CategoryUpdateRequest.java",
    "content": "package com.allog.dallog.category.dto.request;\n\nimport javax.validation.constraints.NotBlank;\n\npublic class CategoryUpdateRequest {\n\n    @NotBlank(message = \"카테고리 이름이 공백일 수 없습니다.\")\n    private String name;\n\n    private CategoryUpdateRequest() {\n    }\n\n    public CategoryUpdateRequest(final String name) {\n        this.name = name;\n    }\n\n    public String getName() {\n        return name;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/dto/request/ExternalCategoryCreateRequest.java",
    "content": "package com.allog.dallog.category.dto.request;\n\nimport javax.validation.constraints.NotBlank;\n\npublic class ExternalCategoryCreateRequest {\n\n    @NotBlank(message = \"외부 캘린더 아이디가 공백일 수 없습니다.\")\n    private String externalId;\n\n    @NotBlank(message = \"외부 캘린더 이름이 공백일 수 없습니다.\")\n    private String name;\n\n    private ExternalCategoryCreateRequest() {\n    }\n\n    public ExternalCategoryCreateRequest(final String externalId, final String name) {\n        this.externalId = externalId;\n        this.name = name;\n    }\n\n    public String getExternalId() {\n        return externalId;\n    }\n\n    public String getName() {\n        return name;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/dto/response/CategoriesResponse.java",
    "content": "package com.allog.dallog.category.dto.response;\n\nimport com.allog.dallog.category.domain.Category;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class CategoriesResponse {\n\n    private List<CategoryResponse> categories;\n\n    private CategoriesResponse() {\n    }\n\n    public CategoriesResponse(final List<Category> categories) {\n        this.categories = toResponses(categories);\n    }\n\n    private List<CategoryResponse> toResponses(final List<Category> categories) {\n        return categories.stream()\n                .map(CategoryResponse::new)\n                .collect(Collectors.toList());\n    }\n\n    public List<CategoryResponse> getCategories() {\n        return categories;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/dto/response/CategoryDetailResponse.java",
    "content": "package com.allog.dallog.category.dto.response;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.member.dto.response.MemberResponse;\nimport java.time.LocalDateTime;\n\npublic class CategoryDetailResponse {\n\n    private Long id;\n    private String name;\n    private String categoryType;\n    private int subscriberCount;\n    private MemberResponse creator;\n    private LocalDateTime createdAt;\n\n    private CategoryDetailResponse() {\n    }\n\n    public CategoryDetailResponse(final Category category, final int subscriberCount) {\n        this(category.getId(), category.getName(), category.getCategoryType().name(), subscriberCount,\n                new MemberResponse(category.getMember()), category.getCreatedAt());\n    }\n\n    public CategoryDetailResponse(final Long id, final String name, final String categoryType,\n                                  final int subscriberCount, final MemberResponse creator,\n                                  final LocalDateTime createdAt) {\n        this.id = id;\n        this.name = name;\n        this.categoryType = categoryType;\n        this.subscriberCount = subscriberCount;\n        this.creator = creator;\n        this.createdAt = createdAt;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getCategoryType() {\n        return categoryType;\n    }\n\n    public int getSubscriberCount() {\n        return subscriberCount;\n    }\n\n    public MemberResponse getCreator() {\n        return creator;\n    }\n\n    public LocalDateTime getCreatedAt() {\n        return createdAt;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/dto/response/CategoryResponse.java",
    "content": "package com.allog.dallog.category.dto.response;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.member.dto.response.MemberResponse;\nimport java.time.LocalDateTime;\n\npublic class CategoryResponse {\n\n    private Long id;\n    private String name;\n    private String categoryType;\n    private MemberResponse creator;\n    private LocalDateTime createdAt;\n\n    private CategoryResponse() {\n    }\n\n    public CategoryResponse(final Category category) {\n        this(category.getId(), category.getName(), category.getCategoryType().name(),\n                new MemberResponse(category.getMember()), category.getCreatedAt());\n    }\n\n    public CategoryResponse(final Long id, final String name, final String categoryType, final MemberResponse creator,\n                            final LocalDateTime createdAt) {\n        this.id = id;\n        this.name = name;\n        this.categoryType = categoryType;\n        this.creator = creator;\n        this.createdAt = createdAt;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getCategoryType() {\n        return categoryType;\n    }\n\n    public MemberResponse getCreator() {\n        return creator;\n    }\n\n    public LocalDateTime getCreatedAt() {\n        return createdAt;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/exception/ExistExternalCategoryException.java",
    "content": "package com.allog.dallog.category.exception;\n\npublic class ExistExternalCategoryException extends RuntimeException {\n\n    public ExistExternalCategoryException(final String message) {\n        super(message);\n    }\n\n    public ExistExternalCategoryException() {\n        this(\"이미 저장된 연동 카테고리입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/exception/InvalidCategoryException.java",
    "content": "package com.allog.dallog.category.exception;\n\npublic class InvalidCategoryException extends RuntimeException {\n\n    public InvalidCategoryException(final String message) {\n        super(message);\n    }\n\n    public InvalidCategoryException() {\n        this(\"잘못된 카테고리입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/exception/NoSuchCategoryException.java",
    "content": "package com.allog.dallog.category.exception;\n\npublic class NoSuchCategoryException extends RuntimeException {\n\n    public NoSuchCategoryException(final String message) {\n        super(message);\n    }\n\n    public NoSuchCategoryException() {\n        this(\"존재하지 않는 카테고리입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/exception/NoSuchExternalCategoryDetailException.java",
    "content": "package com.allog.dallog.category.exception;\n\npublic class NoSuchExternalCategoryDetailException extends RuntimeException {\n\n    public NoSuchExternalCategoryDetailException(final String message) {\n        super(message);\n    }\n\n    public NoSuchExternalCategoryDetailException() {\n        this(\"존재하지 않는 외부 카테고리 정보 입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/category/presentaion/CategoryController.java",
    "content": "package com.allog.dallog.category.presentaion;\n\nimport com.allog.dallog.auth.dto.LoginMember;\nimport com.allog.dallog.auth.presentation.AuthenticationPrincipal;\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.request.CategoryUpdateRequest;\nimport com.allog.dallog.category.dto.response.CategoriesResponse;\nimport com.allog.dallog.category.dto.response.CategoryDetailResponse;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;\nimport com.allog.dallog.category.application.CategoryService;\nimport com.allog.dallog.categoryrole.application.CategoryRoleService;\nimport com.allog.dallog.categoryrole.dto.response.SubscribersResponse;\nimport java.net.URI;\nimport javax.validation.Valid;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PatchMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RequestMapping(\"/api/categories\")\n@RestController\npublic class CategoryController {\n\n    private final CategoryService categoryService;\n    private final CategoryRoleService categoryRoleService;\n\n    public CategoryController(final CategoryService categoryService, final CategoryRoleService categoryRoleService) {\n        this.categoryService = categoryService;\n        this.categoryRoleService = categoryRoleService;\n    }\n\n    @PostMapping\n    public ResponseEntity<CategoryResponse> save(@AuthenticationPrincipal final LoginMember loginMember,\n                                                 @Valid @RequestBody final CategoryCreateRequest request) {\n        CategoryResponse categoryResponse = categoryService.save(loginMember.getId(), request);\n        return ResponseEntity.created(URI.create(\"/api/categories/\" + categoryResponse.getId())).body(categoryResponse);\n    }\n\n    @GetMapping\n    public ResponseEntity<CategoriesResponse> findNormalByName(@RequestParam(defaultValue = \"\") final String name) {\n        return ResponseEntity.ok(categoryService.findNormalByName(name));\n    }\n\n    @GetMapping(\"/{categoryId}\")\n    public ResponseEntity<CategoryDetailResponse> findDetailCategoryById(@PathVariable final Long categoryId) {\n        return ResponseEntity.ok(categoryService.findDetailCategoryById(categoryId));\n    }\n\n    @GetMapping(\"/me/schedule-editable\") // 일정 추가, 수정 모달의 카테고리 목록에 사용됨\n    public ResponseEntity<CategoriesResponse> findScheduleEditableCategories(\n            @AuthenticationPrincipal final LoginMember loginMember) {\n        return ResponseEntity.ok(categoryService.findScheduleEditableCategories(loginMember.getId()));\n    }\n\n    @GetMapping(\"/me/admin\") // 카테고리 관리 페이지에 접근할 수 있는지 판단하기 위해 사용됨\n    public ResponseEntity<CategoriesResponse> findAdminCategories(\n            @AuthenticationPrincipal final LoginMember loginMember) {\n        return ResponseEntity.ok(categoryService.findAdminCategories(loginMember.getId()));\n    }\n\n    @PatchMapping(\"/{categoryId}\")\n    public ResponseEntity<Void> update(@AuthenticationPrincipal final LoginMember loginMember,\n                                       @PathVariable final Long categoryId,\n                                       @Valid @RequestBody final CategoryUpdateRequest request) {\n        categoryService.update(loginMember.getId(), categoryId, request);\n        return ResponseEntity.noContent().build();\n    }\n\n    @DeleteMapping(\"/{categoryId}\")\n    public ResponseEntity<Void> delete(@AuthenticationPrincipal final LoginMember loginMember,\n                                       @PathVariable final Long categoryId) {\n        categoryService.delete(loginMember.getId(), categoryId);\n        return ResponseEntity.noContent().build();\n    }\n\n    @GetMapping(\"/{categoryId}/subscribers\")\n    public ResponseEntity<SubscribersResponse> findSubscribers(@AuthenticationPrincipal final LoginMember loginMember,\n                                                               @PathVariable final Long categoryId) {\n        SubscribersResponse subscribers = categoryRoleService.findSubscribers(loginMember.getId(), categoryId);\n        return ResponseEntity.ok(subscribers);\n    }\n\n    @PatchMapping(\"/{categoryId}/subscribers/{memberId}/role\")\n    public ResponseEntity<Void> updateRole(@AuthenticationPrincipal final LoginMember loginMember,\n                                           @PathVariable final Long categoryId,\n                                           @PathVariable final Long memberId,\n                                           @RequestBody final CategoryRoleUpdateRequest request) {\n        categoryRoleService.updateRole(loginMember.getId(), memberId, categoryId, request);\n        return ResponseEntity.noContent().build();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/application/CategoryRoleService.java",
    "content": "package com.allog.dallog.categoryrole.application;\n\nimport com.allog.dallog.categoryrole.domain.CategoryAuthority;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;\nimport com.allog.dallog.categoryrole.dto.response.SubscribersResponse;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException;\nimport java.util.List;\nimport org.springframework.orm.ObjectOptimisticLockingFailureException;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class CategoryRoleService {\n\n    private final CategoryRoleRepository categoryRoleRepository;\n\n    public CategoryRoleService(final CategoryRoleRepository categoryRoleRepository) {\n        this.categoryRoleRepository = categoryRoleRepository;\n    }\n\n    public SubscribersResponse findSubscribers(final Long loginMemberId, final Long categoryId) {\n        CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(loginMemberId, categoryId);\n        categoryRole.validateAuthority(CategoryAuthority.FIND_SUBSCRIBERS);\n\n        List<CategoryRole> categoryRoles = categoryRoleRepository.findByCategoryId(categoryId);\n        return new SubscribersResponse(categoryRoles);\n    }\n\n    @Transactional\n    public void updateRole(final Long loginMemberId, final Long memberId, final Long categoryId,\n                           final CategoryRoleUpdateRequest request) {\n        try {\n            List<CategoryRole> categoryRolesInCategory = categoryRoleRepository.findByCategoryId(categoryId);\n            CategoryRole roleOfTargetMember = getCategoryRole(memberId, categoryRolesInCategory);\n\n            validateLoginMemberAuthority(loginMemberId, categoryRolesInCategory); // 요청 유저 권한 검증\n            validateIsTargetMemberSoleAdmin(categoryRolesInCategory, roleOfTargetMember); // 대상 유저가 유일한 어드민이 아닌지 검증\n            validateCategoryType(roleOfTargetMember.getCategory()); // 카테고리가 개인, 외부 카테고리가 아닌지 검증\n            categoryRoleRepository.validateManagingCategoryLimit(memberId, request.getCategoryRoleType()); // 관리 개수 검증\n\n            roleOfTargetMember.changeRole(request.getCategoryRoleType());\n        } catch (final ObjectOptimisticLockingFailureException e) {\n            throw NotAbleToChangeRoleException.concurrentIssue();\n        }\n    }\n\n    private CategoryRole getCategoryRole(final Long memberId, final List<CategoryRole> categoryRoles) {\n        return categoryRoles.stream()\n                .filter(it -> it.getMember().getId().equals(memberId))\n                .findFirst()\n                .orElseThrow();\n    }\n\n    private void validateLoginMemberAuthority(final Long loginMemberId, final List<CategoryRole> categoryRoles) {\n        CategoryRole loginMemberCategoryRole = categoryRoles.stream()\n                .filter(categoryRole -> categoryRole.getMember().getId().equals(loginMemberId))\n                .findFirst()\n                .orElseThrow();\n\n        loginMemberCategoryRole.validateAuthority(CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER);\n    }\n\n    private void validateIsTargetMemberSoleAdmin(final List<CategoryRole> categoryRoles,\n                                                 final CategoryRole categoryRole) {\n        if (categoryRole.isAdmin() && categoryRoles.size() == 1) {\n            throw new NotAbleToChangeRoleException();\n        }\n    }\n\n    private void validateCategoryType(final Category category) {\n        if (!category.isNormal()) {\n            throw new NotAbleToChangeRoleException(\"개인 카테고리 또는 외부 카테고리에 대한 회원의 역할을 변경할 수 없습니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryAuthority.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\npublic enum CategoryAuthority {\n    UPDATE_CATEGORY(\"카테고리 수정\"),\n    DELETE_CATEGORY(\"카테고리 제거\"),\n    ADD_SCHEDULE(\"일정 추가\"),\n    UPDATE_SCHEDULE(\"일정 수정\"),\n    DELETE_SCHEDULE(\"일정 제거\"),\n    CHANGE_ROLE_OF_SUBSCRIBER(\"역할 변경\"),\n    FIND_SUBSCRIBERS(\"카테고리 구독자 조회\");\n\n    private final String name;\n\n    CategoryAuthority(final String name) {\n        this.name = name;\n    }\n\n    public String getName() {\n        return name;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRole.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.global.entity.BaseEntity;\nimport com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;\nimport com.allog.dallog.member.domain.Member;\nimport javax.persistence.Entity;\nimport javax.persistence.EnumType;\nimport javax.persistence.Enumerated;\nimport javax.persistence.FetchType;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.JoinColumn;\nimport javax.persistence.ManyToOne;\nimport javax.persistence.Table;\nimport javax.persistence.Version;\n\n@Table(name = \"category_roles\")\n@Entity\npublic class CategoryRole extends BaseEntity {\n\n    public static final int MAX_MANAGING_CATEGORY_COUNT = 50;\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    private Long id;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"categories_id\", nullable = false)\n    private Category category;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"members_id\", nullable = false)\n    private Member member;\n\n    @Enumerated(EnumType.STRING)\n    private CategoryRoleType categoryRoleType;\n\n    @Version\n    private Long version;\n\n    protected CategoryRole() {\n    }\n\n    public CategoryRole(final Category category, final Member member, final CategoryRoleType categoryRoleType) {\n        this.category = category;\n        this.member = member;\n        this.categoryRoleType = categoryRoleType;\n    }\n\n    public boolean isAdmin() {\n        return categoryRoleType.equals(CategoryRoleType.ADMIN);\n    }\n\n    public boolean isNone() {\n        return categoryRoleType.equals(CategoryRoleType.NONE);\n    }\n\n    public void validateAuthority(final CategoryAuthority authority) {\n        if (!ableTo(authority)) {\n            throw new NoCategoryAuthorityException(authority.getName());\n        }\n    }\n\n    public boolean ableTo(final CategoryAuthority authority) {\n        return categoryRoleType.ableTo(authority);\n    }\n\n    public void changeRole(final CategoryRoleType categoryRoleType) {\n        this.categoryRoleType = categoryRoleType;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public Category getCategory() {\n        return category;\n    }\n\n    public Member getMember() {\n        return member;\n    }\n\n    public CategoryRoleType getCategoryRoleType() {\n        return categoryRoleType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRoleRepository.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\nimport com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException;\nimport com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException;\nimport java.util.List;\nimport java.util.Optional;\nimport javax.persistence.LockModeType;\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Lock;\nimport org.springframework.data.jpa.repository.Query;\n\npublic interface CategoryRoleRepository extends JpaRepository<CategoryRole, Long> {\n\n    @Lock(LockModeType.OPTIMISTIC)\n    @Query(\"SELECT cr \"\n            + \"FROM CategoryRole cr \"\n            + \"WHERE cr.member.id = :memberId AND cr.category.id = :categoryId\")\n    Optional<CategoryRole> findByMemberIdAndCategoryId(final Long memberId, final Long categoryId);\n\n    @EntityGraph(attributePaths = {\"member\"})\n    List<CategoryRole> findByCategoryId(final Long categoryId);\n\n    @EntityGraph(attributePaths = {\"category\", \"category.member\"})\n    List<CategoryRole> findByMemberId(final Long memberId);\n\n    @Query(\"SELECT count(cr) \"\n            + \"FROM CategoryRole cr \"\n            + \"WHERE cr.categoryRoleType = :categoryRoleType \"\n            + \"AND cr.member.id = :memberId\")\n    int countByMemberIdAndCategoryRoleType(final Long memberId, final CategoryRoleType categoryRoleType);\n\n    int countByCategoryIdAndCategoryRoleType(final Long categoryId, final CategoryRoleType categoryRoleType);\n\n    void deleteByCategoryId(final Long categoryId);\n\n    default CategoryRole getByMemberIdAndCategoryId(final Long memberId, final Long categoryId) {\n        return findByMemberIdAndCategoryId(memberId, categoryId)\n                .orElseThrow(NoSuchCategoryRoleException::new);\n    }\n\n    default boolean isMemberSoleAdminInCategory(final Long memberId, final Long categoryId) {\n        CategoryRole categoryRole = getByMemberIdAndCategoryId(memberId, categoryId);\n        int adminCount = countByCategoryIdAndCategoryRoleType(categoryId, CategoryRoleType.ADMIN);\n\n        return categoryRole.isAdmin() && adminCount == 1;\n    }\n\n    default void validateManagingCategoryLimit(final Long memberId, final CategoryRoleType categoryRoleType) {\n        int memberAdminCount = countByMemberIdAndCategoryRoleType(memberId, CategoryRoleType.ADMIN);\n\n        if (!categoryRoleType.equals(CategoryRoleType.NONE)\n                && memberAdminCount >= CategoryRole.MAX_MANAGING_CATEGORY_COUNT) {\n            throw new ManagingCategoryLimitExcessException();\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRoleType.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.ADD_SCHEDULE;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.DELETE_CATEGORY;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.DELETE_SCHEDULE;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.FIND_SUBSCRIBERS;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_CATEGORY;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_SCHEDULE;\n\nimport java.util.Arrays;\nimport java.util.EnumSet;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic enum CategoryRoleType {\n    ADMIN(EnumSet.of(UPDATE_CATEGORY, DELETE_CATEGORY, ADD_SCHEDULE, UPDATE_SCHEDULE, DELETE_SCHEDULE,\n            CHANGE_ROLE_OF_SUBSCRIBER, FIND_SUBSCRIBERS)),\n    NONE(Set.of());\n\n    private final Set<CategoryAuthority> authorities;\n\n    CategoryRoleType(final Set<CategoryAuthority> authorities) {\n        this.authorities = authorities;\n    }\n\n    public static Set<CategoryRoleType> getHavingAuthorities(final Set<CategoryAuthority> authorities) {\n        return Arrays.stream(values())\n                .filter(categoryRoleType -> isCategoryRoleTypeContainsAuthorities(authorities, categoryRoleType))\n                .collect(Collectors.toSet());\n    }\n\n    private static boolean isCategoryRoleTypeContainsAuthorities(final Set<CategoryAuthority> authorities,\n                                                                 final CategoryRoleType categoryRoleType) {\n        return categoryRoleType.authorities.containsAll(authorities);\n    }\n\n    public boolean ableTo(final CategoryAuthority authority) {\n        return authorities.contains(authority);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/dto/request/CategoryRoleUpdateRequest.java",
    "content": "package com.allog.dallog.categoryrole.dto.request;\n\nimport com.allog.dallog.categoryrole.domain.CategoryRoleType;\nimport javax.validation.constraints.NotBlank;\n\npublic class CategoryRoleUpdateRequest {\n\n    @NotBlank(message = \"공백일 수 없습니다.\")\n    private CategoryRoleType categoryRoleType;\n\n    private CategoryRoleUpdateRequest() {\n    }\n\n    public CategoryRoleUpdateRequest(final CategoryRoleType categoryRoleType) {\n        this.categoryRoleType = categoryRoleType;\n    }\n\n    public CategoryRoleType getCategoryRoleType() {\n        return categoryRoleType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/dto/response/MemberWithRoleTypeResponse.java",
    "content": "package com.allog.dallog.categoryrole.dto.response;\n\nimport com.allog.dallog.categoryrole.domain.CategoryRoleType;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.member.dto.response.MemberResponse;\n\npublic class MemberWithRoleTypeResponse {\n\n    private MemberResponse member;\n    private CategoryRoleType categoryRoleType;\n\n    private MemberWithRoleTypeResponse() {\n    }\n\n    public MemberWithRoleTypeResponse(final CategoryRole categoryRole) {\n        this.member = new MemberResponse(categoryRole.getMember());\n        this.categoryRoleType = categoryRole.getCategoryRoleType();\n    }\n\n    public MemberResponse getMember() {\n        return member;\n    }\n\n    public CategoryRoleType getCategoryRoleType() {\n        return categoryRoleType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/dto/response/SubscribersResponse.java",
    "content": "package com.allog.dallog.categoryrole.dto.response;\n\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class SubscribersResponse {\n\n    private List<MemberWithRoleTypeResponse> subscribers;\n\n    private SubscribersResponse() {\n    }\n\n    public SubscribersResponse(final List<CategoryRole> categoryRoles) {\n        this.subscribers = toResponses(categoryRoles);\n    }\n\n    private List<MemberWithRoleTypeResponse> toResponses(final List<CategoryRole> categoryRoles) {\n        return categoryRoles.stream()\n                .map(MemberWithRoleTypeResponse::new)\n                .collect(Collectors.toList());\n    }\n\n    public List<MemberWithRoleTypeResponse> getSubscribers() {\n        return subscribers;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/exception/ManagingCategoryLimitExcessException.java",
    "content": "package com.allog.dallog.categoryrole.exception;\n\npublic class ManagingCategoryLimitExcessException extends RuntimeException {\n\n    private static final int MAX_MANAGING_CATEGORY_COUNT = 50;\n\n    public ManagingCategoryLimitExcessException() {\n        super(\"한 사람이 관리할 수 있는 카테고리는 최대 \" + MAX_MANAGING_CATEGORY_COUNT + \"개 입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/exception/NoCategoryAuthorityException.java",
    "content": "package com.allog.dallog.categoryrole.exception;\n\npublic class NoCategoryAuthorityException extends RuntimeException {\n\n    public NoCategoryAuthorityException(final String authorityName) {\n        super(authorityName + \" 권한이 없습니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/exception/NoSuchCategoryRoleException.java",
    "content": "package com.allog.dallog.categoryrole.exception;\n\npublic class NoSuchCategoryRoleException extends RuntimeException {\n\n    public NoSuchCategoryRoleException(final String message) {\n        super(message);\n    }\n\n    public NoSuchCategoryRoleException() {\n        this(\"존재하지 않는 역할입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/categoryrole/exception/NotAbleToChangeRoleException.java",
    "content": "package com.allog.dallog.categoryrole.exception;\n\npublic class NotAbleToChangeRoleException extends RuntimeException {\n\n    public NotAbleToChangeRoleException(final String message) {\n        super(message);\n    }\n\n    public NotAbleToChangeRoleException() {\n        super(\"역할을 변경할 수 없습니다.\");\n    }\n\n    public static NotAbleToChangeRoleException concurrentIssue() {\n        return new NotAbleToChangeRoleException(\"회원님의 권한이 변경되어 카테고리 역할을 수정할 수 없습니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/externalcalendar/application/ExternalCalendarClient.java",
    "content": "package com.allog.dallog.externalcalendar.application;\n\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendar;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport java.util.List;\n\npublic interface ExternalCalendarClient {\n\n    List<ExternalCalendar> getExternalCalendars(final String accessToken);\n\n    List<IntegrationSchedule> getExternalCalendarSchedules(final String accessToken,\n                                                           final Long internalCategoryId,\n                                                           final String externalCalendarId,\n                                                           final String startDateTime, final String endDateTime);\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/externalcalendar/application/ExternalCalendarService.java",
    "content": "package com.allog.dallog.externalcalendar.application;\n\nimport com.allog.dallog.auth.application.OAuthClient;\nimport com.allog.dallog.auth.domain.OAuthToken;\nimport com.allog.dallog.auth.domain.OAuthTokenRepository;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;\nimport org.springframework.stereotype.Service;\n\n@Service\npublic class ExternalCalendarService {\n\n    private final OAuthClient oAuthClient;\n    private final ExternalCalendarClient externalCalendarClient;\n    private final OAuthTokenRepository oAuthTokenRepository;\n\n    public ExternalCalendarService(final OAuthClient oAuthClient, final ExternalCalendarClient externalCalendarClient,\n                                   final OAuthTokenRepository oAuthTokenRepository) {\n        this.oAuthClient = oAuthClient;\n        this.externalCalendarClient = externalCalendarClient;\n        this.oAuthTokenRepository = oAuthTokenRepository;\n    }\n\n    public ExternalCalendarsResponse findByMemberId(final Long memberId) {\n        OAuthToken oAuthToken = oAuthTokenRepository.getByMemberId(memberId);\n\n        String oAuthAccessToken = oAuthClient.getAccessToken(oAuthToken.getRefreshToken()).getAccessToken();\n\n        return new ExternalCalendarsResponse(externalCalendarClient.getExternalCalendars(oAuthAccessToken));\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/externalcalendar/dto/ExternalCalendar.java",
    "content": "package com.allog.dallog.externalcalendar.dto;\n\npublic class ExternalCalendar {\n\n    private String calendarId;\n    private String summary;\n\n    private ExternalCalendar() {\n    }\n\n    public ExternalCalendar(final String calendarId, final String summary) {\n        this.calendarId = calendarId;\n        this.summary = summary;\n    }\n\n    public String getCalendarId() {\n        return calendarId;\n    }\n\n    public String getSummary() {\n        return summary;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/externalcalendar/dto/ExternalCalendarsResponse.java",
    "content": "package com.allog.dallog.externalcalendar.dto;\n\nimport java.util.List;\n\npublic class ExternalCalendarsResponse {\n\n    private List<ExternalCalendar> externalCalendars;\n\n    private ExternalCalendarsResponse() {\n    }\n\n    public ExternalCalendarsResponse(final List<ExternalCalendar> externalCalendars) {\n        this.externalCalendars = externalCalendars;\n    }\n\n    public List<ExternalCalendar> getExternalCalendars() {\n        return externalCalendars;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/externalcalendar/presentation/ExternalCalendarController.java",
    "content": "package com.allog.dallog.externalcalendar.presentation;\n\nimport com.allog.dallog.auth.dto.LoginMember;\nimport com.allog.dallog.auth.presentation.AuthenticationPrincipal;\nimport com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.category.application.CategoryService;\nimport com.allog.dallog.externalcalendar.application.ExternalCalendarService;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;\nimport java.net.URI;\nimport javax.validation.Valid;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RequestMapping(\"/api/external-calendars/me\")\n@RestController\npublic class ExternalCalendarController {\n\n    private final ExternalCalendarService externalCalendarService;\n    private final CategoryService categoryService;\n\n    public ExternalCalendarController(final ExternalCalendarService externalCalendarService,\n                                      final CategoryService categoryService) {\n        this.externalCalendarService = externalCalendarService;\n        this.categoryService = categoryService;\n    }\n\n    @GetMapping\n    public ResponseEntity<ExternalCalendarsResponse> getExternalCalendar(\n            @AuthenticationPrincipal final LoginMember loginMember) {\n        return ResponseEntity.ok(externalCalendarService.findByMemberId(loginMember.getId()));\n    }\n\n    @PostMapping\n    public ResponseEntity<CategoryResponse> save(@AuthenticationPrincipal final LoginMember loginMember,\n                                                 @Valid @RequestBody final ExternalCategoryCreateRequest request) {\n        CategoryResponse response = categoryService.save(loginMember.getId(), request);\n        return ResponseEntity.created(URI.create(\"/api/categories/\" + response.getId())).body(response);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/JpaConfig.java",
    "content": "package com.allog.dallog.global.config;\n\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.data.jpa.repository.config.EnableJpaAuditing;\n\n@Configuration\n@EnableJpaAuditing\npublic class JpaConfig {\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/PropertiesConfig.java",
    "content": "package com.allog.dallog.global.config;\n\nimport com.allog.dallog.global.config.properties.GoogleProperties;\nimport org.springframework.boot.context.properties.EnableConfigurationProperties;\nimport org.springframework.context.annotation.Configuration;\n\n@Configuration\n@EnableConfigurationProperties(GoogleProperties.class)\npublic class PropertiesConfig {\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/WebConfig.java",
    "content": "package com.allog.dallog.global.config;\n\nimport java.util.List;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.web.method.support.HandlerMethodArgumentResolver;\nimport org.springframework.web.servlet.config.annotation.CorsRegistry;\nimport org.springframework.web.servlet.config.annotation.WebMvcConfigurer;\n\n@Configuration\npublic class WebConfig implements WebMvcConfigurer {\n\n    private final List<String> allowOriginUrlPatterns;\n    private final HandlerMethodArgumentResolver authenticationPrincipalArgumentResolver;\n\n    public WebConfig(@Value(\"${cors.allow-origin.urls}\") final List<String> allowOriginUrlPatterns,\n                     final HandlerMethodArgumentResolver authenticationPrincipalArgumentResolver) {\n        this.allowOriginUrlPatterns = allowOriginUrlPatterns;\n        this.authenticationPrincipalArgumentResolver = authenticationPrincipalArgumentResolver;\n    }\n\n    @Override\n    public void addCorsMappings(CorsRegistry registry) {\n        String[] patterns = allowOriginUrlPatterns.stream()\n                .toArray(String[]::new);\n\n        registry.addMapping(\"/**\")\n                .allowedMethods(\"*\")\n                .allowedOriginPatterns(patterns);\n    }\n\n    @Override\n    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {\n        argumentResolvers.add(authenticationPrincipalArgumentResolver);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/cache/CacheConfig.java",
    "content": "package com.allog.dallog.global.config.cache;\n\nimport java.util.List;\nimport org.springframework.cache.CacheManager;\nimport org.springframework.cache.annotation.EnableCaching;\nimport org.springframework.cache.support.SimpleCacheManager;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.scheduling.annotation.EnableScheduling;\nimport org.springframework.scheduling.annotation.Scheduled;\n\n@Configuration\n@EnableCaching\n@EnableScheduling\npublic class CacheConfig {\n\n    public static final String GOOGLE_CALENDAR = \"googleCalendar\";\n    private static final long EXPIRE_AFTER = 60 * 60 * 3;\n\n    @Bean\n    public CacheManager cacheManager() {\n        SimpleCacheManager simpleCacheManager = new SimpleCacheManager();\n        simpleCacheManager.setCaches(List.of(new ExpiringConcurrentMapCache(GOOGLE_CALENDAR, EXPIRE_AFTER)));\n\n        return simpleCacheManager;\n    }\n\n    @Scheduled(cron = \"0 0 0 * * *\")\n    private void evict() {\n        ExpiringConcurrentMapCache cache = (ExpiringConcurrentMapCache) cacheManager().getCache(GOOGLE_CALENDAR);\n        cache.evictAllExpired();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/cache/ExpiringConcurrentMapCache.java",
    "content": "package com.allog.dallog.global.config.cache;\n\nimport java.time.LocalDateTime;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\nimport org.springframework.cache.concurrent.ConcurrentMapCache;\n\npublic class ExpiringConcurrentMapCache extends ConcurrentMapCache {\n\n    private final Map<Object, LocalDateTime> expires = new ConcurrentHashMap<>();\n    private final long expireAfter;\n\n    public ExpiringConcurrentMapCache(final String name, final long expireAfter) {\n        super(name);\n\n        this.expireAfter = expireAfter;\n    }\n\n    @Override\n    protected Object lookup(final Object key) {\n        LocalDateTime expiredDate = expires.get(key);\n        if (Objects.isNull(expiredDate) || isCacheValid(expiredDate)) {\n            return super.lookup(key);\n        }\n\n        expires.remove(key);\n        super.evict(key);\n        return null;\n    }\n\n    @Override\n    public void put(final Object key, final Object value) {\n        LocalDateTime expiredAt = LocalDateTime.now().plusSeconds(expireAfter);\n        expires.put(key, expiredAt);\n\n        super.put(key, value);\n    }\n\n    public void evictAllExpired() {\n        ConcurrentMap<Object, Object> nativeCache = getNativeCache();\n\n        nativeCache.keySet()\n                .stream()\n                .filter(cacheKey -> !isCacheValid(expires.get(cacheKey)))\n                .forEach(super::evict);\n    }\n\n    private boolean isCacheValid(final LocalDateTime expiredDate) {\n        return LocalDateTime.now().isBefore(expiredDate);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/properties/GoogleProperties.java",
    "content": "package com.allog.dallog.global.config.properties;\n\nimport java.util.List;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.boot.context.properties.ConstructorBinding;\n\n@ConfigurationProperties(\"oauth.google\")\n@ConstructorBinding\npublic class GoogleProperties {\n\n    private final String clientId;\n    private final String clientSecret;\n    private final String oAuthEndPoint;\n    private final String responseType;\n    private final List<String> scopes;\n    private final String tokenUri;\n    private final String accessType;\n\n    public GoogleProperties(final String clientId, final String clientSecret, final String oAuthEndPoint,\n                            final String responseType, final List<String> scopes, final String tokenUri,\n                            final String accessType) {\n        this.clientId = clientId;\n        this.clientSecret = clientSecret;\n        this.oAuthEndPoint = oAuthEndPoint;\n        this.responseType = responseType;\n        this.scopes = scopes;\n        this.tokenUri = tokenUri;\n        this.accessType = accessType;\n    }\n\n    public String getClientId() {\n        return clientId;\n    }\n\n    public String getClientSecret() {\n        return clientSecret;\n    }\n\n    public String getOAuthEndPoint() {\n        return oAuthEndPoint;\n    }\n\n    public String getResponseType() {\n        return responseType;\n    }\n\n    public List<String> getScopes() {\n        return scopes;\n    }\n\n    public String getTokenUri() {\n        return tokenUri;\n    }\n\n    public String getAccessType() {\n        return accessType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/replication/DataSourceConfiguration.java",
    "content": "package com.allog.dallog.global.config.replication;\n\nimport static com.allog.dallog.global.config.replication.DataSourceKey.KeyName.REPLICA_1_NAME;\nimport static com.allog.dallog.global.config.replication.DataSourceKey.KeyName.REPLICA_2_NAME;\nimport static com.allog.dallog.global.config.replication.DataSourceKey.KeyName.SOURCE_NAME;\nimport static com.allog.dallog.global.config.replication.DataSourceKey.REPLICA_1;\nimport static com.allog.dallog.global.config.replication.DataSourceKey.REPLICA_2;\nimport static com.allog.dallog.global.config.replication.DataSourceKey.SOURCE;\n\nimport java.util.Map;\nimport javax.sql.DataSource;\nimport org.springframework.beans.factory.annotation.Qualifier;\nimport org.springframework.boot.context.properties.ConfigurationProperties;\nimport org.springframework.boot.jdbc.DataSourceBuilder;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.context.annotation.Primary;\nimport org.springframework.context.annotation.Profile;\nimport org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;\n\n@Configuration\n@Profile({\"prod\", \"dev\"})\npublic class DataSourceConfiguration {\n\n    @Bean\n    @Primary\n    public DataSource dataSource() {\n        DataSource determinedDataSource = routingDataSource(sourceDataSource(), replica1DataSource(),\n                replica2DataSource());\n        return new LazyConnectionDataSourceProxy(determinedDataSource);\n    }\n\n    @Bean\n    @Qualifier(SOURCE_NAME)\n    @ConfigurationProperties(prefix = \"spring.datasource.source\")\n    public DataSource sourceDataSource() {\n        return DataSourceBuilder.create()\n                .build();\n    }\n\n    @Bean\n    @Qualifier(REPLICA_1_NAME)\n    @ConfigurationProperties(prefix = \"spring.datasource.replica1\")\n    public DataSource replica1DataSource() {\n        return DataSourceBuilder.create()\n                .build();\n    }\n\n    @Bean\n    @Qualifier(REPLICA_2_NAME)\n    @ConfigurationProperties(prefix = \"spring.datasource.replica2\")\n    public DataSource replica2DataSource() {\n        return DataSourceBuilder.create()\n                .build();\n    }\n\n    @Bean\n    public DataSource routingDataSource(\n            @Qualifier(SOURCE_NAME) DataSource sourceDataSource,\n            @Qualifier(REPLICA_1_NAME) DataSource replica1DataSource,\n            @Qualifier(REPLICA_2_NAME) DataSource replica2DataSource\n    ) {\n        Map<Object, Object> dataSources = Map.of(\n                SOURCE, sourceDataSource, REPLICA_1, replica1DataSource, REPLICA_2, replica2DataSource\n        );\n\n        RoutingDataSource routingDataSource = new RoutingDataSource();\n        routingDataSource.setTargetDataSources(dataSources);\n        routingDataSource.setDefaultTargetDataSource(sourceDataSource);\n\n        return routingDataSource;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/replication/DataSourceKey.java",
    "content": "package com.allog.dallog.global.config.replication;\n\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic enum DataSourceKey {\n    SOURCE(KeyName.SOURCE_NAME, false),\n    REPLICA_1(KeyName.REPLICA_1_NAME, true),\n    REPLICA_2(KeyName.REPLICA_2_NAME, true);\n\n    private final String key;\n    private final boolean isReplica;\n\n    DataSourceKey(final String key, final boolean isReplica) {\n        this.key = key;\n        this.isReplica = isReplica;\n    }\n\n    public static List<DataSourceKey> getReplicas() {\n        return Arrays.stream(values())\n                .filter(key -> key.isReplica)\n                .collect(Collectors.toList());\n    }\n\n    // 어노테이션에서도 참조할 수 있도록 중첩 클래스에 상수 선언\n    public static class KeyName {\n        public static final String SOURCE_NAME = \"SOURCE\";\n        public static final String REPLICA_1_NAME = \"REPLICA_1\";\n        public static final String REPLICA_2_NAME = \"REPLICA_2\";\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/replication/RandomReplicaKeys.java",
    "content": "package com.allog.dallog.global.config.replication;\n\nimport java.util.List;\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic class RandomReplicaKeys {\n\n    private static final ThreadLocalRandom random = ThreadLocalRandom.current();\n\n    private final List<DataSourceKey> dataSourceKeys;\n    private final int size;\n\n    public RandomReplicaKeys() {\n        this.dataSourceKeys = List.copyOf(DataSourceKey.getReplicas());\n        this.size = dataSourceKeys.size();\n    }\n\n    public DataSourceKey next() {\n        int currentDataSourceIndex = random.nextInt(size);\n        return dataSourceKeys.get(currentDataSourceIndex);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/config/replication/RoutingDataSource.java",
    "content": "package com.allog.dallog.global.config.replication;\n\nimport static com.allog.dallog.global.config.replication.DataSourceKey.SOURCE;\n\nimport org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;\nimport org.springframework.transaction.support.TransactionSynchronizationManager;\n\npublic class RoutingDataSource extends AbstractRoutingDataSource {\n\n    private final RandomReplicaKeys randomReplicaKeys = new RandomReplicaKeys();\n\n    @Override\n    protected Object determineCurrentLookupKey() {\n        boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();\n\n        if (isReadOnly) {\n            return randomReplicaKeys.next();\n        }\n\n        return SOURCE;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/entity/BaseEntity.java",
    "content": "package com.allog.dallog.global.entity;\n\nimport java.time.LocalDateTime;\nimport javax.persistence.Column;\nimport javax.persistence.EntityListeners;\nimport javax.persistence.MappedSuperclass;\nimport org.springframework.data.annotation.CreatedDate;\nimport org.springframework.data.annotation.LastModifiedDate;\nimport org.springframework.data.jpa.domain.support.AuditingEntityListener;\n\n@MappedSuperclass\n@EntityListeners(AuditingEntityListener.class)\npublic abstract class BaseEntity {\n\n    @CreatedDate\n    @Column(name = \"created_at\", nullable = false, updatable = false)\n    private LocalDateTime createdAt;\n\n    @LastModifiedDate\n    @Column(name = \"updated_at\", nullable = false)\n    private LocalDateTime updatedAt;\n\n    public LocalDateTime getCreatedAt() {\n        return createdAt;\n    }\n\n    public LocalDateTime getUpdatedAt() {\n        return updatedAt;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/error/ControllerAdvice.java",
    "content": "package com.allog.dallog.global.error;\n\nimport com.allog.dallog.auth.exception.EmptyAuthorizationHeaderException;\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.auth.exception.NoSuchOAuthTokenException;\nimport com.allog.dallog.auth.exception.NoSuchTokenException;\nimport com.allog.dallog.category.exception.ExistExternalCategoryException;\nimport com.allog.dallog.category.exception.InvalidCategoryException;\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException;\nimport com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;\nimport com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException;\nimport com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException;\nimport com.allog.dallog.member.exception.InvalidMemberException;\nimport com.allog.dallog.member.exception.NoSuchMemberException;\nimport com.allog.dallog.schedule.exception.InvalidScheduleException;\nimport com.allog.dallog.schedule.exception.NoSuchScheduleException;\nimport com.allog.dallog.subscription.exception.ExistSubscriptionException;\nimport com.allog.dallog.subscription.exception.InvalidSubscriptionException;\nimport com.allog.dallog.subscription.exception.NoSuchSubscriptionException;\nimport com.allog.dallog.subscription.exception.NotAbleToUnsubscribeException;\nimport com.allog.dallog.global.error.dto.ErrorReportRequest;\nimport com.allog.dallog.global.error.dto.ErrorResponse;\nimport com.allog.dallog.infrastructure.oauth.exception.OAuthException;\nimport javax.servlet.http.HttpServletRequest;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.http.converter.HttpMessageNotReadableException;\nimport org.springframework.validation.FieldError;\nimport org.springframework.web.HttpRequestMethodNotSupportedException;\nimport org.springframework.web.bind.MethodArgumentNotValidException;\nimport org.springframework.web.bind.annotation.ExceptionHandler;\nimport org.springframework.web.bind.annotation.RestControllerAdvice;\nimport org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;\n\n@RestControllerAdvice\npublic class ControllerAdvice {\n\n    private static final Logger log = LoggerFactory.getLogger(ControllerAdvice.class);\n    private static final String INVALID_DTO_FIELD_ERROR_MESSAGE_FORMAT = \"%s 필드는 %s (전달된 값: %s)\";\n\n    @ExceptionHandler({\n            InvalidCategoryException.class,\n            InvalidMemberException.class,\n            InvalidScheduleException.class,\n            InvalidSubscriptionException.class,\n            ExistSubscriptionException.class,\n            ExistExternalCategoryException.class,\n            NotAbleToChangeRoleException.class,\n            ManagingCategoryLimitExcessException.class,\n            NotAbleToUnsubscribeException.class\n    })\n    public ResponseEntity<ErrorResponse> handleInvalidData(final RuntimeException e) {\n        ErrorResponse errorResponse = new ErrorResponse(e.getMessage());\n        return ResponseEntity.badRequest().body(errorResponse);\n    }\n\n    @ExceptionHandler(HttpMessageNotReadableException.class)\n    public ResponseEntity<ErrorResponse> handleInvalidRequestBody() {\n        ErrorResponse errorResponse = new ErrorResponse(\"잘못된 형식의 Request Body 입니다.\");\n        return ResponseEntity.badRequest().body(errorResponse);\n    }\n\n    @ExceptionHandler(MethodArgumentNotValidException.class)\n    public ResponseEntity<ErrorResponse> handleInvalidDtoField(final MethodArgumentNotValidException e) {\n        FieldError firstFieldError = e.getFieldErrors().get(0);\n        String errorMessage = String.format(INVALID_DTO_FIELD_ERROR_MESSAGE_FORMAT, firstFieldError.getField(),\n                firstFieldError.getDefaultMessage(), firstFieldError.getRejectedValue());\n\n        ErrorResponse errorResponse = new ErrorResponse(errorMessage);\n        return ResponseEntity.badRequest().body(errorResponse);\n    }\n\n    @ExceptionHandler(MethodArgumentTypeMismatchException.class)\n    public ResponseEntity<ErrorResponse> handleTypeMismatch() {\n        ErrorResponse errorResponse = new ErrorResponse(\"잘못된 데이터 타입입니다.\");\n        return ResponseEntity.badRequest().body(errorResponse);\n    }\n\n    @ExceptionHandler({\n            EmptyAuthorizationHeaderException.class,\n            InvalidTokenException.class\n    })\n    public ResponseEntity<ErrorResponse> handleInvalidAuthorization(final RuntimeException e) {\n        ErrorResponse errorResponse = new ErrorResponse(e.getMessage());\n        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);\n    }\n\n    @ExceptionHandler({\n            NoPermissionException.class,\n            NoCategoryAuthorityException.class,\n    })\n    public ResponseEntity<ErrorResponse> handleNoPermission(final RuntimeException e) {\n        ErrorResponse errorResponse = new ErrorResponse(e.getMessage());\n        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);\n    }\n\n    @ExceptionHandler({\n            NoSuchCategoryException.class,\n            NoSuchMemberException.class,\n            NoSuchSubscriptionException.class,\n            NoSuchScheduleException.class,\n            NoSuchTokenException.class,\n            NoSuchOAuthTokenException.class,\n            NoSuchCategoryRoleException.class\n    })\n    public ResponseEntity<ErrorResponse> handleNoSuchData(final RuntimeException e) {\n        ErrorResponse errorResponse = new ErrorResponse(e.getMessage());\n        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);\n    }\n\n    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)\n    public ResponseEntity<ErrorResponse> handleNotSupportedMethod() {\n        ErrorResponse errorResponse = new ErrorResponse(\"지원하지 않는 HTTP 메소드 요청입니다.\");\n        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse);\n    }\n\n    @ExceptionHandler(OAuthException.class)\n    public ResponseEntity<ErrorResponse> handleOAuthException(final RuntimeException e) {\n        log.error(e.getMessage(), e);\n        ErrorResponse errorResponse = new ErrorResponse(e.getMessage());\n        return ResponseEntity.internalServerError().body(errorResponse);\n    }\n\n    @ExceptionHandler(Exception.class)\n    public ResponseEntity<ErrorResponse> handleUnexpectedException(final Exception e,\n                                                                   final HttpServletRequest request) {\n        ErrorReportRequest errorReport = new ErrorReportRequest(request, e);\n        log.error(errorReport.getLogMessage(), e);\n\n        ErrorResponse errorResponse = new ErrorResponse(\"예상하지 못한 서버 에러가 발생했습니다.\");\n        return ResponseEntity.internalServerError().body(errorResponse);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/error/dto/ErrorReportRequest.java",
    "content": "package com.allog.dallog.global.error.dto;\n\nimport javax.servlet.http.HttpServletRequest;\n\npublic class ErrorReportRequest {\n\n    private static final String ERROR_REPORT_FORMAT = \"[%s] %s\";\n\n    private final HttpServletRequest request;\n    private final Exception exception;\n\n    public ErrorReportRequest(final HttpServletRequest request, final Exception exception) {\n        this.request = request;\n        this.exception = exception;\n    }\n\n    public String getLogMessage() {\n        String requestUri = request.getRequestURI();\n        String requestMethod = request.getMethod();\n\n        return String.format(ERROR_REPORT_FORMAT, requestMethod, requestUri);\n    }\n\n    public HttpServletRequest getRequest() {\n        return request;\n    }\n\n    public Exception getException() {\n        return exception;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/global/error/dto/ErrorResponse.java",
    "content": "package com.allog.dallog.global.error.dto;\n\npublic class ErrorResponse {\n\n    private final String message;\n\n    public ErrorResponse(final String message) {\n        this.message = message;\n    }\n\n    public String getMessage() {\n        return message;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/log/DiscordAppender.java",
    "content": "package com.allog.dallog.infrastructure.log;\n\nimport ch.qos.logback.classic.spi.ILoggingEvent;\nimport ch.qos.logback.classic.spi.IThrowableProxy;\nimport ch.qos.logback.classic.spi.StackTraceElementProxy;\nimport ch.qos.logback.core.UnsynchronizedAppenderBase;\nimport com.allog.dallog.infrastructure.log.dto.DiscordWebhookRequest;\nimport com.allog.dallog.infrastructure.log.dto.Embed;\nimport com.allog.dallog.infrastructure.log.dto.Field;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.stream.Collectors;\nimport org.springframework.http.client.SimpleClientHttpRequestFactory;\nimport org.springframework.web.client.RestTemplate;\n\npublic class DiscordAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {\n\n    private static final String TITLE_FORMAT = \"[%s] %s\";\n    private static final String DESCRIPTION_FORMAT = \"%s: %s\";\n    private static final RestTemplate CLIENT;\n\n    static {\n        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();\n        factory.setConnectTimeout(3000);\n        CLIENT = new RestTemplate(factory);\n    }\n\n    private String username;\n    private String embedsColor;\n    private int stackTraceMaxSize;\n    private String webhookUri;\n\n    @Override\n    protected void append(final ILoggingEvent eventObject) {\n        if (!Objects.isNull(webhookUri) && !webhookUri.isEmpty()) {\n            String title = getTitle(eventObject);\n            List<Embed> embeds = getEmbeds(title, embedsColor, eventObject);\n            DiscordWebhookRequest request = new DiscordWebhookRequest(username, embeds);\n\n            CLIENT.postForEntity(webhookUri, request, Void.class);\n        }\n    }\n\n    private String getTitle(final ILoggingEvent eventObject) {\n        return String.format(TITLE_FORMAT, eventObject.getLevel(), eventObject.getMessage());\n    }\n\n    private List<Embed> getEmbeds(final String title, final String embedsColor, final ILoggingEvent eventObject) {\n        if (Objects.isNull(eventObject.getThrowableProxy())) {\n            return List.of(new Embed(title, embedsColor));\n        }\n\n        IThrowableProxy throwableProxy = eventObject.getThrowableProxy();\n        String description = getDescription(throwableProxy);\n        List<Field> fields = getFields(throwableProxy);\n\n        return List.of(new Embed(title, description, embedsColor, fields));\n    }\n\n    private String getDescription(final IThrowableProxy throwableProxy) {\n        return String.format(DESCRIPTION_FORMAT, throwableProxy.getClassName(), throwableProxy.getMessage());\n    }\n\n    private List<Field> getFields(final IThrowableProxy throwableProxy) {\n        List<String> stackTraces = Arrays.stream(throwableProxy.getStackTraceElementProxyArray())\n                .map(StackTraceElementProxy::getSTEAsString)\n                .limit(stackTraceMaxSize)\n                .collect(Collectors.toList());\n\n        return stackTraces.stream()\n                .map(Field::from)\n                .collect(Collectors.toList());\n    }\n\n    public String getUsername() {\n        return username;\n    }\n\n    public void setUsername(final String username) {\n        this.username = username;\n    }\n\n    public String getEmbedsColor() {\n        return embedsColor;\n    }\n\n    public void setEmbedsColor(final String embedsColor) {\n        this.embedsColor = embedsColor;\n    }\n\n    public int getStackTraceMaxSize() {\n        return stackTraceMaxSize;\n    }\n\n    public void setStackTraceMaxSize(final int stackTraceMaxSize) {\n        this.stackTraceMaxSize = stackTraceMaxSize;\n    }\n\n    public String getWebhookUri() {\n        return webhookUri;\n    }\n\n    public void setWebhookUri(final String webhookUri) {\n        this.webhookUri = webhookUri;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/log/dto/DiscordWebhookRequest.java",
    "content": "package com.allog.dallog.infrastructure.log.dto;\n\nimport java.util.List;\n\npublic class DiscordWebhookRequest {\n\n    private String username;\n    private List<Embed> embeds;\n\n    private DiscordWebhookRequest() {\n    }\n\n    public DiscordWebhookRequest(final String username, final List<Embed> embeds) {\n        this.username = username;\n        this.embeds = embeds;\n    }\n\n    public String getUsername() {\n        return username;\n    }\n\n    public List<Embed> getEmbeds() {\n        return embeds;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/log/dto/Embed.java",
    "content": "package com.allog.dallog.infrastructure.log.dto;\n\nimport java.util.List;\n\npublic class Embed {\n\n    private String title;\n    private String description;\n    private String color;\n    private List<Field> fields;\n\n    private Embed() {\n    }\n\n    public Embed(final String title, final String color) {\n        this(title, null, color, null);\n    }\n\n    public Embed(final String title, final String description, final String color, final List<Field> fields) {\n        this.title = title;\n        this.description = description;\n        this.color = color;\n        this.fields = fields;\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public String getDescription() {\n        return description;\n    }\n\n    public String getColor() {\n        return color;\n    }\n\n    public List<Field> getFields() {\n        return fields;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/log/dto/Field.java",
    "content": "package com.allog.dallog.infrastructure.log.dto;\n\npublic class Field {\n\n    private String name;\n    private String value;\n\n    private Field() {\n    }\n\n    private Field(final String name, final String value) {\n        this.name = name;\n        this.value = value;\n    }\n\n    public static Field from(final String steAsString) {\n        String name = steAsString.substring(steAsString.indexOf(\"(\") + 1, steAsString.indexOf(\")\"));\n        String value = steAsString.substring(0, steAsString.indexOf(\"(\"));\n        return new Field(name, value);\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getValue() {\n        return value;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/client/GoogleExternalCalendarClient.java",
    "content": "package com.allog.dallog.infrastructure.oauth.client;\n\nimport com.allog.dallog.externalcalendar.application.ExternalCalendarClient;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendar;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.global.config.cache.CacheConfig;\nimport com.allog.dallog.infrastructure.oauth.dto.GoogleCalendarEventsResponse;\nimport com.allog.dallog.infrastructure.oauth.dto.GoogleCalendarListResponse;\nimport com.allog.dallog.infrastructure.oauth.exception.OAuthException;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Collectors;\nimport org.springframework.boot.web.client.RestTemplateBuilder;\nimport org.springframework.cache.annotation.Cacheable;\nimport org.springframework.http.HttpEntity;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Component;\nimport org.springframework.web.client.HttpClientErrorException;\nimport org.springframework.web.client.RestClientException;\nimport org.springframework.web.client.RestTemplate;\n\n@Component\npublic class GoogleExternalCalendarClient implements ExternalCalendarClient {\n\n    private static final String CALENDAR_LIST_REQUEST_URI = \"https://www.googleapis.com/calendar/v3/users/me/calendarList\";\n    private static final String CALENDAR_EVENTS_REQUEST_URI = \"https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events?singleEvents=true&timeMax={timeMax}&timeMin={timeMin}\";\n    private static final String ACCEPT_HEADER_NAME = \"Accept\";\n\n    private final RestTemplate restTemplate;\n\n    public GoogleExternalCalendarClient(final RestTemplateBuilder restTemplateBuilder) {\n        this.restTemplate = restTemplateBuilder.build();\n    }\n\n    @Override\n    public List<ExternalCalendar> getExternalCalendars(final String accessToken) {\n        HttpEntity<Void> request = new HttpEntity<>(generateCalendarRequestHeaders(accessToken));\n        GoogleCalendarListResponse response = fetchGoogleCalendarList(request).getBody();\n\n        return response.getItems()\n                .stream()\n                .map(item -> new ExternalCalendar(item.getId(), item.getSummary()))\n                .collect(Collectors.toList());\n    }\n\n    private ResponseEntity<GoogleCalendarListResponse> fetchGoogleCalendarList(final HttpEntity<Void> request) {\n        try {\n            return restTemplate.exchange(CALENDAR_LIST_REQUEST_URI, HttpMethod.GET, request,\n                    GoogleCalendarListResponse.class);\n        } catch (final HttpClientErrorException e) {\n            throw new OAuthException(\"외부 캘린더에 대한 권한이 없습니다.\", e);\n        } catch (final RestClientException e) {\n            throw new OAuthException(\"외부 캘린더를 가져올 수 없습니다.\", e);\n        }\n    }\n\n    @Override\n    @Cacheable(value = CacheConfig.GOOGLE_CALENDAR, key = \"#internalCategoryId+#externalCalendarId+#startDateTime+#endDateTime\")\n    public List<IntegrationSchedule> getExternalCalendarSchedules(final String accessToken,\n                                                                  final Long internalCategoryId,\n                                                                  final String externalCalendarId,\n                                                                  final String startDateTime,\n                                                                  final String endDateTime) {\n        HttpEntity<Void> request = new HttpEntity<>(generateCalendarRequestHeaders(accessToken));\n        Map<String, String> uriVariables = generateEventsVariables(externalCalendarId, startDateTime, endDateTime);\n\n        GoogleCalendarEventsResponse response = fetchGoogleCalendarEvents(request, uriVariables).getBody();\n\n        return response.getItems()\n                .stream()\n                .map(event -> event.toIntegrationSchedule(internalCategoryId))\n                .collect(Collectors.toList());\n    }\n\n    private HttpHeaders generateCalendarRequestHeaders(final String accessToken) {\n        HttpHeaders headers = new HttpHeaders();\n        headers.setBearerAuth(accessToken);\n        headers.set(ACCEPT_HEADER_NAME, MediaType.APPLICATION_JSON_VALUE);\n        return headers;\n    }\n\n    private Map<String, String> generateEventsVariables(final String externalCalendarId, final String startDateTime,\n                                                        final String endDateTime) {\n        return Map.of(\n                \"calendarId\", externalCalendarId,\n                \"timeMax\", endDateTime + \"Z\",\n                \"timeMin\", startDateTime + \"Z\"\n        );\n    }\n\n    private ResponseEntity<GoogleCalendarEventsResponse> fetchGoogleCalendarEvents(\n            final HttpEntity<Void> request, final Map<String, String> uriVariables) {\n        try {\n            return restTemplate.exchange(CALENDAR_EVENTS_REQUEST_URI, HttpMethod.GET, request,\n                    GoogleCalendarEventsResponse.class, uriVariables);\n        } catch (final HttpClientErrorException e) {\n            throw new OAuthException(\"외부 일정에 대한 권한이 없습니다.\", e);\n        } catch (final RestClientException e) {\n            throw new OAuthException(\"외부 일정을 가져올 수 없습니다.\", e);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/client/GoogleOAuthClient.java",
    "content": "package com.allog.dallog.infrastructure.oauth.client;\n\nimport com.allog.dallog.auth.application.OAuthClient;\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse;\nimport com.allog.dallog.global.config.properties.GoogleProperties;\nimport com.allog.dallog.infrastructure.oauth.dto.GoogleTokenResponse;\nimport com.allog.dallog.infrastructure.oauth.dto.UserInfo;\nimport com.allog.dallog.infrastructure.oauth.exception.OAuthException;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\nimport org.springframework.boot.web.client.RestTemplateBuilder;\nimport org.springframework.http.HttpEntity;\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Component;\nimport org.springframework.util.LinkedMultiValueMap;\nimport org.springframework.util.MultiValueMap;\nimport org.springframework.web.client.RestClientException;\nimport org.springframework.web.client.RestTemplate;\n\n@Component\npublic class GoogleOAuthClient implements OAuthClient {\n\n    private static final String JWT_DELIMITER = \"\\\\.\";\n\n    private final GoogleProperties properties;\n    private final RestTemplate restTemplate;\n    private final ObjectMapper objectMapper;\n\n    public GoogleOAuthClient(final GoogleProperties properties, final RestTemplateBuilder restTemplateBuilder,\n                             final ObjectMapper objectMapper) {\n        this.properties = properties;\n        this.restTemplate = restTemplateBuilder.build();\n        this.objectMapper = objectMapper;\n    }\n\n    @Override\n    public OAuthMember getOAuthMember(final String code, final String redirectUri) {\n        GoogleTokenResponse googleTokenResponse = requestGoogleToken(code, redirectUri);\n        String payload = getPayload(googleTokenResponse.getIdToken());\n        UserInfo userInfo = parseUserInfo(payload);\n\n        String refreshToken = googleTokenResponse.getRefreshToken();\n        return new OAuthMember(userInfo.getEmail(), userInfo.getName(), userInfo.getPicture(), refreshToken);\n    }\n\n    private GoogleTokenResponse requestGoogleToken(final String code, final String redirectUri) {\n        HttpHeaders headers = new HttpHeaders();\n        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);\n        MultiValueMap<String, String> params = generateTokenParams(code, redirectUri);\n\n        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);\n        return fetchGoogleToken(request).getBody();\n    }\n\n    private MultiValueMap<String, String> generateTokenParams(final String code, final String redirectUri) {\n        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();\n        params.add(\"client_id\", properties.getClientId());\n        params.add(\"client_secret\", properties.getClientSecret());\n        params.add(\"code\", code);\n        params.add(\"grant_type\", \"authorization_code\");\n        params.add(\"redirect_uri\", redirectUri);\n        return params;\n    }\n\n    private ResponseEntity<GoogleTokenResponse> fetchGoogleToken(\n            final HttpEntity<MultiValueMap<String, String>> request) {\n        try {\n            return restTemplate.postForEntity(properties.getTokenUri(), request, GoogleTokenResponse.class);\n        } catch (final RestClientException e) {\n            throw new OAuthException(e);\n        }\n    }\n\n    private String getPayload(final String jwt) {\n        return jwt.split(JWT_DELIMITER)[1];\n    }\n\n    private UserInfo parseUserInfo(final String payload) {\n        String decodedPayload = decodeJwtPayload(payload);\n        try {\n            return objectMapper.readValue(decodedPayload, UserInfo.class);\n        } catch (final JsonProcessingException e) {\n            throw new OAuthException(\"id 토큰을 읽을 수 없습니다.\", e);\n        }\n    }\n\n    private String decodeJwtPayload(final String payload) {\n        return new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8);\n    }\n\n    @Override\n    public OAuthAccessTokenResponse getAccessToken(final String refreshToken) {\n        HttpHeaders headers = new HttpHeaders();\n        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);\n        MultiValueMap<String, String> params = generateAccessTokenParams(refreshToken);\n\n        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);\n        return fetchGoogleAccessToken(request).getBody();\n    }\n\n    private MultiValueMap<String, String> generateAccessTokenParams(final String refreshToken) {\n        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();\n        params.add(\"client_id\", properties.getClientId());\n        params.add(\"client_secret\", properties.getClientSecret());\n        params.add(\"refresh_token\", refreshToken);\n        params.add(\"grant_type\", \"refresh_token\");\n        return params;\n    }\n\n    private ResponseEntity<OAuthAccessTokenResponse> fetchGoogleAccessToken(\n            final HttpEntity<MultiValueMap<String, String>> request) {\n        try {\n            return restTemplate.postForEntity(properties.getTokenUri(), request, OAuthAccessTokenResponse.class);\n        } catch (final RestClientException e) {\n            throw new OAuthException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarEventResponse.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\nimport com.allog.dallog.category.domain.CategoryType;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport java.time.LocalDate;\nimport java.time.LocalDateTime;\nimport java.time.LocalTime;\nimport java.util.Objects;\n\npublic class GoogleCalendarEventResponse {\n\n    private String id;\n    private String summary = \"\";\n    private String description = \"\";\n    private GoogleDateFormat start;\n    private GoogleDateFormat end;\n\n    private GoogleCalendarEventResponse() {\n    }\n\n    public GoogleCalendarEventResponse(final String id, final String summary, final String description,\n                                       final GoogleDateFormat start,\n                                       final GoogleDateFormat end) {\n        this.id = id;\n        this.summary = summary;\n        this.description = description;\n        this.start = start;\n        this.end = end;\n    }\n\n    public IntegrationSchedule toIntegrationSchedule(final Long internalCategoryId) {\n        return new IntegrationSchedule(id, internalCategoryId, summary, getStartDateTime(), getEndDateTime(),\n                description, CategoryType.GOOGLE);\n    }\n\n    private LocalDateTime getStartDateTime() {\n        if (Objects.isNull(start.getDate())) {\n            return LocalDateTime.parse(start.getDateTime().substring(0, 19));\n        }\n\n        return LocalDateTime.of(LocalDate.parse(start.getDate()), LocalTime.MIN);\n    }\n\n    private LocalDateTime getEndDateTime() {\n        if (Objects.isNull(end.getDate())) {\n            return LocalDateTime.parse(end.getDateTime().substring(0, 19));\n        }\n\n        return LocalDateTime.of(LocalDate.parse(end.getDate()), LocalTime.MIN);\n    }\n\n    public String getId() {\n        return id;\n    }\n\n    public String getSummary() {\n        return summary;\n    }\n\n    public String getDescription() {\n        return description;\n    }\n\n    public GoogleDateFormat getStart() {\n        return start;\n    }\n\n    public GoogleDateFormat getEnd() {\n        return end;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarEventsResponse.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\nimport java.util.List;\n\npublic class GoogleCalendarEventsResponse {\n\n    private List<GoogleCalendarEventResponse> items;\n\n    private GoogleCalendarEventsResponse() {\n    }\n\n    public GoogleCalendarEventsResponse(final List<GoogleCalendarEventResponse> items) {\n        this.items = items;\n    }\n\n    public List<GoogleCalendarEventResponse> getItems() {\n        return items;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarListResponse.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\nimport java.util.List;\n\npublic class GoogleCalendarListResponse {\n\n    private List<GoogleCalendarResponse> items;\n\n    private GoogleCalendarListResponse() {\n    }\n\n    public GoogleCalendarListResponse(final List<GoogleCalendarResponse> items) {\n        this.items = items;\n    }\n\n    public List<GoogleCalendarResponse> getItems() {\n        return items;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarResponse.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\npublic class GoogleCalendarResponse {\n\n    private String id;\n    private String summary;\n    private String description;\n\n    private GoogleCalendarResponse() {\n    }\n\n    public GoogleCalendarResponse(final String id, final String summary, final String description) {\n        this.id = id;\n        this.summary = summary;\n        this.description = description;\n    }\n\n    public String getId() {\n        return id;\n    }\n\n    public String getSummary() {\n        return summary;\n    }\n\n    public String getDescription() {\n        return description;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleDateFormat.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\npublic class GoogleDateFormat {\n\n    private String date;\n    private String dateTime;\n\n    private GoogleDateFormat() {\n    }\n\n    public GoogleDateFormat(final String date, final String dateTime) {\n        this.date = date;\n        this.dateTime = dateTime;\n    }\n\n    public String getDate() {\n        return date;\n    }\n\n    public String getDateTime() {\n        return dateTime;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleTokenResponse.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\nimport com.fasterxml.jackson.databind.PropertyNamingStrategies;\nimport com.fasterxml.jackson.databind.annotation.JsonNaming;\n\n@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)\npublic class GoogleTokenResponse {\n\n    private String refreshToken;\n    private String idToken;\n\n    private GoogleTokenResponse() {\n    }\n\n    public GoogleTokenResponse(final String refreshToken, final String idToken) {\n        this.refreshToken = refreshToken;\n        this.idToken = idToken;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n\n    public String getIdToken() {\n        return idToken;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/UserInfo.java",
    "content": "package com.allog.dallog.infrastructure.oauth.dto;\n\npublic class UserInfo {\n\n    private String email;\n    private String name;\n    private String picture;\n\n    private UserInfo() {\n    }\n\n    public UserInfo(final String email, final String name, final String picture) {\n        this.email = email;\n        this.name = name;\n        this.picture = picture;\n    }\n\n    public String getEmail() {\n        return email;\n    }\n\n    public String getName() {\n        return name;\n    }\n\n    public String getPicture() {\n        return picture;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/exception/OAuthException.java",
    "content": "package com.allog.dallog.infrastructure.oauth.exception;\n\npublic class OAuthException extends RuntimeException {\n\n    public OAuthException() {\n        super(\"Oauth 서버와의 통신 과정에서 문제가 발생했습니다.\");\n    }\n\n    public OAuthException(final Exception e) {\n        this(\"Oauth 서버와의 통신 과정에서 문제가 발생했습니다.\", e);\n    }\n\n    public OAuthException(final String message, final Exception e) {\n        super(message, e);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/uri/DevGoogleOAuthUri.java",
    "content": "package com.allog.dallog.infrastructure.oauth.uri;\n\nimport com.allog.dallog.auth.application.OAuthUri;\nimport com.allog.dallog.global.config.properties.GoogleProperties;\nimport org.springframework.context.annotation.Profile;\nimport org.springframework.stereotype.Component;\n\n@Component\n@Profile({\"local\", \"dev\"})\npublic class DevGoogleOAuthUri implements OAuthUri {\n\n    private final GoogleProperties properties;\n\n    public DevGoogleOAuthUri(final GoogleProperties properties) {\n        this.properties = properties;\n    }\n\n    @Override\n    public String generate(final String redirectUri) {\n        return properties.getOAuthEndPoint() + \"?\"\n                + \"client_id=\" + properties.getClientId() + \"&\"\n                + \"redirect_uri=\" + redirectUri + \"&\"\n                + \"response_type=code&\"\n                + \"scope=\" + String.join(\" \", properties.getScopes()) + \"&\"\n                + \"access_type=\" + properties.getAccessType() + \"&\"\n                + \"prompt=consent\";\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/infrastructure/oauth/uri/GoogleOAuthUri.java",
    "content": "package com.allog.dallog.infrastructure.oauth.uri;\n\nimport com.allog.dallog.auth.application.OAuthUri;\nimport com.allog.dallog.global.config.properties.GoogleProperties;\nimport org.springframework.context.annotation.Profile;\nimport org.springframework.stereotype.Component;\n\n@Component\n@Profile(\"prod\")\npublic class GoogleOAuthUri implements OAuthUri {\n\n    private final GoogleProperties properties;\n\n    public GoogleOAuthUri(final GoogleProperties properties) {\n        this.properties = properties;\n    }\n\n    @Override\n    public String generate(final String redirectUri) {\n        return properties.getOAuthEndPoint() + \"?\"\n                + \"client_id=\" + properties.getClientId() + \"&\"\n                + \"redirect_uri=\" + redirectUri + \"&\"\n                + \"response_type=code&\"\n                + \"scope=\" + String.join(\" \", properties.getScopes()) + \"&\"\n                + \"access_type=\" + properties.getAccessType();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/application/MemberService.java",
    "content": "package com.allog.dallog.member.application;\n\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.member.dto.request.MemberUpdateRequest;\nimport com.allog.dallog.member.dto.response.MemberResponse;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class MemberService {\n\n    private final MemberRepository memberRepository;\n\n    public MemberService(final MemberRepository memberRepository) {\n        this.memberRepository = memberRepository;\n    }\n\n    public MemberResponse findById(final Long id) {\n        return new MemberResponse(memberRepository.getById(id));\n    }\n\n    @Transactional\n    public void update(final Long id, final MemberUpdateRequest request) {\n        Member member = memberRepository.getById(id);\n        member.change(request.getDisplayName());\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/domain/Member.java",
    "content": "package com.allog.dallog.member.domain;\n\nimport com.allog.dallog.global.entity.BaseEntity;\nimport com.allog.dallog.member.exception.InvalidMemberException;\nimport java.util.Objects;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport javax.persistence.Column;\nimport javax.persistence.Entity;\nimport javax.persistence.EnumType;\nimport javax.persistence.Enumerated;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.Table;\n\n@Table(name = \"members\")\n@Entity\npublic class Member extends BaseEntity {\n\n    private static final Pattern EMAIL_PATTERN = Pattern.compile(\"^[a-z0-9._-]+@[a-z]+[.]+[a-z]{2,3}$\");\n    private static final int MAX_DISPLAY_NAME_LENGTH = 100;\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    @Column(name = \"id\")\n    private Long id;\n\n    @Column(name = \"email\", nullable = false)\n    private String email;\n\n    @Column(name = \"display_name\", nullable = false)\n    private String displayName;\n\n    @Column(name = \"profile_image_url\", nullable = false)\n    private String profileImageUrl;\n\n    @Enumerated(value = EnumType.STRING)\n    @Column(name = \"social_type\", nullable = false)\n    private SocialType socialType;\n\n    protected Member() {\n    }\n\n    public Member(final String email, final String displayName, final String profileImageUrl,\n                  final SocialType socialType) {\n        validateEmail(email);\n        validateDisplayName(displayName);\n\n        this.email = email;\n        this.displayName = displayName;\n        this.profileImageUrl = profileImageUrl;\n        this.socialType = socialType;\n    }\n\n    private void validateEmail(final String email) {\n        Matcher matcher = EMAIL_PATTERN.matcher(email);\n        if (!matcher.matches()) {\n            throw new InvalidMemberException(\"이메일 형식이 올바르지 않습니다.\");\n        }\n    }\n\n    private void validateDisplayName(final String displayName) {\n        if (displayName.isEmpty() || displayName.length() > MAX_DISPLAY_NAME_LENGTH) {\n            throw new InvalidMemberException(String.format(\"이름은 1자 이상 1자 %d이하여야 합니다.\", MAX_DISPLAY_NAME_LENGTH));\n        }\n    }\n\n    public void change(final String displayName) {\n        validateDisplayName(displayName);\n        this.displayName = displayName;\n    }\n\n    public boolean hasSameId(final Long memberId) {\n        return Objects.equals(this.id, memberId);\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getEmail() {\n        return email;\n    }\n\n    public String getDisplayName() {\n        return displayName;\n    }\n\n    public String getProfileImageUrl() {\n        return profileImageUrl;\n    }\n\n    public SocialType getSocialType() {\n        return socialType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/domain/MemberRepository.java",
    "content": "package com.allog.dallog.member.domain;\n\nimport com.allog.dallog.member.exception.NoSuchMemberException;\nimport java.util.Optional;\nimport org.springframework.data.jpa.repository.JpaRepository;\n\npublic interface MemberRepository extends JpaRepository<Member, Long> {\n\n    Optional<Member> findByEmail(final String email);\n\n    boolean existsByEmail(final String email);\n\n    default Member getById(final Long id) {\n        return findById(id)\n                .orElseThrow(NoSuchMemberException::new);\n    }\n\n    default Member getByEmail(final String email) {\n        return findByEmail(email)\n                .orElseThrow(NoSuchMemberException::new);\n    }\n\n    default void validateExistsById(final Long id) {\n        if (!existsById(id)) {\n            throw new NoSuchMemberException();\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/domain/SocialType.java",
    "content": "package com.allog.dallog.member.domain;\n\npublic enum SocialType {\n\n    GOOGLE, GITHUB;\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/dto/request/MemberUpdateRequest.java",
    "content": "package com.allog.dallog.member.dto.request;\n\nimport javax.validation.constraints.NotBlank;\n\npublic class MemberUpdateRequest {\n\n    @NotBlank(message = \"회원 이름이 공백일 수 없습니다.\")\n    private String displayName;\n\n    private MemberUpdateRequest() {\n    }\n\n    public MemberUpdateRequest(final String displayName) {\n        this.displayName = displayName;\n    }\n\n    public String getDisplayName() {\n        return displayName;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/dto/response/MemberResponse.java",
    "content": "package com.allog.dallog.member.dto.response;\n\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.SocialType;\n\npublic class MemberResponse {\n\n    private Long id;\n    private String email;\n    private String displayName;\n    private String profileImageUrl;\n    private SocialType socialType;\n\n    private MemberResponse() {\n    }\n\n    public MemberResponse(final Long id, final String email, final String displayName, final String profileImageUrl,\n                          final SocialType socialType) {\n        this.id = id;\n        this.email = email;\n        this.displayName = displayName;\n        this.profileImageUrl = profileImageUrl;\n        this.socialType = socialType;\n    }\n\n    public MemberResponse(final Member member) {\n        this(member.getId(), member.getEmail(), member.getDisplayName(), member.getProfileImageUrl(),\n                member.getSocialType());\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getEmail() {\n        return email;\n    }\n\n    public String getDisplayName() {\n        return displayName;\n    }\n\n    public String getProfileImageUrl() {\n        return profileImageUrl;\n    }\n\n    public SocialType getSocialType() {\n        return socialType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/exception/InvalidMemberException.java",
    "content": "package com.allog.dallog.member.exception;\n\npublic class InvalidMemberException extends RuntimeException {\n\n    public InvalidMemberException(final String message) {\n        super(message);\n    }\n\n    public InvalidMemberException() {\n        this(\"잘못된 회원의 정보입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/exception/NoSuchMemberException.java",
    "content": "package com.allog.dallog.member.exception;\n\npublic class NoSuchMemberException extends RuntimeException {\n\n    public NoSuchMemberException(final String message) {\n        super(message);\n    }\n\n    public NoSuchMemberException() {\n        this(\"존재하지 않는 회원입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/member/presentation/MemberController.java",
    "content": "package com.allog.dallog.member.presentation;\n\nimport com.allog.dallog.auth.dto.LoginMember;\nimport com.allog.dallog.auth.presentation.AuthenticationPrincipal;\nimport com.allog.dallog.member.application.MemberService;\nimport com.allog.dallog.member.dto.request.MemberUpdateRequest;\nimport com.allog.dallog.member.dto.response.MemberResponse;\nimport javax.validation.Valid;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PatchMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RequestMapping(\"/api/members\")\n@RestController\npublic class MemberController {\n\n    private final MemberService memberService;\n\n    public MemberController(final MemberService memberService) {\n        this.memberService = memberService;\n    }\n\n    @GetMapping(\"/me\")\n    public ResponseEntity<MemberResponse> findMe(@AuthenticationPrincipal final LoginMember loginMember) {\n        MemberResponse response = memberService.findById(loginMember.getId());\n        return ResponseEntity.ok(response);\n    }\n\n    @PatchMapping(\"/me\")\n    public ResponseEntity<Void> update(@AuthenticationPrincipal LoginMember loginMember,\n                                       @Valid @RequestBody final MemberUpdateRequest request) {\n        memberService.update(loginMember.getId(), request);\n        return ResponseEntity.noContent().build();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/application/CheckedSchedulesFinder.java",
    "content": "package com.allog.dallog.schedule.application;\n\nimport com.allog.dallog.auth.application.OAuthClient;\nimport com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.externalcalendar.application.ExternalCalendarClient;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.schedule.domain.TypedSchedules;\nimport com.allog.dallog.schedule.dto.MaterialToFindSchedules;\nimport com.allog.dallog.schedule.dto.request.DateRangeRequest;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses;\nimport java.time.format.DateTimeFormatter;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.stream.Collectors;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class CheckedSchedulesFinder {\n\n    private static final String DATE_FORMAT = \"yyyy-MM-dd'T'HH:mm:ss\";\n\n    private final ScheduleService scheduleService;\n    private final OAuthClient oAuthClient;\n    private final ExternalCalendarClient externalCalendarClient;\n\n    public CheckedSchedulesFinder(final ScheduleService scheduleService, final OAuthClient oAuthClient,\n                                  final ExternalCalendarClient externalCalendarClient) {\n        this.scheduleService = scheduleService;\n        this.oAuthClient = oAuthClient;\n        this.externalCalendarClient = externalCalendarClient;\n    }\n\n    public IntegrationScheduleResponses findMyCheckedSchedules(final Long memberId, final DateRangeRequest request) {\n        MaterialToFindSchedules material = scheduleService.findInternalByMemberIdAndDateRange(memberId, request);\n\n        List<IntegrationSchedule> schedules = material.getSchedules();\n\n        String refreshToken = material.getRefreshToken();\n        String accessToken = toAccessToken(refreshToken);\n\n        List<IntegrationSchedule> externalSchedules = toExternalSchedules(request, material, accessToken);\n        schedules.addAll(externalSchedules);\n\n        return new IntegrationScheduleResponses(material.getSubscriptions(), new TypedSchedules(schedules));\n    }\n\n    private String toAccessToken(final String refreshToken) {\n        OAuthAccessTokenResponse oAuthToken = oAuthClient.getAccessToken(refreshToken);\n        return oAuthToken.getAccessToken();\n    }\n\n    private List<IntegrationSchedule> toExternalSchedules(final DateRangeRequest request,\n                                                          final MaterialToFindSchedules material,\n                                                          final String accessToken) {\n        List<ExternalCategoryDetail> externalCategoryDetails = material.getExternalCategoryDetails();\n        if (externalCategoryDetails.isEmpty()) {\n            return new ArrayList<>();\n        }\n\n        return externalCategoryDetails.stream()\n                .map(externalCategoryDetail -> findExternalSchedules(request, accessToken, externalCategoryDetail))\n                .flatMap(Collection::stream)\n                .collect(Collectors.toList());\n    }\n\n    private List<IntegrationSchedule> findExternalSchedules(final DateRangeRequest request, final String accessToken,\n                                                            final ExternalCategoryDetail externalCategoryDetail) {\n        String startDateTime = request.getStartDateTime().format(DateTimeFormatter.ofPattern(DATE_FORMAT));\n        String endDateTime = request.getEndDateTime().format(DateTimeFormatter.ofPattern(DATE_FORMAT));\n\n        Category externalCategory = externalCategoryDetail.getCategory();\n        String externalId = externalCategoryDetail.getExternalId();\n\n        return externalCalendarClient.getExternalCalendarSchedules(\n                accessToken, externalCategory.getId(), externalId, startDateTime, endDateTime);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/application/ScheduleService.java",
    "content": "package com.allog.dallog.schedule.application;\n\nimport com.allog.dallog.auth.domain.OAuthToken;\nimport com.allog.dallog.auth.domain.OAuthTokenRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryAuthority;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.category.domain.ExternalCategoryDetailRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.schedule.domain.Schedule;\nimport com.allog.dallog.schedule.domain.ScheduleRepository;\nimport com.allog.dallog.schedule.domain.TypedSchedules;\nimport com.allog.dallog.schedule.dto.MaterialToFindSchedules;\nimport com.allog.dallog.schedule.dto.request.DateRangeRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses;\nimport com.allog.dallog.schedule.dto.response.ScheduleResponse;\nimport com.allog.dallog.subscription.application.ColorPicker;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport com.allog.dallog.subscription.domain.Subscriptions;\nimport java.time.LocalDateTime;\nimport java.util.List;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class ScheduleService {\n\n    private final ScheduleRepository scheduleRepository;\n    private final CategoryRepository categoryRepository;\n    private final CategoryRoleRepository categoryRoleRepository;\n    private final SubscriptionRepository subscriptionRepository;\n    private final OAuthTokenRepository oAuthTokenRepository;\n    private final ExternalCategoryDetailRepository externalCategoryDetailRepository;\n    private final ColorPicker colorPicker;\n\n    public ScheduleService(final ScheduleRepository scheduleRepository, final CategoryRepository categoryRepository,\n                           final CategoryRoleRepository categoryRoleRepository,\n                           final SubscriptionRepository subscriptionRepository,\n                           final OAuthTokenRepository oAuthTokenRepository,\n                           final ExternalCategoryDetailRepository externalCategoryDetailRepository,\n                           final ColorPicker colorPicker) {\n        this.scheduleRepository = scheduleRepository;\n        this.categoryRepository = categoryRepository;\n        this.categoryRoleRepository = categoryRoleRepository;\n        this.subscriptionRepository = subscriptionRepository;\n        this.oAuthTokenRepository = oAuthTokenRepository;\n        this.externalCategoryDetailRepository = externalCategoryDetailRepository;\n        this.colorPicker = colorPicker;\n    }\n\n    @Transactional\n    public ScheduleResponse save(final Long memberId, final Long categoryId, final ScheduleCreateRequest request) {\n        Category category = categoryRepository.getById(categoryId);\n        category.validateNotExternalCategory();\n\n        CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, categoryId);\n        categoryRole.validateAuthority(CategoryAuthority.ADD_SCHEDULE);\n\n        Schedule schedule = scheduleRepository.save(request.toEntity(category));\n        return new ScheduleResponse(schedule);\n    }\n\n    public ScheduleResponse findById(final Long id) {\n        Schedule schedule = scheduleRepository.getById(id);\n        return new ScheduleResponse(schedule);\n    }\n\n    public MaterialToFindSchedules findInternalByMemberIdAndDateRange(final Long memberId,\n                                                                      final DateRangeRequest request) {\n        Subscriptions subscriptions = new Subscriptions(subscriptionRepository.findByMemberId(memberId));\n        List<Category> categories = subscriptions.findInternalCategory();\n        LocalDateTime startDateTime = request.getStartDateTime();\n        LocalDateTime endDateTime = request.getEndDateTime();\n        List<IntegrationSchedule> schedules = toIntegrationSchedules(categories, startDateTime, endDateTime);\n\n        String refreshToken = toRefreshToken(memberId);\n        List<ExternalCategoryDetail> externalCategoryDetails = toCategoryDetails(subscriptions);\n\n        return new MaterialToFindSchedules(subscriptions, schedules, refreshToken, externalCategoryDetails);\n    }\n\n    private String toRefreshToken(final Long memberId) {\n        OAuthToken oAuthToken = oAuthTokenRepository.getByMemberId(memberId);\n        return oAuthToken.getRefreshToken();\n    }\n\n    private List<ExternalCategoryDetail> toCategoryDetails(final Subscriptions subscriptions) {\n        return externalCategoryDetailRepository.findByCategoryIn(subscriptions.findExternalCategory());\n    }\n\n    public IntegrationScheduleResponses findByCategoryIdAndDateRange(final Long categoryId,\n                                                                     final DateRangeRequest request) {\n        Category category = categoryRepository.getById(categoryId);\n        LocalDateTime startDateTime = request.getStartDateTime();\n        LocalDateTime endDateTime = request.getEndDateTime();\n\n        List<IntegrationSchedule> schedules = toIntegrationSchedules(List.of(category), startDateTime, endDateTime);\n        Color color = Color.pick(colorPicker.pickNumber());\n\n        return new IntegrationScheduleResponses(color, new TypedSchedules(schedules));\n    }\n\n    private List<IntegrationSchedule> toIntegrationSchedules(final List<Category> categories,\n                                                             final LocalDateTime startDateTime,\n                                                             final LocalDateTime endDateTime) {\n        return scheduleRepository.getByCategoriesAndBetween(categories, startDateTime, endDateTime);\n    }\n\n    @Transactional\n    public void update(final Long id, final Long memberId, final ScheduleUpdateRequest request) {\n        Long categoryId = request.getCategoryId();\n        Category categoryForUpdate = categoryRepository.getById(categoryId);\n        Schedule schedule = scheduleRepository.getById(id);\n\n        CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, categoryId);\n        categoryRole.validateAuthority(CategoryAuthority.UPDATE_SCHEDULE);\n\n        schedule.change(categoryForUpdate, request.getTitle(), request.getStartDateTime(), request.getEndDateTime(),\n                request.getMemo());\n    }\n\n    @Transactional\n    public void delete(final Long id, final Long memberId) {\n        Schedule schedule = scheduleRepository.getById(id);\n        Long categoryId = schedule.getCategory().getId();\n\n        CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, categoryId);\n        categoryRole.validateAuthority(CategoryAuthority.DELETE_SCHEDULE);\n\n        scheduleRepository.deleteById(id);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/IntegrationSchedule.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport com.allog.dallog.category.domain.CategoryType;\nimport com.allog.dallog.category.domain.Category;\nimport java.time.LocalDateTime;\nimport java.util.Objects;\n\npublic class IntegrationSchedule {\n\n    private static final int ONE_DAY = 1;\n\n    private final String id;\n    private final Long categoryId;\n    private final String title;\n    private final Period period;\n    private final String memo;\n    private final CategoryType categoryType;\n\n    public IntegrationSchedule(final Schedule schedule) {\n        this.id = String.valueOf(schedule.getId());\n        Category category = schedule.getCategory();\n        this.categoryId = category.getId();\n        this.title = schedule.getTitle();\n        this.period = new Period(schedule.getStartDateTime(), schedule.getEndDateTime());\n        this.memo = schedule.getMemo();\n        this.categoryType = category.getCategoryType();\n    }\n\n    public IntegrationSchedule(final String id, final Long categoryId, final String title,\n                               final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo,\n                               final CategoryType categoryType) {\n        this(id, categoryId, title, new Period(startDateTime, endDateTime), memo, categoryType);\n    }\n\n    public IntegrationSchedule(final String id, final Long categoryId, final String title, final Period period,\n                               final String memo, final CategoryType categoryType) {\n        this.id = id;\n        this.categoryId = categoryId;\n        this.title = title;\n        this.period = period;\n        this.memo = memo;\n        this.categoryType = categoryType;\n    }\n\n    public boolean isLongTerms() {\n        return !isAllDays()\n                && period.calculateDayDifference() >= ONE_DAY;\n    }\n\n    public boolean isAllDays() {\n        return period.calculateDayDifference() == ONE_DAY\n                && period.isMidnightToMidnight();\n    }\n\n    public boolean isFewHours() {\n        return period.calculateDayDifference() < ONE_DAY;\n    }\n\n    public boolean isSameCategory(final Category category) {\n        Long categoryId = category.getId();\n        return this.categoryId.equals(categoryId);\n    }\n\n    public String getId() {\n        return id;\n    }\n\n    public Long getCategoryId() {\n        return categoryId;\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return period.getStartDateTime();\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return period.getEndDateTime();\n    }\n\n    public Period getPeriod() {\n        return period;\n    }\n\n    public String getMemo() {\n        return memo;\n    }\n\n    public CategoryType getCategoryType() {\n        return categoryType;\n    }\n\n    @Override\n    public boolean equals(final Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        IntegrationSchedule that = (IntegrationSchedule) o;\n        return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId)\n                && Objects.equals(title, that.title) && Objects.equals(period, that.period)\n                && Objects.equals(memo, that.memo) && Objects.equals(categoryType, that.categoryType);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(id, categoryId, title, period, memo, categoryType);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/IntegrationScheduleComparator.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport java.time.LocalDateTime;\nimport java.util.Comparator;\n\npublic class IntegrationScheduleComparator implements Comparator<IntegrationSchedule> {\n\n    private static final int SAME_CONDITION = 0;\n\n    @Override\n    public int compare(final IntegrationSchedule firstSchedule, final IntegrationSchedule secondSchedule) {\n        LocalDateTime firstScheduleStartDateTime = firstSchedule.getStartDateTime();\n        LocalDateTime secondScheduleStartDateTime = secondSchedule.getStartDateTime();\n\n        int cmp = firstScheduleStartDateTime.compareTo(secondScheduleStartDateTime);\n        if (cmp == SAME_CONDITION) {\n            return compareEndDateTime(firstSchedule, secondSchedule);\n        }\n        return cmp;\n    }\n\n    private int compareEndDateTime(IntegrationSchedule firstSchedule, IntegrationSchedule secondSchedule) {\n        LocalDateTime firstScheduleEndDateTime = firstSchedule.getEndDateTime();\n        LocalDateTime secondScheduleEndDateTime = secondSchedule.getEndDateTime();\n\n        int cmp = secondScheduleEndDateTime.compareTo(firstScheduleEndDateTime);\n        if (cmp == SAME_CONDITION) {\n            return compareByTitle(firstSchedule, secondSchedule);\n        }\n        return cmp;\n    }\n\n    private int compareByTitle(IntegrationSchedule firstSchedule, IntegrationSchedule secondSchedule) {\n        return firstSchedule.getTitle().compareTo(secondSchedule.getTitle());\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/IntegrationSchedules.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class IntegrationSchedules {\n\n    private static final IntegrationScheduleComparator COMPARATOR = new IntegrationScheduleComparator();\n\n    private final List<IntegrationSchedule> values;\n\n    public IntegrationSchedules() {\n        this.values = new ArrayList<>();\n    }\n\n    public void add(final IntegrationSchedule integrationSchedule) {\n        values.add(integrationSchedule);\n    }\n\n    public List<IntegrationSchedule> getSortedValues() {\n        values.sort(COMPARATOR);\n        return List.copyOf(values);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/Period.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport static java.time.LocalTime.MIDNIGHT;\n\nimport java.time.LocalDate;\nimport java.time.LocalDateTime;\nimport java.time.LocalTime;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\n\npublic class Period {\n\n    private final LocalDateTime startDateTime;\n    private final LocalDateTime endDateTime;\n\n    public Period(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n    }\n\n    public long calculateDayDifference() {\n        LocalDate startDate = LocalDate.from(startDateTime);\n        LocalDate endDate = LocalDate.from(endDateTime);\n        return ChronoUnit.DAYS.between(startDate, endDate);\n    }\n\n    public boolean isMidnightToMidnight() {\n        LocalTime startTime = LocalTime.from(startDateTime);\n        LocalTime endTime = LocalTime.from(endDateTime);\n        return startTime.equals(MIDNIGHT) && endTime.equals(MIDNIGHT);\n    }\n\n    public List<Period> slice(final Period otherPeriod) {\n        if (isNotOverlapped(otherPeriod)) {\n            return List.of(this);\n        }\n        return sliceByOtherPeriod(otherPeriod);\n    }\n\n    private boolean isNotOverlapped(final Period otherPeriod) {\n        // other가 좌측 방향으로 멀리 떨어져 겹치지 않을때\n        boolean farFromLeftSideOfBase = otherPeriod.endDateTime\n                .isBefore(startDateTime);\n\n        // other가 우측 방향으로 멀리 떨어져 겹치지 않을때\n        boolean farFromRightSideOfBase = otherPeriod.startDateTime\n                .isAfter(endDateTime);\n\n        return farFromLeftSideOfBase || farFromRightSideOfBase;\n    }\n\n    private List<Period> sliceByOtherPeriod(final Period otherPeriod) {\n        List<Period> periods = new ArrayList<>();\n        if (startDateTime.isBefore(otherPeriod.startDateTime)) {\n            periods.add(new Period(startDateTime, otherPeriod.startDateTime));\n        }\n\n        if (otherPeriod.endDateTime.isBefore(endDateTime)) {\n            periods.add(new Period(otherPeriod.endDateTime, endDateTime));\n        }\n\n        return periods;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Period period = (Period) o;\n        return Objects.equals(startDateTime, period.startDateTime) && Objects.equals(endDateTime, period.endDateTime);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(startDateTime, endDateTime);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/Schedule.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport com.allog.dallog.global.entity.BaseEntity;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.exception.InvalidScheduleException;\nimport java.time.LocalDateTime;\nimport javax.persistence.Column;\nimport javax.persistence.Entity;\nimport javax.persistence.FetchType;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.JoinColumn;\nimport javax.persistence.ManyToOne;\nimport javax.persistence.Table;\n\n@Table(name = \"schedules\")\n@Entity\npublic class Schedule extends BaseEntity {\n\n    private static final int MAX_TITLE_LENGTH = 50;\n    private static final int MAX_MEMO_LENGTH = 255;\n\n    private static final LocalDateTime MIN_DATE_TIME = LocalDateTime.of(1000, 1, 1, 0, 0);\n    private static final LocalDateTime MAX_DATE_TIME = LocalDateTime.of(9999, 12, 31, 11, 59, 59, 999999000);\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    @Column(name = \"id\")\n    private Long id;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"categories_id\", nullable = false)\n    private Category category;\n\n    @Column(name = \"title\", nullable = false)\n    private String title;\n\n    @Column(name = \"start_date_time\", nullable = false)\n    private LocalDateTime startDateTime;\n\n    @Column(name = \"end_date_time\", nullable = false)\n    private LocalDateTime endDateTime;\n\n    @Column(name = \"memo\", nullable = false)\n    private String memo;\n\n    protected Schedule() {\n    }\n\n    public Schedule(final Category category, final String title, final LocalDateTime startDateTime,\n                    final LocalDateTime endDateTime, final String memo) {\n        validateTitleLength(title);\n        validatePeriod(startDateTime, endDateTime);\n        validateMemoLength(memo);\n        this.category = category;\n        this.title = title;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n        this.memo = memo;\n    }\n\n    public void change(final Category category, final String title, final LocalDateTime startDateTime,\n                       final LocalDateTime endDateTime, final String memo) {\n        validateTitleLength(title);\n        validatePeriod(startDateTime, endDateTime);\n        validateMemoLength(memo);\n        this.category = category;\n        this.title = title;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n        this.memo = memo;\n    }\n\n    private void validateTitleLength(final String title) {\n        if (title.length() > MAX_TITLE_LENGTH) {\n            throw new InvalidScheduleException(String.format(\"일정 제목의 길이는 %d을 초과할 수 없습니다.\", MAX_TITLE_LENGTH));\n        }\n    }\n\n    private void validatePeriod(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {\n        if (startDateTime.isAfter(endDateTime)) {\n            throw new InvalidScheduleException(\"종료일시가 시작일시보다 이전일 수 없습니다.\");\n        }\n        if (isNotValidDateTimeRange(startDateTime) || isNotValidDateTimeRange(endDateTime)) {\n            throw new InvalidScheduleException(\n                    String.format(\"일정은 %s부터 %s까지 등록할 수 있습니다.\",\n                            MIN_DATE_TIME.toLocalDate(), MAX_DATE_TIME.toLocalDate())\n            );\n        }\n    }\n\n    private boolean isNotValidDateTimeRange(final LocalDateTime dateTime) {\n        return dateTime.isBefore(MIN_DATE_TIME) || dateTime.isAfter(MAX_DATE_TIME);\n    }\n\n    private void validateMemoLength(final String memo) {\n        if (memo.length() > MAX_MEMO_LENGTH) {\n            throw new InvalidScheduleException(String.format(\"일정 메모의 길이는 %d를 초과할 수 없습니다.\", MAX_MEMO_LENGTH));\n        }\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n\n    public String getMemo() {\n        return memo;\n    }\n\n    public Category getCategory() {\n        return category;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/ScheduleRepository.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.exception.NoSuchScheduleException;\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport org.springframework.data.jpa.repository.Query;\n\npublic interface ScheduleRepository extends JpaRepository<Schedule, Long> {\n\n    void deleteByCategoryIdIn(final List<Long> categoryIds);\n\n    @Query(\"SELECT s \"\n            + \"FROM Schedule s \"\n            + \"JOIN s.category c \"\n            + \"WHERE c IN :categories \"\n            + \"AND s.startDateTime <= :endDate \"\n            + \"AND s.endDateTime >= :startDate\")\n    List<Schedule> findByCategoriesAndBetween(final List<Category> categories, final LocalDateTime startDate,\n                                              final LocalDateTime endDate);\n\n    default Schedule getById(final Long id) {\n        return this.findById(id)\n                .orElseThrow(NoSuchScheduleException::new);\n    }\n\n    default List<IntegrationSchedule> getByCategoriesAndBetween(final List<Category> categories,\n                                                                final LocalDateTime startDateTime,\n                                                                final LocalDateTime endDateTime) {\n        if (categories.isEmpty()) {\n            return new ArrayList<>();\n        }\n\n        List<Schedule> schedules = findByCategoriesAndBetween(categories, startDateTime, endDateTime);\n        return schedules.stream()\n                .map(IntegrationSchedule::new)\n                .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/ScheduleType.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport java.util.Arrays;\nimport java.util.function.Predicate;\n\npublic enum ScheduleType {\n\n    LONG_TERMS(\"longTerms\", IntegrationSchedule::isLongTerms),\n    ALL_DAYS(\"allDays\", IntegrationSchedule::isAllDays),\n    FEW_HOURS(\"fewHours\", IntegrationSchedule::isFewHours);\n\n    private final String name;\n    private final Predicate<IntegrationSchedule> isMatch;\n\n    ScheduleType(final String name, final Predicate<IntegrationSchedule> isMatch) {\n        this.name = name;\n        this.isMatch = isMatch;\n    }\n\n    public static ScheduleType from(final IntegrationSchedule integrationSchedule) {\n        return Arrays.stream(values())\n                .filter(type -> type.isMatch.test(integrationSchedule))\n                .findAny()\n                .orElseThrow(() -> new IllegalArgumentException(\"일치하는 일정 종류가 존재하지 않습니다.\"));\n    }\n\n    public String getName() {\n        return name;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/TypedSchedules.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\npublic class TypedSchedules {\n\n    private Map<ScheduleType, IntegrationSchedules> values;\n\n    public TypedSchedules(final List<IntegrationSchedule> integrationSchedules) {\n        initializeValues();\n        for (IntegrationSchedule integrationSchedule : integrationSchedules) {\n            ScheduleType scheduleType = ScheduleType.from(integrationSchedule);\n            IntegrationSchedules schedules = values.get(scheduleType);\n            schedules.add(integrationSchedule);\n        }\n    }\n\n    private void initializeValues() {\n        this.values = new HashMap<>();\n        for (ScheduleType type : ScheduleType.values()) {\n            values.put(type, new IntegrationSchedules());\n        }\n    }\n\n    public IntegrationSchedules getSortedSchedules(final ScheduleType scheduleType) {\n        return values.get(scheduleType);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/domain/scheduler/Scheduler.java",
    "content": "package com.allog.dallog.schedule.domain.scheduler;\n\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.schedule.domain.Period;\nimport java.time.LocalDateTime;\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class Scheduler {\n\n    private final List<IntegrationSchedule> schedules;\n    private final LocalDateTime startDateTime;\n    private final LocalDateTime endDateTime;\n\n    public Scheduler(final List<IntegrationSchedule> schedules, final LocalDateTime startDateTime,\n                     final LocalDateTime endDate) {\n        this.schedules = schedules;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDate;\n    }\n\n    public List<Period> getPeriods() {\n        List<Period> periods = new ArrayList<>();\n        Period initialBasePeriod = new Period(startDateTime, endDateTime);\n        periods.add(initialBasePeriod);\n\n        for (IntegrationSchedule schedule : schedules) {\n            slicePeriod(periods, schedule);\n        }\n\n        return periods;\n    }\n\n    private void slicePeriod(final List<Period> periods, final IntegrationSchedule schedule) {\n        for (Period period : List.copyOf(periods)) {\n            periods.remove(period);\n            periods.addAll(period.slice(schedule.getPeriod()));\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/MaterialToFindSchedules.java",
    "content": "package com.allog.dallog.schedule.dto;\n\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.subscription.domain.Subscriptions;\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class MaterialToFindSchedules {\n\n    private final Subscriptions subscriptions;\n    private final List<IntegrationSchedule> schedules;\n    private final String refreshToken;\n    private final List<ExternalCategoryDetail> externalCategoryDetails;\n\n    public MaterialToFindSchedules(final Subscriptions subscriptions, final List<IntegrationSchedule> schedules,\n                                   final String refreshToken,\n                                   final List<ExternalCategoryDetail> externalCategoryDetails) {\n        this.subscriptions = subscriptions;\n        this.schedules = new ArrayList<>(schedules);\n        this.refreshToken = refreshToken;\n        this.externalCategoryDetails = new ArrayList<>(externalCategoryDetails);\n    }\n\n    public Subscriptions getSubscriptions() {\n        return subscriptions;\n    }\n\n    public List<IntegrationSchedule> getSchedules() {\n        return schedules;\n    }\n\n    public String getRefreshToken() {\n        return refreshToken;\n    }\n\n    public List<ExternalCategoryDetail> getExternalCategoryDetails() {\n        return externalCategoryDetails;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/request/DateRangeRequest.java",
    "content": "package com.allog.dallog.schedule.dto.request;\n\nimport java.time.LocalDateTime;\nimport java.time.format.DateTimeFormatter;\n\npublic class DateRangeRequest {\n\n    private static final String DATE_FORMAT = \"yyyy-MM-dd'T'HH:mm\";\n\n    private LocalDateTime startDateTime;\n    private LocalDateTime endDateTime;\n\n    public DateRangeRequest(final String startDateTime, final String endDateTime) {\n        this.startDateTime = LocalDateTime.parse(startDateTime, DateTimeFormatter.ofPattern(DATE_FORMAT));\n        this.endDateTime = LocalDateTime.parse(endDateTime, DateTimeFormatter.ofPattern(DATE_FORMAT));\n    }\n\n    public static DateRangeRequest of(final LocalDateTime startDateTime, final LocalDateTime endDateTime) {\n        String startDateTimeFormat = startDateTime.format(DateTimeFormatter.ofPattern(DATE_FORMAT));\n        String endDateTimeFormat = endDateTime.format(DateTimeFormatter.ofPattern(DATE_FORMAT));\n\n        return new DateRangeRequest(startDateTimeFormat, endDateTimeFormat);\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/request/ScheduleCreateRequest.java",
    "content": "package com.allog.dallog.schedule.dto.request;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.domain.Schedule;\nimport java.time.LocalDateTime;\nimport javax.validation.constraints.NotNull;\nimport org.springframework.format.annotation.DateTimeFormat;\n\npublic class ScheduleCreateRequest {\n\n    @NotNull(message = \"Null일 수 없습니다.\")\n    private String title;\n\n    @DateTimeFormat(pattern = \"yyyy-MM-dd'T'HH:mm\")\n    private LocalDateTime startDateTime;\n\n    @DateTimeFormat(pattern = \"yyyy-MM-dd'T'HH:mm\")\n    private LocalDateTime endDateTime;\n\n    @NotNull(message = \"Null일 수 없습니다.\")\n    private String memo;\n\n    private ScheduleCreateRequest() {\n    }\n\n    public ScheduleCreateRequest(final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime,\n                                 final String memo) {\n        this.title = title;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n        this.memo = memo;\n    }\n\n    public Schedule toEntity(final Category category) {\n        return new Schedule(category, title, startDateTime, endDateTime, memo);\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n\n    public String getMemo() {\n        return memo;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/request/ScheduleUpdateRequest.java",
    "content": "package com.allog.dallog.schedule.dto.request;\n\nimport java.time.LocalDateTime;\nimport javax.validation.constraints.NotNull;\nimport org.springframework.format.annotation.DateTimeFormat;\n\npublic class ScheduleUpdateRequest {\n\n    @NotNull(message = \"Null일 수 없습니다.\")\n    private long categoryId;\n\n    @NotNull(message = \"Null일 수 없습니다.\")\n    private String title;\n\n    @DateTimeFormat(pattern = \"yyyy-MM-dd'T'HH:mm\")\n    private LocalDateTime startDateTime;\n\n    @DateTimeFormat(pattern = \"yyyy-MM-dd'T'HH:mm\")\n    private LocalDateTime endDateTime;\n\n    @NotNull(message = \"Null일 수 없습니다.\")\n    private String memo;\n\n    private ScheduleUpdateRequest() {\n    }\n\n    public ScheduleUpdateRequest(final Long categoryId, final String title, final LocalDateTime startDateTime,\n                                 final LocalDateTime endDateTime, final String memo) {\n        this.categoryId = categoryId;\n        this.title = title;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n        this.memo = memo;\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n\n    public String getMemo() {\n        return memo;\n    }\n\n    public Long getCategoryId() {\n        return categoryId;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/response/IntegrationScheduleResponse.java",
    "content": "package com.allog.dallog.schedule.dto.response;\n\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.subscription.domain.Color;\nimport java.time.LocalDateTime;\n\npublic class IntegrationScheduleResponse {\n\n    private final String id;\n    private final String title;\n    private final LocalDateTime startDateTime;\n    private final LocalDateTime endDateTime;\n    private final String memo;\n    private final Long categoryId;\n    private final String colorCode;\n    private final String categoryType;\n\n    public IntegrationScheduleResponse(final IntegrationSchedule integrationSchedule, final Color color) {\n        this(integrationSchedule.getId(), integrationSchedule.getTitle(), integrationSchedule.getStartDateTime(),\n                integrationSchedule.getEndDateTime(), integrationSchedule.getMemo(),\n                integrationSchedule.getCategoryId(), color.getColorCode(),\n                integrationSchedule.getCategoryType().name());\n    }\n\n    public IntegrationScheduleResponse(final String id, final String title, final LocalDateTime startDateTime,\n                                       final LocalDateTime endDateTime, final String memo, final Long categoryId,\n                                       final String colorCode, final String categoryType) {\n        this.id = id;\n        this.title = title;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n        this.memo = memo;\n        this.categoryId = categoryId;\n        this.colorCode = colorCode;\n        this.categoryType = categoryType;\n    }\n\n    public String getId() {\n        return id;\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n\n    public String getMemo() {\n        return memo;\n    }\n\n    public Long getCategoryId() {\n        return categoryId;\n    }\n\n    public String getColorCode() {\n        return colorCode;\n    }\n\n    public String getCategoryType() {\n        return categoryType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/response/IntegrationScheduleResponses.java",
    "content": "package com.allog.dallog.schedule.dto.response;\n\nimport com.allog.dallog.schedule.domain.ScheduleType;\nimport com.allog.dallog.schedule.domain.TypedSchedules;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.domain.Subscriptions;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class IntegrationScheduleResponses {\n\n    private final List<IntegrationScheduleResponse> longTerms;\n    private final List<IntegrationScheduleResponse> allDays;\n    private final List<IntegrationScheduleResponse> fewHours;\n\n    public IntegrationScheduleResponses(final List<IntegrationScheduleResponse> longTerms,\n                                        final List<IntegrationScheduleResponse> allDays,\n                                        final List<IntegrationScheduleResponse> fewHours) {\n        this.longTerms = longTerms;\n        this.allDays = allDays;\n        this.fewHours = fewHours;\n    }\n\n    public IntegrationScheduleResponses(final Subscriptions subscriptions, final TypedSchedules typedSchedules) {\n        this.longTerms = getColoredScheduleResponses(ScheduleType.LONG_TERMS, subscriptions, typedSchedules);\n        this.allDays = getColoredScheduleResponses(ScheduleType.ALL_DAYS, subscriptions, typedSchedules);\n        this.fewHours = getColoredScheduleResponses(ScheduleType.FEW_HOURS, subscriptions, typedSchedules);\n    }\n\n    public IntegrationScheduleResponses(final Color color, final TypedSchedules typedSchedules) {\n        this.longTerms = getColoredScheduleResponses(ScheduleType.LONG_TERMS, color, typedSchedules);\n        this.allDays = getColoredScheduleResponses(ScheduleType.ALL_DAYS, color, typedSchedules);\n        this.fewHours = getColoredScheduleResponses(ScheduleType.FEW_HOURS, color, typedSchedules);\n    }\n\n    private List<IntegrationScheduleResponse> getColoredScheduleResponses(final ScheduleType scheduleType,\n                                                                          final Subscriptions subscriptions,\n                                                                          final TypedSchedules typedSchedules) {\n        return typedSchedules.getSortedSchedules(scheduleType)\n                .getSortedValues()\n                .stream()\n                .map(schedule -> new IntegrationScheduleResponse(schedule, subscriptions.findColor(schedule)))\n                .collect(Collectors.toList());\n    }\n\n    private List<IntegrationScheduleResponse> getColoredScheduleResponses(final ScheduleType scheduleType,\n                                                                          final Color color,\n                                                                          final TypedSchedules typedSchedules) {\n        return typedSchedules.getSortedSchedules(scheduleType)\n                .getSortedValues()\n                .stream()\n                .map(schedule -> new IntegrationScheduleResponse(schedule, color))\n                .collect(Collectors.toList());\n    }\n\n    public List<IntegrationScheduleResponse> getLongTerms() {\n        return longTerms;\n    }\n\n    public List<IntegrationScheduleResponse> getAllDays() {\n        return allDays;\n    }\n\n    public List<IntegrationScheduleResponse> getFewHours() {\n        return fewHours;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/dto/response/ScheduleResponse.java",
    "content": "package com.allog.dallog.schedule.dto.response;\n\nimport com.allog.dallog.schedule.domain.Schedule;\nimport java.time.LocalDateTime;\n\npublic class ScheduleResponse {\n\n    private final Long id;\n    private final Long categoryId;\n    private final String title;\n    private final LocalDateTime startDateTime;\n    private final LocalDateTime endDateTime;\n    private final String memo;\n    private final String categoryType;\n\n    public ScheduleResponse(final Schedule schedule) {\n        this(schedule.getId(), schedule.getCategory().getId(), schedule.getTitle(), schedule.getStartDateTime(),\n                schedule.getEndDateTime(), schedule.getMemo(), schedule.getCategory().getCategoryType().name());\n    }\n\n    public ScheduleResponse(final Long id, final Long categoryId, final String title, final LocalDateTime startDateTime,\n                            final LocalDateTime endDateTime, final String memo, final String categoryType) {\n        this.id = id;\n        this.categoryId = categoryId;\n        this.title = title;\n        this.startDateTime = startDateTime;\n        this.endDateTime = endDateTime;\n        this.memo = memo;\n        this.categoryType = categoryType;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public Long getCategoryId() {\n        return categoryId;\n    }\n\n    public String getTitle() {\n        return title;\n    }\n\n    public LocalDateTime getStartDateTime() {\n        return startDateTime;\n    }\n\n    public LocalDateTime getEndDateTime() {\n        return endDateTime;\n    }\n\n    public String getMemo() {\n        return memo;\n    }\n\n    public String getCategoryType() {\n        return categoryType;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/exception/InvalidScheduleException.java",
    "content": "package com.allog.dallog.schedule.exception;\n\npublic class InvalidScheduleException extends RuntimeException {\n\n    public InvalidScheduleException(final String message) {\n        super(message);\n    }\n\n    public InvalidScheduleException() {\n        this(\"잘못된 일정입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/exception/NoSuchScheduleException.java",
    "content": "package com.allog.dallog.schedule.exception;\n\npublic class NoSuchScheduleException extends RuntimeException {\n\n    public NoSuchScheduleException(final String message) {\n        super(message);\n    }\n\n    public NoSuchScheduleException() {\n        this(\"존재하지 않는 일정입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/schedule/presentation/ScheduleController.java",
    "content": "package com.allog.dallog.schedule.presentation;\n\nimport com.allog.dallog.auth.dto.LoginMember;\nimport com.allog.dallog.auth.presentation.AuthenticationPrincipal;\nimport com.allog.dallog.schedule.application.CheckedSchedulesFinder;\nimport com.allog.dallog.schedule.application.ScheduleService;\nimport com.allog.dallog.schedule.dto.request.DateRangeRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses;\nimport com.allog.dallog.schedule.dto.response.ScheduleResponse;\nimport java.net.URI;\nimport javax.validation.Valid;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.ModelAttribute;\nimport org.springframework.web.bind.annotation.PatchMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RequestMapping(\"/api\")\n@RestController\npublic class ScheduleController {\n\n    private final ScheduleService scheduleService;\n    private final CheckedSchedulesFinder checkedSchedulesFinder;\n\n    public ScheduleController(final ScheduleService scheduleService,\n                              final CheckedSchedulesFinder checkedSchedulesFinder) {\n        this.scheduleService = scheduleService;\n        this.checkedSchedulesFinder = checkedSchedulesFinder;\n    }\n\n    @PostMapping(\"/categories/{categoryId}/schedules\")\n    public ResponseEntity<ScheduleResponse> save(@AuthenticationPrincipal final LoginMember loginMember,\n                                                 @PathVariable final Long categoryId,\n                                                 @Valid @RequestBody final ScheduleCreateRequest request) {\n        ScheduleResponse response = scheduleService.save(loginMember.getId(), categoryId, request);\n        return ResponseEntity.created(URI.create(\"/api/schedules/\" + response.getId())).body(response);\n    }\n\n    @GetMapping(\"/members/me/schedules\")\n    public ResponseEntity<IntegrationScheduleResponses> findMyCheckedSchedules(\n            @AuthenticationPrincipal final LoginMember loginMember, @ModelAttribute DateRangeRequest request) {\n        IntegrationScheduleResponses response = checkedSchedulesFinder.findMyCheckedSchedules(loginMember.getId(),\n                request);\n        return ResponseEntity.ok(response);\n    }\n\n    @GetMapping(\"/categories/{categoryId}/schedules\")\n    public ResponseEntity<IntegrationScheduleResponses> findByCategoryId(@PathVariable final Long categoryId,\n                                                                         @ModelAttribute DateRangeRequest request) {\n        IntegrationScheduleResponses response = scheduleService.findByCategoryIdAndDateRange(categoryId, request);\n        return ResponseEntity.ok(response);\n    }\n\n    @GetMapping(\"/schedules/{scheduleId}\")\n    public ResponseEntity<ScheduleResponse> findById(@PathVariable final Long scheduleId) {\n        ScheduleResponse response = scheduleService.findById(scheduleId);\n        return ResponseEntity.ok(response);\n    }\n\n    @PatchMapping(\"/schedules/{scheduleId}\")\n    public ResponseEntity<Void> update(@AuthenticationPrincipal final LoginMember loginMember,\n                                       @PathVariable final Long scheduleId,\n                                       @Valid @RequestBody final ScheduleUpdateRequest request) {\n        scheduleService.update(scheduleId, loginMember.getId(), request);\n        return ResponseEntity.noContent().build();\n    }\n\n    @DeleteMapping(\"/schedules/{scheduleId}\")\n    public ResponseEntity<Void> delete(@AuthenticationPrincipal final LoginMember loginMember,\n                                       @PathVariable final Long scheduleId) {\n        scheduleService.delete(scheduleId, loginMember.getId());\n        return ResponseEntity.noContent().build();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/application/ColorPicker.java",
    "content": "package com.allog.dallog.subscription.application;\n\n@FunctionalInterface\npublic interface ColorPicker {\n\n    int pickNumber();\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/application/RandomColorPicker.java",
    "content": "package com.allog.dallog.subscription.application;\n\nimport com.allog.dallog.subscription.domain.Color;\nimport java.util.concurrent.ThreadLocalRandom;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class RandomColorPicker implements ColorPicker {\n\n    @Override\n    public int pickNumber() {\n        ThreadLocalRandom threadLocalRandom = ThreadLocalRandom.current();\n        Color[] colors = Color.values();\n        return threadLocalRandom.nextInt(colors.length);\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/application/SubscriptionService.java",
    "content": "package com.allog.dallog.subscription.application;\n\nimport com.allog.dallog.categoryrole.domain.CategoryRoleType;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.domain.Subscription;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest;\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\nimport com.allog.dallog.subscription.dto.response.SubscriptionsResponse;\nimport com.allog.dallog.subscription.exception.NotAbleToUnsubscribeException;\nimport java.util.List;\nimport java.util.stream.Collectors;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Transactional(readOnly = true)\n@Service\npublic class SubscriptionService {\n\n    private final SubscriptionRepository subscriptionRepository;\n    private final MemberRepository memberRepository;\n    private final CategoryRepository categoryRepository;\n    private final CategoryRoleRepository categoryRoleRepository;\n    private final ColorPicker colorPicker;\n\n    public SubscriptionService(final SubscriptionRepository subscriptionRepository,\n                               final MemberRepository memberRepository, final CategoryRepository categoryRepository,\n                               final CategoryRoleRepository categoryRoleRepository, final ColorPicker colorPicker) {\n        this.subscriptionRepository = subscriptionRepository;\n        this.memberRepository = memberRepository;\n        this.categoryRepository = categoryRepository;\n        this.categoryRoleRepository = categoryRoleRepository;\n        this.colorPicker = colorPicker;\n    }\n\n    @Transactional\n    public SubscriptionResponse save(final Long memberId, final Long categoryId) {\n        subscriptionRepository.validateNotExistsByMemberIdAndCategoryId(memberId, categoryId);\n\n        Member member = memberRepository.getById(memberId);\n        Category category = categoryRepository.getById(categoryId);\n        category.validateSubscriptionPossible(member);\n\n        Subscription savedSubscription = createSubscription(member, category);\n        createCategoryRole(member, category);\n\n        return new SubscriptionResponse(savedSubscription);\n    }\n\n    private Subscription createSubscription(final Member member, final Category category) {\n        Color color = Color.pick(colorPicker.pickNumber());\n        return subscriptionRepository.save(new Subscription(member, category, color));\n    }\n\n    private void createCategoryRole(final Member member, final Category category) {\n        CategoryRole categoryRole = new CategoryRole(category, member, CategoryRoleType.NONE);\n        categoryRoleRepository.save(categoryRole);\n    }\n\n    public SubscriptionsResponse findByMemberId(final Long memberId) {\n        List<Subscription> subscriptions = subscriptionRepository.findByMemberId(memberId);\n\n        List<SubscriptionResponse> subscriptionResponses = subscriptions.stream()\n                .map(SubscriptionResponse::new)\n                .collect(Collectors.toList());\n\n        return new SubscriptionsResponse(subscriptionResponses);\n    }\n\n    @Transactional\n    public void update(final Long id, final Long memberId, final SubscriptionUpdateRequest request) {\n        subscriptionRepository.validateExistsByIdAndMemberId(id, memberId);\n        Subscription subscription = subscriptionRepository.getById(id);\n        subscription.change(request.getColor(), request.isChecked());\n    }\n\n    @Transactional\n    public void delete(final Long id, final Long memberId) {\n        Subscription subscription = subscriptionRepository.getById(id);\n        subscription.validateDeletePossible(memberId);\n        subscriptionRepository.deleteById(id);\n\n        deleteCategoryRole(memberId, subscription);\n    }\n\n    private void deleteCategoryRole(final Long memberId, final Subscription subscription) {\n        Category category = subscription.getCategory();\n        CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId());\n\n        if (!categoryRole.isNone()) {\n            throw new NotAbleToUnsubscribeException(\"해당 카테고리에 관리자로 참여중이므로 구독을 해제할 수 없습니다.\");\n        }\n\n        categoryRoleRepository.deleteById(categoryRole.getId());\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/domain/Color.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport com.allog.dallog.subscription.exception.InvalidSubscriptionException;\nimport java.util.Arrays;\n\npublic enum Color {\n\n    COLOR_1(\"#AD1457\"),\n    COLOR_2(\"#D81B60\"),\n    COLOR_3(\"#D50000\"),\n    COLOR_4(\"#E67C73\"),\n    COLOR_5(\"#F4511E\"),\n    COLOR_6(\"#EF6C00\"),\n    COLOR_7(\"#F09300\"),\n    COLOR_8(\"#F6BF26\"),\n    COLOR_9(\"#E4C441\"),\n    COLOR_10(\"#C0CA33\"),\n    COLOR_11(\"#7CB342\"),\n    COLOR_12(\"#33B679\"),\n    COLOR_13(\"#0B8043\"),\n    COLOR_14(\"#009688\"),\n    COLOR_15(\"#039BE5\"),\n    COLOR_16(\"#4285F4\"),\n    COLOR_17(\"#3F51B5\"),\n    COLOR_18(\"#7986CB\"),\n    COLOR_19(\"#B39DDB\"),\n    COLOR_20(\"#9E69AF\"),\n    COLOR_21(\"#8E24AA\"),\n    COLOR_22(\"#795548\"),\n    COLOR_23(\"#616161\"),\n    COLOR_24(\"#A79B8E\");\n\n    private final String colorCode;\n\n    Color(final String colorCode) {\n        this.colorCode = colorCode;\n    }\n\n    public static Color pick(int index) {\n        return Color.values()[index];\n    }\n\n    public static Color from(final String colorCode) {\n        return Arrays.stream(Color.values())\n                .filter(color -> color.getColorCode().equals(colorCode.toUpperCase()))\n                .findFirst()\n                .orElseThrow(() -> new InvalidSubscriptionException(\"(\" + colorCode + \")는 사용할 수 없는 색상입니다.\"));\n    }\n\n    public String getColorCode() {\n        return colorCode;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/domain/Subscription.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.global.entity.BaseEntity;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.member.domain.Member;\nimport javax.persistence.Column;\nimport javax.persistence.Entity;\nimport javax.persistence.EnumType;\nimport javax.persistence.Enumerated;\nimport javax.persistence.FetchType;\nimport javax.persistence.GeneratedValue;\nimport javax.persistence.GenerationType;\nimport javax.persistence.Id;\nimport javax.persistence.JoinColumn;\nimport javax.persistence.ManyToOne;\nimport javax.persistence.Table;\n\n@Table(name = \"subscriptions\")\n@Entity\npublic class Subscription extends BaseEntity {\n\n    @Id\n    @GeneratedValue(strategy = GenerationType.IDENTITY)\n    @Column(name = \"id\")\n    private Long id;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"members_id\", nullable = false)\n    private Member member;\n\n    @ManyToOne(fetch = FetchType.LAZY)\n    @JoinColumn(name = \"categories_id\", nullable = false)\n    private Category category;\n\n    @Enumerated(value = EnumType.STRING)\n    @Column(name = \"color\", nullable = false)\n    private Color color;\n\n    @Column(name = \"checked\", nullable = false)\n    private boolean checked;\n\n    protected Subscription() {\n    }\n\n    public Subscription(final Member member, final Category category, final Color color) {\n        this.member = member;\n        this.category = category;\n        this.color = color;\n        this.checked = true;\n    }\n\n    public void change(final Color color, final boolean checked) {\n        this.color = color;\n        this.checked = checked;\n    }\n\n    public void validateDeletePossible(final Long memberId) {\n        if (!member.hasSameId(memberId)) {\n            throw new NoPermissionException(\"타인의 구독 정보에 접근할 수 없습니다.\");\n        }\n    }\n\n    public boolean hasInternalCategory() {\n        return category.isInternal();\n    }\n\n    public boolean hasExternalCategory() {\n        return category.isExternal();\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public Member getMember() {\n        return member;\n    }\n\n    public Category getCategory() {\n        return category;\n    }\n\n    public Color getColor() {\n        return color;\n    }\n\n    public boolean isChecked() {\n        return checked;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/domain/SubscriptionRepository.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.subscription.exception.ExistSubscriptionException;\nimport com.allog.dallog.subscription.exception.NoSuchSubscriptionException;\nimport java.util.List;\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaRepository;\n\npublic interface SubscriptionRepository extends JpaRepository<Subscription, Long> {\n\n    boolean existsByMemberIdAndCategoryId(final Long memberId, final Long categoryId);\n\n    boolean existsByIdAndMemberId(final Long id, final Long memberId);\n\n    @EntityGraph(attributePaths = {\"category\", \"category.member\"})\n    List<Subscription> findByMemberId(final Long memberId);\n\n    @EntityGraph(attributePaths = {\"category\", \"category.member\"})\n    List<Subscription> findByCategoryId(final Long categoryId);\n\n    void deleteByCategoryIdIn(final List<Long> id);\n\n    default Subscription getById(final Long id) {\n        return findById(id)\n                .orElseThrow(NoSuchSubscriptionException::new);\n    }\n\n    default void validateNotExistsByMemberIdAndCategoryId(final Long memberId, final Long categoryId) {\n        if (existsByMemberIdAndCategoryId(memberId, categoryId)) {\n            throw new ExistSubscriptionException();\n        }\n    }\n\n    default void validateExistsByIdAndMemberId(final Long id, final Long memberId) {\n        if (!existsByIdAndMemberId(id, memberId)) {\n            throw new NoPermissionException();\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/domain/Subscriptions.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class Subscriptions {\n\n    private final List<Subscription> subscriptions;\n\n    public Subscriptions(final List<Subscription> subscriptions) {\n        this.subscriptions = subscriptions;\n    }\n\n    public List<Category> findInternalCategory() {\n        return subscriptions.stream()\n                .filter(Subscription::isChecked)\n                .filter(Subscription::hasInternalCategory)\n                .map(Subscription::getCategory)\n                .collect(Collectors.toList());\n    }\n\n    public List<Category> findExternalCategory() {\n        return subscriptions.stream()\n                .filter(Subscription::isChecked)\n                .filter(Subscription::hasExternalCategory)\n                .map(Subscription::getCategory)\n                .collect(Collectors.toList());\n    }\n\n    public Color findColor(final IntegrationSchedule schedule) {\n        return subscriptions.stream()\n                .filter(subscription -> schedule.isSameCategory(subscription.getCategory()))\n                .findAny()\n                .orElseThrow(() -> new NoSuchCategoryException(\"구독하지 않은 카테고리 입니다.\"))\n                .getColor();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/dto/request/SubscriptionUpdateRequest.java",
    "content": "package com.allog.dallog.subscription.dto.request;\n\nimport com.allog.dallog.subscription.domain.Color;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport javax.validation.constraints.NotBlank;\n\npublic class SubscriptionUpdateRequest {\n\n    @NotBlank(message = \"컬러 코드가 공백일 수 없습니다.\")\n    private String colorCode;\n    private boolean checked;\n\n    private SubscriptionUpdateRequest() {\n    }\n\n    public SubscriptionUpdateRequest(final Color color, final boolean checked) {\n        this(color.getColorCode(), checked);\n    }\n\n    public SubscriptionUpdateRequest(final String colorCode, final boolean checked) {\n        this.colorCode = colorCode;\n        this.checked = checked;\n    }\n\n    public String getColorCode() {\n        return colorCode;\n    }\n\n    @JsonIgnore\n    public Color getColor() {\n        return Color.from(colorCode);\n    }\n\n    public boolean isChecked() {\n        return checked;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/dto/response/SubscriptionResponse.java",
    "content": "package com.allog.dallog.subscription.dto.response;\n\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.domain.Subscription;\n\npublic class SubscriptionResponse {\n\n    private Long id;\n    private CategoryResponse category;\n    private String colorCode;\n\n    private boolean checked;\n\n    private SubscriptionResponse() {\n    }\n\n    public SubscriptionResponse(final Subscription subscription) {\n        this(subscription.getId(), new CategoryResponse(subscription.getCategory()), subscription.getColor(),\n                subscription.isChecked());\n    }\n\n    public SubscriptionResponse(final Long id, final CategoryResponse category, final Color color,\n                                final boolean checked) {\n        this.id = id;\n        this.category = category;\n        this.colorCode = color.getColorCode();\n        this.checked = checked;\n    }\n\n    public Long getId() {\n        return id;\n    }\n\n    public CategoryResponse getCategory() {\n        return category;\n    }\n\n    public String getColorCode() {\n        return colorCode;\n    }\n\n    public boolean isChecked() {\n        return checked;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/dto/response/SubscriptionsResponse.java",
    "content": "package com.allog.dallog.subscription.dto.response;\n\nimport java.util.List;\n\npublic class SubscriptionsResponse {\n\n    private List<SubscriptionResponse> subscriptions;\n\n    private SubscriptionsResponse() {\n    }\n\n    public SubscriptionsResponse(final List<SubscriptionResponse> subscriptions) {\n        this.subscriptions = subscriptions;\n    }\n\n    public List<SubscriptionResponse> getSubscriptions() {\n        return subscriptions;\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/exception/ExistSubscriptionException.java",
    "content": "package com.allog.dallog.subscription.exception;\n\npublic class ExistSubscriptionException extends RuntimeException {\n\n    public ExistSubscriptionException(final String message) {\n        super(message);\n    }\n\n    public ExistSubscriptionException() {\n        this(\"이미 존재하는 구독 정보 입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/exception/InvalidSubscriptionException.java",
    "content": "package com.allog.dallog.subscription.exception;\n\npublic class InvalidSubscriptionException extends RuntimeException {\n\n    public InvalidSubscriptionException(final String message) {\n        super(message);\n    }\n\n    public InvalidSubscriptionException() {\n        this(\"유효하지 않은 구독 정보입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/exception/NoSuchSubscriptionException.java",
    "content": "package com.allog.dallog.subscription.exception;\n\npublic class NoSuchSubscriptionException extends RuntimeException {\n\n    public NoSuchSubscriptionException(final String message) {\n        super(message);\n    }\n\n    public NoSuchSubscriptionException() {\n        this(\"존재하지 않는 구독 정보입니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/exception/NotAbleToUnsubscribeException.java",
    "content": "package com.allog.dallog.subscription.exception;\n\npublic class NotAbleToUnsubscribeException extends RuntimeException {\n\n    public NotAbleToUnsubscribeException(final String message) {\n        super(message);\n    }\n\n    public NotAbleToUnsubscribeException() {\n        this(\"구독 해제할 수 없습니다.\");\n    }\n}\n"
  },
  {
    "path": "backend/src/main/java/com/allog/dallog/subscription/presentation/SubscriptionController.java",
    "content": "package com.allog.dallog.subscription.presentation;\n\nimport com.allog.dallog.auth.dto.LoginMember;\nimport com.allog.dallog.auth.presentation.AuthenticationPrincipal;\nimport com.allog.dallog.subscription.application.SubscriptionService;\nimport com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest;\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\nimport com.allog.dallog.subscription.dto.response.SubscriptionsResponse;\nimport java.net.URI;\nimport javax.validation.Valid;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.web.bind.annotation.DeleteMapping;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PatchMapping;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RequestMapping(\"/api/members/me\")\n@RestController\npublic class SubscriptionController {\n\n    private final SubscriptionService subscriptionService;\n\n    public SubscriptionController(final SubscriptionService subscriptionService) {\n        this.subscriptionService = subscriptionService;\n    }\n\n    @PostMapping(\"/categories/{categoryId}/subscriptions\")\n    public ResponseEntity<SubscriptionResponse> save(@AuthenticationPrincipal final LoginMember loginMember,\n                                                     @PathVariable final Long categoryId) {\n        SubscriptionResponse response = subscriptionService.save(loginMember.getId(), categoryId);\n        return ResponseEntity.created(\n                        URI.create(\"/api/members/me/categories/\" + categoryId + \"/subscriptions/\" + response.getId()))\n                .body(response);\n    }\n\n    @GetMapping(\"/subscriptions\")\n    public ResponseEntity<SubscriptionsResponse> findByMemberId(\n            @AuthenticationPrincipal final LoginMember loginMember) {\n        SubscriptionsResponse response = subscriptionService.findByMemberId(loginMember.getId());\n        return ResponseEntity.ok(response);\n    }\n\n    @PatchMapping(\"/subscriptions/{subscriptionId}\")\n    public ResponseEntity<Void> update(@AuthenticationPrincipal final LoginMember loginMember,\n                                       @PathVariable final Long subscriptionId,\n                                       @Valid @RequestBody final SubscriptionUpdateRequest request) {\n        subscriptionService.update(subscriptionId, loginMember.getId(), request);\n        return ResponseEntity.noContent().build();\n    }\n\n    @DeleteMapping(\"/subscriptions/{subscriptionId}\")\n    public ResponseEntity<Void> delete(@AuthenticationPrincipal final LoginMember loginMember,\n                                       @PathVariable final Long subscriptionId) {\n        subscriptionService.delete(subscriptionId, loginMember.getId());\n        return ResponseEntity.noContent().build();\n    }\n}\n"
  },
  {
    "path": "backend/src/main/resources/application-test.yml",
    "content": "spring:\n  data:\n    web:\n      pageable:\n        max-page-size: 100\n\n  main:\n    allow-bean-definition-overriding: true\n\n  datasource:\n    url: jdbc:h2:~/dallog;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE\n    username: sa\n\n  jpa:\n    properties:\n      hibernate:\n        format_sql: true\n    show-sql: true\n\n    hibernate:\n      ddl-auto: create\n\ncors:\n  allow-origin:\n    urls: http://localhost:3000\n\nsecurity:\n  jwt:\n    token:\n      secret-key: fsmjgbdafmjgbasmfgadbsgmadfhgbfamjghbvmssdgsdfgdf\n      access:\n        expire-length: 3600000 #1시간\n      refresh:\n        expire-length: 1210000000 #14일\n\noauth:\n  google:\n    client-id: hyeonic\n    client-secret: 123\n    oauth-end-point: https://accounts.google.com/o/oauth2/v2/auth\n    response-type: code\n    scopes:\n      - https://www.googleapis.com/auth/userinfo.profile\n      - https://www.googleapis.com/auth/userinfo.email\n    token-uri: https://oauth2.googleapis.com/token\n    access-type: offline\n"
  },
  {
    "path": "backend/src/main/resources/db/prod/schema.sql",
    "content": "CREATE TABLE IF NOT EXISTS members (\n  id BIGINT AUTO_INCREMENT,\n  email VARCHAR(255) NOT NULL,\n  display_name VARCHAR(255) NOT NULL,\n  profile_image_url VARCHAR(255) NOT NULL,\n  social_type VARCHAR(255) NOT NULL,\n  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  PRIMARY KEY (id)\n);\n\nCREATE TABLE IF NOT EXISTS categories (\n  id BIGINT AUTO_INCREMENT,\n  name VARCHAR(255) NOT NULL,\n  members_id BIGINT,\n  category_type VARCHAR(255) NOT NULL,\n  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  PRIMARY KEY (id),\n  FOREIGN KEY (members_id) REFERENCES members (id)\n);\n\nCREATE TABLE IF NOT EXISTS subscriptions (\n  id BIGINT AUTO_INCREMENT,\n  color VARCHAR(255) NOT NULL,\n  checked boolean NOT NULL,\n  members_id BIGINT NOT NULL,\n  categories_id BIGINT NOT NULL,\n  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  PRIMARY KEY (id),\n  FOREIGN KEY (members_id) REFERENCES members (id),\n  FOREIGN KEY (categories_id) REFERENCES categories (id)\n);\n\nCREATE TABLE IF NOT EXISTS schedules (\n  id BIGINT AUTO_INCREMENT,\n  title VARCHAR(255) NOT NULL,\n  start_date_time DATETIME NOT NULL,\n  end_date_time DATETIME NOT NULL,\n  memo VARCHAR(255) NOT NULL,\n  categories_id BIGINT NOT NULL,\n  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  PRIMARY KEY (id),\n  FOREIGN KEY (categories_id) REFERENCES categories (id)\n);\n\nCREATE TABLE IF NOT EXISTS oauth_tokens (\n  id BIGINT AUTO_INCREMENT,\n  refresh_token VARCHAR(255) NOT NULL,\n  members_id BIGINT NOT NULL,\n  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n  PRIMARY KEY (id),\n  FOREIGN KEY (members_id) REFERENCES members (id)\n);\n\nCREATE TABLE IF NOT EXISTS external_category_details (\n    id BIGINT AUTO_INCREMENT,\n    categories_id BIGINT NOT NULL,\n    external_id VARCHAR(255) NOT NULL,\n    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n    updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),\n    PRIMARY KEY (id),\n    FOREIGN KEY (categories_id) REFERENCES categories (id)\n);\n\nCREATE TABLE IF NOT EXISTS category_roles (\n    id BIGINT AUTO_INCREMENT,\n    members_id BIGINT NOT NULL,\n    categories_id BIGINT NOT NULL,\n    category_role_type VARCHAR(255),\n    created_at DATETIME(6) not null DEFAULT CURRENT_TIMESTAMP(6),\n    updated_at DATETIME(6) not null DEFAULT CURRENT_TIMESTAMP(6),\n    PRIMARY KEY (id),\n    FOREIGN KEY (categories_id) REFERENCES categories (id),\n    FOREIGN KEY (members_id) REFERENCES members (id)\n);\n"
  },
  {
    "path": "backend/src/main/resources/logback-spring.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<configuration>\n    <include resource=\"org/springframework/boot/logging/logback/defaults.xml\"/>\n    <include resource=\"org/springframework/boot/logging/logback/console-appender.xml\"/>\n\n    <property name=\"LOG_PATH\" value=\"./logs\"/>\n    <property name=\"LOG_FILE\" value=\"${LOG_PATH}/dallog-%d{yyyy-MM-dd}-%i.log\"/>\n\n    <appender name=\"FILE\" class=\"ch.qos.logback.core.rolling.RollingFileAppender\">\n        <encoder>\n            <pattern>${FILE_LOG_PATTERN}</pattern>\n        </encoder>\n        <file>${LOG_PATH}/dallog.log</file>\n        <rollingPolicy class=\"ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy\">\n            <fileNamePattern>${LOG_FILE}</fileNamePattern>\n            <maxFileSize>10MB</maxFileSize>\n            <maxHistory>10</maxHistory>\n            <totalSizeCap>100MB</totalSizeCap>\n        </rollingPolicy>\n    </appender>\n\n    <springProperty name=\"USERNAME\" source=\"report.discord.username\"/>\n    <springProperty name=\"WEBHOOK_URI\" source=\"report.discord.webhook-uri\"/>\n\n    <appender name=\"DISCORD\" class=\"com.allog.dallog.infrastructure.log.DiscordAppender\">\n        <username>${USERNAME}</username>\n        <embedsColor>15744574</embedsColor>\n        <stackTraceMaxSize>5</stackTraceMaxSize>\n        <webhookUri>${WEBHOOK_URI}</webhookUri>\n    </appender>\n\n    <appender name=\"ASYNC_DISCORD\" class=\"ch.qos.logback.classic.AsyncAppender\">\n        <appender-ref ref=\"DISCORD\"/>\n        <filter class=\"ch.qos.logback.classic.filter.ThresholdFilter\">\n            <level>ERROR</level>\n        </filter>\n    </appender>\n\n    <springProfile name=\"test\">\n        <logger level=\"INFO\" name=\"org.springframework.boot\">\n            <appender-ref ref=\"CONSOLE\"/>\n        </logger>\n        <logger level=\"INFO\" name=\"com.allog.dallog\">\n            <appender-ref ref=\"CONSOLE\"/>\n        </logger>\n        <logger level=\"TRACE\" name=\"org.hibernate.type.descriptor.sql.BasicBinder\">\n            <appender-ref ref=\"CONSOLE\"/>\n        </logger>\n    </springProfile>\n\n    <springProfile name=\"local\">\n        <root level=\"INFO\">\n            <appender-ref ref=\"CONSOLE\"/>\n        </root>\n    </springProfile>\n\n    <springProfile name=\"dev\">\n        <logger level=\"INFO\" name=\"org.springframework.boot\">\n            <appender-ref ref=\"CONSOLE\"/>\n            <appender-ref ref=\"FILE\"/>\n        </logger>\n        <logger level=\"INFO\" name=\"com.allog.dallog\">\n            <appender-ref ref=\"CONSOLE\"/>\n            <appender-ref ref=\"FILE\"/>\n        </logger>\n        <logger level=\"ERROR\" name=\"com.allog.dallog.global.error.ControllerAdvice\">\n            <appender-ref ref=\"ASYNC_DISCORD\"/>\n        </logger>\n    </springProfile>\n\n    <springProfile name=\"prod\">\n        <logger level=\"WARN\" name=\"org.springframework.boot\">\n            <appender-ref ref=\"FILE\"/>\n        </logger>\n        <logger level=\"WARN\" name=\"com.allog.dallog\">\n            <appender-ref ref=\"FILE\"/>\n        </logger>\n        <logger level=\"ERROR\" name=\"com.allog.dallog.global.error.ControllerAdvice\">\n            <appender-ref ref=\"ASYNC_DISCORD\"/>\n        </logger>\n    </springProfile>\n</configuration>\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/AcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport com.allog.dallog.auth.domain.TokenRepository;\nimport com.allog.dallog.common.DatabaseCleaner;\nimport com.allog.dallog.common.config.ExternalApiConfig;\nimport io.restassured.RestAssured;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.test.context.ActiveProfiles;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = ExternalApiConfig.class)\n@ActiveProfiles(\"test\")\nabstract class AcceptanceTest {\n\n    @LocalServerPort\n    private int port;\n\n    @Autowired\n    private DatabaseCleaner databaseCleaner;\n\n    @Autowired\n    private TokenRepository tokenRepository;\n\n    @BeforeEach\n    void setUp() {\n        RestAssured.port = port;\n        databaseCleaner.execute();\n        tokenRepository.deleteAll();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/AuthAcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.OAuth_인증_URI를_생성한다;\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.리프레시_토큰을_통해_새로운_엑세스_토큰을_생성한다;\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다;\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성한다;\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.토큰이_유효한지_검증한다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_401이_반환된다;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.auth.dto.request.TokenRenewalRequest;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport com.allog.dallog.auth.dto.response.AccessTokenResponse;\nimport com.allog.dallog.auth.dto.response.OAuthUriResponse;\nimport com.allog.dallog.common.config.TokenConfig;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.context.annotation.Import;\n\n@Import(TokenConfig.class)\n@DisplayName(\"인증 관련 기능\")\npublic class AuthAcceptanceTest extends AcceptanceTest {\n\n    @DisplayName(\"구글 OAuth 인증 URI를 생성하여 반환한다.\")\n    @Test\n    void 구글_OAuth_인증_URI를_생성하여_반환한다() {\n        // given & when\n        ExtractableResponse<Response> response = OAuth_인증_URI를_생성한다(GOOGLE_PROVIDER);\n        OAuthUriResponse oAuthUriResponse = response.as(OAuthUriResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(oAuthUriResponse.getoAuthUri()).contains(\"https://\");\n        });\n    }\n\n    @DisplayName(\"최초 회원이거나 기존에 존재하는 회원이 다시 로그인하는 경우 토큰들을 발급하고 상태코드 200을 반환한다.\")\n    @Test\n    void 최초_회원이거나_기존에_존재하는_회원이_다시_로그인하는_경우_토큰들을_발급하고_상태코드_200을_반환한다() {\n        // given & when\n        ExtractableResponse<Response> response = 자체_토큰을_생성한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        AccessAndRefreshTokenResponse accessAndRefreshTokenResponse = response.as(AccessAndRefreshTokenResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(accessAndRefreshTokenResponse.getAccessToken()).isNotEmpty();\n            assertThat(accessAndRefreshTokenResponse.getRefreshToken()).isNotEmpty();\n        });\n    }\n\n    @DisplayName(\"만료된 엑세스_토큰으로 웹페이지를 로드하면 상태코드 401을 반환한다.\")\n    @Test\n    void 만료된_엑세스_토큰으로_웹페이지를_로드하면_상태코드_401을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n\n        // when\n        ExtractableResponse<Response> response = 토큰이_유효한지_검증한다(accessToken);\n\n        // then\n        상태코드_401이_반환된다(response);\n    }\n\n    @DisplayName(\"리프레시 토큰을 통해 새로운 엑세스 토큰을 발급하고 200을 반환한다.\")\n    @Test\n    void 리프레시_토큰을_통해_새로운_엑세스_토큰을_발급하고_200을_반환한다() {\n        // given\n        ExtractableResponse<Response> response = 자체_토큰을_생성한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        AccessAndRefreshTokenResponse accessAndRefreshTokenResponse = response.as(AccessAndRefreshTokenResponse.class);\n        TokenRenewalRequest tokenRenewalRequest = new TokenRenewalRequest(\n                accessAndRefreshTokenResponse.getRefreshToken());\n\n        // when\n        ExtractableResponse<Response> actual = 리프레시_토큰을_통해_새로운_엑세스_토큰을_생성한다(tokenRenewalRequest);\n        AccessTokenResponse accessTokenResponse = actual.as(AccessTokenResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(actual);\n            assertThat(accessTokenResponse.getAccessToken()).isNotEmpty();\n        });\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/CategoryAcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.id를_통해_카테고리를_조회한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.내가_등록한_카테고리를_삭제한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.내가_등록한_카테고리를_수정한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.새로운_카테고리를_등록한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.전체_카테고리를_제목_검색을_통해_조회한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.전체_카테고리를_조회한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.회원의_카테고리_역할을_변경한다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.MemberAcceptanceFixtures.자신의_정보를_조회한다;\nimport static com.allog.dallog.acceptance.fixtures.SubscriptionAcceptanceFixtures.카테고리를_구독한다;\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디_생성_요청;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.category.dto.response.CategoriesResponse;\nimport com.allog.dallog.category.dto.response.CategoryDetailResponse;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;\nimport com.allog.dallog.common.fixtures.OAuthFixtures;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\n@DisplayName(\"카테고리 관련 기능\")\npublic class CategoryAcceptanceTest extends AcceptanceTest {\n\n    @DisplayName(\"정상적인 카테고리 정보를 등록하면 상태코드 201을 반환한다.\")\n    @Test\n    void 정상적인_카테고리_정보를_등록하면_상태코드_201을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n\n        // when\n        ExtractableResponse<Response> response = 새로운_카테고리를_등록한다(accessToken, 매트_아고라_생성_요청);\n\n        // then\n        상태코드_201이_반환된다(response);\n    }\n\n    @DisplayName(\"개인 카테고리를 생성하면 201을 반환한다.\")\n    @Test\n    void 개인_카테고리를_생성하면_201을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n\n        // when\n        ExtractableResponse<Response> response = 새로운_카테고리를_등록한다(accessToken, 내_일정_생성_요청);\n\n        // then\n        상태코드_201이_반환된다(response);\n    }\n\n    @DisplayName(\"카테고리를 등록하고 일반 카테고리 전체를 조회한다.\")\n    @Test\n    void 카테고리를_등록하고_일반_카테고리_전체를_조회한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, FE_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, 매트_아고라_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, 후디_JPA_스터디_생성_요청);\n\n        // when\n        ExtractableResponse<Response> response = 전체_카테고리를_조회한다();\n        CategoriesResponse categoriesResponse = response.as(CategoriesResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(categoriesResponse.getCategories()).hasSize(5);\n        });\n    }\n\n    @DisplayName(\"카테고리를 등록하고 제목 검색을 통해 해당하는 카테고리를 조회한다.\")\n    @Test\n    void 카테고리를_등록하고_제목_검색을_통해_해당하는_카테고리를_조회한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, FE_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, 매트_아고라_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, 후디_JPA_스터디_생성_요청);\n\n        // when\n        ExtractableResponse<Response> response = 전체_카테고리를_제목_검색을_통해_조회한다(\"일\");\n        CategoriesResponse categoriesResponse = response.as(CategoriesResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(categoriesResponse.getCategories()).hasSize(3);\n        });\n    }\n\n    @DisplayName(\"등록된 개인 카테고리는 카테고리 목록에서 조회할 수 없다.\")\n    @Test\n    void 등록된_개인_카테고리는_카테고리_목록에서_조회할_수_없다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        /* 공개 카테고리 */\n        새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청);\n        새로운_카테고리를_등록한다(accessToken, FE_일정_생성_요청);\n        /* 개인 카테고리 */\n        새로운_카테고리를_등록한다(accessToken, 내_일정_생성_요청);\n\n        // when\n        ExtractableResponse<Response> response = 전체_카테고리를_제목_검색을_통해_조회한다(\"\");\n        CategoriesResponse categoriesResponse = response.as(CategoriesResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(categoriesResponse.getCategories()).hasSize(3);\n        });\n    }\n\n    @DisplayName(\"카테고리를 등록하고 내가 등록한 카테고리를 수정하면 상태코드 204를 반환한다.\")\n    @Test\n    void 카테고리를_등록하고_내가_등록한_카테고리를_수정하면_상태코드_204를_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        CategoryResponse savedCategory = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class);\n        String newCategoryName = \"우테코 공통 일정\";\n\n        // when\n        ExtractableResponse<Response> response = 내가_등록한_카테고리를_수정한다(accessToken, savedCategory.getId(), newCategoryName);\n        CategoryDetailResponse actual = id를_통해_카테고리를_조회한다(savedCategory.getId())\n                .as(CategoryDetailResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_204가_반환된다(response);\n            assertThat(actual.getName()).isEqualTo(newCategoryName);\n        });\n    }\n\n    @DisplayName(\"카테고리를 등록하고 내가 등록한 카테고리를 삭제하면 상태코드 204를 반환한다.\")\n    @Test\n    void 카테고리를_등록하고_내가_등록한_카테고리를_삭제하면_상태코드_204를_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        CategoryResponse savedCategory = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class);\n\n        // when\n        ExtractableResponse<Response> response = 내가_등록한_카테고리를_삭제한다(accessToken, savedCategory.getId());\n\n        // then\n        상태코드_204가_반환된다(response);\n    }\n\n    @DisplayName(\"특정 구독자의 카테고리 역할을 수정하면 상태코드 204를 반환한다.\")\n    @Test\n    void 특정_구독자의_카테고리_역할을_수정하면_상태코드_204를_반환한다() {\n        // given\n        String 관리자_토큰 = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, OAuthFixtures.후디.getCode());\n        CategoryResponse 카테고리 = 새로운_카테고리를_등록한다(관리자_토큰, 공통_일정_생성_요청).as(CategoryResponse.class);\n\n        String 구독자_토큰 = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, OAuthFixtures.매트.getCode());\n        ExtractableResponse<Response> 회원정보 = 자신의_정보를_조회한다(구독자_토큰);\n        long 구독자_id = 회원정보.body().jsonPath().getLong(\"id\");\n\n        카테고리를_구독한다(구독자_토큰, 카테고리.getId());\n\n        // when\n        ExtractableResponse<Response> response = 회원의_카테고리_역할을_변경한다(관리자_토큰, 카테고리.getId(), 구독자_id,\n                new CategoryRoleUpdateRequest(ADMIN));\n\n        // then\n        상태코드_204가_반환된다(response);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/ExternalCalendarAcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드;\nimport static com.allog.dallog.common.fixtures.ExternalCategoryFixtures.우아한테크코스_생성_요청;\nimport static com.allog.dallog.common.fixtures.ExternalCategoryFixtures.우아한테크코스_이름;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\n\n@DisplayName(\"외부 캘린더 관련 기능\")\nclass ExternalCalendarAcceptanceTest extends AcceptanceTest {\n\n    @DisplayName(\"자신의 외부 캘린더 리스트를 조회하면 200을 반환한다.\")\n    @Test\n    void 자신의_외부_캘린더_리스트를_조회하면_200을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n\n        // when\n        ExtractableResponse<Response> response = RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .accept(MediaType.APPLICATION_JSON_VALUE)\n                .when().get(\"/api/external-calendars/me\")\n                .then().log().all()\n                .statusCode(HttpStatus.OK.value())\n                .extract();\n        ExternalCalendarsResponse externalCalendarsResponse = response.as(ExternalCalendarsResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(externalCalendarsResponse.getExternalCalendars()).hasSize(3);\n        });\n    }\n\n    @DisplayName(\"외부 캘린더를 추가하면 201을 반환한다.\")\n    @Test\n    void 외부_캘린더를_추가하면_201을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n\n        // when\n        ExtractableResponse<Response> response = RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .accept(MediaType.APPLICATION_JSON_VALUE)\n                .body(우아한테크코스_생성_요청)\n                .when().post(\"/api/external-calendars/me\")\n                .then().log().all()\n                .statusCode(HttpStatus.CREATED.value())\n                .extract();\n        CategoryResponse categoryResponse = response.as(CategoryResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_201이_반환된다(response);\n            assertThat(categoryResponse.getName()).isEqualTo(우아한테크코스_이름);\n        });\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/MemberAcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.MemberAcceptanceFixtures.자신의_정보를_조회한다;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_이름;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_이메일;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_프로필;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.member.dto.request.MemberUpdateRequest;\nimport com.allog.dallog.member.dto.response.MemberResponse;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\n\n@DisplayName(\"회원 관련 기능\")\npublic class MemberAcceptanceTest extends AcceptanceTest {\n\n    @DisplayName(\"등록된 회원이 자신의 정보를 조회하면 상태코드 200을 반환한다.\")\n    @Test\n    void 등록된_회원이_자신의_정보를_조회하면_상태코드_200_을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n\n        // when\n        ExtractableResponse<Response> response = 자신의_정보를_조회한다(accessToken);\n        MemberResponse memberResponse = response.as(MemberResponse.class);\n\n        // then\n        assertAll(() -> {\n            상태코드_200이_반환된다(response);\n            assertThat(memberResponse.getEmail()).isEqualTo(MEMBER_이메일);\n            assertThat(memberResponse.getDisplayName()).isEqualTo(MEMBER_이름);\n            assertThat(memberResponse.getProfileImageUrl()).isEqualTo(MEMBER_프로필);\n        });\n    }\n\n    @DisplayName(\"등록된 회원이 자신의 이름을 변경하면 상태코드 204를 반환한다.\")\n    @Test\n    void 등록된_회원이_자신의_이름을_변경하면_상태코드_204를_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        String 패트_이름 = \"패트\";\n        MemberUpdateRequest request = new MemberUpdateRequest(패트_이름);\n\n        // when\n        ExtractableResponse<Response> response = RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(request)\n                .when().patch(\"/api/members/me\")\n                .then().log().all()\n                .extract();\n\n        // then\n        상태코드_204가_반환된다(response);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/ScheduleAcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다;\nimport static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.새로운_카테고리를_등록한다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.새로운_일정을_등록한다;\nimport static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.일정_아이디로_일정을_단건_조회한다;\nimport static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.일정을_삭제한다;\nimport static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.일정을_수정한다;\nimport static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.카테고리_아이디로_일정_리스트를_조회한다;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_시작일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_종료일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_네번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_두번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_세번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_첫번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_네번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_다섯번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_두번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_세번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_첫번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_두번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_세번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_첫번째_일정;\n\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.common.fixtures.OAuthFixtures;\nimport com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest;\nimport com.allog.dallog.schedule.dto.response.ScheduleResponse;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\n@DisplayName(\"일정 관련 기능\")\nclass ScheduleAcceptanceTest extends AcceptanceTest {\n\n    @DisplayName(\"정상적인 일정정보를 등록하면 상태코드 201을 반환한다.\")\n    @Test\n    void 정상적인_일정정보를_등록하면_상태코드_201을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class);\n\n        // when\n        ExtractableResponse<Response> response = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId());\n\n        // then\n        상태코드_201이_반환된다(response);\n    }\n\n    @DisplayName(\"카테고리로 일정을 조회하면 상태코드 200을 반환한다.\")\n    @Test\n    void 카테고리로_일정을_조회하면_상태코드_200을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, OAuthFixtures.매트.getCode());\n        CategoryResponse BE_일정 = 새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청).as(CategoryResponse.class);\n\n        /* 장기간 일정 */\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_첫번째_요청);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_두번째_요청);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_세번째_요청);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_네번째_요청);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_다섯번째_요청);\n\n        /* 종일 일정 */\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 종일_첫번째_일정);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 종일_두번째_일정);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 종일_세번째_일정);\n\n        /* 몇시간 일정 */\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_첫번째_일정);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_두번째_일정);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_세번째_일정);\n        새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_네번째_일정);\n\n        // when\n        ExtractableResponse<Response> response = 카테고리_아이디로_일정_리스트를_조회한다(accessToken, BE_일정.getId(), \"2022-07-01T00:00\",\n                \"2022-08-15T23:59\");\n\n        // then\n        상태코드_200이_반환된다(response);\n    }\n\n    @DisplayName(\"일정 ID로 일정을 단건조회_하면 상태코드 200을 반환한다.\")\n    @Test\n    void 일정_ID로_일정을_단건조회_하면_상태코드_200을_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class);\n        ScheduleResponse 알록달록_회의 = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()).as(ScheduleResponse.class);\n\n        // when\n        ExtractableResponse<Response> response = 일정_아이디로_일정을_단건_조회한다(accessToken, 알록달록_회의.getId());\n\n        // then\n        상태코드_200이_반환된다(response);\n    }\n\n    @DisplayName(\"일정을 수정하면 상태코드 204를 반환한다.\")\n    @Test\n    void 일정을_수정하면_상태코드_204를_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class);\n        ScheduleResponse 알록달록_회의 = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()).as(ScheduleResponse.class);\n\n        ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(공통_일정_응답.getId(), 레벨_인터뷰_제목, 레벨_인터뷰_시작일시,\n                레벨_인터뷰_종료일시, 레벨_인터뷰_메모);\n\n        // when\n        ExtractableResponse<Response> response = 일정을_수정한다(accessToken, 알록달록_회의.getId(), 일정_수정_요청);\n\n        // then\n        상태코드_204가_반환된다(response);\n    }\n\n    @DisplayName(\"일정을 삭제하면 상태코드 204를 반환한다.\")\n    @Test\n    void 일정을_삭제하면_상태코드_204를_반환한다() {\n        // given\n        String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class);\n        ScheduleResponse 알록달록_회의 = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()).as(ScheduleResponse.class);\n\n        // when\n        ExtractableResponse<Response> response = 일정을_삭제한다(accessToken, 알록달록_회의.getId());\n\n        // then\n        상태코드_204가_반환된다(response);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/SubscriptionAcceptanceTest.java",
    "content": "package com.allog.dallog.acceptance;\n\nimport static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다;\nimport static com.allog.dallog.acceptance.fixtures.SubscriptionAcceptanceFixtures.구독_목록을_조회한다;\nimport static com.allog.dallog.acceptance.fixtures.SubscriptionAcceptanceFixtures.카테고리를_구독한다;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_CREATOR_인증_코드;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_생성_요청;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest;\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\nimport com.allog.dallog.subscription.dto.response.SubscriptionsResponse;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\n\n@DisplayName(\"구독 관련 기능\")\npublic class SubscriptionAcceptanceTest extends AcceptanceTest {\n\n    @DisplayName(\"인증된 회원이 카테고리를 구독하면 201을 반환한다.\")\n    @Test\n    void 인증된_회원이_카테고리를_구독하면_201을_반환한다() {\n        // given\n        String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드);\n        CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청);\n\n        // when\n        ExtractableResponse<Response> response = RestAssured.given().log().all()\n                .auth().oauth2(memberToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .when().post(\"/api/members/me/categories/{categoryId}/subscriptions\", 공통_일정.getId())\n                .then().log().all()\n                .statusCode(HttpStatus.CREATED.value())\n                .extract();\n\n        // then\n        상태코드_201이_반환된다(response);\n    }\n\n    @DisplayName(\"인증된 회원이 구독 목록을 조회하면 200을 반환한다.\")\n    @Test\n    void 인증된_회원이_구독_목록을_조회하면_200을_반환한다() {\n        // given\n        String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드);\n\n        CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청);\n        CategoryResponse BE_일정 = 새로운_카테고리를_등록한다(creatorToken, BE_일정_생성_요청);\n        CategoryResponse FE_일정 = 새로운_카테고리를_등록한다(creatorToken, FE_일정_생성_요청);\n\n        카테고리를_구독한다(memberToken, 공통_일정.getId());\n        카테고리를_구독한다(memberToken, BE_일정.getId());\n        카테고리를_구독한다(memberToken, FE_일정.getId());\n\n        // when\n        ExtractableResponse<Response> response = 구독_목록을_조회한다(memberToken);\n        SubscriptionsResponse subscriptionsResponse = response.as(SubscriptionsResponse.class);\n\n        // then\n        assertAll(() -> {\n            assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());\n            assertThat(subscriptionsResponse.getSubscriptions()).hasSize(4); // 개인 카테고리 1 + given에서 등록한 카테고리 3\n        });\n    }\n\n    @DisplayName(\"인증된 회원이 자신의 구독 정보를 수정할 경우 204를 반환한다.\")\n    @Test\n    void 인증된_회원이_자신의_구독_정보를_수정할_경우_204를_반환한다() {\n        // given\n        String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드);\n\n        CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청);\n\n        SubscriptionResponse subscriptionResponse = 카테고리를_구독한다(memberToken, 공통_일정.getId());\n        SubscriptionUpdateRequest request = new SubscriptionUpdateRequest(Color.COLOR_1, true);\n\n        // when\n        ExtractableResponse<Response> response = RestAssured.given().log().all()\n                .auth().oauth2(memberToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(request)\n                .when().patch(\"/api/members/me/subscriptions/{subscriptionId}\", subscriptionResponse.getId())\n                .then().log().all()\n                .statusCode(HttpStatus.NO_CONTENT.value())\n                .extract();\n\n        SubscriptionsResponse subscriptionsResponse = 구독_목록을_조회한다(memberToken).as(SubscriptionsResponse.class);\n\n        // then\n        List<SubscriptionResponse> subscriptions = subscriptionsResponse.getSubscriptions();\n        SubscriptionResponse foundSubscriptionResponse = subscriptions.stream()\n                .filter(subscription -> subscriptionResponse.getId().equals(subscription.getId()))\n                .findAny()\n                .get();\n\n        assertAll(() -> {\n            상태코드_204가_반환된다(response);\n            assertThat(foundSubscriptionResponse.getColorCode()).isEqualTo(request.getColorCode());\n            assertThat(foundSubscriptionResponse.isChecked()).isTrue();\n        });\n    }\n\n    @DisplayName(\"구독을 취소할 경우 204를 반환한다.\")\n    @Test\n    void 구독을_취소할_경우_204를_반환한다() {\n        // given\n        String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드);\n        String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드);\n\n        CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청);\n        CategoryResponse BE_일정 = 새로운_카테고리를_등록한다(creatorToken, BE_일정_생성_요청);\n        CategoryResponse FE_일정 = 새로운_카테고리를_등록한다(creatorToken, FE_일정_생성_요청);\n\n        SubscriptionResponse subscriptionResponse = 카테고리를_구독한다(memberToken, 공통_일정.getId());\n        카테고리를_구독한다(memberToken, BE_일정.getId());\n        카테고리를_구독한다(memberToken, FE_일정.getId());\n\n        // when\n        ExtractableResponse<Response> response = RestAssured.given().log().all()\n                .auth().oauth2(memberToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .when().delete(\"/api/members/me/subscriptions/{subscriptionId}\", subscriptionResponse.getId())\n                .then().log().all()\n                .statusCode(HttpStatus.NO_CONTENT.value())\n                .extract();\n\n        // then\n        assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value());\n    }\n\n    private CategoryResponse 새로운_카테고리를_등록한다(final String accessToken, final CategoryCreateRequest request) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(request)\n                .when().post(\"/api/categories\")\n                .then().log().all()\n                .extract()\n                .as(CategoryResponse.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/fixtures/AuthAcceptanceFixtures.java",
    "content": "package com.allog.dallog.acceptance.fixtures;\n\nimport com.allog.dallog.auth.dto.request.TokenRenewalRequest;\nimport com.allog.dallog.auth.dto.request.TokenRequest;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\n\npublic class AuthAcceptanceFixtures {\n\n    public static ExtractableResponse<Response> OAuth_인증_URI를_생성한다(final String oauthProvider) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .when().get(\"/api/auth/{oauthProvider}/oauth-uri?redirectUri={redirectUri}\", oauthProvider,\n                        \"https://dallog.me/oauth\")\n                .then().log().all()\n                .statusCode(HttpStatus.OK.value())\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 자체_토큰을_생성한다(final String oauthProvider, final String code) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(new TokenRequest(code, \"https://dallog.me/oauth\"))\n                .when().post(\"/api/auth/{oauthProvider}/token\", oauthProvider)\n                .then().log().all()\n                .statusCode(HttpStatus.OK.value())\n                .extract();\n    }\n\n    public static String 자체_토큰을_생성하고_엑세스_토큰을_반환한다(final String oauthProvider, final String code) {\n        AccessAndRefreshTokenResponse accessAndRefreshTokenResponse = RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(new TokenRequest(code, \"https://dallog.me/oauth\"))\n                .when().post(\"/api/auth/{oauthProvider}/token\", oauthProvider)\n                .then().log().all()\n                .statusCode(HttpStatus.OK.value())\n                .extract()\n                .as(AccessAndRefreshTokenResponse.class);\n\n        return accessAndRefreshTokenResponse.getAccessToken();\n    }\n\n    public static ExtractableResponse<Response> 리프레시_토큰을_통해_새로운_엑세스_토큰을_생성한다(\n            final TokenRenewalRequest tokenRenewalRequest) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(tokenRenewalRequest)\n                .when().post(\"/api/auth/token/access\")\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 토큰이_유효한지_검증한다(final String accessToken) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .when().get(\"/api/auth/validate/token\")\n                .then().log().all()\n                .extract();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/fixtures/CategoryAcceptanceFixtures.java",
    "content": "package com.allog.dallog.acceptance.fixtures;\n\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.request.CategoryUpdateRequest;\nimport com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.springframework.http.MediaType;\n\npublic class CategoryAcceptanceFixtures {\n\n    public static ExtractableResponse<Response> 새로운_카테고리를_등록한다(final String accessToken,\n                                                               final CategoryCreateRequest request) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(request)\n                .when().post(\"/api/categories\")\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 전체_카테고리를_조회한다() {\n        return RestAssured.given().log().all()\n                .when().get(\"/api/categories\")\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 전체_카테고리를_제목_검색을_통해_조회한다(final String name) {\n        return RestAssured.given().log().all()\n                .when().get(\"/api/categories?name={name}\", name)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> id를_통해_카테고리를_조회한다(final Long id) {\n        return RestAssured.given().log().all()\n                .when().get(\"/api/categories/{categoryId}\", id)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 내가_등록한_카테고리를_수정한다(final String accessToken, final Long id,\n                                                                  final String name) {\n        CategoryUpdateRequest request = new CategoryUpdateRequest(name);\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(request)\n                .when().patch(\"/api/categories/{categoryId}\", id)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 내가_등록한_카테고리를_삭제한다(final String accessToken, final Long id) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .when().delete(\"/api/categories/{categoryId}\", id)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 회원의_카테고리_역할을_변경한다(final String accessToken, final Long categoryId,\n                                                                  final Long memberId,\n                                                                  final CategoryRoleUpdateRequest request) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .body(request)\n                .when().patch(\"/api/categories/{categoryId}/subscribers/{memberId}/role\", categoryId, memberId)\n                .then().log().all()\n                .extract();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/fixtures/CommonAcceptanceFixtures.java",
    "content": "package com.allog.dallog.acceptance.fixtures;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.springframework.http.HttpStatus;\n\npublic class CommonAcceptanceFixtures {\n\n    public static void 상태코드_200이_반환된다(final ExtractableResponse<Response> response) {\n        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());\n    }\n\n    public static void 상태코드_201이_반환된다(final ExtractableResponse<Response> response) {\n        assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());\n    }\n\n    public static void 상태코드_204가_반환된다(final ExtractableResponse<Response> response) {\n        assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value());\n    }\n\n    public static void 상태코드_404가_반환된다(final ExtractableResponse<Response> response) {\n        assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND.value());\n    }\n\n    public static void 상태코드_401이_반환된다(final ExtractableResponse<Response> response) {\n        assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/fixtures/MemberAcceptanceFixtures.java",
    "content": "package com.allog.dallog.acceptance.fixtures;\n\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\n\npublic class MemberAcceptanceFixtures {\n\n    public static ExtractableResponse<Response> 자신의_정보를_조회한다(final String token) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(token)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .when().get(\"/api/members/me\")\n                .then().log().all()\n                .statusCode(HttpStatus.OK.value())\n                .extract();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/fixtures/ScheduleAcceptanceFixtures.java",
    "content": "package com.allog.dallog.acceptance.fixtures;\n\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목;\n\nimport com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport java.util.HashMap;\nimport java.util.Map;\nimport org.springframework.http.MediaType;\n\npublic class ScheduleAcceptanceFixtures {\n\n    public static ExtractableResponse<Response> 새로운_일정을_등록한다(final String accessToken, final Long categoryId) {\n\n        Map<String, String> params = new HashMap<>();\n        params.put(\"title\", 알록달록_회의_제목);\n        params.put(\"startDateTime\", \"2022-07-04T13:00\");\n        params.put(\"endDateTime\", \"2022-07-05T16:00\");\n        params.put(\"memo\", 알록달록_회의_메모);\n\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .auth().oauth2(accessToken)\n                .body(params)\n                .when().post(\"/api/categories/{categoryId}/schedules\", categoryId)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 새로운_일정을_등록한다(final String accessToken, final Long categoryId,\n                                                             final ScheduleCreateRequest request) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .auth().oauth2(accessToken)\n                .body(request)\n                .when().post(\"/api/categories/{categoryId}/schedules\", categoryId)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 일정을_수정한다(final String accessToken, final Long scheduleId,\n                                                         final ScheduleUpdateRequest request) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .auth().oauth2(accessToken)\n                .body(request)\n                .when().patch(\"/api/schedules/{scheduleId}\", scheduleId)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 일정을_삭제한다(final String accessToken, final Long scheduleId) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .when().delete(\"/api/schedules/{scheduleId}\", scheduleId)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 일정_아이디로_일정을_단건_조회한다(final String accessToken, final Long scheduleId) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .auth().oauth2(accessToken)\n                .when().get(\"/api/schedules/{scheduleId}\", scheduleId)\n                .then().log().all()\n                .extract();\n    }\n\n    public static ExtractableResponse<Response> 카테고리_아이디로_일정_리스트를_조회한다(final String accessToken,\n                                                                       final Long categoryId,\n                                                                       final String startDateTime,\n                                                                       final String endDateTime) {\n        return RestAssured.given().log().all()\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .auth().oauth2(accessToken)\n                .when()\n                .get(\"/api/categories/{categoryId}/schedules?startDateTime={startDateTime}&endDateTime={endDateTime}\",\n                        categoryId, startDateTime, endDateTime)\n                .then().log().all()\n                .extract();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/acceptance/fixtures/SubscriptionAcceptanceFixtures.java",
    "content": "package com.allog.dallog.acceptance.fixtures;\n\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\nimport io.restassured.RestAssured;\nimport io.restassured.response.ExtractableResponse;\nimport io.restassured.response.Response;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.MediaType;\n\npublic class SubscriptionAcceptanceFixtures {\n\n    public static ExtractableResponse<Response> 구독_목록을_조회한다(final String accessToken) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .when().get(\"/api/members/me/subscriptions\")\n                .then().log().all()\n                .statusCode(HttpStatus.OK.value())\n                .extract();\n    }\n\n    public static SubscriptionResponse 카테고리를_구독한다(final String accessToken, final Long categoryId) {\n        return RestAssured.given().log().all()\n                .auth().oauth2(accessToken)\n                .contentType(MediaType.APPLICATION_JSON_VALUE)\n                .when().post(\"/api/members/me/categories/{categoryId}/subscriptions\", categoryId)\n                .then().log().all()\n                .statusCode(HttpStatus.CREATED.value())\n                .extract()\n                .as(SubscriptionResponse.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/application/AuthServiceTest.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_이메일;\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.MEMBER;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.auth.dto.request.TokenRenewalRequest;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport com.allog.dallog.auth.dto.response.AccessTokenResponse;\nimport com.allog.dallog.auth.event.MemberSavedEvent;\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.test.context.event.ApplicationEvents;\nimport org.springframework.test.context.event.RecordApplicationEvents;\n\n@RecordApplicationEvents\nclass AuthServiceTest extends ServiceTest {\n\n    @Autowired\n    private AuthService authService;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private ApplicationEvents events;\n\n    @DisplayName(\"토큰 생성을 하면 OAuth 서버에서 인증 후 토큰을 반환한다\")\n    @Test\n    void 토큰_생성을_하면_OAuth_서버에서_인증_후_토큰들을_반환한다() {\n        // given & when\n        AccessAndRefreshTokenResponse actual = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n\n        // then\n        assertAll(() -> {\n            assertThat(actual.getAccessToken()).isNotEmpty();\n            assertThat(actual.getRefreshToken()).isNotEmpty();\n            assertThat(events.stream(MemberSavedEvent.class).count()).isEqualTo(1);\n        });\n    }\n\n    @DisplayName(\"Authorization Code를 받으면 회원이 데이터베이스에 저장된다.\")\n    @Test\n    void Authorization_Code를_받으면_회원이_데이터베이스에_저장된다() {\n        // given & when\n        authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n\n        // then\n        assertThat(memberRepository.existsByEmail(MEMBER_이메일)).isTrue();\n\n        assertAll(() -> {\n            // SutbOAuthClient가 반환하는 OAuthMember의 이메일\n            assertThat(memberRepository.existsByEmail(MEMBER_이메일)).isTrue();\n            assertThat(events.stream(MemberSavedEvent.class).count()).isEqualTo(1);\n        });\n    }\n\n    @DisplayName(\"이미 가입된 회원에 대한 Authorization Code를 전달받으면 추가로 회원이 생성되지 않는다\")\n    @Test\n    void 이미_가입된_회원에_대한_Authorization_Code를_전달받으면_추가로_회원이_생성되지_않는다() {\n        // 이미 가입된 회원이 소셜 로그인 버튼을 클릭했을 경우엔 회원가입 과정이 생략되고, 곧바로 access token이 발급되어야 한다.\n\n        // given\n        authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n\n        // when\n        authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n        List<Member> actual = memberRepository.findAll();\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n\n    @DisplayName(\"이미 가입된 회원이고 저장된 RefreshToken이 있으면, 저장된 RefreshToken을 반환한다.\")\n    @Test\n    void 이미_가입된_회원이고_저장된_RefreshToken이_있으면_저장된_RefreshToken을_반환한다() {\n        // 이미 가입된 회원이 소셜 로그인 버튼을 클릭했을 경우엔 회원가입 과정이 생략되고, 곧바로 access token과 refreshtoken이 발급되어야 한다.\n\n        // given\n        AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n\n        // when\n        AccessAndRefreshTokenResponse actual = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n\n        // then\n        assertThat(actual.getRefreshToken()).isEqualTo(response.getRefreshToken());\n    }\n\n    @DisplayName(\"리프레시 토큰으로 새로운 엑세스 토큰을 발급한다.\")\n    @Test\n    void 리프레시_토큰으로_새로운_엑세스_토큰을_발급한다() {\n        // given\n        AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n        TokenRenewalRequest tokenRenewalRequest = new TokenRenewalRequest(response.getRefreshToken());\n\n        // when\n        AccessTokenResponse accessTokenResponse = authService.generateAccessToken(tokenRenewalRequest);\n\n        // then\n        assertThat(accessTokenResponse.getAccessToken()).isNotEmpty();\n    }\n\n    @DisplayName(\"리프레시 토큰으로 새로운 엑세스 토큰을 발급 할 때, 리프레시 토큰이 존재하지 않으면 예외를 던진다.\")\n    @Test\n    void 리프레시_토큰으로_새로운_엑세스_토큰을_발급_할_때_리프레시_토큰이_존재하지_않으면_예외를_던진다() {\n        // given\n        authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember());\n        TokenRenewalRequest tokenRenewalRequest = new TokenRenewalRequest(\"DummyRefreshToken\");\n\n        // when & then\n        assertThatThrownBy(() -> authService.generateAccessToken(tokenRenewalRequest))\n                .isInstanceOf(InvalidTokenException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/application/AuthTokenCreatorTest.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.auth.domain.AuthToken;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass AuthTokenCreatorTest extends ServiceTest {\n\n    @Autowired\n    private TokenCreator tokenCreator;\n\n    @DisplayName(\"엑세스 토큰과 리프레시 토큰을 발급한다.\")\n    @Test\n    void 엑세스_토큰과_리프레시_토큰을_발급한다() {\n        // given\n        Long memberId = 1L;\n\n        // when\n        AuthToken authToken = tokenCreator.createAuthToken(memberId);\n\n        // then\n        assertThat(authToken.getAccessToken()).isNotEmpty();\n        assertThat(authToken.getRefreshToken()).isNotEmpty();\n    }\n\n    @DisplayName(\"리프레시 토큰으로 엑세스 토큰을 발급한다.\")\n    @Test\n    void 리프레시_토큰으로_엑세스_토큰을_발급한다() {\n        // given\n        Long memberId = 1L;\n        AuthToken authToken = tokenCreator.createAuthToken(memberId);\n\n        // when\n        AuthToken actual = tokenCreator.renewAuthToken(authToken.getRefreshToken());\n\n        // then\n        assertThat(actual.getAccessToken()).isNotEmpty();\n        assertThat(actual.getRefreshToken()).isNotEmpty();\n    }\n\n    @DisplayName(\"토큰에서 페이로드를 추출한다.\")\n    @Test\n    void 토큰에서_페이로드를_추출한다() {\n        // given\n        Long memberId = 1L;\n        AuthToken authToken = tokenCreator.createAuthToken(memberId);\n\n        // when\n        Long actual = tokenCreator.extractPayload(authToken.getAccessToken());\n\n        // then\n        assertThat(actual).isEqualTo(memberId);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/application/JwtTokenProviderTest.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass JwtTokenProviderTest {\n\n    private static final String JWT_SECRET_KEY = \"A\".repeat(32); // Secret Key는 최소 32바이트 이상이어야함.\n    private static final int JWT_ACCESS_TOKEN_EXPIRE_LENGTH = 3600;\n    private static final int JWT_REFRESH_TOKEN_EXPIRE_LENGTH = 3600;\n    private static final String PAYLOAD = \"payload\";\n\n    private final JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(JWT_SECRET_KEY,\n            JWT_ACCESS_TOKEN_EXPIRE_LENGTH, JWT_REFRESH_TOKEN_EXPIRE_LENGTH);\n\n    @DisplayName(\"엑세스 토큰을 생성한다.\")\n    @Test\n    void 엑세스_토큰을_생성한다() {\n        // given & when\n        String actual = jwtTokenProvider.createAccessToken(PAYLOAD);\n\n        // then\n        assertThat(actual.split(\"\\\\.\")).hasSize(3);\n    }\n\n    @DisplayName(\"리프레시 토큰을 생성한다.\")\n    @Test\n    void 리프레시_토큰을_생성한다() {\n        // given & when\n        String actual = jwtTokenProvider.createRefreshToken(PAYLOAD);\n\n        // then\n        assertThat(actual.split(\"\\\\.\")).hasSize(3);\n    }\n\n    @DisplayName(\"토큰의 Payload를 가져온다.\")\n    @Test\n    void 토큰의_Payload를_가져온다() {\n        // given\n        String token = jwtTokenProvider.createAccessToken(PAYLOAD);\n\n        // when\n        String actual = jwtTokenProvider.getPayload(token);\n\n        // then\n        assertThat(actual).isEqualTo(PAYLOAD);\n    }\n\n    @DisplayName(\"엑세스 토큰을 검증하여 만료된 경우 예외를 던진다.\")\n    @Test\n    void 엑세스_토큰을_검증하여_만료된_경우_예외를_던진다() {\n        // given\n        TokenProvider expiredJwtTokenProvider = new JwtTokenProvider(JWT_SECRET_KEY, 0, 0);\n        String expiredToken = expiredJwtTokenProvider.createAccessToken(PAYLOAD);\n\n        // when & then\n        assertThatThrownBy(() -> jwtTokenProvider.validateToken(expiredToken))\n                .isInstanceOf(InvalidTokenException.class);\n    }\n\n    @DisplayName(\"리프레시 토큰을 검증하여 만료된 경우 예외를 던진다.\")\n    @Test\n    void 리프레시_토큰을_검증하여_만료된_경우_예외를_던진다() {\n        // given\n        TokenProvider expiredJwtTokenProvider = new JwtTokenProvider(JWT_SECRET_KEY, 0, 0);\n        String expiredToken = expiredJwtTokenProvider.createRefreshToken(PAYLOAD);\n\n        // when & then\n        assertThatThrownBy(() -> jwtTokenProvider.validateToken(expiredToken))\n                .isInstanceOf(InvalidTokenException.class);\n    }\n\n    @DisplayName(\"토큰을 검증하여 유효하지 않으면 예외를 던진다.\")\n    @Test\n    void 토큰을_검증하여_유효하지_않으면_예외를_던진다() {\n        // given\n        String malformedToken = \"malformed\";\n\n        // when & then\n        assertThatThrownBy(() -> jwtTokenProvider.validateToken(malformedToken))\n                .isInstanceOf(InvalidTokenException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/application/StubTokenProvider.java",
    "content": "package com.allog.dallog.auth.application;\n\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport io.jsonwebtoken.Claims;\nimport io.jsonwebtoken.Jws;\nimport io.jsonwebtoken.JwtException;\nimport io.jsonwebtoken.Jwts;\nimport io.jsonwebtoken.SignatureAlgorithm;\nimport io.jsonwebtoken.security.Keys;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Date;\nimport javax.crypto.SecretKey;\n\npublic class StubTokenProvider implements TokenProvider {\n\n    private final SecretKey key;\n    private final long accessTokenValidityInMilliseconds = 0;\n    private final long refreshTokenValidityInMilliseconds = 360000;\n\n    public StubTokenProvider(final String secretKey) {\n        this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));\n    }\n\n    @Override\n    public String createAccessToken(final String payload) {\n        return createToken(payload, accessTokenValidityInMilliseconds);\n    }\n\n    @Override\n    public String createRefreshToken(final String payload) {\n        return createToken(payload, refreshTokenValidityInMilliseconds);\n    }\n\n    private String createToken(final String payload, final Long validityInMilliseconds) {\n        Date now = new Date();\n        Date validity = new Date(now.getTime() + validityInMilliseconds);\n\n        return Jwts.builder()\n                .setSubject(payload)\n                .setIssuedAt(now)\n                .setExpiration(validity)\n                .signWith(key, SignatureAlgorithm.HS256)\n                .compact();\n    }\n\n    @Override\n    public String getPayload(final String token) {\n        return Jwts.parserBuilder()\n                .setSigningKey(key)\n                .build()\n                .parseClaimsJws(token)\n                .getBody()\n                .getSubject();\n    }\n\n    @Override\n    public void validateToken(final String token) {\n        try {\n            Jws<Claims> claims = Jwts.parserBuilder()\n                    .setSigningKey(key)\n                    .build()\n                    .parseClaimsJws(token);\n\n            claims.getBody()\n                    .getExpiration()\n                    .before(new Date());\n        } catch (final JwtException | IllegalArgumentException e) {\n            throw new InvalidTokenException(\"권한이 없습니다.\");\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/domain/AuthTokenTest.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.auth.exception.NoSuchTokenException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass AuthTokenTest {\n\n    @DisplayName(\"같은 리프레시 토큰 값이면 정상적으로 메서드를 종료한다.\")\n    @Test\n    void 같은_리프레시_토큰_값이면_정상적으로_메서드를_종료한다() {\n        // given\n        AuthToken authToken = new AuthToken(\"dummyAccessToken\", \"dummyRefreshToken\");\n\n        // when & then\n        authToken.validateHasSameRefreshToken(authToken.getRefreshToken());\n    }\n\n    @DisplayName(\"같은 리프레시 토큰 값이 아니면 예외를 발생한다.\")\n    @Test\n    void 같은_리프레시_토큰_값이_아니면_예외를_발생한다() {\n        // given\n        AuthToken authToken = new AuthToken(\"dummyAccessToken\", \"dummyRefreshToken\");\n\n        // when & then\n        assertThatThrownBy(() -> authToken.validateHasSameRefreshToken(\"invalidRefreshToken\"))\n                .isInstanceOf(NoSuchTokenException.class);\n    }\n\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/domain/InMemoryAuthTokenRepositoryTest.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.auth.exception.NoSuchTokenException;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass InMemoryAuthTokenRepositoryTest {\n\n    private final TokenRepository tokenRepository = new InMemoryAuthTokenRepository();\n\n    @BeforeEach\n    void setUp() {\n        tokenRepository.deleteAll();\n    }\n\n    @DisplayName(\"토큰을 저장한다.\")\n    @Test\n    void 토큰을_저장한다() {\n        // given\n        Long dummyMemberId = 1L;\n        String dummyRefreshToken = \"dummy token\";\n\n        // when\n        tokenRepository.save(dummyMemberId, dummyRefreshToken);\n\n        // then\n        assertThat(tokenRepository.getToken(dummyMemberId)).isEqualTo(dummyRefreshToken);\n    }\n\n    @DisplayName(\"MemberId에 해당하는 토큰이 있으면 true를 반환한다.\")\n    @Test\n    void MemberId에_해당하는_토큰이_있으면_true를_반환한다() {\n        // given\n        Long dummyMemberId = 1L;\n        String dummyRefreshToken = \"dummy token\";\n        tokenRepository.save(dummyMemberId, dummyRefreshToken);\n\n        // when\n        boolean actual = tokenRepository.exist(dummyMemberId);\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"MemberId에 해당하는 토큰이 없으면 false를 반환한다.\")\n    @Test\n    void MemberId에_해당하는_토큰이_없으면_false를_반환한다() {\n        // given\n        Long dummyMemberId = 1L;\n        String dummyRefreshToken = \"dummy token\";\n\n        // when\n        boolean actual = tokenRepository.exist(dummyMemberId);\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"MemberId에 해당하는 토큰을 가져온다.\")\n    @Test\n    void MemberId에_해당하는_토큰을_가져온다() {\n        // given\n        Long dummyMemberId = 1L;\n        String dummyRefreshToken = \"dummy token\";\n        tokenRepository.save(dummyMemberId, dummyRefreshToken);\n\n        // when\n        String actual = tokenRepository.getToken(dummyMemberId);\n\n        // then\n        assertThat(actual).isEqualTo(dummyRefreshToken);\n    }\n\n    @DisplayName(\"MemberId에 해당하는 토큰이 없으면 예외를 발생한다.\")\n    @Test\n    void MemberId에_해당하는_토큰이_없으면_예외를_발생한다() {\n        // given\n        Long dummyMemberId = 1L;\n\n        // when & then\n        assertThatThrownBy(() -> tokenRepository.getToken(dummyMemberId))\n                .isInstanceOf(NoSuchTokenException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/domain/OAuthTokenRepositoryTest.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static com.allog.dallog.common.fixtures.OAuthTokenFixtures.REFRESH_TOKEN;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport java.util.Optional;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass OAuthTokenRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private OAuthTokenRepository oAuthTokenRepository;\n\n    @DisplayName(\"member id의 OAuthToken이 존재할 경우 true를 반환한다.\")\n    @Test\n    void member_id의_OAuthToken이_존재할_경우_true를_반환한다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n        oAuthTokenRepository.save(new OAuthToken(매트, REFRESH_TOKEN));\n\n        // when\n        boolean actual = oAuthTokenRepository.existsByMemberId(매트.getId());\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"member id의 OAuthToken이 존재하지 않을 경우 false를 반환한다.\")\n    @Test\n    void member_id의_OAuthToken이_존재하지_않을_경우_false를_반환한다() {\n        // given & when\n        boolean actual = oAuthTokenRepository.existsByMemberId(0L);\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"member id의 OAuthToken이 존재할 경우 Optional은 비어있지 않다.\")\n    @Test\n    void member_id의_OAuthToken이_존재할_경우_Optional은_비어있지_않다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n        oAuthTokenRepository.save(new OAuthToken(매트, REFRESH_TOKEN));\n\n        // when\n        Optional<OAuthToken> actual = oAuthTokenRepository.findByMemberId(매트.getId());\n\n        // then\n        assertThat(actual).isNotEmpty();\n    }\n\n    @DisplayName(\"member id의 OAuthToken이 존재하지 않을 경우 비어있다.\")\n    @Test\n    void member_id의_OAuthToken이_존재하지_않을_경우_비어있다() {\n        // given & when\n        Optional<OAuthToken> actual = oAuthTokenRepository.findByMemberId(0L);\n\n        // then\n        assertThat(actual).isEmpty();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/domain/OAuthTokenTest.java",
    "content": "package com.allog.dallog.auth.domain;\n\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport com.allog.dallog.member.domain.Member;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass OAuthTokenTest {\n\n    @DisplayName(\"OAuth token을 생성한다.\")\n    @Test\n    void OAuth_token을_생성한다() {\n        // given\n        Member 매트 = 매트();\n        String refreshToken = \"adasaegsfadasdasfgfgrgredksgdffa\";\n\n        // when & then\n        assertDoesNotThrow(() -> new OAuthToken(매트, refreshToken));\n    }\n\n    @DisplayName(\"refresh token을 교체한다.\")\n    @Test\n    void refresh_token을_교체한다() {\n        // given\n        Member 매트 = 매트();\n        String refreshToken = \"adasaegsfadasdasfgfgrgredksgdffa\";\n        OAuthToken oAuthToken = new OAuthToken(매트, refreshToken);\n\n        String updatedRefreshToken = \"dfgsbnskjglnafgkajfnakfjgngejlkrqgn\";\n\n        // when\n        oAuthToken.change(updatedRefreshToken);\n\n        // then\n        assertThat(oAuthToken.getRefreshToken()).isEqualTo(updatedRefreshToken);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/auth/presentation/AuthControllerTest.java",
    "content": "package com.allog.dallog.auth.presentation;\n\nimport static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_리뉴얼_토큰_요청;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_리뉴얼_토큰_응답;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_인증_코드_토큰_요청;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_인증_코드_토큰_응답;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.OAUTH_PROVIDER;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.OAuth_로그인_링크;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.BDDMockito.given;\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;\nimport static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;\nimport static org.springframework.restdocs.request.RequestDocumentation.pathParameters;\nimport static org.springframework.restdocs.request.RequestDocumentation.requestParameters;\nimport static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport com.allog.dallog.auth.exception.InvalidTokenException;\nimport com.allog.dallog.common.annotation.ControllerTest;\nimport com.allog.dallog.infrastructure.oauth.exception.OAuthException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\nimport org.springframework.restdocs.payload.JsonFieldType;\n\nclass AuthControllerTest extends ControllerTest {\n\n    @DisplayName(\"OAuth 소셜 로그인을 위한 링크와 상태코드 200을 반환한다.\")\n    @Test\n    void OAuth_소셜_로그인을_위한_링크와_상태코드_200을_반환한다() throws Exception {\n        // given\n        given(oAuthUri.generate(any())).willReturn(OAuth_로그인_링크);\n\n        // when & then\n        mockMvc.perform(get(\"/api/auth/{oauthProvider}/oauth-uri?redirectUri={redirectUri}\", GOOGLE_PROVIDER,\n                        \"https://dallog.me/oauth\"))\n                .andDo(print())\n                .andDo(document(\"auth/generateLink\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"oauthProvider\").description(\"OAuth 로그인 제공자 (GOOGLE)\")\n                        ),\n                        requestParameters(\n                                parameterWithName(\"redirectUri\").description(\"OAuth Redirect URI\")\n                        ),\n                        responseFields(\n                                fieldWithPath(\"oAuthUri\").type(JsonFieldType.STRING).description(\"OAuth 소셜 로그인 링크\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"OAuth 로그인을 하면 token과 상태코드 200을 반환한다.\")\n    @Test\n    void OAuth_로그인을_하면_token과_상태코드_200을_반환한다() throws Exception {\n        // given\n        given(authService.generateAccessAndRefreshToken(any())).willReturn(MEMBER_인증_코드_토큰_응답());\n\n        // when & then\n        mockMvc.perform(post(\"/api/auth/{oauthProvider}/token\", OAUTH_PROVIDER)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(MEMBER_인증_코드_토큰_요청())))\n                .andDo(print())\n                .andDo(document(\"auth/generateTokens\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"oauthProvider\").description(\"OAuth 로그인 제공자\")\n                        ),\n                        requestFields(\n                                fieldWithPath(\"code\").type(JsonFieldType.STRING).description(\"OAuth 로그인 인증 코드\"),\n                                fieldWithPath(\"redirectUri\").type(JsonFieldType.STRING)\n                                        .description(\"OAuth Redirect URI\")\n                        ),\n                        responseFields(\n                                fieldWithPath(\"accessToken\").type(JsonFieldType.STRING).description(\"달록 Access Token\"),\n                                fieldWithPath(\"refreshToken\").type(JsonFieldType.STRING).description(\"달록 Refresh Token\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"OAuth 로그인 과정에서 Resource Server 에러가 발생하면 상태코드 500을 반환한다.\")\n    @Test\n    void OAuth_로그인_과정에서_Resource_Server_에러가_발생하면_상태코드_500을_반환한다() throws Exception {\n        // given\n        given(authService.generateAccessAndRefreshToken(any())).willThrow(new OAuthException());\n\n        // when & then\n        mockMvc.perform(post(\"/api/auth/{oauthProvider}/token\", OAUTH_PROVIDER)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(MEMBER_인증_코드_토큰_요청())))\n                .andDo(print())\n                .andDo(document(\"auth/generateTokens/failByResourceServerError\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"oauthProvider\").description(\"OAuth 로그인 제공자\")\n                        ),\n                        requestFields(\n                                fieldWithPath(\"code\").type(JsonFieldType.STRING).description(\"OAuth 로그인 인증 코드\"),\n                                fieldWithPath(\"redirectUri\").type(JsonFieldType.STRING)\n                                        .description(\"OAuth Redirect URI\")\n                        )\n                ))\n                .andExpect(status().isInternalServerError());\n    }\n\n    @DisplayName(\"리프레시 토큰을 통해 새로운 엑세스 토큰을 발급하면 상태코드 200을 반환한다.\")\n    @Test\n    void 리프레시_토큰을_통해_새로운_엑세스_토큰을_발급하면_상태코드_200을_반환한다() throws Exception {\n        // given\n        given(authService.generateAccessToken(any())).willReturn(MEMBER_리뉴얼_토큰_응답());\n\n        // when & then\n        mockMvc.perform(post(\"/api/auth/token/access\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(MEMBER_리뉴얼_토큰_요청())))\n                .andDo(print())\n                .andDo(document(\"auth/generateRenewalToken\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestFields(\n                                fieldWithPath(\"refreshToken\").type(JsonFieldType.STRING)\n                                        .description(\"OAuth 리프레시 토큰 인증 코드\")\n                        ),\n                        responseFields(\n                                fieldWithPath(\"accessToken\").type(JsonFieldType.STRING)\n                                        .description(\"달록 Renewal Access Token\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"잘못된 리프레시 토큰으로 새로운 엑세스 토큰을 발급하려 하면 상태코드 401을 반환한다.\")\n    @Test\n    void 존재하지_않는_리프레시_토큰으로_새로운_엑세스_토큰을_발급하려_하면_상태코드_401을_반환한다() throws Exception {\n        // given\n        given(authService.generateAccessToken(any())).willThrow(new InvalidTokenException());\n\n        // when & then\n        mockMvc.perform(post(\"/api/auth/token/access\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(MEMBER_리뉴얼_토큰_요청())))\n                .andDo(print())\n                .andDo(document(\"auth/generateRenewalToken/invalidTokenError\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestFields(\n                                fieldWithPath(\"refreshToken\").type(JsonFieldType.STRING)\n                                        .description(\"OAuth 리프레시 토큰 인증 코드\")\n                        )\n                ))\n                .andExpect(status().isUnauthorized());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/application/CategoryServiceTest.java",
    "content": "package com.allog.dallog.category.application;\n\nimport static com.allog.dallog.category.domain.CategoryType.GOOGLE;\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.category.domain.CategoryType.PERSONAL;\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN;\nimport static com.allog.dallog.common.Constants.개인_카테고리_이름;\nimport static com.allog.dallog.common.Constants.스터디_카테고리_이름;\nimport static com.allog.dallog.common.Constants.외부_카테고리_ID;\nimport static com.allog.dallog.common.Constants.외부_카테고리_이름;\nimport static com.allog.dallog.common.Constants.취업_일정_메모;\nimport static com.allog.dallog.common.Constants.취업_일정_시작일;\nimport static com.allog.dallog.common.Constants.취업_일정_제목;\nimport static com.allog.dallog.common.Constants.취업_일정_종료일;\nimport static com.allog.dallog.common.Constants.취업_카테고리_이름;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.auth.event.MemberSavedEvent;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.domain.CategoryType;\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.request.CategoryUpdateRequest;\nimport com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;\nimport com.allog.dallog.category.dto.response.CategoriesResponse;\nimport com.allog.dallog.category.dto.response.CategoryDetailResponse;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.category.exception.ExistExternalCategoryException;\nimport com.allog.dallog.category.exception.InvalidCategoryException;\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException;\nimport com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.schedule.domain.ScheduleRepository;\nimport com.allog.dallog.schedule.exception.NoSuchScheduleException;\nimport com.allog.dallog.subscription.domain.Subscription;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport com.allog.dallog.subscription.exception.NoSuchSubscriptionException;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.transaction.annotation.Transactional;\n\nclass CategoryServiceTest extends ServiceTest {\n\n    private final CategoryCreateRequest 취업_카테고리_생성_요청 = new CategoryCreateRequest(취업_카테고리_이름, NORMAL);\n    private final CategoryCreateRequest 개인_카테고리_생성_요청 = new CategoryCreateRequest(개인_카테고리_이름, PERSONAL);\n    private final ExternalCategoryCreateRequest 외부_카테고리_생성_요청 = new ExternalCategoryCreateRequest(외부_카테고리_ID,\n            외부_카테고리_이름);\n\n    @Autowired\n    private CategoryService categoryService;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private CategoryRoleRepository categoryRoleRepository;\n\n    @Autowired\n    private ScheduleRepository scheduleRepository;\n\n    @Test\n    void 카테고리를_생성한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        CategoryResponse actual = categoryService.save(나인.회원().getId(), 취업_카테고리_생성_요청);\n\n        // then\n        assertThat(actual.getName()).isEqualTo(취업_카테고리_이름);\n    }\n\n    @Test\n    void 개인_카테고리를_생성한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        CategoryResponse 개인_카테고리_응답 = categoryService.save(나인.회원().getId(), 개인_카테고리_생성_요청);\n\n        // then\n        Category actual = categoryRepository.findById(개인_카테고리_응답.getId()).get();\n\n        assertAll(() -> {\n            assertThat(actual.getName()).isEqualTo(개인_카테고리_이름);\n            assertThat(actual.isPersonal()).isTrue();\n        });\n    }\n\n    @Test\n    void 카테고리를_생성할_때_자동으로_구독한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        categoryService.save(나인.회원().getId(), 취업_카테고리_생성_요청);\n\n        // then\n        List<Subscription> actual = subscriptionRepository.findByMemberId(나인.회원().getId());\n        assertThat(actual).hasSize(1);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리를_생성할_때_권한을_최고_관리자로_생성한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        CategoryResponse 응답 = categoryService.save(나인.회원().getId(), 취업_카테고리_생성_요청);\n\n        // then\n        CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 응답.getId());\n        assertThat(actual.getCategoryRoleType()).isEqualTo(ADMIN);\n    }\n\n    @ParameterizedTest\n    @ValueSource(strings = {\"\", \"일이삼사오육칠팔구십일이삼사오육칠팔구십일\", \"알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 카테고리\"})\n    void 카테고리를_생성할_때_이름이_공백이거나_길이가_20을_초과하면_예외가_발생한다(final String invalidName) {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        CategoryCreateRequest 카테고리_생성_요청 = new CategoryCreateRequest(invalidName, NORMAL);\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.save(나인.회원().getId(), 카테고리_생성_요청))\n                .isInstanceOf(InvalidCategoryException.class);\n    }\n\n    @Test\n    void 외부_카테고리를_생성한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        CategoryResponse 응답 = categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청);\n\n        // then\n        Category actual = categoryRepository.findById(응답.getId()).get();\n        assertAll(() -> {\n            assertThat(actual.getName()).isEqualTo(외부_카테고리_이름);\n            assertThat(actual.getCategoryType()).isEqualTo(GOOGLE);\n        });\n    }\n\n    @Test\n    void 중복되는_외부_카테고리를_생성하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청);\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청))\n                .isInstanceOf(ExistExternalCategoryException.class);\n    }\n\n    @Test\n    void 외부_카테고리를_생성할_때_자동으로_구독한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청);\n\n        // then\n        List<Subscription> actual = subscriptionRepository.findByMemberId(나인.회원().getId());\n        assertThat(actual).hasSize(1);\n    }\n\n    @Test\n    void 저장된_회원의_개인_카테고리를_생성하고_자동으로_구독하고_카테고리_역할을_부여한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        MemberSavedEvent event = new MemberSavedEvent(나인.회원().getId());\n\n        // when\n        categoryService.savePersonalCategory(event);\n\n        // then\n        List<Category> categories = categoryRepository.findByMemberId(나인.회원().getId());\n        List<Subscription> subscriptions = subscriptionRepository.findByMemberId(나인.회원().getId());\n        List<CategoryRole> categoryRoles = categoryRoleRepository.findByMemberId(나인.회원().getId());\n\n        assertAll(() -> {\n            assertThat(categories).hasSize(1)\n                    .extracting(\"categoryType\")\n                    .containsExactly(CategoryType.PERSONAL);\n            assertThat(subscriptions).hasSize(1)\n                    .extracting(\"checked\")\n                    .containsExactly(true);\n            assertThat(categoryRoles).hasSize(1)\n                    .extracting(\"categoryRoleType\")\n                    .containsExactly(ADMIN);\n        });\n    }\n\n    @Test\n    void 제목에_검색어가_포함된_카테고리를_가져온다() {\n        // given\n        나인().카테고리를_생성한다(외부_카테고리_이름, GOOGLE)\n                .카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .카테고리를_생성한다(스터디_카테고리_이름, NORMAL);\n\n        // when\n        CategoriesResponse actual = categoryService.findNormalByName(\"취업\");\n\n        // then\n        assertThat(actual.getCategories()).hasSize(1);\n    }\n\n    @Test\n    void 제목에_검색어가_포함된_카테고리를_가져올때_개인_카테고리는_제외한다() {\n        // given\n        나인().카테고리를_생성한다(개인_카테고리_이름, PERSONAL)\n                .카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .카테고리를_생성한다(스터디_카테고리_이름, NORMAL);\n\n        // when\n        CategoriesResponse actual = categoryService.findNormalByName(\"\");\n\n        // then\n        assertThat(actual.getCategories()).hasSize(2);\n    }\n\n    @Transactional\n    @Test\n    void 관리권한이_최고_관리자인_카테고리_목록을_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(스터디_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_생성한다(스터디_카테고리_이름, NORMAL)\n                .카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n\n        // when\n        CategoriesResponse actual = categoryService.findScheduleEditableCategories(티거.회원().getId());\n\n        // then\n        assertThat(actual.getCategories().size()).isEqualTo(3);\n    }\n\n    @Test\n    void id로_카테고리_단건_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when\n        CategoryDetailResponse actual = categoryService.findDetailCategoryById(나인.카테고리().getId());\n\n        // then\n        assertAll(() -> {\n            assertThat(actual.getId()).isEqualTo(나인.카테고리().getId());\n            assertThat(actual.getName()).isEqualTo(나인.카테고리().getName());\n        });\n    }\n\n    @Test\n    void id로_카테고리_단건_조회할_때_없으면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.findDetailCategoryById(나인.카테고리().getId() + 1))\n                .isInstanceOf(NoSuchCategoryException.class);\n    }\n\n    @Test\n    void 권한이_최고_관리자인_카테고리를_수정한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(\"새로운 취업 카테고리 이름\");\n\n        // when\n        categoryService.update(나인.회원().getId(), 나인.카테고리().getId(), 카테고리_수정_요청);\n\n        //then\n        Category actual = categoryRepository.getById(나인.카테고리().getId());\n        assertThat(actual.getName()).isEqualTo(\"새로운 취업 카테고리 이름\");\n    }\n\n    @Test\n    void 권한이_최고_관리자가_아닌_카테고리를_수정하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(\"새로운 취업 카테고리 이름\");\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.update(티거.회원().getId(), 나인.카테고리().getId(), 카테고리_수정_요청))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 카테고리를_수정할_때_카테고리가_없으면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(\"새로운 취업 카테고리 이름\");\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.update(나인.회원().getId(), -1L, 카테고리_수정_요청))\n                .isInstanceOf(NoSuchCategoryException.class);\n    }\n\n    @Test\n    void 권한이_최고_관리자인_카테고리를_삭제한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when\n        categoryService.delete(나인.회원().getId(), 나인.카테고리().getId());\n\n        //then\n        assertThatThrownBy(() -> categoryRepository.getById(나인.카테고리().getId()))\n                .isInstanceOf(NoSuchCategoryException.class);\n    }\n\n    @Test\n    void 권한이_최고_관리자가_아닌_카테고리를_삭제하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.delete(티거.회원().getId(), 나인.카테고리().getId()))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 카테고리를_생성할_때_최고_관리자인_카테고리가_50개면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n        for (int i = 0; i < 50; i++) {\n            나인.카테고리를_생성한다(\"카테고리 \" + i, NORMAL);\n        }\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.save(나인.회원().getId(), BE_일정_생성_요청))\n                .isInstanceOf(ManagingCategoryLimitExcessException.class);\n    }\n\n    @Test\n    void 없는_카테고리를_삭제하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.delete(나인.회원().getId(), -1L))\n                .isInstanceOf(NoSuchCategoryException.class);\n    }\n\n    @Test\n    void 카테고리를_삭제할_때_생성한_일정도_모두_삭제한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        // when\n        categoryService.delete(나인.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        assertAll(() -> {\n            assertThatThrownBy(() -> scheduleRepository.getById(나인.카테고리_일정().getId()))\n                    .isInstanceOf(NoSuchScheduleException.class);\n        });\n    }\n\n    @Test\n    void 카테고리를_삭제할_때_구독_정보도_모두_삭제한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when\n        categoryService.delete(나인.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        assertThatThrownBy(() -> subscriptionRepository.getById(티거.구독().getId()))\n                .isInstanceOf(NoSuchSubscriptionException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리를_삭제할_때_카테고리_권한도_모두_삭제한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        CategoryRole 권한 = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 나인.카테고리().getId());\n\n        // when\n        categoryService.delete(나인.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        boolean actual = categoryRoleRepository.findById(권한.getId()).isPresent();\n        assertThat(actual).isFalse();\n    }\n\n    @Test\n    void 개인_카테고리를_삭제하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(개인_카테고리_이름, PERSONAL);\n\n        // when & then\n        assertThatThrownBy(() -> categoryService.delete(나인.회원().getId(), 나인.카테고리().getId()))\n                .isInstanceOf(InvalidCategoryException.class);\n    }\n\n    @Test\n    void 외부_서비스_연동_카테고리를_삭제한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(외부_카테고리_이름, GOOGLE);\n\n        // when\n        categoryService.delete(나인.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        assertThatThrownBy(() -> categoryRepository.getById(나인.카테고리().getId()))\n                .isInstanceOf(NoSuchCategoryException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/application/ExternalCategoryDetailServiceTest.java",
    "content": "package com.allog.dallog.category.application;\n\nimport static com.allog.dallog.category.domain.CategoryType.GOOGLE;\nimport static com.allog.dallog.common.Constants.외부_카테고리_이름;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.category.domain.ExternalCategoryDetailRepository;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass ExternalCategoryDetailServiceTest extends ServiceTest {\n\n    @Autowired\n    private ExternalCategoryDetailService externalCategoryDetailService;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private ExternalCategoryDetailRepository externalCategoryDetailRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @Test\n    void 월별_일정을_조회하면_회원의_외부_카테고리_전체를_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().외부_카테고리를_등록한다(외부_카테고리_이름, GOOGLE);\n\n        // when\n        List<ExternalCategoryDetail> actual = externalCategoryDetailService.findByMemberId(나인.회원().getId());\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/domain/CategoryRepositoryTest.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_이름;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_이름;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_이름;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.리버;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport java.util.List;\nimport java.util.Objects;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass CategoryRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @DisplayName(\"카테고리 제목과 타입을 통해 해당하는 카테고리를 조회한다.\")\n    @Test\n    void 카테고리_제목과_타입을_통해_해당하는_카테고리를_조회한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 공통_일정));\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, BE_일정));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, FE_일정));\n        Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 매트_아고라));\n        Category 후디_JPA_스터디 = categoryRepository.save(후디_JPA_스터디(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 후디_JPA_스터디));\n        Category 내_일정 = categoryRepository.save(내_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 내_일정));\n        Category 우아한테크코스_일정 = categoryRepository.save(우아한테크코스_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 우아한테크코스_일정));\n\n        // when\n        List<Category> actual = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, \"일\");\n\n        // then\n        assertThat(actual).hasSize(3)\n                .extracting(Category::getName)\n                .contains(공통_일정_이름, BE_일정_이름, FE_일정_이름);\n    }\n\n    @DisplayName(\"카테고리 이름 검색 결과가 존재하지 않는 경우 아무것도 조회 하지 않는다.\")\n    @Test\n    void 카테고리_이름_검색_결과가_존재하지_않는_경우_아무것도_조회_하지_않는다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 공통_일정));\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, BE_일정));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, FE_일정));\n        Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 매트_아고라));\n\n        // when\n        List<Category> actual = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, \"파랑\");\n\n        // then\n        assertThat(actual).hasSize(0);\n    }\n\n    @DisplayName(\"구독자수가 많은 순서로 정렬하여 반환한다.\")\n    @Test\n    void 구독자수가_많은_순서로_정렬하여_반환한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, 공통_일정));\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, BE_일정));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n        subscriptionRepository.save(색상1_구독(관리자, FE_일정));\n\n        Member 매트 = memberRepository.save(매트());\n        Member 리버 = memberRepository.save(리버());\n        Member 후디 = memberRepository.save(후디());\n        Member 파랑 = memberRepository.save(파랑());\n\n        subscriptionRepository.save(색상1_구독(매트, 공통_일정));\n        subscriptionRepository.save(색상1_구독(리버, 공통_일정));\n        subscriptionRepository.save(색상1_구독(후디, 공통_일정));\n        subscriptionRepository.save(색상1_구독(파랑, 공통_일정));\n\n        subscriptionRepository.save(색상1_구독(매트, BE_일정));\n        subscriptionRepository.save(색상1_구독(리버, BE_일정));\n        subscriptionRepository.save(색상1_구독(후디, BE_일정));\n\n        // when\n        List<Category> actual = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, \"\");\n\n        // then\n        assertThat(actual).hasSize(3)\n                .containsExactlyInAnyOrder(공통_일정, BE_일정, FE_일정);\n    }\n\n    @DisplayName(\"member id와 categoryType을 기반으로 조회한다.\")\n    @Test\n    void member_id와_categoryType을_기반으로_조회한다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n        categoryRepository.save(공통_일정(매트));\n        categoryRepository.save(BE_일정(매트));\n        categoryRepository.save(FE_일정(매트));\n        categoryRepository.save(매트_아고라(매트));\n        categoryRepository.save(후디_JPA_스터디(매트));\n\n        // when\n        List<Category> actual = categoryRepository.findByMemberIdAndCategoryType(매트.getId(), NORMAL);\n\n        // then\n        assertThat(actual).hasSize(5);\n    }\n\n    @DisplayName(\"특정 회원이 생성한 카테고리를 조회한다.\")\n    @Test\n    void 특정_회원이_생성한_카테고리를_조회한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        categoryRepository.save(공통_일정(관리자));\n        categoryRepository.save(BE_일정(관리자));\n        categoryRepository.save(FE_일정(관리자));\n\n        Member 후디 = memberRepository.save(후디());\n        categoryRepository.save(후디_JPA_스터디(후디));\n\n        // when\n        List<Category> categories = categoryRepository.findByMemberId(관리자.getId());\n\n        // then\n        assertAll(() -> {\n            assertThat(categories).hasSize(3)\n                    .extracting(Category::getName)\n                    .containsExactlyInAnyOrder(공통_일정_이름, BE_일정_이름, FE_일정_이름);\n            assertThat(\n                    categories.stream()\n                            .map(Category::getCreatedAt)\n                            .allMatch(Objects::nonNull))\n                    .isTrue();\n        });\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/domain/CategoryTest.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport com.allog.dallog.category.exception.InvalidCategoryException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass CategoryTest {\n\n    @DisplayName(\"카테고리를 생성한다.\")\n    @Test\n    void 카테고리를_생성한다() {\n        // given\n        String name = \"BE 공식일정\";\n\n        // when & then\n        assertDoesNotThrow(() -> new Category(name, 후디()));\n    }\n\n    @DisplayName(\"카테고리 이름이 공백인 경우 예외를 던진다.\")\n    @Test\n    void 카테고리_이름이_공백인_경우_예외를_던진다() {\n        // given\n        String name = \"\";\n\n        // when & then\n        assertThatThrownBy(() -> new Category(name, 후디()))\n                .isInstanceOf(InvalidCategoryException.class);\n    }\n\n    @DisplayName(\"카테고리 이름의 길이가 20을 초과하는 경우 예외를 던진다.\")\n    @ParameterizedTest\n    @ValueSource(strings = {\"일이삼사오육칠팔구십일이삼사오육칠팔구십일\",\n            \"알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 카테고리\"})\n    void 카테고리_이름의_길이가_20을_초과하는_경우_예외를_던진다(final String name) {\n        // given & when & then\n        assertThatThrownBy(() -> new Category(name, 후디()))\n                .isInstanceOf(InvalidCategoryException.class);\n    }\n\n    @DisplayName(\"개인 카테고리의 이름을 수정하는 경우 예외를 던진다.\")\n    @Test\n    void 개인_카테고리의_이름을_수정하는_경우_예외를_던진다() {\n        // given\n        Category 내_일정 = 내_일정(관리자());\n\n        // when & then\n        assertThatThrownBy(() -> 내_일정.changeName(\"바꿀 이름\"))\n                .isInstanceOf(InvalidCategoryException.class);\n    }\n\n    @DisplayName(\"제공된 회원의 ID와 카테고리를 생성한 회원의 ID가 일치하지 않으면 false를 반환한다.\")\n    @Test\n    void 제공된_회원의_ID와_카테고리를_생성한_회원의_ID가_일치하지_않으면_false를_반환한다() {\n        // given\n        Category BE_일정 = BE_일정(관리자());\n\n        // when\n        boolean actual = BE_일정.isCreatorId(999L);\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"개인 카테고리면 true를 반환한다.\")\n    @Test\n    void 개인_카테고리면_true를_반환한다() {\n        // given\n        Category 내_일정 = 내_일정(관리자());\n\n        // when\n        boolean actual = 내_일정.isPersonal();\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"외부 연동 카테고리면 true를 반환한다.\")\n    @Test\n    void 외부_연동_카테고리면_true를_반환한다() {\n        // given\n        Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자());\n\n        // when\n        boolean actual = 우아한테크코스_일정.isExternal();\n\n        // then\n        assertThat(actual).isTrue();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/domain/CategoryTypeTest.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\n\npublic class CategoryTypeTest {\n\n    @DisplayName(\"카테고리 종류를 가져온다.\")\n    @ParameterizedTest\n    @EnumSource\n    void 카테고리_종류를_가져온다(final CategoryType categoryType) {\n        // given & when & then\n        assertAll(() -> {\n            assertThat(CategoryType.from(categoryType.name())).isEqualTo(categoryType);\n            assertThat(CategoryType.from(categoryType.name().toLowerCase())).isEqualTo(categoryType);\n        });\n    }\n\n    @DisplayName(\"존재하지 않는 카테고리 종류인 경우 예외를 던진다.\")\n    @Test\n    void 존재하지_않는_카테고리_종류인_경우_예외를_던진다() {\n        // given\n        String notExistingCategoryType = \"존재하지 않는 카테고리 종류\";\n\n        // when & then\n        assertThatThrownBy(() -> CategoryType.from(notExistingCategoryType))\n                .isInstanceOf(NoSuchCategoryException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/domain/ExternalCategoryDetailRepositoryTest.java",
    "content": "package com.allog.dallog.category.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport com.allog.dallog.category.exception.ExistExternalCategoryException;\nimport com.allog.dallog.category.exception.NoSuchExternalCategoryDetailException;\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\npublic class ExternalCategoryDetailRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private ExternalCategoryDetailRepository externalCategoryDetailRepository;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @DisplayName(\"존재하지 않는 외부 카테고리 세부정보를 가져오는 경우 예외를 던진다.\")\n    @Test\n    void 존재하지_않는_외부_카테고리_세부정보를_가져오는_경우_예외를_던진다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자);\n        categoryRepository.save(우아한테크코스_일정);\n\n        externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스_일정, \"externalId\"));\n\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n\n        // when & then\n        assertThatThrownBy(() -> externalCategoryDetailRepository.getByCategory(공통_일정))\n                .isInstanceOf(NoSuchExternalCategoryDetailException.class);\n    }\n\n    @DisplayName(\"새로운 외부 카테고리 세부정보인 경우 예외를 던지지 않는다.\")\n    @Test\n    void 새로운_외부_카테고리_세부정보인_경우_예외를_던지지_않는다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자);\n        categoryRepository.save(우아한테크코스_일정);\n\n        String externalId = \"externalId\";\n        externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스_일정, externalId));\n\n        // when & then\n        assertDoesNotThrow(() -> externalCategoryDetailRepository\n                .validateExistByExternalIdAndCategoryIn(externalId, List.of()));\n    }\n\n    @DisplayName(\"이미 존재하는 외부 카테고리 세부정보인 경우 예외를 던진다.\")\n    @Test\n    void 이미_존재하는_외부_카테고리_세부정보인_경우_예외를_던진다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자);\n        categoryRepository.save(우아한테크코스_일정);\n\n        String externalId = \"externalId\";\n        externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스_일정, externalId));\n\n        // when & then\n        assertThatThrownBy(() -> externalCategoryDetailRepository\n                .validateExistByExternalIdAndCategoryIn(externalId, List.of(우아한테크코스_일정)))\n                .isInstanceOf(ExistExternalCategoryException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/category/presentation/CategoryControllerTest.java",
    "content": "package com.allog.dallog.category.presentation;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.NONE;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_세부_응답;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_응답;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_이름;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.리버;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디_응답;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.BDDMockito.given;\nimport static org.mockito.BDDMockito.willDoNothing;\nimport static org.mockito.BDDMockito.willThrow;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;\nimport static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;\nimport static org.springframework.restdocs.request.RequestDocumentation.pathParameters;\nimport static org.springframework.restdocs.request.RequestDocumentation.requestParameters;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.request.CategoryUpdateRequest;\nimport com.allog.dallog.category.dto.response.CategoriesResponse;\nimport com.allog.dallog.category.dto.response.CategoryDetailResponse;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.category.exception.InvalidCategoryException;\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.categoryrole.domain.CategoryAuthority;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleType;\nimport com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;\nimport com.allog.dallog.categoryrole.dto.response.SubscribersResponse;\nimport com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;\nimport com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException;\nimport com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException;\nimport com.allog.dallog.common.annotation.ControllerTest;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\nimport org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;\n\nclass CategoryControllerTest extends ControllerTest {\n\n    private static final String AUTHORIZATION_HEADER_NAME = \"Authorization\";\n    private static final String AUTHORIZATION_HEADER_VALUE = \"Bearer aaaaaaaa.bbbbbbbb.cccccccc\";\n    private static final String INVALID_CATEGORY_NAME = \"20글자를 초과하는 유효하지 않은 카테고리 이름\";\n    private static final String CATEGORY_NAME_OVER_LENGTH_EXCEPTION_MESSAGE = \"카테고리 이름의 길이는 20을 초과할 수 없습니다.\";\n\n    @DisplayName(\"카테고리를 생성한다.\")\n    @Test\n    void 카테고리를_생성한다() throws Exception {\n        // given\n        CategoryResponse 카테고리 = BE_일정_응답(후디_응답);\n        given(categoryService.save(any(), any(CategoryCreateRequest.class))).willReturn(카테고리);\n\n        // when & then\n        mockMvc.perform(post(\"/api/categories\")\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(BE_일정_생성_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"category/save\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                requestHeaders(\n                                        headerWithName(\"Authorization\").description(\"JWT 토큰\")),\n                                requestFields(\n                                        fieldWithPath(\"name\").description(\"카테고리 이름 (최대 20글자)\"),\n                                        fieldWithPath(\"categoryType\").description(\"카테고리 타입 (NORMAL | PERSONAL | GOOGLE)\")\n                                ),\n                                responseFields(\n                                        fieldWithPath(\"id\").description(\"카테고리 ID\"),\n                                        fieldWithPath(\"name\").description(\"카테고리 이름\"),\n                                        fieldWithPath(\"categoryType\").description(\"카테고리 타입 (NORMAL | PERSONAL | GOOGLE)\"),\n                                        fieldWithPath(\"creator.id\").description(\"카테고리 생성자 ID\"),\n                                        fieldWithPath(\"creator.email\").description(\"카테고리 생성자 이메일\"),\n                                        fieldWithPath(\"creator.displayName\").description(\"카테고리 생성자 이름\"),\n                                        fieldWithPath(\"creator.profileImageUrl\").description(\"카테고리 생성자 프로필 이미지 URL\"),\n                                        fieldWithPath(\"creator.socialType\").description(\"카테고리 생성자의 소셜 타입\"),\n                                        fieldWithPath(\"createdAt\").description(\"카테고리 생성일자\")\n                                )\n                        )\n                )\n                .andExpect(status().isCreated());\n    }\n\n    @DisplayName(\"잘못된 이름 형식으로 카테고리를 생성하면 400 Bad Request가 발생한다.\")\n    @Test\n    void 잘못된_이름_형식으로_카테고리를_생성하면_400_Bad_Request가_발생한다() throws Exception {\n        // given\n        CategoryCreateRequest 잘못된_카테고리_생성_요청 = new CategoryCreateRequest(INVALID_CATEGORY_NAME, NORMAL);\n\n        willThrow(new InvalidCategoryException(CATEGORY_NAME_OVER_LENGTH_EXCEPTION_MESSAGE))\n                .given(categoryService)\n                .save(any(), any(CategoryCreateRequest.class));\n\n        // when & then\n        mockMvc.perform(post(\"/api/categories\")\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(잘못된_카테고리_생성_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"category/save/failByInvalidNameFormat\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                requestHeaders(\n                                        headerWithName(\"Authorization\").description(\"JWT 토큰\"))\n                        )\n                )\n                .andExpect(status().isBadRequest());\n    }\n\n    @DisplayName(\"생성된 카테고리를 전부 조회한다.\")\n    @Test\n    void 생성된_카테고리를_전부_조회한다() throws Exception {\n        // given\n        List<Category> 일정_목록 = List.of(공통_일정(관리자()), BE_일정(관리자()), FE_일정(관리자()), 후디_JPA_스터디(후디()), 매트_아고라(매트()));\n        CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록);\n        given(categoryService.findNormalByName(any())).willReturn(categoriesResponse);\n\n        // when & then\n        mockMvc.perform(get(\"/api/categories\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findAllByName/allByNoName\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint())\n                        )\n                )\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"카테고리 제목을 활용하여 조회한다.\")\n    @Test\n    void 카테고리_제목을_활용하여_조회한다() throws Exception {\n        // given\n        List<Category> 일정_목록 = List.of(BE_일정(관리자()), FE_일정(관리자()));\n        CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록);\n        given(categoryService.findNormalByName(any())).willReturn(categoriesResponse);\n\n        // when & then\n        mockMvc.perform(get(\"/api/categories?name={name}\", \"E\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findAllByName/filterByName\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                requestParameters(\n                                        parameterWithName(\"name\").description(\"카테고리 검색어\")\n                                )\n                        )\n                )\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"내가 일정을 편집할 수 있는 카테고리를 전부 조회한다.\")\n    @Test\n    void 내가_일정을_편집할_수_있는_카테고리를_전부_조회한다() throws Exception {\n        // given\n        List<Category> 일정_목록 = List.of(공통_일정(관리자()), BE_일정(관리자()), FE_일정(관리자()));\n        CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록);\n        given(categoryService.findScheduleEditableCategories(any())).willReturn(categoriesResponse);\n\n        // when & then\n        mockMvc.perform(get(\"/api/categories/me/schedule-editable\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findScheduleEditableCategories\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint())\n                        )\n                )\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"내가 ADMIN으로 있는 카테고리를 전부 조회한다.\")\n    @Test\n    void 내가_ADMIN으로_있는_카테고리를_전부_조회한다() throws Exception {\n        // given\n        List<Category> 일정_목록 = List.of(공통_일정(관리자()), BE_일정(관리자()), FE_일정(관리자()));\n        CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록);\n        given(categoryService.findAdminCategories(any())).willReturn(categoriesResponse);\n\n        // when & then\n        mockMvc.perform(get(\"/api/categories/me/admin\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findAdminCategories\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint())\n                        )\n                )\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"카테고리 ID로 카테고리를 단건 조회한다.\")\n    @Test\n    void 카테고리_ID로_카테고리를_단건_조회한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        CategoryDetailResponse BE_일정_응답 = BE_일정_세부_응답(후디_응답, 150);\n        given(categoryService.findDetailCategoryById(any())).willReturn(BE_일정_응답);\n\n        // when & then\n        mockMvc.perform(RestDocumentationRequestBuilders.get(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findDetailCategoryById\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"카테고리 ID로 카테고리를 단건 조회시 존재하지 않으면 404 Not Found가 발생한다.\")\n    @Test\n    void 카테고리_ID로_카테고리를_단건_조회시_존재하지_않으면_404_Not_Found를_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        given(categoryService.findDetailCategoryById(any()))\n                .willThrow(new NoSuchCategoryException());\n\n        // when & then\n        mockMvc.perform(RestDocumentationRequestBuilders.get(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findDetailCategoryById/failByNoCategory\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"카테고리를 수정한다.\")\n    @Test\n    void 카테고리를_수정한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        willDoNothing()\n                .given(categoryService)\n                .update(any(), any(), any());\n        CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(BE_일정_이름);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .content(objectMapper.writeValueAsString(카테고리_수정_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"category/update\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"카테고리 수정 시 존재하지 않으면 404 Not Found가 발생한다.\")\n    @Test\n    void 카테고리_수정_시_존재하지_않으면_404_Not_Found를_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        willThrow(NoSuchCategoryException.class)\n                .willDoNothing()\n                .given(categoryService)\n                .update(any(), any(), any());\n        CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(BE_일정_이름);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .content(objectMapper.writeValueAsString(카테고리_수정_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"category/update/failByNoCategory\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"잘못된 이름 형식으로 카테고리를 수정하면 400 Bad Request가 발생한다.\")\n    @Test\n    void 잘못된_이름_형식으로_카테고리를_수정하면_400_Bad_Request가_발생한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        willThrow(new InvalidCategoryException(CATEGORY_NAME_OVER_LENGTH_EXCEPTION_MESSAGE))\n                .willDoNothing()\n                .given(categoryService)\n                .update(any(), any(), any());\n        CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(INVALID_CATEGORY_NAME);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .content(objectMapper.writeValueAsString(카테고리_수정_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"category/update/failByInvalidNameFormat\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isBadRequest());\n    }\n\n    @DisplayName(\"카테고리를 제거한다.\")\n    @Test\n    void 카테고리를_제거한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        willDoNothing()\n                .given(categoryService)\n                .delete(any(), any());\n\n        // when & then\n        mockMvc.perform(delete(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/delete\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"카테고리 제거 시 존재하지 않으면 404 Not Found가 발생한다\")\n    @Test\n    void 카테고리_제거_시_존재하지_않으면_404_Not_Found가_발생한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        willThrow(new NoSuchCategoryException(\"존재하지 않는 카테고리를 삭제할 수 없습니다.\"))\n                .willDoNothing()\n                .given(categoryService)\n                .delete(any(), any());\n\n        // when & then\n        mockMvc.perform(delete(\"/api/categories/{categoryId}\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/delete/failByNoCategory\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"ADMIN은 다른 구독자의 카테고리 역할을 변경할 수 있다.\")\n    @Test\n    void ADMIN은_다른_구독자의_카테고리_역할을_변경할_수_있다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long memberId = 2L;\n        willDoNothing()\n                .given(categoryRoleService)\n                .updateRole(any(), any(), any(), any());\n\n        CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}/subscribers/{memberId}/role\", categoryId, memberId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(역할_수정_요청))\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/updateRole\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\"),\n                                        parameterWithName(\"memberId\").description(\"회원 ID\")\n                                ),\n                                requestFields(\n                                        fieldWithPath(\"categoryRoleType\").description(\"역할 (ADMIN | NONE)\")\n                                )\n                        )\n                )\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"ADMIN이 아닌 회원은 다른 구독자의 카테고리 역할을 변경하면 403 Forbidden이 발생한다.\")\n    @Test\n    void ADMIN이_아닌_회원은_다른_구독자의_카테고리_역할을_변경하면_403_Forbidden이_발생한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long memberId = 2L;\n\n        willThrow(new NoCategoryAuthorityException(CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER.getName()))\n                .willDoNothing()\n                .given(categoryRoleService)\n                .updateRole(any(), any(), any(), any());\n\n        CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}/subscribers/{memberId}/role\", categoryId, memberId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(역할_수정_요청))\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/updateRole/failByNoPermission\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\"),\n                                        parameterWithName(\"memberId\").description(\"회원 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isForbidden());\n    }\n\n    @DisplayName(\"카테고리 역할이 변경될 회원이 해당 카테고리를 구독하지 않은 상황이라면 404 NotFound가 발생한다.\")\n    @Test\n    void 카테고리_역할이_변경될_회원이_해당_카테고리를_구독하지_않은_상황이라면_404_NotFound가_발생한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long memberId = 2L;\n\n        willThrow(new NoSuchCategoryRoleException())\n                .willDoNothing()\n                .given(categoryRoleService)\n                .updateRole(any(), any(), any(), any());\n\n        CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}/subscribers/{memberId}/role\", categoryId, memberId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsBytes(역할_수정_요청))\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/updateRole/failByCategoryRoleNotFound\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\"),\n                                        parameterWithName(\"memberId\").description(\"회원 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"자기 자신이 유일한 ADMIN이라면 자신의 역할을 변경할 수 없다.\")\n    @Test\n    void 자기_자신이_유일한_ADMIN이라면_자신의_역할을_변경할_수_없다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long memberId = 2L;\n\n        willThrow(new NotAbleToChangeRoleException(\"변경 대상 회원이 유일한 ADMIN이므로 다른 역할로 변경할 수 없습니다.\"))\n                .willDoNothing()\n                .given(categoryRoleService)\n                .updateRole(any(), any(), any(), any());\n\n        CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/categories/{categoryId}/subscribers/{memberId}/role\", categoryId, memberId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(역할_수정_요청))\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/updateRole/failBySoleAdmin\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\"),\n                                        parameterWithName(\"memberId\").description(\"회원 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isBadRequest());\n    }\n\n    @DisplayName(\"특정 카테고리의 구독자 목록을 조회한다.\")\n    @Test\n    void 특정_카테고리의_구독자_목록을_조회한다() throws Exception {\n        // given\n        long categoryId = 10;\n\n        Category 카테고리 = 공통_일정(관리자());\n        List<CategoryRole> categoryRoles = List.of(\n                new CategoryRole(카테고리, 매트(), NONE),\n                new CategoryRole(카테고리, 리버(), NONE),\n                new CategoryRole(카테고리, 파랑(), NONE),\n                new CategoryRole(카테고리, 후디(), NONE)\n        );\n\n        given(categoryRoleService.findSubscribers(any(), any()))\n                .willReturn(new SubscribersResponse(categoryRoles));\n\n        // when & then\n        mockMvc.perform(RestDocumentationRequestBuilders.get(\"/api/categories/{categoryId}/subscribers\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findSubscribers\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"특정 카테고리의 구독자 목록을 ADMIN이 아닌 회원이 조회하는 경우 403에러가 발생한다.\")\n    @Test\n    void 특정_카테고리의_구독자_목록을_ADMIN이_아닌_회원이_조회하는_경우_403에러가_발생한다() throws Exception {\n        // given\n        long categoryId = 10;\n\n        given(categoryRoleService.findSubscribers(any(), any()))\n                .willThrow(new NoCategoryAuthorityException(\"카테고리 구독자 조회 권한이 없습니다.\"));\n\n        // when & then\n        mockMvc.perform(RestDocumentationRequestBuilders.get(\"/api/categories/{categoryId}/subscribers\", categoryId)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                )\n                .andDo(print())\n                .andDo(document(\"category/findSubscribers/failByNoAuthority\",\n                                preprocessRequest(prettyPrint()),\n                                preprocessResponse(prettyPrint()),\n                                pathParameters(\n                                        parameterWithName(\"categoryId\").description(\"카테고리 ID\")\n                                )\n                        )\n                )\n                .andExpect(status().isForbidden());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/categoryrole/application/CategoryRoleServiceTest.java",
    "content": "package com.allog.dallog.categoryrole.application;\n\nimport static com.allog.dallog.category.domain.CategoryType.GOOGLE;\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.category.domain.CategoryType.PERSONAL;\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN;\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.NONE;\nimport static com.allog.dallog.common.Constants.개인_카테고리_이름;\nimport static com.allog.dallog.common.Constants.나인_이름;\nimport static com.allog.dallog.common.Constants.나인_이메일;\nimport static com.allog.dallog.common.Constants.나인_프로필_URL;\nimport static com.allog.dallog.common.Constants.외부_카테고리_이름;\nimport static com.allog.dallog.common.Constants.취업_카테고리_이름;\nimport static com.allog.dallog.common.Constants.티거_이름;\nimport static com.allog.dallog.common.Constants.티거_이메일;\nimport static com.allog.dallog.common.Constants.티거_프로필_URL;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;\nimport com.allog.dallog.categoryrole.dto.response.SubscribersResponse;\nimport com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException;\nimport com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;\nimport com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.transaction.annotation.Transactional;\n\nclass CategoryRoleServiceTest extends ServiceTest {\n\n    private final CategoryRoleUpdateRequest 카테고리_관리권한_부여_요청 = new CategoryRoleUpdateRequest(ADMIN);\n    private final CategoryRoleUpdateRequest 카테고리_관리권한_해제_요청 = new CategoryRoleUpdateRequest(NONE);\n\n    @Autowired\n    private CategoryRoleService categoryRoleService;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private CategoryRoleRepository categoryRoleRepository;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @Test\n    void 카테고리의_구독자_목록을_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        티거().카테고리를_구독한다(나인.카테고리());\n\n        // when\n        SubscribersResponse actual = categoryRoleService.findSubscribers(나인.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        assertThat(actual.getSubscribers().size()).isEqualTo(2);\n    }\n\n    @Test\n    void 관리자_권한이_아닌_회원이_카테고리_구독자_목록을_조회하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> categoryRoleService.findSubscribers(티거.회원().getId(), 나인.카테고리().getId()))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 관리자_역할로_변경하려는_회원이_이미_50개_이상의_카테고리에_권한이_있으면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n        for (int i = 0; i < 50; i++) {\n            티거.카테고리를_생성한다(\"카테고리 \" + i, NORMAL);\n        }\n\n        // when & then\n        assertThatThrownBy(\n                () -> categoryRoleService.updateRole(\n                        나인.회원().getId(), 티거.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청))\n                .isInstanceOf(ManagingCategoryLimitExcessException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_권한이_관리자인_회원이_다른_관리자의_권한을_변경한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n\n        // when\n        categoryRoleService.updateRole(티거.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_해제_요청);\n\n        // then\n        CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 나인.카테고리().getId());\n        assertThat(actual.getCategoryRoleType()).isEqualTo(NONE);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_권한이_관리자인_회원이_구독자의_권한을_변경한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when\n        categoryRoleService.updateRole(나인.회원().getId(), 티거.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청);\n\n        // then\n        CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(티거.회원().getId(), 나인.카테고리().getId());\n        assertThat(actual.getCategoryRoleType()).isEqualTo(ADMIN);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_권한이_관리자인_회원이_자신의_권한을_변경한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n\n        // when\n        categoryRoleService.updateRole(나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_해제_요청);\n\n        // then\n        CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 나인.카테고리().getId());\n        assertThat(actual.getCategoryRoleType()).isEqualTo(NONE);\n    }\n\n    @Test\n    void 카테고리_권한이_없는_회원이_다른_회원의_권한을_변경하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(\n                () -> categoryRoleService.updateRole(\n                        티거.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 유일한_카테고리_관리자인_회원이_자신의_권한을_변경하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when & then\n        assertThatThrownBy(() -> categoryRoleService.updateRole(\n                나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청))\n                .isInstanceOf(NotAbleToChangeRoleException.class);\n    }\n\n    @Test\n    void 개인_카테고리에_대한_회원의_권한을_변경하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(개인_카테고리_이름, PERSONAL);\n\n        // when & then\n        assertThatThrownBy(() -> categoryRoleService.updateRole(\n                나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청))\n                .isInstanceOf(NotAbleToChangeRoleException.class);\n    }\n\n    @Test\n    void 외부_카테고리에_대한_회원의_권한을_변경하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(외부_카테고리_이름, GOOGLE);\n\n        // when & then\n        assertThatThrownBy(() -> categoryRoleService.updateRole(\n                나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청))\n                .isInstanceOf(NotAbleToChangeRoleException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/categoryrole/domain/CategoryRoleRepositoryTest.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass CategoryRoleRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private CategoryRoleRepository categoryRoleRepository;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @DisplayName(\"member id와 category id를 기반으로 조회한다.\")\n    @Test\n    void member_id와_category_id를_기반으로_조회한다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n        Category BE_일정 = categoryRepository.save(BE_일정(매트));\n\n        CategoryRole savedCategoryRoleType = categoryRoleRepository.save(\n                new CategoryRole(BE_일정, 매트, CategoryRoleType.ADMIN));\n\n        // when\n        CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(매트.getId(), BE_일정.getId());\n\n        // then\n        assertThat(actual).isEqualTo(savedCategoryRoleType);\n    }\n\n    @DisplayName(\"category id를 기반으로 조회한다.\")\n    @Test\n    void category_id를_기반으로_조회한다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n        Category BE_일정 = categoryRepository.save(BE_일정(매트));\n        categoryRoleRepository.save(new CategoryRole(BE_일정, 매트, CategoryRoleType.ADMIN));\n\n        // when\n        List<CategoryRole> actual = categoryRoleRepository.findByMemberId(매트.getId());\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n\n    @DisplayName(\"특정 카테고리에 admin이 혼자인지 확인한다.\")\n    @Test\n    void 특정_카테고리에_admin이_혼자인지_확인한다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n        Category BE_일정 = categoryRepository.save(BE_일정(매트));\n        categoryRoleRepository.save(new CategoryRole(BE_일정, 매트, CategoryRoleType.ADMIN));\n\n        // when\n        boolean actual = categoryRoleRepository.isMemberSoleAdminInCategory(매트.getId(), BE_일정.getId());\n\n        // then\n        assertThat(actual).isTrue();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/categoryrole/domain/CategoryRoleTest.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.member.domain.Member;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nclass CategoryRoleTest {\n\n    @DisplayName(\"역할이 특정 권한을 가지고 있는지 확인한다.\")\n    @CsvSource(value = {\"ADMIN,UPDATE_CATEGORY,true\", \"NONE,UPDATE_CATEGORY,false\"})\n    @ParameterizedTest\n    void 역할이_특정_권한을_가지고_있는지_확인한다(final CategoryRoleType roleType, final CategoryAuthority authority,\n                                 final boolean expected) {\n        // given\n        Category BE_일정 = BE_일정(관리자());\n        Member 후디 = 후디();\n        CategoryRole categoryRole = new CategoryRole(BE_일정, 후디, roleType);\n\n        // when\n        boolean actual = categoryRole.ableTo(authority);\n\n        // then\n        assertThat(actual).isEqualTo(expected);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/categoryrole/domain/CategoryRoleTypeTest.java",
    "content": "package com.allog.dallog.categoryrole.domain;\n\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.ADD_SCHEDULE;\nimport static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_SCHEDULE;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.util.Set;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nclass CategoryRoleTypeTest {\n\n    @DisplayName(\"역할 유형이 권한을 가지고 있는지 확인한다.\")\n    @CsvSource(value = {\"ADMIN,UPDATE_CATEGORY,true\", \"NONE,UPDATE_CATEGORY,false\"})\n    @ParameterizedTest\n    void 역할_유형이_권한을_가지고_있는지_확인한다(final CategoryRoleType roleType, final CategoryAuthority authority,\n                                 final boolean expected) {\n        // given & when\n        boolean actual = roleType.ableTo(authority);\n\n        // then\n        assertThat(actual).isEqualTo(expected);\n    }\n\n    @DisplayName(\"주어진 권한 목록을 모두 가지고 있는 역할 유형 목록을 가져온다.\")\n    @Test\n    void 주어진_권한_목록을_가지고_있는_역할_유형_목록을_가져온다() {\n        // given, when\n        Set<CategoryRoleType> actual = CategoryRoleType.getHavingAuthorities(Set.of(ADD_SCHEDULE, UPDATE_SCHEDULE));\n\n        // then\n        assertThat(actual).containsExactly(CategoryRoleType.ADMIN);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/Constants.java",
    "content": "package com.allog.dallog.common;\n\nimport java.time.LocalDateTime;\n\npublic class Constants {\n\n    public static final String 나인_이메일 = \"nine@email.com\";\n    public static final String 나인_이름 = \"nine\";\n    public static final String 나인_프로필_URL = \"/nine.png\";\n\n    public static final String 티거_이메일 = \"tigger@email.com\";\n    public static final String 티거_이름 = \"tigger\";\n    public static final String 티거_프로필_URL = \"/tigger.png\";\n\n    public static final String 개인_카테고리_이름 = \"개인 카테고리\";\n    public static final String 취업_카테고리_이름 = \"취업 카테고리\";\n    public static final String 스터디_카테고리_이름 = \"스터디 카테고리\";\n    public static final String 외부_카테고리_이름 = \"외부 카테고리\";\n    public static final String 외부_카테고리_ID = \"example@email.com\";\n\n    public static final String 취업_일정_제목 = \"취업 일정\";\n    public static final String 취업_일정_메모 = \"취업 일정 메모\";\n    public static final LocalDateTime 취업_일정_시작일 = LocalDateTime.of(2022, 7, 1, 0, 0);\n    public static final LocalDateTime 취업_일정_종료일 = LocalDateTime.of(2022, 7, 7, 16, 0);\n\n    public static final String 면접_일정_제목 = \"취업 일정\";\n    public static final String 면접_일정_메모 = \"취업 일정 메모\";\n    public static final LocalDateTime 면접_일정_시작일 = LocalDateTime.of(2022, 7, 3, 0, 0);\n    public static final LocalDateTime 면접_일정_종료일 = LocalDateTime.of(2022, 7, 7, 16, 0);\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/DatabaseCleaner.java",
    "content": "package com.allog.dallog.common;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\nimport javax.persistence.EntityManager;\nimport javax.persistence.Table;\nimport javax.persistence.metamodel.Type;\nimport org.springframework.stereotype.Component;\nimport org.springframework.transaction.annotation.Transactional;\n\n@Component\npublic class DatabaseCleaner {\n\n    private final EntityManager entityManager;\n    private final List<String> tableNames;\n\n    public DatabaseCleaner(final EntityManager entityManager) {\n        this.entityManager = entityManager;\n        this.tableNames = entityManager.getMetamodel()\n                .getEntities()\n                .stream()\n                .map(Type::getJavaType)\n                .map(javaType -> javaType.getAnnotation(Table.class))\n                .map(Table::name)\n                .collect(Collectors.toList());\n    }\n\n    @Transactional\n    public void execute() {\n        entityManager.flush();\n        entityManager.createNativeQuery(\"SET foreign_key_checks = 0\").executeUpdate();\n\n        for (String tableName : tableNames) {\n            entityManager.createNativeQuery(\"TRUNCATE TABLE \" + tableName).executeUpdate();\n        }\n\n        entityManager.createNativeQuery(\"SET foreign_key_checks = 1\").executeUpdate();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/annotation/ControllerTest.java",
    "content": "package com.allog.dallog.common.annotation;\n\nimport com.allog.dallog.auth.application.AuthService;\nimport com.allog.dallog.auth.application.OAuthUri;\nimport com.allog.dallog.auth.presentation.AuthController;\nimport com.allog.dallog.category.application.CategoryService;\nimport com.allog.dallog.category.presentaion.CategoryController;\nimport com.allog.dallog.categoryrole.application.CategoryRoleService;\nimport com.allog.dallog.common.config.ExternalApiConfig;\nimport com.allog.dallog.externalcalendar.application.ExternalCalendarService;\nimport com.allog.dallog.externalcalendar.presentation.ExternalCalendarController;\nimport com.allog.dallog.member.application.MemberService;\nimport com.allog.dallog.member.presentation.MemberController;\nimport com.allog.dallog.schedule.application.CheckedSchedulesFinder;\nimport com.allog.dallog.schedule.application.ScheduleService;\nimport com.allog.dallog.schedule.presentation.ScheduleController;\nimport com.allog.dallog.subscription.application.SubscriptionService;\nimport com.allog.dallog.subscription.presentation.SubscriptionController;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;\nimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;\nimport org.springframework.boot.test.mock.mockito.MockBean;\nimport org.springframework.context.annotation.Import;\nimport org.springframework.test.context.ActiveProfiles;\nimport org.springframework.test.web.servlet.MockMvc;\n\n@AutoConfigureRestDocs\n@WebMvcTest({\n        AuthController.class,\n        CategoryController.class,\n        ExternalCalendarController.class,\n        MemberController.class,\n        ScheduleController.class,\n        SubscriptionController.class\n})\n@Import(ExternalApiConfig.class)\n@ActiveProfiles(\"test\")\npublic abstract class ControllerTest {\n\n    @Autowired\n    protected MockMvc mockMvc;\n\n    @Autowired\n    protected ObjectMapper objectMapper;\n\n    @MockBean\n    protected AuthService authService;\n\n    @MockBean\n    protected OAuthUri oAuthUri;\n\n    @MockBean\n    protected CategoryService categoryService;\n\n    @MockBean\n    protected CategoryRoleService categoryRoleService;\n\n    @MockBean\n    protected MemberService memberService;\n\n    @MockBean\n    protected ExternalCalendarService externalCalendarService;\n\n    @MockBean\n    protected ScheduleService scheduleService;\n\n    @MockBean\n    protected CheckedSchedulesFinder checkedSchedulesFinder;\n\n    @MockBean\n    protected SubscriptionService subscriptionService;\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/annotation/RepositoryTest.java",
    "content": "package com.allog.dallog.common.annotation;\n\nimport com.allog.dallog.global.config.JpaConfig;\nimport org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;\nimport org.springframework.context.annotation.Import;\nimport org.springframework.test.context.ActiveProfiles;\n\n@DataJpaTest\n@Import(JpaConfig.class)\n@ActiveProfiles(\"test\")\npublic abstract class RepositoryTest {\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/annotation/ServiceTest.java",
    "content": "package com.allog.dallog.common.annotation;\n\nimport static com.allog.dallog.common.Constants.나인_이름;\nimport static com.allog.dallog.common.Constants.나인_이메일;\nimport static com.allog.dallog.common.Constants.나인_프로필_URL;\nimport static com.allog.dallog.common.Constants.티거_이름;\nimport static com.allog.dallog.common.Constants.티거_이메일;\nimport static com.allog.dallog.common.Constants.티거_프로필_URL;\n\nimport com.allog.dallog.auth.application.AuthService;\nimport com.allog.dallog.auth.domain.TokenRepository;\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport com.allog.dallog.common.DatabaseCleaner;\nimport com.allog.dallog.common.builder.BuilderSupporter;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.common.config.ExternalApiConfig;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.test.context.ActiveProfiles;\n\n@SpringBootTest(classes = ExternalApiConfig.class)\n@ActiveProfiles(\"test\")\npublic abstract class ServiceTest {\n\n    @Autowired\n    private AuthService authService;\n\n    @Autowired\n    private TokenRepository tokenRepository;\n\n    @Autowired\n    private DatabaseCleaner databaseCleaner;\n\n    @Autowired\n    private BuilderSupporter builderSupporter;\n\n    @BeforeEach\n    void setUp() {\n        databaseCleaner.execute();\n        tokenRepository.deleteAll();\n    }\n\n    protected Long toMemberId(final OAuthMember oAuthMember) {\n        AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(oAuthMember);\n        return authService.extractMemberId(response.getRefreshToken());\n    }\n\n    protected GivenBuilder 나인() {\n        GivenBuilder 나인 = new GivenBuilder(builderSupporter);\n        나인.회원_가입을_한다(나인_이메일, 나인_이름, 나인_프로필_URL);\n        return 나인;\n    }\n\n    protected GivenBuilder 티거() {\n        GivenBuilder 티거 = new GivenBuilder(builderSupporter);\n        티거.회원_가입을_한다(티거_이메일, 티거_이름, 티거_프로필_URL);\n        return 티거;\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/builder/BuilderSupporter.java",
    "content": "package com.allog.dallog.common.builder;\n\nimport com.allog.dallog.auth.domain.OAuthTokenRepository;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.domain.ExternalCategoryDetailRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.schedule.domain.ScheduleRepository;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport org.springframework.stereotype.Component;\n\n@Component\npublic class BuilderSupporter {\n\n    private final MemberRepository memberRepository;\n    private final OAuthTokenRepository oAuthTokenRepository;\n    private final CategoryRepository categoryRepository;\n    private final ExternalCategoryDetailRepository externalCategoryDetailRepository;\n    private final CategoryRoleRepository categoryRoleRepository;\n    private final SubscriptionRepository subscriptionRepository;\n    private final ScheduleRepository scheduleRepository;\n\n    public BuilderSupporter(final MemberRepository memberRepository,\n                            final OAuthTokenRepository oAuthTokenRepository,\n                            final CategoryRepository categoryRepository,\n                            final ExternalCategoryDetailRepository externalCategoryDetailRepository,\n                            final CategoryRoleRepository categoryRoleRepository,\n                            final SubscriptionRepository subscriptionRepository,\n                            final ScheduleRepository scheduleRepository) {\n        this.memberRepository = memberRepository;\n        this.oAuthTokenRepository = oAuthTokenRepository;\n        this.categoryRepository = categoryRepository;\n        this.externalCategoryDetailRepository = externalCategoryDetailRepository;\n        this.categoryRoleRepository = categoryRoleRepository;\n        this.subscriptionRepository = subscriptionRepository;\n        this.scheduleRepository = scheduleRepository;\n    }\n\n    public MemberRepository memberRepository() {\n        return memberRepository;\n    }\n\n    public OAuthTokenRepository oAuthTokenRepository() {\n        return oAuthTokenRepository;\n    }\n\n    public CategoryRepository categoryRepository() {\n        return categoryRepository;\n    }\n\n    public ExternalCategoryDetailRepository externalCategoryDetailRepository() {\n        return externalCategoryDetailRepository;\n    }\n\n    public CategoryRoleRepository categoryRoleRepository() {\n        return categoryRoleRepository;\n    }\n\n    public SubscriptionRepository subscriptionRepository() {\n        return subscriptionRepository;\n    }\n\n    public ScheduleRepository scheduleRepository() {\n        return scheduleRepository;\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/builder/GivenBuilder.java",
    "content": "package com.allog.dallog.common.builder;\n\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN;\nimport static com.allog.dallog.categoryrole.domain.CategoryRoleType.NONE;\nimport static com.allog.dallog.common.Constants.외부_카테고리_ID;\nimport static com.allog.dallog.subscription.domain.Color.COLOR_1;\n\nimport com.allog.dallog.auth.domain.OAuthToken;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryType;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.SocialType;\nimport com.allog.dallog.schedule.domain.Schedule;\nimport com.allog.dallog.subscription.domain.Subscription;\nimport java.time.LocalDateTime;\n\npublic final class GivenBuilder {\n\n    private final BuilderSupporter bs;\n    private Member member;\n    private Category category;\n    private CategoryRole categoryRole;\n    private Subscription subscription;\n    private Schedule schedule;\n\n    public GivenBuilder(final BuilderSupporter bs) {\n        this.bs = bs;\n    }\n\n    public GivenBuilder 회원_가입을_한다(final String email, final String name,\n                                  final String profile) {\n        Member member = new Member(email, name, profile, SocialType.GOOGLE);\n        this.member = bs.memberRepository().save(member);\n        OAuthToken oAuthToken = new OAuthToken(this.member, \"aaa\");\n        bs.oAuthTokenRepository().save(oAuthToken);\n        return this;\n    }\n\n    public GivenBuilder 카테고리를_생성한다(final String categoryName,\n                                   final CategoryType categoryType) {\n        Category category = new Category(categoryName, this.member, categoryType);\n        CategoryRole categoryRole = new CategoryRole(category, this.member, ADMIN);\n        Subscription subscription = new Subscription(this.member, category, COLOR_1);\n        this.category = bs.categoryRepository().save(category);\n        this.categoryRole = bs.categoryRoleRepository().save(categoryRole);\n        this.subscription = bs.subscriptionRepository().save(subscription);\n        return this;\n    }\n\n    public GivenBuilder 카테고리를_구독한다(final Category category) {\n        Subscription subscription = new Subscription(this.member, category, COLOR_1);\n        CategoryRole categoryRole = new CategoryRole(category, this.member, NONE);\n        this.subscription = bs.subscriptionRepository().save(subscription);\n        this.categoryRole = bs.categoryRoleRepository().save(categoryRole);\n        return this;\n    }\n\n    public GivenBuilder 카테고리_관리_권한을_부여한다(final Member otherMember, final Category category) {\n        CategoryRole categoryRole = bs.categoryRoleRepository().getByMemberIdAndCategoryId(\n                otherMember.getId(),\n                category.getId());\n        categoryRole.changeRole(ADMIN);\n        bs.categoryRoleRepository().save(categoryRole);\n        return this;\n    }\n\n    public GivenBuilder 카테고리_관리_권한을_해제한다(final Member otherMember, final Category category) {\n        CategoryRole categoryRole = bs.categoryRoleRepository().getByMemberIdAndCategoryId(\n                otherMember.getId(),\n                category.getId());\n        categoryRole.changeRole(NONE);\n        bs.categoryRoleRepository().save(categoryRole);\n        return this;\n    }\n\n    public GivenBuilder 외부_카테고리를_등록한다(final String categoryName, final CategoryType categoryType) {\n        Category category = new Category(categoryName, this.member, categoryType);\n        ExternalCategoryDetail externalCategoryDetail = new ExternalCategoryDetail(category, 외부_카테고리_ID);\n        Subscription subscription = new Subscription(this.member, category, COLOR_1);\n        this.category = bs.categoryRepository().save(category);\n        bs.externalCategoryDetailRepository().save(externalCategoryDetail);\n        this.subscription = bs.subscriptionRepository().save(subscription);\n        return this;\n    }\n\n    public GivenBuilder 일정을_생성한다(final String title, final LocalDateTime start, final LocalDateTime end,\n                                 final String memo) {\n        Schedule schedule = new Schedule(this.category, title, start, end, memo);\n        this.schedule = bs.scheduleRepository().save(schedule);\n        return this;\n    }\n\n    public Member 회원() {\n        return member;\n    }\n\n    public Category 카테고리() {\n        return category;\n    }\n\n    public Subscription 구독() {\n        return subscription;\n    }\n\n    public Schedule 카테고리_일정() {\n        return schedule;\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/config/ExternalApiConfig.java",
    "content": "package com.allog.dallog.common.config;\n\nimport com.allog.dallog.auth.application.OAuthClient;\nimport com.allog.dallog.auth.application.OAuthUri;\nimport com.allog.dallog.externalcalendar.application.ExternalCalendarClient;\nimport com.allog.dallog.infrastructure.oauth.client.StubExternalCalendarClient;\nimport com.allog.dallog.infrastructure.oauth.client.StubOAuthClient;\nimport com.allog.dallog.infrastructure.oauth.uri.StubOAuthUri;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\n\n@TestConfiguration\npublic class ExternalApiConfig {\n\n    @Bean\n    public OAuthClient oAuthClient() {\n        return new StubOAuthClient();\n    }\n\n    @Bean\n    public ExternalCalendarClient externalCalendarClient() {\n        return new StubExternalCalendarClient();\n    }\n\n    @Bean\n    public OAuthUri oAuthUri() {\n        return new StubOAuthUri();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/config/TokenConfig.java",
    "content": "package com.allog.dallog.common.config;\n\nimport static com.allog.dallog.common.fixtures.AuthFixtures.더미_시크릿_키;\n\nimport com.allog.dallog.auth.application.StubTokenProvider;\nimport com.allog.dallog.auth.application.TokenProvider;\nimport org.springframework.boot.test.context.TestConfiguration;\nimport org.springframework.context.annotation.Bean;\n\n@TestConfiguration\npublic class TokenConfig {\n\n    @Bean\n    public TokenProvider tokenProvider() {\n        return new StubTokenProvider(더미_시크릿_키);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/AuthFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.리버;\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.매트;\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.파랑;\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.후디;\n\nimport com.allog.dallog.auth.dto.request.TokenRenewalRequest;\nimport com.allog.dallog.auth.dto.request.TokenRequest;\nimport com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;\nimport com.allog.dallog.auth.dto.response.AccessTokenResponse;\n\npublic class AuthFixtures {\n\n    public static final String GOOGLE_PROVIDER = \"google\";\n    public static final String OAUTH_PROVIDER = \"oauthProvider\";\n\n    public static final String STUB_MEMBER_인증_코드 = \"member authorization code\";\n    public static final String STUB_MEMBER_REFRESH_인증_코드 = \"member refresh authorization code\";\n    public static final String STUB_CREATOR_인증_코드 = \"creator authorization code\";\n\n    public static final String 더미_엑세스_토큰 = \"aaaaa.bbbbb.ccccc\";\n    public static final String 더미_리프레시_토큰 = \"ccccc.bbbbb.aaaaa\";\n    public static final String 토큰_정보 = \"Bearer \" + 더미_엑세스_토큰;\n    public static final String OAuth_로그인_링크 = \"https://accounts.google.com/o/oauth2/v2/auth\";\n\n    public static final String MEMBER_이메일 = \"member@email.com\";\n    public static final String MEMBER_이름 = \"member\";\n    public static final String MEMBER_프로필 = \"/member.png\";\n    public static final String MEMBER_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.ccccccccc\";\n\n    public static final String CREATOR_이메일 = \"creator@email.com\";\n    public static final String CREATOR_이름 = \"creator\";\n    public static final String CREATOR_프로필 = \"/creator.png\";\n    public static final String CREATOR_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.ccccccccc\";\n\n    public static final String 더미_시크릿_키 = \"asdfasarspofjkosdfasdjkflikasndflkasndsdfjkadsnfkjasdn\";\n\n    public static final String STUB_OAUTH_ACCESS_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.cccccccccc\";\n\n    public static TokenRequest 관리자_인증_코드_토큰_요청() {\n        return new TokenRequest(관리자.getCode(), \"https://dallog.me/oauth\");\n    }\n\n    public static TokenRequest 파랑_인증_코드_토큰_요청() {\n        return new TokenRequest(파랑.getCode(), \"https://dallog.me/oauth\");\n    }\n\n    public static TokenRequest 리버_인증_코드_토큰_요청() {\n        return new TokenRequest(리버.getCode(), \"https://dallog.me/oauth\");\n    }\n\n    public static TokenRequest 후디_인증_코드_토큰_요청() {\n        return new TokenRequest(후디.getCode(), \"https://dallog.me/oauth\");\n    }\n\n    public static TokenRequest 매트_인증_코드_토큰_요청() {\n        return new TokenRequest(매트.getCode(), \"https://dallog.me/oauth\");\n    }\n\n    public static TokenRequest MEMBER_인증_코드_토큰_요청() {\n        return new TokenRequest(STUB_MEMBER_인증_코드, \"https://dallog.me/oauth\");\n    }\n\n    public static AccessAndRefreshTokenResponse MEMBER_인증_코드_토큰_응답() {\n        return new AccessAndRefreshTokenResponse(STUB_MEMBER_인증_코드, STUB_MEMBER_REFRESH_인증_코드);\n    }\n\n    public static TokenRenewalRequest MEMBER_리뉴얼_토큰_요청() {\n        return new TokenRenewalRequest(더미_리프레시_토큰);\n    }\n\n    public static AccessTokenResponse MEMBER_리뉴얼_토큰_응답() {\n        return new AccessTokenResponse(더미_엑세스_토큰);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/CategoryFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport static com.allog.dallog.category.domain.CategoryType.GOOGLE;\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.category.domain.CategoryType.PERSONAL;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.dto.request.CategoryCreateRequest;\nimport com.allog.dallog.category.dto.response.CategoryDetailResponse;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.dto.response.MemberResponse;\nimport java.lang.reflect.Field;\nimport java.time.LocalDateTime;\n\npublic class CategoryFixtures {\n\n    /* 공통 일정 카테고리 */\n    public static final String 공통_일정_이름 = \"공통 일정\";\n    public static final CategoryCreateRequest 공통_일정_생성_요청 = new CategoryCreateRequest(공통_일정_이름, NORMAL);\n\n    /* BE 일정 카테고리 */\n    public static final String BE_일정_이름 = \"BE 일정\";\n    public static final CategoryCreateRequest BE_일정_생성_요청 = new CategoryCreateRequest(BE_일정_이름, NORMAL);\n    public static final CategoryCreateRequest 외부_BE_일정_생성_요청 = new CategoryCreateRequest(BE_일정_이름, GOOGLE);\n\n    /* FE 일정 카테고리 */\n    public static final String FE_일정_이름 = \"FE 일정\";\n    public static final CategoryCreateRequest FE_일정_생성_요청 = new CategoryCreateRequest(FE_일정_이름, NORMAL);\n    public static final CategoryCreateRequest 외부_FE_일정_생성_요청 = new CategoryCreateRequest(FE_일정_이름, GOOGLE);\n\n    /* 매트 아고라 카테고리 */\n    public static final String 매트_아고라_이름 = \"매트 아고라\";\n    public static final CategoryCreateRequest 매트_아고라_생성_요청 = new CategoryCreateRequest(매트_아고라_이름, NORMAL);\n\n    /* 후디 JPA 스터디 카테고리 */\n    public static final String 후디_JPA_스터디_이름 = \"후디 JPA 스터디\";\n    public static final CategoryCreateRequest 후디_JPA_스터디_생성_요청 = new CategoryCreateRequest(후디_JPA_스터디_이름, NORMAL);\n\n    /* 내 일정 카테고리 */\n    public static final String 내_일정_이름 = \"내 일정\";\n    public static final CategoryCreateRequest 내_일정_생성_요청 = new CategoryCreateRequest(내_일정_이름, PERSONAL);\n\n    /* 우아한테크코스 외부 일정 카테고리 */\n    public static final String 우아한테크코스_이름 = \"우아한테크코스\";\n    public static final CategoryCreateRequest 우아한테크코스_외부_일정_생성_요청 = new CategoryCreateRequest(우아한테크코스_이름, GOOGLE);\n\n    public static Category 공통_일정(final Member creator) {\n        return new Category(공통_일정_이름, creator);\n    }\n\n    public static Category BE_일정(final Member creator) {\n        return new Category(BE_일정_이름, creator);\n    }\n\n    public static Category FE_일정(final Member creator) {\n        return new Category(FE_일정_이름, creator);\n    }\n\n    public static Category 매트_아고라(final Member creator) {\n        return new Category(매트_아고라_이름, creator);\n    }\n\n    public static Category 후디_JPA_스터디(final Member creator) {\n        return new Category(후디_JPA_스터디_이름, creator);\n    }\n\n    public static Category 내_일정(final Member creator) {\n        return new Category(내_일정_이름, creator, PERSONAL);\n    }\n\n    public static Category 우아한테크코스_일정(final Member creator) {\n        return new Category(우아한테크코스_이름, creator, GOOGLE);\n    }\n\n    public static CategoryResponse 공통_일정_응답(final MemberResponse creatorResponse) {\n        return new CategoryResponse(1L, 공통_일정_이름, NORMAL.name(), creatorResponse, LocalDateTime.now());\n    }\n\n    public static CategoryResponse BE_일정_응답(final MemberResponse creatorResponse) {\n        return new CategoryResponse(2L, BE_일정_이름, NORMAL.name(), creatorResponse, LocalDateTime.now());\n    }\n\n    public static CategoryResponse FE_일정_응답(final MemberResponse creatorResponse) {\n        return new CategoryResponse(3L, FE_일정_이름, NORMAL.name(), creatorResponse, LocalDateTime.now());\n    }\n\n    public static CategoryResponse 매트_아고라_응답(final MemberResponse creatorResponse) {\n        return new CategoryResponse(4L, 매트_아고라_이름, NORMAL.name(), creatorResponse, LocalDateTime.now());\n    }\n\n    public static CategoryResponse 후디_JPA_스터디_응답(final MemberResponse creatorResponse) {\n        return new CategoryResponse(5L, 후디_JPA_스터디_이름, NORMAL.name(), creatorResponse, LocalDateTime.now());\n    }\n\n    public static CategoryDetailResponse BE_일정_세부_응답(final MemberResponse creatorResponse, final int subscriberCount) {\n        return new CategoryDetailResponse(1L, BE_일정_이름, NORMAL.name(), subscriberCount, creatorResponse,\n                LocalDateTime.now());\n    }\n\n    public static Category setId(final Category category, final Long id) {\n        try {\n            Field idField = Category.class.getDeclaredField(\"id\");\n            idField.setAccessible(true);\n            idField.set(category, id);\n            return category;\n        } catch (final NoSuchFieldException | IllegalAccessException e) {\n            throw new IllegalArgumentException(e.getMessage());\n        }\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/ExternalCalendarFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendar;\n\npublic class ExternalCalendarFixtures {\n\n    public static final ExternalCalendar 대한민국_공휴일 = new ExternalCalendar(\n            \"ko.south_korea#holiday@group.v.calendar.google.com\", \"대한민국 공휴일\");\n\n    public static final ExternalCalendar 우아한테크코스 = new ExternalCalendar(\n            \"en.south_korea#holiday@group.v.calendar.google.com\", \"우아한테크코스\");\n\n    public static final ExternalCalendar 내_일정 = new ExternalCalendar(\"example@email.com\", \"내 일정\");\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/ExternalCategoryFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;\n\npublic class ExternalCategoryFixtures {\n\n    public static final String 대한민국_공휴일_이름 = \"대한민국 공휴일\";\n    public static final String 우아한테크코스_이름 = \"우아한테크코스\";\n    public static final String 내_일정_이름 = \"내 일정\";\n\n    public static final ExternalCategoryCreateRequest 대한민국_공휴일_생성_요청 = new ExternalCategoryCreateRequest(\n            \"ko.south_korea#holiday@group.v.calendar.google.com\", 대한민국_공휴일_이름);\n\n    public static final ExternalCategoryCreateRequest 우아한테크코스_생성_요청 = new ExternalCategoryCreateRequest(\n            \"en.south_korea#holiday@group.v.calendar.google.com\", 우아한테크코스_이름);\n\n    public static final ExternalCategoryCreateRequest 내_일정_생성_요청 = new ExternalCategoryCreateRequest(\n            \"example@email.com\", 내_일정_이름);\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/IntegrationScheduleFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport static com.allog.dallog.category.domain.CategoryType.GOOGLE;\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\n\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.schedule.domain.Period;\nimport java.time.LocalDateTime;\n\npublic class IntegrationScheduleFixtures {\n\n    public static final IntegrationSchedule 점심_식사 = new IntegrationSchedule(\"1\", 2L, \"점심 식사\",\n            new Period(LocalDateTime.of(2022, 8, 16, 11, 00), LocalDateTime.of(2022, 8, 16, 13, 00)), \"\", NORMAL);\n\n    public static final IntegrationSchedule 달록_여행 = new IntegrationSchedule(\"2\", 2L, \"달록 여행\",\n            new Period(LocalDateTime.of(2022, 8, 24, 00, 00), LocalDateTime.of(2022, 8, 25, 23, 59)), \"\", NORMAL);\n\n    public static final IntegrationSchedule 레벨3_방학 = new IntegrationSchedule(\"gsgadfgqwrtqwerfgasdasdasd\", 1L,\n            \"레벨3 방학\", new Period(LocalDateTime.of(2022, 8, 20, 00, 00), LocalDateTime.of(2022, 8, 20, 00, 00)), \"\",\n            GOOGLE);\n\n    public static final IntegrationSchedule 포수타 = new IntegrationSchedule(\"asgasgasfgadfgdf\", 1L,\n            \"포수타\", new Period(LocalDateTime.of(2022, 8, 12, 14, 00), LocalDateTime.of(2022, 8, 12, 14, 30)), \"\",\n            GOOGLE);\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/MemberFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport static com.allog.dallog.member.domain.SocialType.GOOGLE;\n\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.dto.response.MemberResponse;\n\npublic class MemberFixtures {\n\n    /* 관리자 */\n    public static final String 관리자_이메일 = \"dallog.admin@gmail.com\";\n    public static final String 관리자_이름 = \"관리자\";\n    public static final String 관리자_프로필 = \"/admin.png\";\n    public static final MemberResponse 관리자_응답 = new MemberResponse(1L, 관리자_이메일, 관리자_이름, 관리자_프로필, GOOGLE);\n\n    /* 파랑 */\n    public static final String 파랑_이메일 = \"parang@email.com\";\n    public static final String 파랑_이름 = \"파랑\";\n    public static final String 파랑_프로필 = \"/parang.png\";\n    public static final MemberResponse 파랑_응답 = new MemberResponse(2L, 파랑_이메일, 파랑_이름, 파랑_프로필, GOOGLE);\n\n    /* 리버 */\n    public static final String 리버_이메일 = \"leaver@email.com\";\n    public static final String 리버_이름 = \"리버\";\n    public static final String 리버_프로필 = \"/leaver.png\";\n    public static final MemberResponse 리버_응답 = new MemberResponse(3L, 리버_이메일, 리버_이름, 리버_프로필, GOOGLE);\n\n    /* 후디 */\n    public static final String 후디_이메일 = \"devhudi@gmail.com\";\n    public static final String 후디_이름 = \"후디\";\n    public static final String 후디_프로필 = \"/hudi.png\";\n    public static final MemberResponse 후디_응답 = new MemberResponse(4L, 후디_이메일, 후디_이름, 후디_프로필, GOOGLE);\n\n    /* 매트 */\n    public static final String 매트_이메일 = \"dev.hyeonic@gmail.com\";\n    public static final String 매트_이름 = \"매트\";\n    public static final String 매트_프로필 = \"/mat.png\";\n    public static final MemberResponse 매트_응답 = new MemberResponse(5L, 매트_이메일, 매트_이름, 매트_프로필, GOOGLE);\n\n    public static Member 관리자() {\n        return new Member(관리자_이메일, 관리자_이름, 관리자_프로필, GOOGLE);\n    }\n\n    public static Member 파랑() {\n        return new Member(파랑_이메일, 파랑_이름, 파랑_프로필, GOOGLE);\n    }\n\n    public static Member 리버() {\n        return new Member(리버_이메일, 리버_이름, 리버_프로필, GOOGLE);\n    }\n\n    public static Member 후디() {\n        return new Member(후디_이메일, 후디_이름, 후디_프로필, GOOGLE);\n    }\n\n    public static Member 매트() {\n        return new Member(매트_이메일, 매트_이름, 매트_프로필, GOOGLE);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/OAuthFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport java.util.Arrays;\nimport java.util.NoSuchElementException;\n\npublic enum OAuthFixtures {\n\n    관리자(\"관리자\", 관리자()),\n    파랑(\"파랑\", 파랑()),\n    리버(\"리버\", 리버()),\n    후디(\"후디\", 후디()),\n    매트(\"매트\", 매트()),\n    MEMBER(\"member authorization code\", MEMBER()),\n    CREATOR(\"creator authorization code\", CREATOR());\n\n    private String code;\n    private OAuthMember oAuthMember;\n\n    OAuthFixtures(final String code, final OAuthMember oAuthMember) {\n        this.code = code;\n        this.oAuthMember = oAuthMember;\n    }\n\n    public static OAuthMember parseOAuthMember(final String code) {\n        OAuthFixtures oAuthFixtures = Arrays.stream(values())\n                .filter(value -> value.code.equals(code))\n                .findFirst()\n                .orElseThrow(NoSuchElementException::new);\n        return oAuthFixtures.oAuthMember;\n    }\n\n    private static OAuthMember 관리자() {\n        String 관리자_이메일 = \"dallog.admin@gmail.com\";\n        String 관리자_이름 = \"관리자\";\n        String 관리자_프로필 = \"/admin.png\";\n        String 관리자_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.cccccccccc\";\n        return new OAuthMember(관리자_이메일, 관리자_이름, 관리자_프로필, 관리자_REFRESH_TOKEN);\n    }\n\n    private static OAuthMember 파랑() {\n        String 파랑_이메일 = \"parang@email.com\";\n        String 파랑_이름 = \"파랑\";\n        String 파랑_프로필 = \"/parang.png\";\n        String 파랑_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.cccccccccc\";\n        return new OAuthMember(파랑_이메일, 파랑_이름, 파랑_프로필, 파랑_REFRESH_TOKEN);\n    }\n\n    private static OAuthMember 리버() {\n        String 리버_이메일 = \"leaver@email.com\";\n        String 리버_이름 = \"리버\";\n        String 리버_프로필 = \"/leaver.png\";\n        String 리버_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.cccccccccc\";\n        return new OAuthMember(리버_이메일, 리버_이름, 리버_프로필, 리버_REFRESH_TOKEN);\n    }\n\n    private static OAuthMember 후디() {\n        String 후디_이메일 = \"devhudi@gmail.com\";\n        String 후디_이름 = \"후디\";\n        String 후디_프로필 = \"/hudi.png\";\n        String 후디_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.cccccccccc\";\n        return new OAuthMember(후디_이메일, 후디_이름, 후디_프로필, 후디_REFRESH_TOKEN);\n    }\n\n    private static OAuthMember 매트() {\n        String 매트_이메일 = \"dev.hyeonic@gmail.com\";\n        String 매트_이름 = \"매트\";\n        String 매트_프로필 = \"/mat.png\";\n        String 매트_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.cccccccccc\";\n        return new OAuthMember(매트_이메일, 매트_이름, 매트_프로필, 매트_REFRESH_TOKEN);\n    }\n\n    private static OAuthMember MEMBER() {\n        String MEMBER_이메일 = \"member@email.com\";\n        String MEMBER_이름 = \"member\";\n        String MEMBER_프로필 = \"/member.png\";\n        String MEMBER_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.ccccccccc\";\n        return new OAuthMember(MEMBER_이메일, MEMBER_이름, MEMBER_프로필, MEMBER_REFRESH_TOKEN);\n    }\n\n    private static OAuthMember CREATOR() {\n        String CREATOR_이메일 = \"creator@email.com\";\n        String CREATOR_이름 = \"creator\";\n        String CREATOR_프로필 = \"/creator.png\";\n        String CREATOR_REFRESH_TOKEN = \"aaaaaaaaaa.bbbbbbbbbb.ccccccccc\";\n        return new OAuthMember(CREATOR_이메일, CREATOR_이름, CREATOR_프로필, CREATOR_REFRESH_TOKEN);\n    }\n\n    public String getCode() {\n        return code;\n    }\n\n    public OAuthMember getOAuthMember() {\n        return oAuthMember;\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/OAuthTokenFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport com.allog.dallog.auth.domain.OAuthToken;\nimport com.allog.dallog.member.domain.Member;\n\npublic class OAuthTokenFixtures {\n\n    public static final String REFRESH_TOKEN = \"adasdqwrwggsdfsdfaasfadfsdvsvzsdrw\";\n\n    public static OAuthToken OAUTH_TOKEN(final Member member) {\n        return new OAuthToken(member, REFRESH_TOKEN);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/ScheduleFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.domain.Schedule;\nimport com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;\nimport com.allog.dallog.schedule.dto.response.ScheduleResponse;\nimport java.time.LocalDateTime;\n\npublic class ScheduleFixtures {\n\n    /* 날짜 */\n    public static final LocalDateTime 날짜_2022년_7월_1일_0시_0분 = LocalDateTime.of(2022, 7, 1, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_7일_16시_0분 = LocalDateTime.of(2022, 7, 7, 16, 0);\n    public static final LocalDateTime 날짜_2022년_7월_10일_0시_0분 = LocalDateTime.of(2022, 7, 10, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_10일_11시_59분 = LocalDateTime.of(2022, 7, 10, 23, 59);\n    public static final LocalDateTime 날짜_2022년_7월_11일_0시_0분 = LocalDateTime.of(2022, 7, 11, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_15일_16시_0분 = LocalDateTime.of(2022, 7, 15, 16, 0);\n    public static final LocalDateTime 날짜_2022년_7월_16일_16시_0분 = LocalDateTime.of(2022, 7, 16, 16, 0);\n    public static final LocalDateTime 날짜_2022년_7월_16일_16시_1분 = LocalDateTime.of(2022, 7, 16, 16, 1);\n    public static final LocalDateTime 날짜_2022년_7월_16일_18시_0분 = LocalDateTime.of(2022, 7, 16, 18, 0);\n    public static final LocalDateTime 날짜_2022년_7월_16일_20시_0분 = LocalDateTime.of(2022, 7, 16, 20, 0);\n    public static final LocalDateTime 날짜_2022년_7월_17일_23시_59분 = LocalDateTime.of(2022, 7, 17, 23, 59);\n    public static final LocalDateTime 날짜_2022년_7월_20일_0시_0분 = LocalDateTime.of(2022, 7, 20, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_20일_11시_59분 = LocalDateTime.of(2022, 7, 20, 23, 59);\n    public static final LocalDateTime 날짜_2022년_7월_21일_0시_0분 = LocalDateTime.of(2022, 7, 21, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_27일_0시_0분 = LocalDateTime.of(2022, 7, 27, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_27일_11시_59분 = LocalDateTime.of(2022, 7, 27, 23, 59);\n    public static final LocalDateTime 날짜_2022년_7월_28일_0시_0분 = LocalDateTime.of(2022, 7, 28, 0, 0);\n    public static final LocalDateTime 날짜_2022년_7월_31일_0시_0분 = LocalDateTime.of(2022, 7, 31, 0, 0);\n    public static final LocalDateTime 날짜_2022년_8월_15일_14시_0분 = LocalDateTime.of(2022, 8, 15, 14, 0);\n    public static final LocalDateTime 날짜_2022년_8월_15일_17시_0분 = LocalDateTime.of(2022, 8, 15, 17, 0);\n    public static final LocalDateTime 날짜_2022년_8월_15일_23시_59분 = LocalDateTime.of(2022, 8, 15, 23, 59);\n\n    /* 알록달록 회의 */\n    public static final String 알록달록_회의_제목 = \"알록달록 회의\";\n    public static final LocalDateTime 알록달록_회의_시작일시 = LocalDateTime.of(2022, 7, 15, 16, 0);\n    public static final LocalDateTime 알록달록_회의_종료일시 = LocalDateTime.of(2022, 7, 16, 16, 0);\n    public static final String 알록달록_회의_메모 = \"알록달록 회의가 있어요\";\n    public static final ScheduleCreateRequest 알록달록_회의_생성_요청 = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시,\n            알록달록_회의_종료일시, 알록달록_회의_메모);\n    public static final ScheduleResponse 알록달록_회의_응답 = new ScheduleResponse(1L, 1L, 알록달록_회의_제목, 알록달록_회의_시작일시,\n            알록달록_회의_종료일시, 알록달록_회의_메모, \"NORMAL\");\n\n    /* 알록달록 회식 */\n    public static final String 알록달록_회식_제목 = \"알록달록 회식\";\n    public static final LocalDateTime 알록달록_회식_시작일시 = LocalDateTime.of(2022, 7, 7, 16, 0);\n    public static final LocalDateTime 알록달록_회식_종료일시 = LocalDateTime.of(2022, 7, 9, 16, 0);\n    public static final String 알록달록_회식_메모 = \"알록달록 회식이 있어요\";\n    public static final ScheduleCreateRequest 알록달록_회식_생성_요청 = new ScheduleCreateRequest(알록달록_회식_제목, 알록달록_회식_시작일시,\n            알록달록_회식_종료일시, 알록달록_회식_메모);\n\n    /* 레벨 인터뷰 */\n    public static final String 레벨_인터뷰_제목 = \"레벨 인터뷰\";\n    public static final LocalDateTime 레벨_인터뷰_시작일시 = LocalDateTime.of(2022, 8, 7, 13, 0);\n    public static final LocalDateTime 레벨_인터뷰_종료일시 = LocalDateTime.of(2022, 8, 7, 15, 0);\n    public static final String 레벨_인터뷰_메모 = \"레벨 인터뷰가 예정되어 있습니다.\";\n    public static final ScheduleCreateRequest 레벨_인터뷰_생성_요청 = new ScheduleCreateRequest(레벨_인터뷰_제목, 레벨_인터뷰_시작일시,\n            레벨_인터뷰_종료일시, 레벨_인터뷰_메모);\n\n    public static final String 매고라_제목 = \"매고라\";\n    public static final String 매고라_메모 = \"매고라가 예정되어 있습니다.\";\n\n    /* 장기간 일정 */\n    public static final ScheduleCreateRequest 장기간_첫번째_요청 = new ScheduleCreateRequest(\"장기간 첫번째\", 날짜_2022년_7월_1일_0시_0분,\n            날짜_2022년_8월_15일_14시_0분, \"\");\n    public static final ScheduleCreateRequest 장기간_두번째_요청 = new ScheduleCreateRequest(\"장기간 두번째\", 날짜_2022년_7월_1일_0시_0분,\n            날짜_2022년_7월_31일_0시_0분, \"\");\n    public static final ScheduleCreateRequest 장기간_세번째_요청 = new ScheduleCreateRequest(\"장기간 세번째\", 날짜_2022년_7월_1일_0시_0분,\n            날짜_2022년_7월_16일_16시_1분, \"\");\n    public static final ScheduleCreateRequest 장기간_네번째_요청 = new ScheduleCreateRequest(\"장기간 네번째\", 날짜_2022년_7월_7일_16시_0분,\n            날짜_2022년_7월_15일_16시_0분, \"\");\n    public static final ScheduleCreateRequest 장기간_다섯번째_요청 = new ScheduleCreateRequest(\"장기간 다섯번째\", 날짜_2022년_7월_31일_0시_0분,\n            날짜_2022년_8월_15일_17시_0분, \"\");\n\n    /* 종일 일정 */\n    public static final ScheduleCreateRequest 종일_첫번째_일정 = new ScheduleCreateRequest(\"종일 첫번째\", 날짜_2022년_7월_10일_0시_0분,\n            날짜_2022년_7월_11일_0시_0분, \"\");\n    public static final ScheduleCreateRequest 종일_두번째_일정 = new ScheduleCreateRequest(\"종일 두번째\", 날짜_2022년_7월_20일_0시_0분,\n            날짜_2022년_7월_21일_0시_0분, \"\");\n    public static final ScheduleCreateRequest 종일_세번째_일정 = new ScheduleCreateRequest(\"종일 세번째\", 날짜_2022년_7월_27일_0시_0분,\n            날짜_2022년_7월_28일_0시_0분, \"\");\n\n    /* 몇시간 일정 */\n    public static final ScheduleCreateRequest 몇시간_첫번째_일정 = new ScheduleCreateRequest(\"몇시간 첫번째\", 날짜_2022년_7월_16일_16시_0분,\n            날짜_2022년_7월_16일_20시_0분, \"\");\n    public static final ScheduleCreateRequest 몇시간_두번째_일정 = new ScheduleCreateRequest(\"몇시간 두번째\", 날짜_2022년_7월_16일_16시_0분,\n            날짜_2022년_7월_16일_18시_0분, \"\");\n    public static final ScheduleCreateRequest 몇시간_세번째_일정 = new ScheduleCreateRequest(\"몇시간 세번째\", 날짜_2022년_7월_16일_16시_0분,\n            날짜_2022년_7월_16일_16시_1분, \"\");\n    public static final ScheduleCreateRequest 몇시간_네번째_일정 = new ScheduleCreateRequest(\"몇시간 네번째\", 날짜_2022년_7월_16일_18시_0분,\n            날짜_2022년_7월_16일_18시_0분, \"\");\n\n    public static Schedule 알록달록_회의(final Category category) {\n        return new Schedule(category, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모);\n    }\n\n    public static Schedule 알록달록_회식(final Category category) {\n        return new Schedule(category, 알록달록_회식_제목, 알록달록_회식_시작일시, 알록달록_회식_종료일시, 알록달록_회식_메모);\n    }\n\n    public static Schedule 레벨_인터뷰(final Category category) {\n        return new Schedule(category, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/common/fixtures/SubscriptionFixtures.java",
    "content": "package com.allog.dallog.common.fixtures;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.domain.Subscription;\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\n\npublic class SubscriptionFixtures {\n\n    public static Subscription 색상1_구독(final Member member, final Category category) {\n        return new Subscription(member, category, Color.COLOR_1);\n    }\n\n    public static SubscriptionResponse 색상1_구독_응답(final CategoryResponse categoryResponse) {\n        return new SubscriptionResponse(1L, categoryResponse, Color.COLOR_1, true);\n    }\n\n    public static Subscription 색상2_구독(final Member member, final Category category) {\n        return new Subscription(member, category, Color.COLOR_2);\n    }\n\n    public static SubscriptionResponse 색상2_구독_응답(final CategoryResponse categoryResponse) {\n        return new SubscriptionResponse(2L, categoryResponse, Color.COLOR_2, true);\n    }\n\n    public static Subscription 색상3_구독(final Member member, final Category category) {\n        return new Subscription(member, category, Color.COLOR_3);\n    }\n\n    public static SubscriptionResponse 색상3_구독_응답(final CategoryResponse categoryResponse) {\n        return new SubscriptionResponse(3L, categoryResponse, Color.COLOR_3, true);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/externalcalendar/application/ExternalCalendarServiceTest.java",
    "content": "package com.allog.dallog.externalcalendar.application;\n\nimport static com.allog.dallog.common.Constants.나인_이름;\nimport static com.allog.dallog.common.Constants.나인_이메일;\nimport static com.allog.dallog.common.Constants.나인_프로필_URL;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.auth.domain.OAuthTokenRepository;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass ExternalCalendarServiceTest extends ServiceTest {\n\n    @Autowired\n    private ExternalCalendarService externalCalendarService;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private OAuthTokenRepository oAuthTokenRepository;\n\n    @Test\n    void 회원의_외부_캘린더_목록을_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        ExternalCalendarsResponse actual = externalCalendarService.findByMemberId(나인.회원().getId());\n\n        // then\n        assertThat(actual.getExternalCalendars()).hasSize(3);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/externalcalendar/presentation/ExternalCalendarControllerTest.java",
    "content": "package com.allog.dallog.externalcalendar.presentation;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_응답;\nimport static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.대한민국_공휴일;\nimport static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.우아한테크코스;\nimport static com.allog.dallog.common.fixtures.ExternalCategoryFixtures.우아한테크코스_생성_요청;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디_응답;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.BDDMockito.given;\nimport static org.mockito.BDDMockito.willThrow;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;\nimport static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;\nimport com.allog.dallog.category.exception.ExistExternalCategoryException;\nimport com.allog.dallog.common.annotation.ControllerTest;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendar;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\nimport org.springframework.restdocs.payload.JsonFieldType;\n\nclass ExternalCalendarControllerTest extends ControllerTest {\n\n    private static final String AUTHORIZATION_HEADER_NAME = \"Authorization\";\n    private static final String AUTHORIZATION_HEADER_VALUE = \"Bearer aaaaaaaa.bbbbbbbb.cccccccc\";\n\n    @DisplayName(\"외부 캘린더의 일정을 조회하면 상태코드 200을 반환한다.\")\n    @Test\n    void 외부_캘린더의_일정을_조회하면_상태코드_200을_반환한다() throws Exception {\n        // given\n        List<ExternalCalendar> ExternalCalendars = List.of(대한민국_공휴일, 우아한테크코스, 대한민국_공휴일);\n        given(externalCalendarService.findByMemberId(any())).willReturn(\n                new ExternalCalendarsResponse(ExternalCalendars));\n\n        // when & then\n        mockMvc.perform(get(\"/api/external-calendars/me\")\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"externalCalendar/get\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"외부 캘린더를 카테고리로 저장하면 상태코드 201을 반환한다.\")\n    @Test\n    void 외부_캘린더를_카테고리로_저장하면_상태코드_201을_반환한다() throws Exception {\n        // given\n        given(categoryService.save(any(), any(ExternalCategoryCreateRequest.class))).willReturn(공통_일정_응답(후디_응답));\n\n        // when & then\n        mockMvc.perform(post(\"/api/external-calendars/me\")\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(우아한테크코스_생성_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"externalCalendar/save\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        ),\n                        requestFields(\n                                fieldWithPath(\"externalId\").type(JsonFieldType.STRING).description(\"외부 캘린더 id\"),\n                                fieldWithPath(\"name\").type(JsonFieldType.STRING).description(\"캘린더 이름\")\n                        )))\n                .andExpect(status().isCreated());\n    }\n\n    @DisplayName(\"외부 캘린더를 중복하여 저장하면 상태코드 400을 반환한다.\")\n    @Test\n    void 외부_캘린더를_중복하여_저장하면_상태코드_400을_반환한다() throws Exception {\n        // given\n        willThrow(new ExistExternalCategoryException())\n                .given(categoryService)\n                .save(any(), any(ExternalCategoryCreateRequest.class));\n\n        // when & then\n        mockMvc.perform(post(\"/api/external-calendars/me\")\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(우아한테크코스_생성_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"externalCalendar/duplicated-save\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        ),\n                        requestFields(\n                                fieldWithPath(\"externalId\").type(JsonFieldType.STRING).description(\"외부 캘린더 id\"),\n                                fieldWithPath(\"name\").type(JsonFieldType.STRING).description(\"캘린더 이름\")\n                        )))\n                .andExpect(status().isBadRequest());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/infrastructure/oauth/client/StubExternalCalendarClient.java",
    "content": "package com.allog.dallog.infrastructure.oauth.client;\n\nimport static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.내_일정;\nimport static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.대한민국_공휴일;\nimport static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.우아한테크코스;\nimport static com.allog.dallog.common.fixtures.IntegrationScheduleFixtures.레벨3_방학;\nimport static com.allog.dallog.common.fixtures.IntegrationScheduleFixtures.포수타;\n\nimport com.allog.dallog.externalcalendar.application.ExternalCalendarClient;\nimport com.allog.dallog.externalcalendar.dto.ExternalCalendar;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport java.util.List;\n\npublic class StubExternalCalendarClient implements ExternalCalendarClient {\n\n    @Override\n    public List<ExternalCalendar> getExternalCalendars(final String accessToken) {\n        return List.of(대한민국_공휴일, 우아한테크코스, 내_일정);\n    }\n\n    @Override\n    public List<IntegrationSchedule> getExternalCalendarSchedules(final String accessToken,\n                                                                  final Long internalCategoryId,\n                                                                  final String externalCalendarId,\n                                                                  final String startDateTime,\n                                                                  final String endDateTime) {\n        return List.of(포수타, 레벨3_방학);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/infrastructure/oauth/client/StubOAuthClient.java",
    "content": "package com.allog.dallog.infrastructure.oauth.client;\n\nimport static com.allog.dallog.common.fixtures.AuthFixtures.STUB_OAUTH_ACCESS_TOKEN;\n\nimport com.allog.dallog.auth.application.OAuthClient;\nimport com.allog.dallog.auth.dto.OAuthMember;\nimport com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse;\nimport com.allog.dallog.common.fixtures.OAuthFixtures;\n\npublic class StubOAuthClient implements OAuthClient {\n\n    @Override\n    public OAuthMember getOAuthMember(final String code, final String redirectUri) {\n        return OAuthFixtures.parseOAuthMember(code);\n    }\n\n    @Override\n    public OAuthAccessTokenResponse getAccessToken(final String refreshToken) {\n        return new OAuthAccessTokenResponse(STUB_OAUTH_ACCESS_TOKEN);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/infrastructure/oauth/uri/StubOAuthUri.java",
    "content": "package com.allog.dallog.infrastructure.oauth.uri;\n\nimport com.allog.dallog.auth.application.OAuthUri;\n\npublic class StubOAuthUri implements OAuthUri {\n\n    @Override\n    public String generate(final String redirectUri) {\n        return \"https://localhost:3000\";\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/member/application/MemberServiceTest.java",
    "content": "package com.allog.dallog.member.application;\n\nimport static com.allog.dallog.common.Constants.나인_이름;\nimport static com.allog.dallog.common.Constants.나인_이메일;\nimport static com.allog.dallog.common.Constants.나인_프로필_URL;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.member.dto.request.MemberUpdateRequest;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass MemberServiceTest extends ServiceTest {\n\n    private final MemberUpdateRequest 나인_이름_수정_요청 = new MemberUpdateRequest(\"텐\");\n\n    @Autowired\n    private MemberService memberService;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @DisplayName(\"회원을 조회한다.\")\n    @Test\n    void 회원을_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when & then\n        assertThat(memberService.findById(나인.회원().getId()).getId())\n                .isEqualTo(나인.회원().getId());\n    }\n\n    @DisplayName(\"회원의 이름을 수정한다.\")\n    @Test\n    void 회원의_이름을_수정한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when\n        memberService.update(나인.회원().getId(), 나인_이름_수정_요청);\n\n        // then\n        Member actual = memberRepository.getById(나인.회원().getId());\n        assertThat(actual.getDisplayName()).isEqualTo(\"텐\");\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/member/domain/MemberRepositoryTest.java",
    "content": "package com.allog.dallog.member.domain;\n\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑_이메일;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.exception.NoSuchMemberException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass MemberRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @DisplayName(\"중복된 이메일이 존재하는 경우 true를 반환한다.\")\n    @Test\n    void 중복된_이메일이_존재하는_경우_true를_반환한다() {\n        // given\n        memberRepository.save(파랑());\n\n        // when & then\n        assertThat(memberRepository.existsByEmail(파랑_이메일)).isTrue();\n    }\n\n    @DisplayName(\"이메일을 통해 회원을 찾는다.\")\n    @Test\n    void 이메일을_통해_회원을_찾는다() {\n        // given\n        Member 파랑 = memberRepository.save(파랑());\n\n        // when\n        Member actual = memberRepository.getByEmail(파랑_이메일);\n\n        // then\n        assertThat(actual.getId()).isEqualTo(파랑.getId());\n    }\n\n    @DisplayName(\"존재하지 않는 email을 조회할 경우 예외를 던진다.\")\n    @Test\n    void 존재하지_않는_email을_조회할_경우_예외를_던진다() {\n        // given\n        String email = \"dev.hyeonic@gmail.com\";\n\n        // given & when & then\n        assertThatThrownBy(() -> memberRepository.getByEmail(email))\n                .isInstanceOf(NoSuchMemberException.class);\n    }\n\n    @DisplayName(\"존재하지 않는 id이면 예외를 던진다.\")\n    @Test\n    void 존재하지_않는_id이면_예외를_던진다() {\n        // given\n        Long id = 0L;\n\n        // when & then\n        assertThatThrownBy(() -> memberRepository.validateExistsById(id))\n                .isInstanceOf(NoSuchMemberException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/member/domain/MemberTest.java",
    "content": "package com.allog.dallog.member.domain;\n\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑_이름;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑_이메일;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑_프로필;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport com.allog.dallog.member.exception.InvalidMemberException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass MemberTest {\n\n    @DisplayName(\"회원을 생성한다.\")\n    @Test\n    void 회원을_생성한다() {\n        // given & when & then\n        assertDoesNotThrow(() -> new Member(파랑_이메일, 파랑_이름, 파랑_프로필, SocialType.GOOGLE));\n    }\n\n    @DisplayName(\"회원의 email이 형식이 맞지 않으면 예외를 던진다.\")\n    @ParameterizedTest\n    @ValueSource(strings = {\"dev.hyeonic@\", \"dev.hyeonicgmail.com\", \"dev.hyeonic@gmail\", \"@gmail.com\", \"dev.hyeonic\"})\n    void 회원의_email이_형식이_맞지_않으면_예외를_던진다(final String email) {\n        // given & when & then\n        assertThatThrownBy(() -> new Member(email, 파랑_이름, 파랑_프로필, SocialType.GOOGLE))\n                .isInstanceOf(InvalidMemberException.class);\n    }\n\n    @DisplayName(\"회원의 이름이 1 ~ 100 사이가 아닌 경우 예외를 던진다.\")\n    @ParameterizedTest\n    @ValueSource(strings = {\"\",\n            \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십일\"})\n    void 회원의_이름이_1_에서_100_사이가_아닌_경우_예외를_던진다(final String displayName) {\n        // given & when & then\n        assertThatThrownBy(() -> new Member(파랑_이메일, displayName, 파랑_프로필, SocialType.GOOGLE))\n                .isInstanceOf(InvalidMemberException.class);\n    }\n\n    @DisplayName(\"회원의 이름을 변경한다.\")\n    @Test\n    void 회원의_이름을_변경한다() {\n        // given\n        Member member = 매트();\n        String 패트_이름 = \"패트\";\n\n        // when\n        member.change(패트_이름);\n\n        // then\n        assertThat(member.getDisplayName()).isEqualTo(패트_이름);\n    }\n\n    @DisplayName(\"변경하기 위한 회원의 이름이 1 ~ 100 사이가 아닌 경우 예외를 던진다.\")\n    @ParameterizedTest\n    @ValueSource(strings = {\"\",\n            \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십\"\n                    + \"일이삼사오육칠팔구십일\"})\n    void 변경하기_위한_회원의_이름이_1_에서_100_사이가_아닌_경우_예외를_던진다(final String displayName) {\n        // given\n        Member member = 매트();\n\n        // when & then\n        assertThatThrownBy(() -> member.change(displayName))\n                .isInstanceOf(InvalidMemberException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/member/presentation/MemberControllerTest.java",
    "content": "package com.allog.dallog.member.presentation;\n\nimport static com.allog.dallog.common.fixtures.AuthFixtures.더미_엑세스_토큰;\nimport static com.allog.dallog.common.fixtures.AuthFixtures.토큰_정보;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑_응답;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.BDDMockito.given;\nimport static org.mockito.BDDMockito.willDoNothing;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport com.allog.dallog.common.annotation.ControllerTest;\nimport com.allog.dallog.member.dto.request.MemberUpdateRequest;\nimport com.allog.dallog.member.exception.NoSuchMemberException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\nimport org.springframework.restdocs.payload.JsonFieldType;\n\nclass MemberControllerTest extends ControllerTest {\n\n    private static final String AUTHORIZATION_HEADER_NAME = \"Authorization\";\n    private static final String AUTHORIZATION_HEADER_VALUE = \"Bearer aaaaaaaa.bbbbbbbb.cccccccc\";\n\n    @DisplayName(\"자신의 회원 정보를 조회한다.\")\n    @Test\n    void 자신의_회원_정보를_조회한다() throws Exception {\n        //given\n        given(memberService.findById(파랑_응답.getId())).willReturn(파랑_응답);\n        given(authService.extractMemberId(더미_엑세스_토큰)).willReturn(파랑_응답.getId());\n\n        // when & then\n        mockMvc.perform(get(\"/api/members/me\")\n                        .header(AUTHORIZATION_HEADER_NAME, 토큰_정보)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"member/findMe\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 엑세스 토큰\")\n                        ),\n                        responseFields(\n                                fieldWithPath(\"id\").description(\"회원 ID\"),\n                                fieldWithPath(\"email\").description(\"회원 이메일\"),\n                                fieldWithPath(\"displayName\").description(\"회원 이름\"),\n                                fieldWithPath(\"profileImageUrl\").description(\"회원 프로필 이미지 URL\"),\n                                fieldWithPath(\"socialType\").description(\"회원 소셜 타입\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"존재하지 않는 회원의 정보를 조회하려고 하면 예외를 발생한다.\")\n    @Test\n    void 존재하지_않는_회원의_정보를_조회하려고_하면_예외를_발생한다() throws Exception {\n        // given\n        given(memberService.findById(0L)).willThrow(new NoSuchMemberException());\n\n        // when & then\n        mockMvc.perform(get(\"/api/members/me\")\n                        .header(AUTHORIZATION_HEADER_NAME, 토큰_정보)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                )\n                .andDo(print())\n                .andDo(document(\"member/findMe/failNoMember\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 엑세스 토큰\")\n                        )\n                ))\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"등록된 회원이 자신의 이름을 수정한다.\")\n    @Test\n    void 등록된_회원이_자신의_이름을_수정한다() throws Exception {\n        // given\n        willDoNothing()\n                .given(memberService)\n                .update(any(), any());\n        MemberUpdateRequest 회원_수정_요청 = new MemberUpdateRequest(\"패트\");\n\n        // when & then\n        mockMvc.perform(patch(\"/api/members/me\")\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .content(objectMapper.writeValueAsString(회원_수정_요청))\n                )\n                .andDo(print())\n                .andDo(document(\"member/update\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 엑세스 토큰\")\n                        ),\n                        requestFields(\n                                fieldWithPath(\"displayName\").type(JsonFieldType.STRING).description(\"수정할 이름\")\n                        )))\n                .andExpect(status().isNoContent());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/application/CheckedSchedulesFinderTest.java",
    "content": "package com.allog.dallog.schedule.application;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_외부_일정_생성_요청;\nimport static com.allog.dallog.common.fixtures.OAuthFixtures.MEMBER;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_네번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_두번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_세번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_첫번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_네번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_다섯번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_두번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_세번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_첫번째_요청;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_두번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_세번째_일정;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_첫번째_일정;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.category.application.CategoryService;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.domain.ExternalCategoryDetail;\nimport com.allog.dallog.category.domain.ExternalCategoryDetailRepository;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.schedule.dto.request.DateRangeRequest;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponse;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass CheckedSchedulesFinderTest extends ServiceTest {\n\n    @Autowired\n    private CheckedSchedulesFinder checkedSchedulesFinder;\n\n    @Autowired\n    private CategoryService categoryService;\n\n    @Autowired\n    private ExternalCategoryDetailRepository externalCategoryDetailRepository;\n\n    @Autowired\n    private ScheduleService scheduleService;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @DisplayName(\"시작일시와 종료일시로 회원의 달력을 일정 유형에 따라 분류하고 정렬하여 반환한다.\")\n    @Test\n    void 시작일시와_종료일시로_회원의_달력을_일정_유형에_따라_분류하고_정렬하여_반환한다() {\n        // given\n        Long memberId = toMemberId(MEMBER.getOAuthMember());\n\n        CategoryResponse BE_일정_응답 = categoryService.save(memberId, BE_일정_생성_요청);\n        Category BE_일정 = categoryRepository.getById(BE_일정_응답.getId());\n\n        /* 장기간 일정 */\n        scheduleService.save(memberId, BE_일정.getId(), 장기간_첫번째_요청);\n        scheduleService.save(memberId, BE_일정.getId(), 장기간_두번째_요청);\n        scheduleService.save(memberId, BE_일정.getId(), 장기간_세번째_요청);\n        scheduleService.save(memberId, BE_일정.getId(), 장기간_네번째_요청);\n        scheduleService.save(memberId, BE_일정.getId(), 장기간_다섯번째_요청);\n\n        /* 종일 일정 */\n        scheduleService.save(memberId, BE_일정.getId(), 종일_첫번째_일정);\n        scheduleService.save(memberId, BE_일정.getId(), 종일_두번째_일정);\n        scheduleService.save(memberId, BE_일정.getId(), 종일_세번째_일정);\n\n        /* 몇시간 일정 */\n        scheduleService.save(memberId, BE_일정.getId(), 몇시간_첫번째_일정);\n        scheduleService.save(memberId, BE_일정.getId(), 몇시간_두번째_일정);\n        scheduleService.save(memberId, BE_일정.getId(), 몇시간_세번째_일정);\n        scheduleService.save(memberId, BE_일정.getId(), 몇시간_네번째_일정);\n\n        CategoryResponse 우아한테크코스_외부_일정_응답 = categoryService.save(memberId, 우아한테크코스_외부_일정_생성_요청);\n        Category 우아한테크코스 = categoryRepository.getById(우아한테크코스_외부_일정_응답.getId());\n        externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스, \"dfggsdfasdasadsgs\"));\n\n        // when\n        IntegrationScheduleResponses integrationScheduleResponses = checkedSchedulesFinder.findMyCheckedSchedules(\n                memberId, new DateRangeRequest(\"2022-07-01T00:00\", \"2022-08-15T23:59\"));\n\n        // then\n        assertAll(() -> {\n            assertThat(integrationScheduleResponses.getLongTerms()).extracting(IntegrationScheduleResponse::getTitle)\n                    .contains(\"장기간 첫번째\", \"장기간 두번째\", \"장기간 세번째\", \"장기간 네번째\", \"장기간 다섯번째\");\n            assertThat(integrationScheduleResponses.getAllDays()).extracting(IntegrationScheduleResponse::getTitle)\n                    .contains(\"종일 첫번째\", \"종일 두번째\", \"종일 세번째\");\n            assertThat(integrationScheduleResponses.getFewHours()).extracting(IntegrationScheduleResponse::getTitle)\n                    .contains(\"몇시간 첫번째\", \"몇시간 두번째\", \"몇시간 세번째\", \"몇시간 네번째\");\n        });\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/application/ScheduleServiceTest.java",
    "content": "package com.allog.dallog.schedule.application;\n\nimport static com.allog.dallog.category.domain.CategoryType.GOOGLE;\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.common.Constants.나인_이름;\nimport static com.allog.dallog.common.Constants.나인_이메일;\nimport static com.allog.dallog.common.Constants.나인_프로필_URL;\nimport static com.allog.dallog.common.Constants.면접_일정_메모;\nimport static com.allog.dallog.common.Constants.면접_일정_시작일;\nimport static com.allog.dallog.common.Constants.면접_일정_제목;\nimport static com.allog.dallog.common.Constants.면접_일정_종료일;\nimport static com.allog.dallog.common.Constants.스터디_카테고리_이름;\nimport static com.allog.dallog.common.Constants.취업_일정_메모;\nimport static com.allog.dallog.common.Constants.취업_일정_시작일;\nimport static com.allog.dallog.common.Constants.취업_일정_제목;\nimport static com.allog.dallog.common.Constants.취업_일정_종료일;\nimport static com.allog.dallog.common.Constants.취업_카테고리_이름;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.auth.domain.OAuthTokenRepository;\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.schedule.domain.Schedule;\nimport com.allog.dallog.schedule.domain.ScheduleRepository;\nimport com.allog.dallog.schedule.dto.request.DateRangeRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponse;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses;\nimport com.allog.dallog.schedule.dto.response.ScheduleResponse;\nimport com.allog.dallog.schedule.exception.InvalidScheduleException;\nimport com.allog.dallog.schedule.exception.NoSuchScheduleException;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport java.time.LocalDateTime;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.transaction.annotation.Transactional;\n\nclass ScheduleServiceTest extends ServiceTest {\n\n    private final ScheduleCreateRequest 취업_일정_생성_요청 = new ScheduleCreateRequest(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일,\n            취업_일정_메모);\n    private final DateRangeRequest 구간_일정_조회_요청 = new DateRangeRequest(\"2022-07-01T00:00\", \"2022-08-15T23:59\");\n\n    @Autowired\n    private ScheduleService scheduleService;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private OAuthTokenRepository oAuthTokenRepository;\n\n    @Autowired\n    private ScheduleRepository scheduleRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @Autowired\n    private CategoryRoleRepository categoryRoleRepository;\n\n    @Test\n    void 관리_권한이_있는_회원은_카테고리에_새로운_일정을_생성할_수_있다() {\n        // given & when\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        // then\n        assertThat(나인.카테고리_일정().getTitle()).isEqualTo(취업_일정_제목);\n    }\n\n    @Test\n    void 관리_권한이_없는_회원이_카고리에_새로운_일정을_생성하려_하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(티거.회원().getId(), 나인.카테고리().getId(), 취업_일정_생성_요청))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_생성자라도_관리_권한이_없으면_새로운_일정을_생성할_때_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n        티거.카테고리_관리_권한을_해제한다(나인.회원(), 나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 취업_일정_생성_요청))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 새로운_일정을_생성_할_때_일정_제목의_길이가_50을_초과하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        String 잘못된_일정_제목 = \"일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일\";\n        ScheduleCreateRequest 잘못된_일정_생성_요청 = new ScheduleCreateRequest(잘못된_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 잘못된_일정_생성_요청)).\n                isInstanceOf(InvalidScheduleException.class);\n    }\n\n    @Test\n    void 새로운_일정을_생성_할_때_일정_메모의_길이가_255를_초과하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        String 잘못된_일정_메모 = \"1\".repeat(256);\n        ScheduleCreateRequest 잘못된_일정_생성_요청 = new ScheduleCreateRequest(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 잘못된_일정_메모);\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 잘못된_일정_생성_요청)).\n                isInstanceOf(InvalidScheduleException.class);\n    }\n\n    @Test\n    void 새로운_일정을_생성_할_때_종료일시가_시작일시_이전이라면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        ScheduleCreateRequest 잘못된_일정_생성_요청 = new ScheduleCreateRequest(취업_일정_제목, 취업_일정_종료일, 취업_일정_시작일, 취업_일정_메모);\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 잘못된_일정_생성_요청)).\n                isInstanceOf(InvalidScheduleException.class);\n    }\n\n    @Test\n    void 존재하지_않는_카테고리에_일정을_추가하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인();\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 0L, 취업_일정_생성_요청)).\n                isInstanceOf(NoSuchCategoryException.class);\n    }\n\n    @Test\n    void 외부_연동_카테고리에_일정을_추가하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, GOOGLE);\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 취업_일정_생성_요청)).\n                isInstanceOf(NoPermissionException.class);\n    }\n\n    @Test\n    void 단건_일정을_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        // when\n        ScheduleResponse actual = scheduleService.findById(나인.카테고리_일정().getId());\n\n        // then\n        assertAll(() -> {\n            assertThat(actual.getId()).isEqualTo(나인.카테고리().getId());\n            assertThat(actual.getTitle()).isEqualTo(취업_일정_제목);\n            assertThat(actual.getStartDateTime()).isEqualTo(취업_일정_시작일);\n            assertThat(actual.getEndDateTime()).isEqualTo(취업_일정_종료일);\n            assertThat(actual.getMemo()).isEqualTo(취업_일정_메모);\n        });\n    }\n\n    @Test\n    void 존재하지_않는_일정을_단건_조회하면_예외가_발생한다() {\n        // given\n        Long 잘못된_아이디 = 0L;\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.findById(잘못된_아이디))\n                .isInstanceOf(NoSuchScheduleException.class);\n    }\n\n    @Transactional\n    @Test\n    void 월별_일정_조회를_하면_통합일정_정보를_반환한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모)\n                .일정을_생성한다(면접_일정_제목, 면접_일정_시작일, 면접_일정_종료일, 면접_일정_메모);\n\n        // when\n        List<IntegrationSchedule> actual = scheduleService.findInternalByMemberIdAndDateRange(나인.회원().getId(),\n                구간_일정_조회_요청).getSchedules();\n\n        // then\n        assertThat(actual).hasSize(2);\n    }\n\n    @Test\n    void 카테고리_별_통합_일정_정보를_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().회원_가입을_한다(나인_이메일, 나인_이름, 나인_프로필_URL)\n                .카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(\"첫번째 장기 일정\", LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 8, 15, 14, 0), \"\")\n                .일정을_생성한다(\"두번째 장기 일정\", LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 31, 0, 0), \"\")\n                .일정을_생성한다(\"세번째 장기 일정\", LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 16, 16, 1), \"\")\n                .일정을_생성한다(\"네번째 장기 일정\", LocalDateTime.of(2022, 7, 7, 16, 0), LocalDateTime.of(2022, 7, 15, 16, 0), \"\")\n                .일정을_생성한다(\"다섯번째 장기 일정\", LocalDateTime.of(2022, 7, 31, 0, 0), LocalDateTime.of(2022, 8, 15, 17, 0), \"\")\n                .일정을_생성한다(\"첫번째 종일 일정\", LocalDateTime.of(2022, 7, 10, 0, 0), LocalDateTime.of(2022, 7, 11, 0, 0), \"\")\n                .일정을_생성한다(\"두번째 종일 일정\", LocalDateTime.of(2022, 7, 27, 0, 0), LocalDateTime.of(2022, 7, 28, 0, 0), \"\")\n                .일정을_생성한다(\"첫번째 몇시간 일정\", LocalDateTime.of(2022, 7, 16, 16, 0), LocalDateTime.of(2022, 7, 16, 20, 0), \"\")\n                .일정을_생성한다(\"두번째 몇시간 일정\", LocalDateTime.of(2022, 7, 16, 16, 0), LocalDateTime.of(2022, 7, 16, 18, 0), \"\");\n\n        // when\n        IntegrationScheduleResponses actual = scheduleService.findByCategoryIdAndDateRange(나인.카테고리().getId(),\n                구간_일정_조회_요청);\n\n        // then\n        assertAll(() -> {\n            assertThat(actual.getLongTerms()).extracting(IntegrationScheduleResponse::getTitle)\n                    .contains(\"첫번째 장기 일정\", \"두번째 장기 일정\", \"세번째 장기 일정\", \"네번째 장기 일정\", \"다섯번째 장기 일정\");\n            assertThat(actual.getAllDays()).extracting(IntegrationScheduleResponse::getTitle)\n                    .contains(\"첫번째 종일 일정\", \"두번째 종일 일정\");\n            assertThat(actual.getFewHours()).extracting(IntegrationScheduleResponse::getTitle)\n                    .contains(\"첫번째 몇시간 일정\", \"두번째 몇시간 일정\");\n        });\n    }\n\n    @Test\n    void 일정을_수정한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        // when\n        ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), \"제목\", 취업_일정_시작일, 취업_일정_종료일, \"메모\");\n        scheduleService.update(나인.카테고리_일정().getId(), 나인.회원().getId(), 일정_수정_요청);\n\n        // then\n        Schedule actual = scheduleRepository.getById(나인.카테고리_일정().getId());\n        assertAll(\n                () -> {\n                    assertThat(actual.getId()).isEqualTo(나인.카테고리_일정().getId());\n                    assertThat(actual.getTitle()).isEqualTo(\"제목\");\n                    assertThat(actual.getStartDateTime()).isEqualTo(취업_일정_시작일);\n                    assertThat(actual.getEndDateTime()).isEqualTo(취업_일정_종료일);\n                    assertThat(actual.getMemo()).isEqualTo(\"메모\");\n                }\n        );\n    }\n\n    @Test\n    void 관리_권한이_없는_회원이_카테고리의_일정을_수정하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), \"제목\", 취업_일정_시작일, 취업_일정_종료일, \"메모\");\n\n        assertThatThrownBy(() -> scheduleService.update(나인.카테고리_일정().getId(), 티거.회원().getId(), 일정_수정_요청))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_생성자라도_관리_권한이_없으면_카테고리의_일정을_수정할_떄_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n        티거.카테고리_관리_권한을_해제한다(나인.회원(), 나인.카테고리());\n\n        // when & then\n        ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), \"제목\", 취업_일정_시작일, 취업_일정_종료일, \"메모\");\n\n        assertThatThrownBy(() -> scheduleService.update(나인.카테고리_일정().getId(), 나인.회원().getId(), 일정_수정_요청))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 존재하지_않은_일정을_수정하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when & then\n        ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), \"제목\", 취업_일정_시작일, 취업_일정_종료일, \"메모\");\n\n        assertThatThrownBy(() -> scheduleService.update(0L, 나인.회원().getId(), 일정_수정_요청))\n                .isInstanceOf(NoSuchScheduleException.class);\n    }\n\n    @Test\n    void 일정의_카테고리를_변경한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        Schedule 기존_일정 = 나인.카테고리_일정();\n        나인.카테고리를_생성한다(스터디_카테고리_이름, NORMAL);\n\n        // when\n        ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), \"제목\", 취업_일정_시작일, 취업_일정_종료일, \"메모\");\n        scheduleService.update(기존_일정.getId(), 나인.회원().getId(), 일정_수정_요청);\n\n        // then\n        Schedule actual = scheduleRepository.getById(기존_일정.getId());\n        assertThat(actual.getCategory().getId()).isEqualTo(나인.카테고리().getId());\n    }\n\n    @Test\n    void 관리_권한이_있는_회원은_카테고리의_일정을_삭제할_수_있다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        // when\n        scheduleService.delete(나인.카테고리_일정().getId(), 나인.회원().getId());\n\n        // then\n        assertThatThrownBy(() -> scheduleRepository.getById(나인.카테고리_일정().getId()))\n                .isInstanceOf(NoSuchScheduleException.class);\n    }\n\n    @Test\n    void 관리_권한이_없는_회원이_카테고리의_일정을_삭제하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.delete(나인.카테고리_일정().getId(), 티거.회원().getId()))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_생성자라도_관리_권한이_없으면_일정을_삭제하려할때_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL)\n                .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n        티거.카테고리_관리_권한을_해제한다(나인.회원(), 나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.delete(나인.카테고리_일정().getId(), 나인.회원().getId()))\n                .isInstanceOf(NoCategoryAuthorityException.class);\n    }\n\n    @Test\n    void 존재하지_않은_일정을_삭제하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when & then\n        assertThatThrownBy(() -> scheduleService.delete(0L, 나인.회원().getId()))\n                .isInstanceOf(NoSuchScheduleException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/domain/IntegrationScheduleTest.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_시작일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_종료일시;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport java.time.LocalDateTime;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass IntegrationScheduleTest {\n\n    @DisplayName(\"일정을 생성한다.\")\n    @Test\n    void 일정을_생성한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n\n        // when & then\n        assertDoesNotThrow(\n                () -> new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모,\n                        NORMAL));\n    }\n\n    @DisplayName(\"LongTerm인지 확인 할 때, AllDays가 아니고 일정의 시작일과 종료일이 다르면 true를 반환한다.\")\n    @Test\n    void LongTerm인지_확인_할_때_AllDays가_아니고_일정의_시작일과_종료일이_다르면_true를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 1),\n                LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isLongTerms();\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"LongTerm인지 확인 할 때, 일정의 시작일과 종료일이 같으면 false를 반환한다.\")\n    @Test\n    void LongTerm인지_확인_할_때_일정의_시작일과_종료일이_같으면_false를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 1),\n                LocalDateTime.of(2022, 7, 1, 23, 59), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isLongTerms();\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"LongTerm인지 확인 할 때, 일정의 시작일과 종료일이 달라도 AllDays면 false를 반환한다.\")\n    @Test\n    void LongTerm인지_확인_할_때_일정의_시작일과_종료일이_달라도_AllDays면_false를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 0),\n                LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isLongTerms();\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"AllDays인지 확인 할 때, 일정의 일차가 하루고 시작시간과 종료시간 모두 자정이면 true를 반환한다.\")\n    @Test\n    void AllDays인지_확인_할_때_일정의_일차가_하루고_시작시간과_종료시간이_모두_자정이면_true를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 0),\n                LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isAllDays();\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"AllDays인지 확인 할 때, 일정의 일차가 하루여도 시작시간과 종료시간이 자정이 아니면 false를 반환한다.\")\n    @Test\n    void AllDays인지_확인_할_때_일정의_일차가_하루여도_시작시간과_종료시간이__자정이_아니면_false를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 0),\n                LocalDateTime.of(2022, 7, 2, 0, 1), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isAllDays();\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"FewHours인지 확인 할 때, 일정의 시작일과 종료일이 같으면 true를 반환한다.\")\n    @Test\n    void FewHours인지_확인_할_때_일정의_시작일과_종료일이_같으면_true를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 0),\n                LocalDateTime.of(2022, 7, 1, 11, 59), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isFewHours();\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"FewHours인지 확인 할 때, 일정의 시작일과 종료일이 다르면 false를 반환한다.\")\n    @Test\n    void FewHours인지_확인_할_때_일정의_시작일과_종료일이_다르면_false를_반환한다() {\n        // given\n        String id = \"1\";\n        Long categoryId = 1L;\n        IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목,\n                LocalDateTime.of(2022, 7, 1, 0, 0),\n                LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL);\n\n        // when\n        boolean actual = integrationSchedule.isFewHours();\n\n        // then\n        assertThat(actual).isFalse();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/domain/IntegrationSchedulesTest.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.time.LocalDateTime;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass IntegrationSchedulesTest {\n\n    @DisplayName(\"겹치는 일정이 하나도 없을 때, 일정 시작일시가 빠른 순서대로 정렬된다.\")\n    @Test\n    void 겹치는_일정이_하나도_없을_때_일정_시작일시가_빠른_순서대로_정렬된다() {\n        // given\n        Long categoryId = 1L;\n        IntegrationSchedule 첫번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"1\", categoryId, \"일정1\",\n                LocalDateTime.of(2022, 3, 1, 0, 0),\n                LocalDateTime.of(2022, 3, 2, 0, 0), \"일정1\", NORMAL);\n\n        IntegrationSchedule 두번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"2\", categoryId, \"일정2\",\n                LocalDateTime.of(2022, 3, 3, 0, 0),\n                LocalDateTime.of(2022, 3, 4, 0, 0), \"일정2\", NORMAL);\n\n        IntegrationSchedule 세번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"3\", categoryId, \"일정3\",\n                LocalDateTime.of(2022, 3, 5, 0, 0),\n                LocalDateTime.of(2022, 3, 7, 0, 0), \"일정3\", NORMAL);\n\n        // when\n        IntegrationSchedules integrationSchedules = new IntegrationSchedules();\n        integrationSchedules.add(세번째로_정렬되어야_하는_일정);\n        integrationSchedules.add(두번째로_정렬되어야_하는_일정);\n        integrationSchedules.add(첫번째로_정렬되어야_하는_일정);\n\n        // then\n        assertThat(integrationSchedules.getSortedValues())\n                .extracting(IntegrationSchedule::getTitle)\n                .containsExactly(\"일정1\", \"일정2\", \"일정3\");\n    }\n\n    @DisplayName(\"일정의 시작일시가 겹친다면, 일정 종료일시가 느린 순서대로 정렬된다.\")\n    @Test\n    void 일정의_시작일시가_겹친다면_일정_종료일시가_느린_순서대로_정렬된다() {\n        // given\n        Long categoryId = 1L;\n        IntegrationSchedule 첫번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"1\", categoryId, \"일정1\",\n                LocalDateTime.of(2022, 3, 1, 0, 0),\n                LocalDateTime.of(2022, 3, 10, 0, 0), \"일정1\", NORMAL);\n\n        IntegrationSchedule 두번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"2\", categoryId, \"일정2\",\n                LocalDateTime.of(2022, 3, 1, 0, 0),\n                LocalDateTime.of(2022, 3, 7, 0, 0), \"일정2\", NORMAL);\n\n        IntegrationSchedule 세번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"3\", categoryId, \"일정3\",\n                LocalDateTime.of(2022, 3, 5, 0, 0),\n                LocalDateTime.of(2022, 3, 5, 0, 0), \"일정3\", NORMAL);\n\n        // when\n        IntegrationSchedules integrationSchedules = new IntegrationSchedules();\n        integrationSchedules.add(두번째로_정렬되어야_하는_일정);\n        integrationSchedules.add(세번째로_정렬되어야_하는_일정);\n        integrationSchedules.add(첫번째로_정렬되어야_하는_일정);\n\n        // then\n        assertThat(integrationSchedules.getSortedValues())\n                .extracting(IntegrationSchedule::getTitle)\n                .containsExactly(\"일정1\", \"일정2\", \"일정3\");\n    }\n\n    @DisplayName(\"일정의 시작일시가 겹치고, 종료일시도 겹칠때는 일정의 제목을 사전기준 오름차순으로 정렬된다.\")\n    @Test\n    void 일정의_시작일시가_겹치고_종료일시도_겹칠때는_일정의_제목을_사전기준_오름차순으로_정렬된다() {\n        // given\n        Long categoryId = 1L;\n        IntegrationSchedule 첫번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"1\", categoryId, \"가\",\n                LocalDateTime.of(2022, 3, 1, 0, 0),\n                LocalDateTime.of(2022, 3, 10, 0, 0), \"일정1\", NORMAL);\n\n        IntegrationSchedule 두번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"2\", categoryId, \"나\",\n                LocalDateTime.of(2022, 3, 1, 0, 0),\n                LocalDateTime.of(2022, 3, 10, 0, 0), \"일정2\", NORMAL);\n\n        IntegrationSchedule 세번째로_정렬되어야_하는_일정 = new IntegrationSchedule(\"3\", categoryId, \"다\",\n                LocalDateTime.of(2022, 3, 1, 0, 0),\n                LocalDateTime.of(2022, 3, 10, 0, 0), \"일정3\", NORMAL);\n\n        // when\n        IntegrationSchedules integrationSchedules = new IntegrationSchedules();\n        integrationSchedules.add(세번째로_정렬되어야_하는_일정);\n        integrationSchedules.add(두번째로_정렬되어야_하는_일정);\n        integrationSchedules.add(첫번째로_정렬되어야_하는_일정);\n\n        // then\n        assertThat(integrationSchedules.getSortedValues())\n                .extracting(IntegrationSchedule::getTitle)\n                .containsExactly(\"가\", \"나\", \"다\");\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/domain/PeriodTest.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport java.time.LocalDateTime;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass PeriodTest {\n\n    @DisplayName(\"시작일시와 종료일시의 날짜 차이를 반환한다.\")\n    @Test\n    void 시작일시와_종료일시의_날짜_차이를_반환한다() {\n        // given\n        LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 0, 0);\n        LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 4, 0, 0);\n\n        Period period = new Period(startDateTime, endDateTime);\n\n        // when\n        long dayDifference = period.calculateDayDifference();\n\n        // then\n        assertThat(dayDifference).isEqualTo(2);\n    }\n\n    @DisplayName(\"시작시간과 종료시간이 모두 자정이면 true를 반환한다.\")\n    @Test\n    void 시작시간과_종료시간이_모두_자정이면_true를_반환한다() {\n        // given\n        LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 0, 0);\n        LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 4, 0, 0);\n\n        Period period = new Period(startDateTime, endDateTime);\n\n        // when\n        boolean actual = period.isMidnightToMidnight();\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"시작시간과 종료시간 중 하나라도 자정이 아니면 false를 반환한다.\")\n    @Test\n    void 시작시간과_종료시간_중_하나라도_자정이_아니면_false를_반환한다() {\n        // given\n        LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 10, 10);\n        LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 4, 0, 0);\n\n        Period period = new Period(startDateTime, endDateTime);\n\n        // when\n        boolean actual = period.isMidnightToMidnight();\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"기간 뺄셈시 상대 기간이 우측에 걸쳐있을 때의 결과를 계산한다.\")\n    @Test\n    void 기간_뺄셈시_상대_기간이_우측에_걸쳐있을_때의_결과를_계산한다() {\n        // given\n        LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0);\n        LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 2, 23, 59);\n        Period basePeriod = new Period(baseStartDateTime, baseEndDateTime);\n\n        LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 18, 0);\n        LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 3, 18, 0);\n        Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime);\n\n        // when\n        List<Period> actual = basePeriod.slice(otherPeriod);\n\n        // then\n        assertAll(() -> {\n            assertThat(actual).hasSize(1);\n            assertThat(actual.get(0).getStartDateTime()).isEqualTo(baseStartDateTime);\n            assertThat(actual.get(0).getEndDateTime()).isEqualTo(otherStartDateTime);\n        });\n    }\n\n    @DisplayName(\"기간 뺄셈시 상대 기간이 좌측에 걸쳐있을 때의 결과를 계산한다.\")\n    @Test\n    void 기간_뺄셈시_상대_기간이_좌측에_걸쳐있을_때의_결과를_계산한다() {\n        // given\n        LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 2, 0, 0);\n        LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59);\n        Period basePeriod = new Period(baseStartDateTime, baseEndDateTime);\n\n        LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 18, 0);\n        LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 2, 18, 0);\n        Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime);\n\n        // when\n        List<Period> actual = basePeriod.slice(otherPeriod);\n\n        // then\n        assertAll(() -> {\n            assertThat(actual).hasSize(1);\n            assertThat(actual.get(0).getStartDateTime()).isEqualTo(otherEndDateTime);\n            assertThat(actual.get(0).getEndDateTime()).isEqualTo(baseEndDateTime);\n        });\n    }\n\n    @DisplayName(\"기간 뺄셈시 상대 기간이 안쪽에 포함될때 결과를 계산한다.\")\n    @Test\n    void 기간_뺄셈시_상대_기간이_안쪽에_포함될때_결과를_계산한다() {\n        // given\n        LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0);\n        LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59);\n        Period basePeriod = new Period(baseStartDateTime, baseEndDateTime);\n\n        LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 18, 0);\n        LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 2, 18, 0);\n        Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime);\n\n        // when\n        List<Period> actual = basePeriod.slice(otherPeriod);\n\n        // then\n        assertAll(() -> {\n            assertThat(actual).hasSize(2);\n            assertThat(actual.get(0).getStartDateTime()).isEqualTo(baseStartDateTime);\n            assertThat(actual.get(0).getEndDateTime()).isEqualTo(otherStartDateTime);\n            assertThat(actual.get(1).getStartDateTime()).isEqualTo(otherEndDateTime);\n            assertThat(actual.get(1).getEndDateTime()).isEqualTo(baseEndDateTime);\n        });\n    }\n\n    @DisplayName(\"기간 뺄셈시 상대 기간과 완벽히 일치하면 빈 리스트를 반환한다.\")\n    @Test\n    void 기간_뺄셈시_상대_기간과_완벽히_일치하면_빈_리스트를_반환한다() {\n        // given\n        LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0);\n        LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59);\n        Period basePeriod = new Period(baseStartDateTime, baseEndDateTime);\n\n        LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0);\n        LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59);\n        Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime);\n\n        // when\n        List<Period> actual = basePeriod.slice(otherPeriod);\n\n        // then\n        assertThat(actual).hasSize(0);\n    }\n\n    @DisplayName(\"기간 뺄셈시 상대 기간과 겹치지 않으면 자기자신을 리스트로 반환한다.\")\n    @Test\n    void 기간_뺄셈시_상대_기간과_겹치지_않으면_자기자신을_리스트로_반환한다() {\n        // given\n        LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0);\n        LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 2, 0, 0);\n        Period basePeriod = new Period(baseStartDateTime, baseEndDateTime);\n\n        LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 3, 0, 0);\n        LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59);\n        Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime);\n\n        // when\n        List<Period> actual = basePeriod.slice(otherPeriod);\n\n        // then\n        assertAll(() -> {\n            assertThat(actual).hasSize(1);\n            assertThat(actual.get(0)).isEqualTo(basePeriod);\n        });\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/domain/ScheduleRepositoryTest.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_11시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_15일_16시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_1분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_18시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_20시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_17일_23시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_1일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_11시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_11시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_31일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_7일_16시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_14시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_17시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_23시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.매고라_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.매고라_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회식_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회식_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목;\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport java.time.LocalDateTime;\nimport java.util.Collections;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass ScheduleRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private ScheduleRepository scheduleRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @DisplayName(\"특정 카테고리들에 속한 일정을 전부 삭제한다.\")\n    @Test\n    void 특정_카테고리들에_속한_일정을_전부_삭제한다() {\n        // given\n        Member 관리자 = 관리자();\n        memberRepository.save(관리자);\n\n        Category BE_일정 = BE_일정(관리자);\n        Category FE_일정 = FE_일정(관리자);\n        Category 공통_일정 = 공통_일정(관리자);\n        categoryRepository.save(BE_일정);\n        categoryRepository.save(FE_일정);\n        categoryRepository.save(공통_일정);\n\n        Schedule 알록달록_회의_BE = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분,\n                알록달록_회의_메모);\n        Schedule 알록달록_회식_BE = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분,\n                알록달록_회식_메모);\n        Schedule 알록달록_회의_FE = new Schedule(FE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분,\n                알록달록_회의_메모);\n        Schedule 알록달록_회식_FE = new Schedule(FE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분,\n                알록달록_회식_메모);\n        Schedule 알록달록_회의_공통 = new Schedule(공통_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분,\n                알록달록_회의_메모);\n        Schedule 알록달록_회식_공통 = new Schedule(공통_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분,\n                알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의_BE);\n        scheduleRepository.save(알록달록_회식_BE);\n        scheduleRepository.save(알록달록_회의_FE);\n        scheduleRepository.save(알록달록_회식_FE);\n        scheduleRepository.save(알록달록_회의_공통);\n        scheduleRepository.save(알록달록_회식_공통);\n\n        // when\n        scheduleRepository.deleteByCategoryIdIn(List.of(BE_일정.getId(), FE_일정.getId(), 공통_일정.getId()));\n\n        // then\n        assertThat(scheduleRepository.findAll()).hasSize(0);\n    }\n\n    @DisplayName(\"카테코리와 시작일시, 종료일시를 전달하면 그 사이에 해당하는 일정을 조회한다.\")\n    @Test\n    void 카테고리와_시작일시_종료일시를_전달하면_그_사이에_해당하는_일정을_조회한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(List.of(BE_일정),\n                날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분);\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n\n    @DisplayName(\"조회하기 위한 category 리스트의 크기가 0인 경우 빈 리스트를 반환한다.\")\n    @Test\n    void 조회하기_위한_category_리스트의_크기가_0인_경우_빈_리스트를_반환한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n\n        List<Category> categories = Collections.emptyList();\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual).hasSize(0);\n    }\n\n    @DisplayName(\"카테고리가 여러 개 일 때, 카테고리와 시작일시, 종료일시를 전달하면 그 사이에 해당하는 일정을 조회한다.\")\n    @Test\n    void 카테고리가_여러_개_일_때_카테고리와_시작일시_종료일시를_전달하면_그_사이에_해당하는_일정을_조회한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n        Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        Schedule 레벨_인터뷰 = new Schedule(FE_일정, 레벨_인터뷰_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 레벨_인터뷰_메모);\n\n        Schedule 매고라 = new Schedule(매트_아고라, 매고라_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 매고라_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n        scheduleRepository.save(레벨_인터뷰);\n        scheduleRepository.save(매고라);\n\n        List<Category> categories = List.of(BE_일정);\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate,\n                endDate);\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n\n    @DisplayName(\"카테고리가 여러 개 일 때, 카테고리 리스트와 시작일시, 종료일시를 전달하면 그 사이에 해당하는 일정을 조회한다.\")\n    @Test\n    void 카테고리가_여러_개_일_때_카테고리_리스트와_시작일시_종료일시를_전달하면_그_사이에_해당하는_일정을_조회한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n        Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        Schedule 레벨_인터뷰 = new Schedule(FE_일정, 레벨_인터뷰_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 레벨_인터뷰_메모);\n\n        Schedule 매고라 = new Schedule(매트_아고라, 매고라_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 매고라_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n        scheduleRepository.save(레벨_인터뷰);\n        scheduleRepository.save(매고라);\n\n        List<Category> categories = List.of(BE_일정, FE_일정, 매트_아고라);\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual).hasSize(3);\n    }\n\n    @DisplayName(\"카테고리와 시작일시, 종료일시를 전달할 때 일정의 시작날짜가 종료일시와 같으면 조회한다.\")\n    @Test\n    void 시작일시와_종료일시를_전달할_때_일정의_시작일시와_같으면_조회된다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n\n        List<Category> categories = List.of(BE_일정);\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_15일_16시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n\n    @DisplayName(\"카테고리와 시작일시, 종료일시를 전달할 때 일정의 시작날짜가 종료일시 이후이면 조회되지 않는다.\")\n    @Test\n    void 카테고리와_시작일시_종료일시를_전달할_때_일정의_시작날짜가_종료일시_이후이면_조회되지_않는다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n\n        List<Category> categories = List.of(BE_일정);\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_7일_16시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual).hasSize(0);\n    }\n\n    @DisplayName(\"카테고리와 시작일시, 종료일시를 전달할 때 일정의 종료날짜가 시작일시와 같으면 조회된다.\")\n    @Test\n    void 카테고리와_시작일시와_종료일시를_전달할_때_일정의_종료날짜가_시작일시와_같으면_조회된다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n\n        List<Category> categories = List.of(BE_일정);\n        LocalDateTime startDate = 날짜_2022년_7월_16일_16시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual).hasSize(1);\n    }\n\n    @DisplayName(\"카테고리와 시작일시, 종료일시를 전달할 때 일정의 종료날짜가 시작일시 이전이면 조회되지 않는다.\")\n    @Test\n    void 카테고리와_시작일시와_종료일시를_전달할_때_일정의_종료날짜가_시작일시_이전이면_조회되지_않는다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n\n        Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모);\n        Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모);\n\n        scheduleRepository.save(알록달록_회의);\n        scheduleRepository.save(알록달록_회식);\n\n        List<Category> categories = List.of(BE_일정);\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_7월_7일_16시_0분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual).hasSize(0);\n    }\n\n    @DisplayName(\"시작일시와 종료일시로 특정 카테고리의 일정을 조회한다.\")\n    @Test\n    void 시작일시와_종료일시로_특정_카테고리의_일정을_조회한다() {\n        // given\n        Member 후디 = memberRepository.save(후디());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(후디));\n        Category FE_일정 = categoryRepository.save(FE_일정(후디));\n        Category 공통_일정 = categoryRepository.save(공통_일정(후디));\n\n        /* BE 일정 */\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 1\", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_8월_15일_14시_0분, \"\"));\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 2\", 날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_10일_11시_59분, \"\"));\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 3\", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_20시_0분, \"\"));\n\n        /* FE 일정 */\n        scheduleRepository.save(new Schedule(FE_일정, \"FE 1\", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분, \"\"));\n        scheduleRepository.save(new Schedule(FE_일정, \"FE 2\", 날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_20일_11시_59분, \"\"));\n        scheduleRepository.save(new Schedule(FE_일정, \"FE 3\", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_18시_0분, \"\"));\n\n        /* 공통 일정 */\n        scheduleRepository.save(new Schedule(공통_일정, \"공통 1\", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_16일_16시_1분, \"\"));\n        scheduleRepository.save(new Schedule(공통_일정, \"공통 2\", 날짜_2022년_7월_27일_0시_0분, 날짜_2022년_7월_27일_11시_59분, \"\"));\n        scheduleRepository.save(new Schedule(공통_일정, \"공통 3\", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_16시_1분, \"\"));\n\n        List<Category> categories = List.of(BE_일정, FE_일정);\n        LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDate = 날짜_2022년_8월_15일_23시_59분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate);\n\n        // then\n        assertThat(actual)\n                .extracting(IntegrationSchedule::getTitle)\n                .containsOnly(\"BE 1\", \"BE 2\", \"BE 3\", \"FE 1\", \"FE 2\", \"FE 3\");\n    }\n\n    @DisplayName(\"시작일시와 종료일시로 특정 카테고리의 일정을 조회할 때 범위 밖의 일정은 제외된다.\")\n    @Test\n    void 시작일시와_종료일시로_특정_카테고리의_일정을_조회할_때_범위_밖의_일정은_제외된다() {\n        // given\n        Member 후디 = memberRepository.save(후디());\n\n        Category BE_일정 = categoryRepository.save(BE_일정(후디));\n        Category FE_일정 = categoryRepository.save(FE_일정(후디));\n\n        /* BE 일정 */\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 1 포함\", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_8월_15일_14시_0분, \"\"));\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 2 포함\", 날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_10일_11시_59분, \"\"));\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 3 포함\", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_20시_0분, \"\"));\n        scheduleRepository.save(new Schedule(BE_일정, \"BE 3 미포함\", 날짜_2022년_7월_31일_0시_0분, 날짜_2022년_8월_15일_17시_0분, \"\"));\n\n        /* FE 일정 */\n        scheduleRepository.save(new Schedule(FE_일정, \"FE 1 포함\", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분, \"\"));\n        scheduleRepository.save(new Schedule(FE_일정, \"FE 2 포함\", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_18시_0분, \"\"));\n        scheduleRepository.save(new Schedule(FE_일정, \"FE 3 미포함\", 날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_20일_11시_59분, \"\"));\n\n        List<Category> categories = List.of(BE_일정, FE_일정);\n        LocalDateTime startDateTime = 날짜_2022년_7월_1일_0시_0분;\n        LocalDateTime endDateTime = 날짜_2022년_7월_17일_23시_59분;\n\n        // when\n        List<IntegrationSchedule> actual = scheduleRepository.getByCategoriesAndBetween(categories,\n                startDateTime, endDateTime);\n\n        // then\n        assertThat(actual).extracting(IntegrationSchedule::getTitle)\n                .containsOnly(\"BE 1 포함\", \"BE 2 포함\", \"BE 3 포함\", \"FE 1 포함\", \"FE 2 포함\");\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/domain/ScheduleTest.java",
    "content": "package com.allog.dallog.schedule.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_시작일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_종료일시;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.exception.InvalidScheduleException;\nimport java.time.LocalDateTime;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\npublic class ScheduleTest {\n\n    @DisplayName(\"일정을 생성한다.\")\n    @Test\n    void 일정을_생성한다() {\n        // given\n        Category BE_일정_카테고리 = BE_일정(관리자());\n\n        // when & then\n        assertDoesNotThrow(() -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모));\n    }\n\n    @DisplayName(\"일정 시작 일시가 가능한 범위를 벗어나는 경우 예외를 던진다.\")\n    @Test\n    void 일정_시작_일시가_가능한_범위를_벗어나는_경우_예외를_던진다() {\n        //given\n        Category BE_일정_카테고리 = BE_일정(관리자());\n        LocalDateTime 잘못된_시작_일시 = LocalDateTime.MIN;\n\n        // when & then\n        assertThatThrownBy(\n                () -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목,\n                        잘못된_시작_일시, 알록달록_회의_종료일시, 알록달록_회의_메모)\n        ).isInstanceOf(InvalidScheduleException.class);\n    }\n\n    @DisplayName(\"일정 종료 일시가 가능한 범위를 벗어나는 경우 예외를 던진다.\")\n    @Test\n    void 일정_종료_일시가_가능한_범위를_벗어나는_경우_예외를_던진다() {\n        //given\n        Category BE_일정_카테고리 = BE_일정(관리자());\n        LocalDateTime 잘못된_종료_일시 = LocalDateTime.MAX;\n\n        // when & then\n        assertThatThrownBy(\n                () -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목,\n                        알록달록_회의_시작일시, 잘못된_종료_일시, 알록달록_회의_메모)\n        ).isInstanceOf(InvalidScheduleException.class);\n    }\n\n\n    @DisplayName(\"일정 제목의 길이가 50을 초과하는 경우 예외를 던진다.\")\n    @ParameterizedTest\n    @ValueSource(strings = {\"일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일\",\n            \"알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 회의\"})\n    void 일정_제목의_길이가_50을_초과하는_경우_예외를_던진다(final String 잘못된_일정_제목) {\n        //given\n        Category BE_일정_카테고리 = BE_일정(관리자());\n\n        // when & then\n        assertThatThrownBy(() -> new Schedule(BE_일정_카테고리, 잘못된_일정_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모))\n                .isInstanceOf(InvalidScheduleException.class);\n    }\n\n    @DisplayName(\"일정 메모의 길이가 255를 초과하는 경우 예외를 던진다.\")\n    @Test\n    void 일정_메모의_길이가_255를_초과하는_경우_예외를_던진다() {\n        // given\n        String 잘못된_메모 = \"1\".repeat(256);\n        Category BE_일정_카테고리 = BE_일정(관리자());\n\n        // when & then\n        assertThatThrownBy(() -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 잘못된_메모))\n                .isInstanceOf(InvalidScheduleException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/domain/scheduler/SchedulerTest.java",
    "content": "package com.allog.dallog.schedule.domain.scheduler;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_11시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_15일_16시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_1분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_18시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_20시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_1일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_11시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_11시_59분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_31일_0시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_7일_16시_0분;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_14시_0분;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.schedule.domain.IntegrationSchedule;\nimport com.allog.dallog.schedule.domain.Period;\nimport java.time.LocalDateTime;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass SchedulerTest {\n\n    @DisplayName(\"겹치지 않는 기간을 계산한다.\")\n    @Test\n    void 겹치지_않는_기간을_계산한다() {\n        // given\n        /* 사람들의 일정 목록 */\n        Category 공통_일정 = 공통_일정(관리자());\n        String 일정_제목 = \"일정 제목\";\n        String 일정_메모 = \"일정 메모\";\n\n        IntegrationSchedule 일정1 = new IntegrationSchedule(\"1\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_7일_16시_0분,\n                날짜_2022년_7월_10일_0시_0분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정2 = new IntegrationSchedule(\"2\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_10일_11시_59분,\n                날짜_2022년_7월_15일_16시_0분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정3 = new IntegrationSchedule(\"3\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_16일_16시_0분,\n                날짜_2022년_7월_16일_16시_1분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정4 = new IntegrationSchedule(\"4\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_16일_18시_0분,\n                날짜_2022년_7월_16일_20시_0분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정5 = new IntegrationSchedule(\"5\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_16일_20시_0분,\n                날짜_2022년_7월_20일_0시_0분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정6 = new IntegrationSchedule(\"6\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_20일_11시_59분,\n                날짜_2022년_7월_27일_0시_0분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정7 = new IntegrationSchedule(\"7\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_27일_11시_59분,\n                날짜_2022년_7월_31일_0시_0분, 일정_메모, NORMAL);\n        IntegrationSchedule 일정8 = new IntegrationSchedule(\"8\", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_31일_0시_0분,\n                날짜_2022년_8월_15일_14시_0분, 일정_메모, NORMAL);\n\n        List<IntegrationSchedule> 일정_목록 = List.of(일정1, 일정2, 일정3, 일정4, 일정5, 일정6, 일정7, 일정8);\n\n        // when\n        LocalDateTime startDateTime = LocalDateTime.of(2022, 7, 1, 0, 0);\n        LocalDateTime endDateTime = LocalDateTime.of(2022, 8, 31, 0, 0);\n        Scheduler scheduler = new Scheduler(일정_목록, startDateTime, endDateTime);\n        List<Period> actual = scheduler.getPeriods();\n\n        // then\n        assertAll(() -> {\n            assertThat(actual).hasSize(7);\n            assertThat(actual).containsExactly(\n                    new Period(날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_7일_16시_0분),\n                    new Period(날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_10일_11시_59분),\n                    new Period(날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분),\n                    new Period(날짜_2022년_7월_16일_16시_1분, 날짜_2022년_7월_16일_18시_0분),\n                    new Period(날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_20일_11시_59분),\n                    new Period(날짜_2022년_7월_27일_0시_0분, 날짜_2022년_7월_27일_11시_59분),\n                    new Period(날짜_2022년_8월_15일_14시_0분, endDateTime)\n            );\n        });\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/schedule/presentation/ScheduleControllerTest.java",
    "content": "package com.allog.dallog.schedule.presentation;\n\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_시작일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_종료일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_시작일시;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_응답;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목;\nimport static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_종료일시;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.BDDMockito.given;\nimport static org.mockito.BDDMockito.willDoNothing;\nimport static org.mockito.BDDMockito.willThrow;\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;\nimport static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;\nimport static org.springframework.restdocs.request.RequestDocumentation.pathParameters;\nimport static org.springframework.restdocs.request.RequestDocumentation.requestParameters;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;\nimport static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;\nimport static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.common.annotation.ControllerTest;\nimport com.allog.dallog.schedule.dto.request.ScheduleCreateRequest;\nimport com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponse;\nimport com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses;\nimport com.allog.dallog.schedule.exception.NoSuchScheduleException;\nimport com.allog.dallog.subscription.domain.Color;\nimport java.time.LocalDateTime;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\nimport org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;\n\nclass ScheduleControllerTest extends ControllerTest {\n\n    private static final String AUTHORIZATION_HEADER_NAME = \"Authorization\";\n    private static final String AUTHORIZATION_HEADER_VALUE = \"Bearer aaaaaaaa.bbbbbbbb.cccccccc\";\n\n    @DisplayName(\"일정 정보를 등록하면 상태코드 201을 반환한다.\")\n    @Test\n    void 일정_정보를_등록하면_상태코드_201을_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        ScheduleCreateRequest request = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모);\n\n        given(scheduleService.save(any(), any(), any())).willReturn(알록달록_회의_응답);\n\n        // when & then\n        mockMvc.perform(post(\"/api/categories/{categoryId}/schedules\", categoryId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(request)))\n                .andDo(print())\n                .andDo(document(\"schedule/save\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isCreated());\n    }\n\n    @DisplayName(\"일정 정보를 등록할때 해당 카테고리에 권한이 없으면 403을 반환한다.\")\n    @Test\n    void 일정_정보를_등록할때_해당_카테고리에_권한이_없으면_403을_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        ScheduleCreateRequest request = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모);\n\n        given(scheduleService.save(any(), any(), any())).willThrow(new NoPermissionException());\n\n        // when & then\n        mockMvc.perform(post(\"/api/categories/{categoryId}/schedules\", categoryId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(request)))\n                .andDo(print())\n                .andDo(document(\"schedule/save/failByNoPermission\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isForbidden());\n    }\n\n    @DisplayName(\"일정 생성시 전달한 카테고리가 존재하지 않는다면 404를 반환한다.\")\n    @Test\n    void 일정_생성시_전달한_카테고리가_존재하지_않는다면_404를_반환한다() throws Exception {\n        // given\n        Long categoryId = 0L;\n        ScheduleCreateRequest request = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모);\n\n        given(scheduleService.save(any(), any(), any())).willThrow(new NoSuchCategoryException());\n\n        // when & then\n        mockMvc.perform(post(\"/api/categories/{categoryId}/schedules\", categoryId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(request)))\n                .andDo(print())\n                .andDo(document(\"schedule/save/failByNoCategory\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"일정을 단건 조회 하면 상태코드 200을 반환한다\")\n    @Test\n    void 일정을_단건_조회_하면_상태코드_200을_반환한다() throws Exception {\n        // given\n        Long scheduleId = 1L;\n\n        given(scheduleService.findById(scheduleId)).willReturn(알록달록_회의_응답);\n\n        // when & then\n        mockMvc.perform(get(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"schedule/findById\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"일정을 단건 조회 할 때 일정이 존재하지 않으면 상태코드 404를 반환한다.\")\n    @Test\n    void 일정을_단건_조회_할_때_일정이_존재하지_않으면_상태코드_404를_반환한다() throws Exception {\n        // given\n        Long scheduleId = 1L;\n\n        given(scheduleService.findById(scheduleId)).willThrow(new NoSuchScheduleException());\n\n        // when & then\n        mockMvc.perform(get(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"schedule/findById/failByNoSchedule\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isNotFound());\n    }\n\n\n    @DisplayName(\"일정을 수정하는데 성공하면 204를 반환한다.\")\n    @Test\n    void 일정을_수정하는데_성공하면_204를_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long scheduleId = 1L;\n        ScheduleUpdateRequest 수정_요청 = new ScheduleUpdateRequest(categoryId, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시,\n                레벨_인터뷰_메모);\n        willDoNothing()\n                .given(scheduleService)\n                .update(any(), any(), any());\n\n        // when & then\n        mockMvc.perform(RestDocumentationRequestBuilders.patch(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(수정_요청)))\n                .andDo(print())\n                .andDo(document(\"schedule/update\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"scheduleId\").description(\"일정 ID\")\n                        )\n                ))\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"일정을 수정하는데 해당 일정의 카테고리에 대한 권한이 없다면 403을 반환한다.\")\n    @Test\n    void 일정을_수정하는데_해당_일정의_카테고리에_대한_권한이_없다면_403을_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long scheduleId = 1L;\n        ScheduleUpdateRequest 수정_요청 = new ScheduleUpdateRequest(categoryId, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시,\n                레벨_인터뷰_메모);\n        willThrow(new NoPermissionException())\n                .given(scheduleService)\n                .update(any(), any(), any());\n\n        // when & then\n        mockMvc.perform(patch(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(수정_요청)))\n                .andDo(print())\n                .andDo(document(\"schedule/update/failByNoPermission\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isForbidden());\n    }\n\n    @DisplayName(\"일정을 수정하는데 일정이 존재하지 않는 경우 404를 반환한다\")\n    @Test\n    void 일정을_수정하는데_일정이_존재하지_않는_경우_404를_반환한다() throws Exception {\n        // given\n        Long categoryId = 1L;\n        Long scheduleId = 1L;\n        ScheduleUpdateRequest 수정_요청 = new ScheduleUpdateRequest(categoryId, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시,\n                레벨_인터뷰_메모);\n        willThrow(new NoSuchScheduleException())\n                .given(scheduleService)\n                .update(any(), any(), any());\n\n        // when & then\n        mockMvc.perform(patch(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(수정_요청)))\n                .andDo(print())\n                .andDo(document(\"schedule/update/failByNoSchedule\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"일정을 제거하는데 성공하면 204를 반환한다.\")\n    @Test\n    void 일정을_제거하는데_성공하면_204를_반환한다() throws Exception {\n        // given\n        Long scheduleId = 1L;\n        willDoNothing()\n                .given(scheduleService)\n                .delete(any(), any());\n\n        // when & then\n        mockMvc.perform(RestDocumentationRequestBuilders.delete(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE))\n                .andDo(print())\n                .andDo(document(\"schedule/delete\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"scheduleId\").description(\"일정 ID\")\n                        )\n                ))\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"일정을 제거하는데 해당 일정의 카테고리에 대한 권한이 없다면 403을 반환한다.\")\n    @Test\n    void 일정을_제거하는데_해당_일정의_카테고리에_대한_권한이_없다면_403을_반환한다() throws Exception {\n        // given\n        Long scheduleId = 1L;\n        willThrow(new NoPermissionException())\n                .given(scheduleService)\n                .delete(any(), any());\n\n        // when & then\n        mockMvc.perform(delete(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE))\n                .andDo(print())\n                .andDo(document(\"schedule/delete/failByNoPermission\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isForbidden());\n    }\n\n    @DisplayName(\"일정을 제거하는데 일정이 존재하지 않는 경우 404를 반환한다\")\n    @Test\n    void 일정을_제거하는데_일정이_존재하지_않는_경우_404를_반환한다() throws Exception {\n        // given\n        Long scheduleId = 1L;\n        willThrow(new NoSuchScheduleException())\n                .given(scheduleService)\n                .delete(any(), any());\n\n        // when & then\n        mockMvc.perform(delete(\"/api/schedules/{scheduleId}\", scheduleId)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE))\n                .andDo(print())\n                .andDo(document(\"schedule/delete/failByNoSchedule\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint())\n                ))\n                .andExpect(status().isNotFound());\n    }\n\n    @DisplayName(\"회원의 일정 목록을 정상적으로 조회하면 200을 반환한다.\")\n    @Test\n    void 회원의_일정_목록을_정상적으로_조회하면_200을_반환한다() throws Exception {\n        // given\n        String startDate = \"2022-07-31T00:00\";\n        String endDate = \"2022-09-03T00:00\";\n\n        IntegrationScheduleResponse 장기간_일정_1 = new IntegrationScheduleResponse(\"1L\", \"장기간 일정 1\",\n                LocalDateTime.of(2022, 8, 1, 0, 0),\n                LocalDateTime.of(2022, 8, 3, 0, 0), \"장기간 일정 1의 메모\", 1L, Color.COLOR_1.getColorCode(), \"NORMAL\");\n        IntegrationScheduleResponse 장기간_일정_2 = new IntegrationScheduleResponse(\"1L\", \"장기간 일정 2\",\n                LocalDateTime.of(2022, 8, 3, 0, 0),\n                LocalDateTime.of(2022, 8, 10, 0, 0), \"장기간 일정 2의 메모\", 3L, Color.COLOR_2.getColorCode(), \"NORMAL\");\n\n        IntegrationScheduleResponse 종일_일정_1 = new IntegrationScheduleResponse(\"1L\", \"종일 일정 1\",\n                LocalDateTime.of(2022, 8, 1, 0, 0),\n                LocalDateTime.of(2022, 8, 1, 23, 59), \"종일 일정 1의 메모\", 1L, Color.COLOR_3.getColorCode(), \"NORMAL\");\n        IntegrationScheduleResponse 종일_일정_2 = new IntegrationScheduleResponse(\"1L\", \"종일 일정 2\",\n                LocalDateTime.of(2022, 8, 5, 0, 0),\n                LocalDateTime.of(2022, 8, 5, 23, 59), \"종일 일정 2의 메모\", 3L, Color.COLOR_4.getColorCode(), \"NORMAL\");\n\n        IntegrationScheduleResponse 짧은_일정_1 = new IntegrationScheduleResponse(\"1L\", \"짧은 일정 1\",\n                LocalDateTime.of(2022, 8, 1, 0, 0),\n                LocalDateTime.of(2022, 8, 1, 1, 0), \"짧은 일정 1의 메모\", 1L, Color.COLOR_5.getColorCode(), \"NORMAL\");\n        IntegrationScheduleResponse 짧은_일정_2 = new IntegrationScheduleResponse(\"1L\", \"짧은 일정 2\",\n                LocalDateTime.of(2022, 8, 5, 17, 0),\n                LocalDateTime.of(2022, 8, 5, 19, 0), \"짧은 일정 2의 메모\", 3L, Color.COLOR_6.getColorCode(), \"NORMAL\");\n\n        IntegrationScheduleResponses integrationScheduleResponses = new IntegrationScheduleResponses(\n                List.of(장기간_일정_1, 장기간_일정_2),\n                List.of(종일_일정_1, 종일_일정_2), List.of(짧은_일정_1, 짧은_일정_2));\n\n        given(checkedSchedulesFinder.findMyCheckedSchedules(any(), any()))\n                .willReturn(integrationScheduleResponses);\n\n        // when & then\n        mockMvc.perform(\n                        get(\"/api/members/me/schedules?startDateTime={startDate}&endDateTime={endDate}\", startDate, endDate)\n                                .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE))\n                .andDo(print())\n                .andDo(document(\"schedule/findSchedulesByMemberId\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestParameters(\n                                parameterWithName(\"startDateTime\").description(\"일정 조회 시작 범위 (yyyy-mm-dd'T'HH:mm)\"),\n                                parameterWithName(\"endDateTime\").description(\"일정 조회 마지막 범위 (yyyy-mm-dd'T'HH:mm)\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"카테고리 별 일정 목록을 정상적으로 조회하면 200을 반환한다.\")\n    @Test\n    void 카테고리_별_일정_목록을_정상적으로_조회하면_200을_반환한다() throws Exception {\n        // given\n        String startDate = \"2022-07-31T00:00\";\n        String endDate = \"2022-09-03T00:00\";\n\n        IntegrationScheduleResponse 장기간_일정_1 = new IntegrationScheduleResponse(\"1L\", \"장기간 일정 1\",\n                LocalDateTime.of(2022, 8, 1, 0, 0),\n                LocalDateTime.of(2022, 8, 3, 0, 0), \"장기간 일정 1의 메모\", 1L, Color.COLOR_1.getColorCode(), \"NORMAL\");\n        IntegrationScheduleResponse 장기간_일정_2 = new IntegrationScheduleResponse(\"1L\", \"장기간 일정 2\",\n                LocalDateTime.of(2022, 8, 3, 0, 0),\n                LocalDateTime.of(2022, 8, 10, 0, 0), \"장기간 일정 2의 메모\", 3L, Color.COLOR_2.getColorCode(), \"NORMAL\");\n\n        IntegrationScheduleResponse 종일_일정_1 = new IntegrationScheduleResponse(\"1L\", \"종일 일정 1\",\n                LocalDateTime.of(2022, 8, 1, 0, 0),\n                LocalDateTime.of(2022, 8, 1, 23, 59), \"종일 일정 1의 메모\", 1L, Color.COLOR_3.getColorCode(), \"NORMAL\");\n        IntegrationScheduleResponse 종일_일정_2 = new IntegrationScheduleResponse(\"1L\", \"종일 일정 2\",\n                LocalDateTime.of(2022, 8, 5, 0, 0),\n                LocalDateTime.of(2022, 8, 5, 23, 59), \"종일 일정 2의 메모\", 3L, Color.COLOR_4.getColorCode(), \"NORMAL\");\n\n        IntegrationScheduleResponse 짧은_일정_1 = new IntegrationScheduleResponse(\"1L\", \"짧은 일정 1\",\n                LocalDateTime.of(2022, 8, 1, 0, 0),\n                LocalDateTime.of(2022, 8, 1, 1, 0), \"짧은 일정 1의 메모\", 1L, Color.COLOR_5.getColorCode(), \"NORMAL\");\n        IntegrationScheduleResponse 짧은_일정_2 = new IntegrationScheduleResponse(\"1L\", \"짧은 일정 2\",\n                LocalDateTime.of(2022, 8, 5, 17, 0),\n                LocalDateTime.of(2022, 8, 5, 19, 0), \"짧은 일정 2의 메모\", 3L, Color.COLOR_6.getColorCode(), \"NORMAL\");\n\n        IntegrationScheduleResponses integrationScheduleResponses = new IntegrationScheduleResponses(\n                List.of(장기간_일정_1, 장기간_일정_2),\n                List.of(종일_일정_1, 종일_일정_2), List.of(짧은_일정_1, 짧은_일정_2));\n\n        given(scheduleService.findByCategoryIdAndDateRange(any(), any()))\n                .willReturn(integrationScheduleResponses);\n\n        // when & then\n        mockMvc.perform(\n                        get(\"/api/categories/{categoryId}/schedules?startDateTime={startDate}&endDateTime={endDate}\", 1L,\n                                startDate, endDate))\n                .andDo(print())\n                .andDo(document(\"schedule/findSchedulesByCategoryId\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestParameters(\n                                parameterWithName(\"startDateTime\").description(\"일정 조회 시작 범위 (yyyy-mm-dd'T'HH:mm)\"),\n                                parameterWithName(\"endDateTime\").description(\"일정 조회 마지막 범위 (yyyy-mm-dd'T'HH:mm)\")\n                        )\n                ))\n                .andExpect(status().isOk());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/subscription/application/SubscriptionServiceTest.java",
    "content": "package com.allog.dallog.subscription.application;\n\nimport static com.allog.dallog.category.domain.CategoryType.NORMAL;\nimport static com.allog.dallog.category.domain.CategoryType.PERSONAL;\nimport static com.allog.dallog.common.Constants.나인_이름;\nimport static com.allog.dallog.common.Constants.나인_이메일;\nimport static com.allog.dallog.common.Constants.나인_프로필_URL;\nimport static com.allog.dallog.common.Constants.스터디_카테고리_이름;\nimport static com.allog.dallog.common.Constants.취업_카테고리_이름;\nimport static com.allog.dallog.common.Constants.티거_이름;\nimport static com.allog.dallog.common.Constants.티거_이메일;\nimport static com.allog.dallog.common.Constants.티거_프로필_URL;\nimport static com.allog.dallog.subscription.domain.Color.COLOR_1;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertAll;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRole;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleRepository;\nimport com.allog.dallog.categoryrole.domain.CategoryRoleType;\nimport com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException;\nimport com.allog.dallog.common.annotation.ServiceTest;\nimport com.allog.dallog.common.builder.GivenBuilder;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.subscription.domain.Subscription;\nimport com.allog.dallog.subscription.domain.SubscriptionRepository;\nimport com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest;\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\nimport com.allog.dallog.subscription.dto.response.SubscriptionsResponse;\nimport com.allog.dallog.subscription.exception.ExistSubscriptionException;\nimport com.allog.dallog.subscription.exception.InvalidSubscriptionException;\nimport com.allog.dallog.subscription.exception.NoSuchSubscriptionException;\nimport com.allog.dallog.subscription.exception.NotAbleToUnsubscribeException;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.transaction.annotation.Transactional;\n\nclass SubscriptionServiceTest extends ServiceTest {\n\n    private final SubscriptionUpdateRequest 구독_정보_변경_요청 = new SubscriptionUpdateRequest(COLOR_1, true);\n\n    @Autowired\n    private SubscriptionService subscriptionService;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private CategoryRoleRepository categoryRoleRepository;\n\n    @Test\n    void 구독을_생성한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거();\n\n        // when\n        SubscriptionResponse response = subscriptionService.save(티거.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        assertThat(response.getCategory().getName()).isEqualTo(취업_카테고리_이름);\n    }\n\n    @Test\n    void 타인의_개인_카테고리를_구독하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, PERSONAL);\n\n        GivenBuilder 티거 = 티거();\n\n        // when & then\n        assertThatThrownBy(() -> subscriptionService.save(티거.회원().getId(), 나인.카테고리().getId()))\n                .isInstanceOf(NoPermissionException.class)\n                .hasMessage(\"구독 권한이 없는 카테고리입니다.\");\n    }\n\n    @Test\n    void 이미_구독한_카테고리를_다시_구독하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, PERSONAL);\n\n        // when & then\n        assertThatThrownBy(() -> subscriptionService.save(나인.회원().getId(), 나인.카테고리().getId()))\n                .isInstanceOf(ExistSubscriptionException.class);\n    }\n\n    @Test\n    void 단건_구독_정보를_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, PERSONAL);\n\n        // when\n        Subscription actual = subscriptionRepository.getById(나인.구독().getId());\n\n        // then\n        assertThat(actual.getCategory().getId()).isEqualTo(나인.카테고리().getId());\n    }\n\n    @Test\n    void 회원의_구독_목록을_조회한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리를_생성한다(스터디_카테고리_이름, NORMAL);\n        티거.카테고리를_구독한다(나인.카테고리());\n\n        // when\n        SubscriptionsResponse actual = subscriptionService.findByMemberId(티거.회원().getId());\n\n        // then\n        assertThat(actual.getSubscriptions()).hasSize(2);\n    }\n\n    @Test\n    void 구독_정보를_수정한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        // when\n        subscriptionService.update(나인.구독().getId(), 나인.회원().getId(), 구독_정보_변경_요청);\n\n        // then\n        Subscription actual = subscriptionRepository.getById(나인.구독().getId());\n        assertAll(() -> {\n            assertThat(actual.getColor()).isEqualTo(COLOR_1);\n            assertThat(actual.isChecked()).isTrue();\n        });\n    }\n\n    @ParameterizedTest\n    @ValueSource(strings = {\"#111\", \"#1111\", \"#11111\", \"123456\", \"#**1234\", \"##12345\", \"334172#\", \"#00FF00\"})\n    void 존재하지_않는_색상으로_구독_정보를_수정하려_하면_예외가_발생한다(final String colorCode) {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        SubscriptionUpdateRequest 잘못된_구독_변경_요청 = new SubscriptionUpdateRequest(colorCode, true);\n\n        // when & then\n        assertThatThrownBy(() -> subscriptionService.update(나인.구독().getId(), 나인.회원().getId(), 잘못된_구독_변경_요청))\n                .isInstanceOf(InvalidSubscriptionException.class);\n    }\n\n    @Test\n    void 구독_정보를_삭제한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when\n        subscriptionService.delete(티거.구독().getId(), 티거.회원().getId());\n\n        // then\n        assertThatThrownBy(() -> subscriptionRepository.getById(티거.구독().getId()))\n                .isInstanceOf(NoSuchSubscriptionException.class);\n    }\n\n    @Test\n    void 자신의_구독_정보가_아닌_구독을_삭제할_경우_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> subscriptionService.delete(티거.구독().getId(), 나인.회원().getId()))\n                .isInstanceOf(NoPermissionException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리를_구독하면_카테고리에_대한_구독자_권한이_생성된다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거();\n\n        // when\n        subscriptionService.save(티거.회원().getId(), 나인.카테고리().getId());\n\n        // then\n        CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(티거.회원().getId(), 나인.카테고리().getId());\n        assertThat(actual.getCategoryRoleType()).isEqualTo(CategoryRoleType.NONE);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리를_구독_해제하면_카테고리에_대한_권한이_제거된다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        // when\n        subscriptionService.delete(티거.구독().getId(), 티거.회원().getId());\n\n        // then\n        assertThatThrownBy(() -> categoryRoleRepository.getByMemberIdAndCategoryId(티거.회원().getId(), 나인.카테고리().getId()))\n                .isInstanceOf(NoSuchCategoryRoleException.class);\n    }\n\n    @Transactional\n    @Test\n    void 카테고리_권한이_관리자_일때_구독_해제를_하려하면_예외가_발생한다() {\n        // given\n        GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL);\n\n        GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리());\n\n        나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리());\n\n        // when & then\n        assertThatThrownBy(() -> subscriptionService.delete(티거.구독().getId(), 티거.회원().getId()))\n                .isInstanceOf(NotAbleToUnsubscribeException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/subscription/domain/ColorTest.java",
    "content": "package com.allog.dallog.subscription.domain;\n\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.subscription.application.ColorPicker;\nimport com.allog.dallog.subscription.exception.InvalidSubscriptionException;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass ColorTest {\n\n    @DisplayName(\"랜덤으로 색상을 가져온다.\")\n    @Test\n    void 랜덤으로_색상을_가져온다() {\n        // given\n        ColorPicker colorPicker = () -> 0;\n        int randomIndex = colorPicker.pickNumber();\n\n        // when\n        Color actual = Color.pick(randomIndex);\n\n        // then\n        assertThat(actual).isEqualTo(Color.COLOR_1);\n    }\n\n    @DisplayName(\"color code에 맞는 색상을 가져온다.\")\n    @ParameterizedTest\n    @EnumSource\n    void color_code에_맞는_색상을_가져온다(final Color color) {\n        // given & when & then\n        assertThat(Color.from(color.getColorCode())).isEqualTo(color);\n    }\n\n    @DisplayName(\"소문자로 들어온 color code도 가져온다.\")\n    @ParameterizedTest\n    @EnumSource\n    void 소문자로_들어온_color_code도_가져온다(final Color color) {\n        // given\n        String lowerColorCode = color.getColorCode().toLowerCase();\n\n        // when & then\n        assertThat(Color.from(lowerColorCode)).isEqualTo(color);\n    }\n\n    @DisplayName(\"존재하지 않는 color code인 경우 예외가 발생한다.\")\n    @ParameterizedTest\n    @ValueSource(strings = {\"#asdfe\", \"#adfqwerse\"})\n    void 존재하지_않는_color_code인_경우_예외가_발생한다(final String colorCode) {\n        // given & when & then\n        assertThatThrownBy(() -> Color.from(colorCode))\n                .isInstanceOf(InvalidSubscriptionException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/subscription/domain/SubscriptionRepositoryTest.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상2_구독;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상3_구독;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.domain.CategoryRepository;\nimport com.allog.dallog.common.annotation.RepositoryTest;\nimport com.allog.dallog.member.domain.Member;\nimport com.allog.dallog.member.domain.MemberRepository;\nimport com.allog.dallog.subscription.exception.ExistSubscriptionException;\nimport com.allog.dallog.subscription.exception.NoSuchSubscriptionException;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\n\nclass SubscriptionRepositoryTest extends RepositoryTest {\n\n    @Autowired\n    private MemberRepository memberRepository;\n\n    @Autowired\n    private CategoryRepository categoryRepository;\n\n    @Autowired\n    private SubscriptionRepository subscriptionRepository;\n\n    @DisplayName(\"존재하지 않는 카테고리를 확인할 경우 true를 반환한다.\")\n    @Test\n    void 존재하지_않는_카테고리를_확인할_경우_true를_반환한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n\n        Member 매트 = memberRepository.save(매트());\n\n        // when\n        boolean actual = subscriptionRepository.existsByMemberIdAndCategoryId(매트.getId(), 공통_일정.getId());\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"이미 존재하는 카테고리를 확인할 경우 true를 반환한다.\")\n    @Test\n    void 이미_존재하는_카테고리를_확인할_경우_true를_반환한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n\n        Member 매트 = memberRepository.save(매트());\n        subscriptionRepository.save(색상1_구독(매트, 공통_일정));\n\n        // when\n        boolean actual = subscriptionRepository.existsByMemberIdAndCategoryId(매트.getId(), 공통_일정.getId());\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"회원의 특정 구독 정보 여부를 확인한다.\")\n    @Test\n    void 회원의_특정_구독_정보_여부를_확인한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n\n        Member 후디 = memberRepository.save(후디());\n        Subscription 색상1_구독 = 색상1_구독(후디, 공통_일정);\n        subscriptionRepository.save(색상1_구독);\n\n        // when\n        boolean actual = subscriptionRepository.existsByIdAndMemberId(색상1_구독.getId(), 후디.getId());\n\n        // then\n        assertThat(actual).isTrue();\n    }\n\n    @DisplayName(\"회원의 존재하지 않는 구독 정보 여부를 확인한다.\")\n    @Test\n    void 회원의_존재하지_않는_구독_정보_여부를_확인한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        // when\n        boolean actual = subscriptionRepository.existsByIdAndMemberId(0L, 관리자.getId());\n\n        // then\n        assertThat(actual).isFalse();\n    }\n\n    @DisplayName(\"회원 정보를 기반으로 구독 정보를 조회한다.\")\n    @Test\n    void 회원_정보를_기반으로_구독_정보를_조회한다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category 공통_일정 = categoryRepository.save(공통_일정(관리자));\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n\n        Member 후디 = memberRepository.save(후디());\n        subscriptionRepository.save(색상1_구독(후디, 공통_일정));\n        subscriptionRepository.save(색상2_구독(후디, BE_일정));\n        subscriptionRepository.save(색상3_구독(후디, FE_일정));\n\n        // when\n        List<Subscription> subscriptions = subscriptionRepository.findByMemberId(후디.getId());\n\n        // then\n        assertThat(subscriptions).hasSize(3);\n    }\n\n    @DisplayName(\"회원의 구독 정보가 존재하지 않는 경우 빈 리스트가 조회된다.\")\n    @Test\n    void 회원의_구독_정보가_존재하지_않는_경우_빈_리스트가_조회된다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n\n        // when\n        List<Subscription> subscriptions = subscriptionRepository.findByMemberId(관리자.getId());\n\n        // then\n        assertThat(subscriptions).isEmpty();\n    }\n\n    @DisplayName(\"특정 카테고리들에 속한 구독을 전부 삭제한다\")\n    @Test\n    void 특정_카테고리들에_속한_구독을_전부_삭제한다() {\n        // given\n        Member 관리자 = 관리자();\n        memberRepository.save(관리자);\n        Member 파랑 = 파랑();\n        memberRepository.save(파랑);\n\n        Category BE_일정 = BE_일정(관리자);\n        Category FE_일정 = FE_일정(관리자);\n        Category 공통_일정 = 공통_일정(관리자);\n        categoryRepository.save(BE_일정);\n        categoryRepository.save(FE_일정);\n        categoryRepository.save(공통_일정);\n\n        subscriptionRepository.save(색상1_구독(관리자, BE_일정));\n        subscriptionRepository.save(색상2_구독(관리자, FE_일정));\n        subscriptionRepository.save(색상3_구독(관리자, 공통_일정));\n        subscriptionRepository.save(색상1_구독(파랑, BE_일정));\n        subscriptionRepository.save(색상2_구독(파랑, FE_일정));\n        subscriptionRepository.save(색상3_구독(파랑, 공통_일정));\n\n        // when\n        subscriptionRepository.deleteByCategoryIdIn(List.of(\n                BE_일정.getId(), FE_일정.getId(), 공통_일정.getId()\n        ));\n\n        // then\n        assertThat(subscriptionRepository.findAll()).hasSize(0);\n    }\n\n    @DisplayName(\"존재하지 않는 id인 경우 예외를 던진다.\")\n    @Test\n    void 존재하지_않는_id인_경우_예외를_던진다() {\n        // given\n        Long id = 0L;\n\n        // when & then\n        assertThatThrownBy(() -> subscriptionRepository.getById(id))\n                .isInstanceOf(NoSuchSubscriptionException.class);\n    }\n\n    @DisplayName(\"특정 member가 특정 category를 구독한 경우 예외를 던진다.\")\n    @Test\n    void 특정_member가_특정_category를_구독한_경우_예외를_던진다() {\n        // given\n        Member 관리자 = memberRepository.save(관리자());\n        Category BE_일정 = categoryRepository.save(BE_일정(관리자));\n        Category FE_일정 = categoryRepository.save(FE_일정(관리자));\n\n        // when\n        Member 매트 = memberRepository.save(매트());\n        subscriptionRepository.save(색상1_구독(매트, BE_일정)); // BE만 구독\n\n        // then\n        assertThatThrownBy(() ->\n                subscriptionRepository.validateNotExistsByMemberIdAndCategoryId(매트.getId(), BE_일정.getId()))\n                .isInstanceOf(ExistSubscriptionException.class);\n    }\n\n    @DisplayName(\"특정 구독 id가 특정 member의 구독이 아닌 경우 예외를 던진다.\")\n    @Test\n    void 특정_구독_id가_특정_member의_구독이_아닌_경우_예외를_던진다() {\n        // given\n        Member 매트 = memberRepository.save(매트());\n\n        // when & then\n        assertThatThrownBy(() ->\n                subscriptionRepository.validateExistsByIdAndMemberId(0L, 매트.getId()))\n                .isInstanceOf(NoPermissionException.class);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/subscription/domain/SubscriptionTest.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.후디;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.member.domain.Member;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass SubscriptionTest {\n\n    @DisplayName(\"구독을 생성한다.\")\n    @Test\n    void 구독을_생성한다() {\n        // given\n        Member 후디 = 후디();\n        Category 후디_JPA_스터디 = 후디_JPA_스터디(후디);\n        Color color = Color.COLOR_1;\n\n        // when & then\n        assertDoesNotThrow(() -> new Subscription(후디, 후디_JPA_스터디, color));\n    }\n\n    @DisplayName(\"구독이 생성되면 기본적으로 체크된다.\")\n    @Test\n    void 구독이_생성되면_기본적으로_체크된다() {\n        // given\n        Member 매트 = 매트();\n        Category 매트_아고라 = 매트_아고라(매트);\n        Color color = Color.COLOR_1;\n\n        // when\n        Subscription actual = new Subscription(매트, 매트_아고라, color);\n\n        // then\n        assertThat(actual.isChecked()).isTrue();\n    }\n\n    @DisplayName(\"구독의 색 정보를 수정한다.\")\n    @Test\n    void 구독의_색_정보를_수정한다() {\n        // given\n        Member 매트 = 매트();\n        Category 매트_아고라 = 매트_아고라(매트);\n\n        // when \n        Subscription actual = 색상1_구독(매트, 매트_아고라);\n        actual.change(Color.COLOR_1, actual.isChecked());\n\n        // then\n        assertThat(actual.getColor()).isEqualTo(Color.COLOR_1);\n    }\n\n    @DisplayName(\"구독의 체크 유무를 수정한다.\")\n    @Test\n    void 구독의_체크_유무를_수정한다() {\n        // given\n        Member 매트 = 매트();\n        Category 매트_아고라 = 매트_아고라(매트);\n\n        // when\n        Subscription actual = 색상1_구독(매트, 매트_아고라);\n        actual.change(actual.getColor(), !actual.isChecked());\n\n        // then\n        assertThat(actual.isChecked()).isFalse();\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/subscription/domain/SubscriptionsTest.java",
    "content": "package com.allog.dallog.subscription.domain;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.setId;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정;\nimport static com.allog.dallog.common.fixtures.IntegrationScheduleFixtures.달록_여행;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.파랑;\nimport static com.allog.dallog.subscription.domain.Color.COLOR_1;\nimport static com.allog.dallog.subscription.domain.Color.COLOR_2;\nimport static com.allog.dallog.subscription.domain.Color.COLOR_3;\nimport static com.allog.dallog.subscription.domain.Color.COLOR_4;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\n\nimport com.allog.dallog.category.domain.Category;\nimport com.allog.dallog.category.exception.NoSuchCategoryException;\nimport com.allog.dallog.member.domain.Member;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\n\nclass SubscriptionsTest {\n\n    @DisplayName(\"체크된 카테고리 중 내부 카테고리의 아이디를 찾는다.\")\n    @Test\n    void 체크된_카테고리_중_내부_카테고리의_아이디를_찾는다() {\n        // given\n        Member 파랑 = 파랑();\n        Category 공통_일정 = setId(공통_일정(파랑), 1L);\n        Category BE_일정 = setId(BE_일정(파랑), 2L);\n        Category 내_일정 = setId(내_일정(파랑), 3L);\n        Category 우아한테크코스_일정 = setId(우아한테크코스_일정(파랑), 4L);\n\n        Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1);\n        Subscription BE_일정_구독 = new Subscription(파랑, BE_일정, COLOR_2);\n        Subscription 내_일정_구독 = new Subscription(파랑, 내_일정, COLOR_3);\n        Subscription 우아한테크코스_일정_구독 = new Subscription(파랑, 우아한테크코스_일정, COLOR_4);\n\n        BE_일정_구독.change(COLOR_2, false);\n\n        Subscriptions subscriptions =\n                new Subscriptions(List.of(공통_일정_구독, BE_일정_구독, 내_일정_구독, 우아한테크코스_일정_구독));\n\n        // when & then\n        assertThat(subscriptions.findInternalCategory()).isEqualTo(List.of(공통_일정, 내_일정));\n    }\n\n    @DisplayName(\"체크된 카테고리 중 외부 카테고리의 아이디를 찾는다.\")\n    @Test\n    void 체크된_카테고리_중_외부_카테고리의_아이디를_찾는다() {\n        // given\n        Member 파랑 = 파랑();\n        Category 공통_일정 = setId(공통_일정(파랑), 1L);\n        Category BE_일정 = setId(BE_일정(파랑), 2L);\n        Category 내_일정 = setId(내_일정(파랑), 3L);\n        Category 우아한테크코스_일정 = setId(우아한테크코스_일정(파랑), 4L);\n\n        Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1);\n        Subscription BE_일정_구독 = new Subscription(파랑, BE_일정, COLOR_2);\n        Subscription 내_일정_구독 = new Subscription(파랑, 내_일정, COLOR_3);\n        Subscription 우아한테크코스_일정_구독 = new Subscription(파랑, 우아한테크코스_일정, COLOR_4);\n\n        Subscriptions subscriptions =\n                new Subscriptions(List.of(공통_일정_구독, BE_일정_구독, 내_일정_구독, 우아한테크코스_일정_구독));\n\n        // when & then\n        assertThat(subscriptions.findExternalCategory()).isEqualTo(List.of(우아한테크코스_일정));\n    }\n\n    @DisplayName(\"특정 스케줄의 구독 색상을 찾는다.\")\n    @Test\n    void 특정_스케줄의_구독_색상을_찾는다() {\n        // given\n        Member 파랑 = 파랑();\n        Category 공통_일정 = setId(공통_일정(파랑), 1L);\n        Category BE_일정 = setId(BE_일정(파랑), 2L);\n        Category 내_일정 = setId(내_일정(파랑), 3L);\n        Category 우아한테크코스_일정 = setId(우아한테크코스_일정(파랑), 4L);\n\n        Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1);\n        Subscription BE_일정_구독 = new Subscription(파랑, BE_일정, COLOR_2);\n        Subscription 내_일정_구독 = new Subscription(파랑, 내_일정, COLOR_3);\n        Subscription 우아한테크코스_일정_구독 = new Subscription(파랑, 우아한테크코스_일정, COLOR_4);\n\n        Subscriptions subscriptions =\n                new Subscriptions(List.of(공통_일정_구독, BE_일정_구독, 내_일정_구독, 우아한테크코스_일정_구독));\n\n        // when & then\n        assertThat(subscriptions.findColor(달록_여행)).isEqualTo(COLOR_2);\n    }\n\n    @DisplayName(\"구독하지 않은 스케줄의 구독 색상을 찾는 경우 예외를 던진다\")\n    @Test\n    void 구독하지_않은_스케줄의_구독_색상을_찾는_경우_예외를_던진다() {\n        // given\n        Member 파랑 = 파랑();\n        Category 공통_일정 = setId(공통_일정(파랑), 1L);\n        setId(BE_일정(파랑), 2L);\n\n        Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1);\n\n        Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독));\n\n        // when & then\n        assertThatThrownBy(() -> subscriptions.findColor(달록_여행)).isInstanceOf(NoSuchCategoryException.class);\n    }\n\n    @DisplayName(\"구독한 카테고리중 내부 카테고리를 찾아 반환한다.\")\n    @Test\n    void 구독한_카테고리중_내부_카테고리를_찾아_반환한다() {\n        // given\n        Member 파랑 = 파랑();\n        Category 공통_일정 = setId(공통_일정(파랑), 1L);\n        setId(BE_일정(파랑), 2L);\n\n        Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1);\n\n        Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독));\n\n        // when\n        List<Category> categories = subscriptions.findInternalCategory();\n\n        // then\n        assertThat(categories).hasSize(1);\n    }\n\n    @DisplayName(\"구독한 카테고리중 외부 카테고리를 찾아 반환한다.\")\n    @Test\n    void 구독한_카테고리중_외부_카테고리를_찾아_반환한다() {\n        // given\n        Member 파랑 = 파랑();\n        Category 공통_일정 = setId(공통_일정(파랑), 1L);\n        setId(BE_일정(파랑), 2L);\n\n        Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1);\n\n        Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독));\n\n        // when\n        List<Category> categories = subscriptions.findExternalCategory();\n\n        // then\n        assertThat(categories).hasSize(0);\n    }\n}\n"
  },
  {
    "path": "backend/src/test/java/com/allog/dallog/subscription/presentation/SubscriptionControllerTest.java",
    "content": "package com.allog.dallog.subscription.presentation;\n\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_응답;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_응답;\nimport static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_응답;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.관리자_응답;\nimport static com.allog.dallog.common.fixtures.MemberFixtures.매트_응답;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독_응답;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상2_구독_응답;\nimport static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상3_구독_응답;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.BDDMockito.given;\nimport static org.mockito.BDDMockito.willDoNothing;\nimport static org.mockito.BDDMockito.willThrow;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;\nimport static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;\nimport static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;\nimport static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;\nimport static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;\nimport static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;\nimport static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;\nimport static org.springframework.restdocs.request.RequestDocumentation.pathParameters;\nimport static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;\nimport static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;\n\nimport com.allog.dallog.auth.exception.NoPermissionException;\nimport com.allog.dallog.category.dto.response.CategoryResponse;\nimport com.allog.dallog.common.annotation.ControllerTest;\nimport com.allog.dallog.subscription.domain.Color;\nimport com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest;\nimport com.allog.dallog.subscription.dto.response.SubscriptionResponse;\nimport com.allog.dallog.subscription.dto.response.SubscriptionsResponse;\nimport com.allog.dallog.subscription.exception.ExistSubscriptionException;\nimport java.util.List;\nimport org.junit.jupiter.api.DisplayName;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.http.MediaType;\nimport org.springframework.restdocs.payload.JsonFieldType;\n\nclass SubscriptionControllerTest extends ControllerTest {\n\n    private static final String AUTHORIZATION_HEADER_NAME = \"Authorization\";\n    private static final String AUTHORIZATION_HEADER_VALUE = \"Bearer aaaaa.bbbbb.ccccc\";\n\n    @DisplayName(\"회원과 카테고리 정보를 기반으로 구독한다.\")\n    @Test\n    void 회원과_카테고리_정보를_기반으로_구독한다() throws Exception {\n        // given\n        CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답);\n        SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답);\n\n        given(authService.extractMemberId(any())).willReturn(매트_응답.getId());\n        given(subscriptionService.save(any(), any())).willReturn(색상1_구독_응답);\n\n        // when & then\n        mockMvc.perform(post(\"/api/members/me/categories/{categoryId}/subscriptions\", 공통_일정_응답.getId())\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"subscription/save\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"categoryId\").description(\"카테고리 id\")\n                        ),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )))\n                .andExpect(status().isCreated());\n    }\n\n    @DisplayName(\"회원이 이미 카테고리를 구독한 경우 예외를 던진다.\")\n    @Test\n    void 회원이_이미_카테고리를_구독한_경우_예외를_던진다() throws Exception {\n        // given\n        given(authService.extractMemberId(any())).willReturn(매트_응답.getId());\n        given(subscriptionService.save(any(), any())).willThrow(new ExistSubscriptionException());\n\n        // when & then\n        mockMvc.perform(\n                        post(\"/api/members/me/categories/{categoryId}/subscriptions\", 1L)\n                                .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                                .accept(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"subscription/save/failByAlreadyExisting\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"categoryId\").description(\"카테고리 id\")\n                        ),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )))\n                .andExpect(status().isBadRequest());\n    }\n\n    @DisplayName(\"타인의 개인 카테고리 구독 요청시 403 Forbidden을 반환한다.\")\n    @Test\n    void 타인의_개인_카테고리_구독_요청시_403_Forbidden을_반환한다() throws Exception {\n        // given\n        given(subscriptionService.save(any(), any()))\n                .willThrow(new NoPermissionException(\"구독 권한이 없는 카테고리입니다.\"));\n\n        // when & then\n        mockMvc.perform(\n                        post(\"/api/members/me/categories/{categoryId}/subscriptions\", 1L)\n                                .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                                .accept(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"subscription/save/failBySubscribingPrivateCategoryOfOther\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"categoryId\").description(\"카테고리 id\")\n                        ),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )))\n                .andExpect(status().isForbidden());\n    }\n\n    @DisplayName(\"자신의 구독 정보를 조회한다.\")\n    @Test\n    void 자신의_구독_정보를_조회한다() throws Exception {\n        // given\n        CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답);\n        CategoryResponse BE_일정_응답 = BE_일정_응답(관리자_응답);\n        CategoryResponse FE_일정_응답 = FE_일정_응답(관리자_응답);\n\n        SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답);\n        SubscriptionResponse 색상2_구독_응답 = 색상2_구독_응답(BE_일정_응답);\n        SubscriptionResponse 색상3_구독_응답 = 색상3_구독_응답(FE_일정_응답);\n\n        given(authService.extractMemberId(any())).willReturn(매트_응답.getId());\n\n        List<SubscriptionResponse> subscriptionResponses = List.of(색상1_구독_응답, 색상2_구독_응답, 색상3_구독_응답);\n        given(subscriptionService.findByMemberId(any())).willReturn(new SubscriptionsResponse(subscriptionResponses));\n\n        // when & then\n        mockMvc.perform(get(\"/api/members/me/subscriptions\")\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .accept(MediaType.APPLICATION_JSON)\n                        .contentType(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"subscription/findMine\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )))\n                .andExpect(status().isOk());\n    }\n\n    @DisplayName(\"자신의 구독 정보를 수정한다.\")\n    @Test\n    void 자신의_구독_정보를_수정한다() throws Exception {\n        // given\n        CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답);\n        SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답);\n        SubscriptionUpdateRequest request = new SubscriptionUpdateRequest(Color.COLOR_2, true);\n\n        given(authService.extractMemberId(any())).willReturn(매트_응답.getId());\n        willDoNothing().given(subscriptionService)\n                .update(색상1_구독_응답.getId(), 매트_응답.getId(), request);\n\n        // when & then\n        mockMvc.perform(patch(\"/api/members/me/subscriptions/{subscriptionId}\", 색상1_구독_응답.getId())\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .contentType(MediaType.APPLICATION_JSON)\n                        .content(objectMapper.writeValueAsString(request)))\n                .andDo(print())\n                .andDo(document(\"subscription/update\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"subscriptionId\").description(\"구독 id\")\n                        ),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        ),\n                        requestFields(\n                                fieldWithPath(\"colorCode\").type(JsonFieldType.STRING).description(\"구독 색 정보\"),\n                                fieldWithPath(\"checked\").type(JsonFieldType.BOOLEAN).description(\"체크 유무\")\n                        )))\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"구독 id를 기반으로 자신의 구독 정보를 삭제한다.\")\n    @Test\n    void 구독_id를_기반으로_자신의_구독_정보를_삭제한다() throws Exception {\n        // given\n        CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답);\n        SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답);\n\n        given(authService.extractMemberId(any())).willReturn(매트_응답.getId());\n        willDoNothing().given(subscriptionService)\n                .delete(색상1_구독_응답.getId(), 매트_응답.getId());\n\n        // when & then\n        mockMvc.perform(delete(\"/api/members/me/subscriptions/{subscriptionId}\", 색상1_구독_응답.getId())\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .contentType(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"subscription/delete\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"subscriptionId\").description(\"구독 id\")\n                        ),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )))\n                .andExpect(status().isNoContent());\n    }\n\n    @DisplayName(\"구독 제거시 자신이 가지고 있지 않은 구독 정보인 경우 예외를 던진다.\")\n    @Test\n    void 구독_제거시_자신이_가지고_있지_않은_구독_정보인_경우_예외를_던진다() throws Exception {\n        // given\n        given(authService.extractMemberId(any())).willReturn(매트_응답.getId());\n        willThrow(new NoPermissionException())\n                .willDoNothing()\n                .given(subscriptionService)\n                .delete(any(), any());\n\n        // when & then\n        mockMvc.perform(delete(\"/api/members/me/subscriptions/{subscriptionId}\", 1L)\n                        .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)\n                        .contentType(MediaType.APPLICATION_JSON))\n                .andDo(print())\n                .andDo(document(\"subscription/delete/failByNoPermission\",\n                        preprocessRequest(prettyPrint()),\n                        preprocessResponse(prettyPrint()),\n                        pathParameters(\n                                parameterWithName(\"subscriptionId\").description(\"구독 id\")\n                        ),\n                        requestHeaders(\n                                headerWithName(\"Authorization\").description(\"JWT 토큰\")\n                        )))\n                .andExpect(status().isForbidden());\n    }\n}\n"
  },
  {
    "path": "backend/src/test/resources/application.yml",
    "content": "spring:\n  profiles:\n    active: test\n"
  },
  {
    "path": "frontend/.eslintrc.json",
    "content": "{\n  \"parser\": \"@typescript-eslint/parser\",\n  \"plugins\": [\"@typescript-eslint\", \"react-hooks\", \"prettier\"],\n  \"extends\": [\n    \"plugin:react/recommended\",\n    \"plugin:import/errors\",\n    \"plugin:import/warnings\",\n    \"plugin:import/recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"plugin:prettier/recommended\"\n  ],\n  \"rules\": {\n    \"react/react-in-jsx-scope\": \"off\",\n    \"react/no-unknown-property\": [\n      \"error\",\n      {\n        \"ignore\": [\"css\"]\n      }\n    ],\n    \"react-hooks/rules-of-hooks\": \"error\",\n    \"react-hooks/exhaustive-deps\": \"warn\",\n    \"import/named\": \"off\",\n    \"import/no-unresolved\": \"off\",\n    \"@typescript-eslint/no-var-requires\": \"off\"\n  },\n  \"settings\": {\n    \"react\": {\n      \"version\": \"detect\"\n    }\n  }\n}\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "node_modules\ndist\n.env\n"
  },
  {
    "path": "frontend/.prettierrc.json",
    "content": "{\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\",\n  \"bracketSpacing\": true,\n  \"bracketSameLine\": false,\n  \"arrowParens\": \"always\",\n  \"endOfLine\": \"auto\",\n  \"importOrder\": [\n    \"@/hooks/(.*)$\",\n    \"@/@types\",\n    \"@/recoil/(atoms|selectors)\",\n    \"@/styles/(.*)$\",\n    \"@/(components/@common/|components/|pages/)(.*)$\",\n    \"@/constants\",\n    \"@/utils\",\n    \"@/domains\",\n    \"@/(api|mocks)/(.*)$\",\n    \"react-icons\",\n    \"^[./]\"\n  ],\n  \"importOrderSeparation\": true,\n  \"importOrderSortSpecifiers\": true,\n  \"importOrderCaseInsensitive\": true\n}\n"
  },
  {
    "path": "frontend/.storybook/main.js",
    "content": "const path = require('path');\n\nmodule.exports = {\n  stories: ['../src/**/*.stories.@(tsx)'],\n  addons: [\n    '@storybook/addon-links',\n    '@storybook/addon-essentials',\n    '@storybook/addon-interactions',\n  ],\n  framework: '@storybook/react',\n  core: {\n    builder: '@storybook/builder-webpack5',\n  },\n  webpackFinal: async (config) => {\n    config.resolve.alias = {\n      ...config.resolve.alias,\n      '@': path.resolve(__dirname, '../src/'),\n    };\n    config.resolve.extensions.push('.ts', '.tsx');\n\n    return config;\n  },\n};\n"
  },
  {
    "path": "frontend/.storybook/preview-body.html",
    "content": "<style>\n  html {\n    font-size: 4px;\n  }\n</style>\n"
  },
  {
    "path": "frontend/.storybook/preview.js",
    "content": "import { ThemeProvider } from '@emotion/react';\nimport { QueryClient, QueryClientProvider } from 'react-query';\nimport { BrowserRouter as Router } from 'react-router-dom';\nimport { RecoilRoot } from 'recoil';\n\nimport GlobalStyle from '../src/styles/GlobalStyle';\nimport theme from '../src/styles/theme';\n\nconst queryClient = new QueryClient();\n\nexport const parameters = {\n  actions: { argTypesRegex: '^on[A-Z].*' },\n  controls: {\n    matchers: {\n      color: /(background|color)$/i,\n      date: /Date$/,\n    },\n  },\n};\n\nexport const decorators = [\n  (Story) => (\n    <RecoilRoot>\n      <ThemeProvider theme={theme}>\n        <GlobalStyle />\n\n        <QueryClientProvider client={queryClient}>\n          <Router>\n            <Story />\n          </Router>\n        </QueryClientProvider>\n      </ThemeProvider>\n    </RecoilRoot>\n  ),\n];\n"
  },
  {
    "path": "frontend/babel.config.js",
    "content": "module.exports = {\n  presets: [\n    '@babel/preset-react',\n    '@babel/preset-env',\n    '@babel/preset-typescript',\n    '@emotion/babel-preset-css-prop',\n  ],\n  plugins: [\n    '@emotion',\n    [\n      '@babel/plugin-transform-runtime',\n      {\n        corejs: 3,\n        proposals: true,\n      },\n    ],\n  ],\n};\n"
  },
  {
    "path": "frontend/jest.config.js",
    "content": "/** @type {import('@jest/types').Config.InitialOptions} */\nconst config = {\n  verbose: true,\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"dallog\",\n  \"version\": \"2.0.0\",\n  \"description\": \"share calendar dallog\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"dev\": \"webpack serve --mode development\",\n    \"prod-build\": \"webpack --mode production\",\n    \"dev-build\": \"webpack --mode development\",\n    \"storybook\": \"start-storybook -p 6006\",\n    \"build-storybook\": \"build-storybook\",\n    \"pretty\": \"prettier . --write\",\n    \"check-lint\": \"eslint . --ext .js,.jsx,.ts,.tsx\",\n    \"check-prettier\": \"prettier -c ./src\",\n    \"test\": \"jest --setupFiles ./setupFile.js\",\n    \"report\": \"webpack-bundle-analyzer --port 4200 dist/stats.json\"\n  },\n  \"dependencies\": {\n    \"@babel/runtime-corejs3\": \"^7.20.1\",\n    \"@emotion/react\": \"^11.9.3\",\n    \"@emotion/styled\": \"^11.9.3\",\n    \"axios\": \"^0.27.2\",\n    \"dotenv-webpack\": \"^8.0.0\",\n    \"emotion-reset\": \"^3.0.1\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-query\": \"^3.39.1\",\n    \"react-router-dom\": \"^6.3.0\",\n    \"recoil\": \"^0.7.4\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.18.6\",\n    \"@babel/plugin-transform-runtime\": \"^7.19.6\",\n    \"@babel/preset-env\": \"^7.18.6\",\n    \"@babel/preset-react\": \"^7.18.6\",\n    \"@babel/preset-typescript\": \"^7.18.6\",\n    \"@emotion/babel-plugin\": \"^11.9.2\",\n    \"@emotion/babel-preset-css-prop\": \"^11.2.0\",\n    \"@storybook/addon-actions\": \"^6.5.9\",\n    \"@storybook/addon-essentials\": \"^6.5.9\",\n    \"@storybook/addon-interactions\": \"^6.5.9\",\n    \"@storybook/addon-links\": \"^6.5.9\",\n    \"@storybook/builder-webpack5\": \"^6.5.9\",\n    \"@storybook/manager-webpack5\": \"^6.5.9\",\n    \"@storybook/react\": \"^6.5.9\",\n    \"@storybook/testing-library\": \"^0.0.13\",\n    \"@storybook/testing-react\": \"^1.3.0\",\n    \"@testing-library/react\": \"^13.3.0\",\n    \"@trivago/prettier-plugin-sort-imports\": \"^3.2.0\",\n    \"@types/body-parser\": \"^1.19.2\",\n    \"@types/graceful-fs\": \"^4.1.5\",\n    \"@types/jest\": \"^28.1.6\",\n    \"@types/node\": \"^18.0.1\",\n    \"@types/react\": \"^18.0.14\",\n    \"@types/react-dom\": \"^18.0.5\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.30.4\",\n    \"@typescript-eslint/parser\": \"^5.30.4\",\n    \"babel-loader\": \"^8.2.5\",\n    \"compression-webpack-plugin\": \"^10.0.0\",\n    \"css-loader\": \"^6.7.1\",\n    \"css-minimizer-webpack-plugin\": \"^4.1.0\",\n    \"eslint\": \"^8.19.0\",\n    \"eslint-config-prettier\": \"^8.5.0\",\n    \"eslint-plugin-import\": \"^2.26.0\",\n    \"eslint-plugin-prettier\": \"^4.2.1\",\n    \"eslint-plugin-react\": \"^7.30.1\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"file-loader\": \"^6.2.0\",\n    \"fork-ts-checker-webpack-plugin\": \"^7.2.13\",\n    \"html-webpack-plugin\": \"^5.5.0\",\n    \"jest\": \"^28.1.3\",\n    \"jest-environment-jsdom\": \"^28.1.3\",\n    \"mini-css-extract-plugin\": \"^2.6.1\",\n    \"prettier\": \"^2.7.1\",\n    \"react-icons\": \"^4.4.0\",\n    \"style-loader\": \"^3.3.1\",\n    \"typescript\": \"^4.7.4\",\n    \"webpack\": \"^5.73.0\",\n    \"webpack-bundle-analyzer\": \"^4.6.1\",\n    \"webpack-cli\": \"^4.10.0\",\n    \"webpack-dev-server\": \"^4.9.3\"\n  }\n}\n"
  },
  {
    "path": "frontend/setupFile.js",
    "content": "import { setGlobalConfig } from '@storybook/testing-react';\n\nimport * as globalStorybookConfig from './.storybook/preview';\n\nsetGlobalConfig(globalStorybookConfig);\n"
  },
  {
    "path": "frontend/src/@types/calendar.ts",
    "content": "interface CalendarType {\n  year: number;\n  month: number;\n  date: number;\n  day: number;\n}\n\nexport { CalendarType };\n"
  },
  {
    "path": "frontend/src/@types/category.ts",
    "content": "import { ProfileType } from '@/@types/profile';\nimport { ValueOf } from '@/@types/util';\n\nimport { CATEGORY_TYPE, ROLE } from '@/constants/category';\n\ntype CategoryRoleType = ValueOf<typeof ROLE>;\n\ninterface CategoryType {\n  id: number;\n  name: string;\n  creator: ProfileType;\n  createdAt: string;\n  categoryType: ValueOf<typeof CATEGORY_TYPE>;\n}\n\ninterface CategorySubscriberType {\n  member: ProfileType;\n  categoryRoleType: CategoryRoleType;\n}\n\ninterface SingleCategoryType extends CategoryType {\n  subscriberCount: number;\n}\n\nexport { CategoryType, CategoryRoleType, CategorySubscriberType, SingleCategoryType };\n"
  },
  {
    "path": "frontend/src/@types/custom.d.ts",
    "content": "declare module '*.png';\ndeclare module '*.gif';\ndeclare module '*.jpg';\ndeclare module '*.jpeg';\n"
  },
  {
    "path": "frontend/src/@types/emotion.d.ts",
    "content": "import { SerializedStyles } from '@emotion/react';\n\ninterface ColorsType {\n  [key: string]: string;\n}\n\ntype FlexType = Record<'row' | 'column', SerializedStyles>;\n\ninterface MQType {\n  laptop: string;\n  tablet: string;\n  mobile: string;\n}\n\ndeclare module '@emotion/react' {\n  export interface Theme {\n    colors: ColorsType;\n    flex: FlexType;\n    mq?: MQType;\n  }\n}\n"
  },
  {
    "path": "frontend/src/@types/googleCalendar.ts",
    "content": "interface GoogleCalendarGetResponseType {\n  externalCalendars: Array<{\n    calendarId: string;\n    summary: string;\n  }>;\n}\n\ninterface GoogleCalendarPostBodyType {\n  externalId: string;\n  name: string;\n}\n\nexport { GoogleCalendarGetResponseType, GoogleCalendarPostBodyType };\n"
  },
  {
    "path": "frontend/src/@types/index.ts",
    "content": "import { SerializedStyles } from '@emotion/react';\n\ninterface FieldsetCssPropType {\n  div?: SerializedStyles;\n  input?: SerializedStyles;\n  label?: SerializedStyles;\n}\n\ninterface SelectCssPropType {\n  select?: SerializedStyles;\n  optionBox?: SerializedStyles;\n  option?: SerializedStyles;\n}\n\ninterface InputRefType {\n  [index: string]: React.RefObject<HTMLInputElement>;\n}\n\ninterface ModalPosType {\n  top?: number;\n  right?: number;\n  bottom?: number;\n  left?: number;\n}\n\nexport { FieldsetCssPropType, InputRefType, ModalPosType, SelectCssPropType };\n"
  },
  {
    "path": "frontend/src/@types/profile.ts",
    "content": "interface ProfileType {\n  id: number;\n  email: string;\n  displayName: string;\n  profileImageUrl: string;\n  socialType: string;\n}\n\ninterface ProfileGetResponseType {\n  data: ProfileType;\n}\n\nexport { ProfileType, ProfileGetResponseType };\n"
  },
  {
    "path": "frontend/src/@types/schedule.ts",
    "content": "import { ValueOf } from '@/@types/util';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\nimport { SCHEDULE } from '@/constants/schedule';\n\ntype ScheduleResponseKeyType = ValueOf<typeof SCHEDULE.RESPONSE_TYPE>;\n\ntype ScheduleResponseType = Record<ScheduleResponseKeyType, Array<ScheduleType>>;\n\ninterface ScheduleType {\n  id: string;\n  categoryId: number;\n  title: string;\n  startDateTime: string;\n  endDateTime: string;\n  memo: string;\n  colorCode: string;\n  categoryType: ValueOf<typeof CATEGORY_TYPE>;\n}\n\nexport { ScheduleResponseKeyType, ScheduleResponseType, ScheduleType };\n"
  },
  {
    "path": "frontend/src/@types/subscription.ts",
    "content": "import { CategoryType } from './category';\n\ninterface SubscriptionType {\n  id: number;\n  category: CategoryType;\n  colorCode: string;\n  checked: boolean;\n}\n\nexport { SubscriptionType };\n"
  },
  {
    "path": "frontend/src/@types/util.ts",
    "content": "type ValueOf<T> = T[keyof T];\n\nexport { ValueOf };\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "content": "import { AxiosError } from 'axios';\nimport { lazy, Suspense } from 'react';\nimport { useIsMutating, useQueryClient } from 'react-query';\nimport { Route, Routes } from 'react-router-dom';\n\nimport { useLoginAgain } from '@/hooks/@queries/login';\nimport useSnackBar from '@/hooks/useSnackBar';\n\nimport NavBar from '@/components/NavBar/NavBar';\nimport ProtectRoute from '@/components/ProtectRoute/ProtectRoute';\nimport SnackBar from '@/components/SnackBar/SnackBar';\nimport CalendarPage from '@/pages/CalendarPage/CalendarPage';\nimport CategoryPage from '@/pages/CategoryPage/CategoryPage';\n\nimport { PATH } from '@/constants';\nimport { CACHE_KEY, RESPONSE } from '@/constants/api';\nimport { ERROR_MESSAGE } from '@/constants/message';\n\nconst AuthPage = lazy(() => import('@/pages/AuthPage/AuthPage'));\nconst NotFoundPage = lazy(() => import('@/pages/NotFoundPage/NotFoundPage'));\nconst PrivacyPolicyPage = lazy(() => import('@/pages/PrivacyPolicyPage/PrivacyPolicyPage'));\n\nfunction App() {\n  const { openSnackBar } = useSnackBar();\n\n  const queryClient = useQueryClient();\n  const isMutatingLoginAgain = useIsMutating(CACHE_KEY.LOGIN_AGAIN);\n\n  const { mutate } = useLoginAgain();\n\n  const onError = (error: unknown) => {\n    if (error instanceof AxiosError && error.response?.status === RESPONSE.STATUS.UNAUTHORIZED) {\n      !isMutatingLoginAgain && mutate();\n\n      return;\n    }\n\n    error instanceof AxiosError\n      ? openSnackBar(error.response?.data.message ?? ERROR_MESSAGE.DEFAULT)\n      : openSnackBar(ERROR_MESSAGE.DEFAULT);\n  };\n\n  queryClient.setDefaultOptions({\n    queries: {\n      retry: 1,\n      retryDelay: 0,\n      onError,\n      staleTime: 1 * 60 * 1000,\n    },\n    mutations: {\n      retry: 1,\n      retryDelay: 0,\n      onError,\n    },\n  });\n\n  return (\n    <Suspense fallback={<></>}>\n      <NavBar />\n      <Routes>\n        <Route element={<ProtectRoute />}>\n          <Route path={PATH.MAIN} element={<CalendarPage />} />\n          <Route path={PATH.CATEGORY} element={<CategoryPage />} />\n        </Route>\n        <Route path={PATH.AUTH} element={<AuthPage />} />\n        <Route path={PATH.POLICY} element={<PrivacyPolicyPage />} />\n        <Route path=\"*\" element={<NotFoundPage />} />\n      </Routes>\n      <SnackBar />\n    </Suspense>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "frontend/src/api/category.ts",
    "content": "import { CategoryRoleType, CategorySubscriberType, CategoryType } from '@/@types/category';\n\nimport dallogApi from './';\n\nconst categoryApi = {\n  endpoint: {\n    admin: '/api/categories/me/admin',\n    editable: '/api/categories/me/schedule-editable',\n    entire: '/api/categories',\n    my: '/api/categories/me',\n    schedules: (categoryId: number) => `/api/categories/${categoryId}/schedules`,\n    subscribers: (categoryId: number) => `/api/categories/${categoryId}/subscribers`,\n    role: (categoryId: number, memberId: number) =>\n      `/api/categories/${categoryId}/subscribers/${memberId}/role`,\n  },\n  headers: {\n    'Content-Type': 'application/json',\n    Accept: 'application/json',\n  },\n\n  getAdmin: async (accessToken: string) => {\n    const response = await dallogApi.get(categoryApi.endpoint.admin, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n      transformResponse: (res) => JSON.parse(res).categories,\n    });\n\n    return response;\n  },\n\n  getEditable: async (accessToken: string) => {\n    const response = await dallogApi.get(categoryApi.endpoint.editable, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n      transformResponse: (res) => JSON.parse(res).categories,\n    });\n\n    return response;\n  },\n\n  getEntire: async (name: string) => {\n    const response = await dallogApi.get<CategoryType[]>(categoryApi.endpoint.entire, {\n      params: { name },\n      headers: categoryApi.headers,\n      transformResponse: (res) => JSON.parse(res).categories,\n    });\n\n    return response;\n  },\n\n  getSingle: async (categoryId?: number) => {\n    const response = await dallogApi.get(`${categoryApi.endpoint.entire}/${categoryId}`, {\n      headers: { ...categoryApi.headers },\n    });\n\n    return response;\n  },\n\n  getSubscribers: async (accessToken: string, categoryId: number) => {\n    const response = await dallogApi.get<CategorySubscriberType[]>(\n      categoryApi.endpoint.subscribers(categoryId),\n      {\n        headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n        transformResponse: (res) => JSON.parse(res).subscribers,\n      }\n    );\n\n    return response;\n  },\n\n  getMy: async (accessToken: string) => {\n    const response = await dallogApi.get<CategoryType[]>(categoryApi.endpoint.my, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n      transformResponse: (res) => JSON.parse(res).categories,\n    });\n\n    return response;\n  },\n\n  getSchedules: async (categoryId: number, startDateTime: string, endDateTime: string) => {\n    const response = await dallogApi.get(\n      `${categoryApi.endpoint.schedules(\n        categoryId\n      )}?startDateTime=${startDateTime}&endDateTime=${endDateTime}`,\n      {\n        headers: { ...categoryApi.headers },\n      }\n    );\n\n    return response;\n  },\n\n  post: async (accessToken: string, body: Pick<CategoryType, 'name' | 'categoryType'>) => {\n    const response = await dallogApi.post(categoryApi.endpoint.entire, body, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  patch: async (accessToken: string, categoryId: number, body: Pick<CategoryType, 'name'>) => {\n    const response = await dallogApi.patch(`${categoryApi.endpoint.entire}/${categoryId}`, body, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  patchRole: async (\n    accessToken: string,\n    categoryId: number,\n    memberId: number,\n    body: { categoryRoleType: CategoryRoleType }\n  ) => {\n    const response = await dallogApi.patch(categoryApi.endpoint.role(categoryId, memberId), body, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  delete: async (accessToken: string, categoryId: number) => {\n    const response = await dallogApi.delete(`${categoryApi.endpoint.entire}/${categoryId}`, {\n      headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n};\n\nexport default categoryApi;\n"
  },
  {
    "path": "frontend/src/api/googleCalendar.ts",
    "content": "import { GoogleCalendarGetResponseType, GoogleCalendarPostBodyType } from '@/@types/googleCalendar';\n\nimport dallogApi from './';\n\nconst googleCalendarApi = {\n  endpoint: '/api/external-calendars/me',\n\n  headers: {\n    'Content-Type': 'application/json',\n    Accept: 'application/json',\n  },\n\n  get: async (accessToken: string) => {\n    const response = await dallogApi.get<GoogleCalendarGetResponseType>(\n      googleCalendarApi.endpoint,\n      {\n        headers: { ...googleCalendarApi.headers, Authorization: `Bearer ${accessToken}` },\n      }\n    );\n\n    return response;\n  },\n\n  post: async (accessToken: string, body: GoogleCalendarPostBodyType) => {\n    const response = await dallogApi.post(googleCalendarApi.endpoint, body, {\n      headers: { ...googleCalendarApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n};\n\nexport default googleCalendarApi;\n"
  },
  {
    "path": "frontend/src/api/index.ts",
    "content": "import axios from 'axios';\n\nconst dallogApi = axios.create({\n  baseURL: process.env.API_URL,\n});\n\nexport default dallogApi;\n"
  },
  {
    "path": "frontend/src/api/login.ts",
    "content": "import dallogApi from './';\n\nconst loginApi = {\n  endPoint: {\n    googleEntry: '/api/auth/google/oauth-uri',\n    googleToken: '/api/auth/google/token',\n    validate: '/api/auth/validate/token',\n    again: '/api/auth/token/access',\n  },\n  headers: {\n    'Content-Type': 'application/json',\n    Accept: 'application/json',\n  },\n\n  getUrl: async () => {\n    const { data } = await dallogApi.get(\n      `${loginApi.endPoint.googleEntry}?redirectUri=${location.href}oauth`\n    );\n\n    return data.oAuthUri;\n  },\n\n  auth: async (code: string | null) => {\n    const { data } = await dallogApi.post(loginApi.endPoint.googleToken, {\n      code,\n      redirectUri: location.href.split('?')[0],\n    });\n\n    return data;\n  },\n\n  relogin: async (refreshToken: string | null) => {\n    const { data } = await dallogApi.post(loginApi.endPoint.again, {\n      refreshToken,\n    });\n\n    return data.accessToken;\n  },\n\n  validate: async (accessToken: string) => {\n    const response = await dallogApi.get(loginApi.endPoint.validate, {\n      headers: { ...loginApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n};\n\nexport default loginApi;\n"
  },
  {
    "path": "frontend/src/api/profile.ts",
    "content": "import { ProfileType } from '@/@types/profile';\n\nimport dallogApi from './';\n\nconst profileApi = {\n  endpoint: {\n    get: '/api/members/me',\n    delete: '/api/members/me',\n    patch: '/api/members/me',\n  },\n  headers: {\n    'Content-Type': 'application/json',\n    Accept: 'application/json',\n  },\n\n  get: async (accessToken: string) => {\n    const response = await dallogApi.get<ProfileType>(profileApi.endpoint.get, {\n      headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  delete: async (accessToken: string) => {\n    const response = await dallogApi.delete(profileApi.endpoint.delete, {\n      headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  patch: async (accessToken: string, body: Pick<ProfileType, 'displayName'>) => {\n    const response = await dallogApi.patch(profileApi.endpoint.patch, body, {\n      headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n};\n\nexport default profileApi;\n"
  },
  {
    "path": "frontend/src/api/schedule.ts",
    "content": "import { ScheduleResponseType, ScheduleType } from '@/@types/schedule';\n\nimport { getDayOffsetDateTime } from '@/utils/date';\n\nimport dallogApi from './';\n\nconst scheduleApi = {\n  endpoint: {\n    get: '/api/members/me/schedules',\n    post: (categoryId: number) => `/api/categories/${categoryId}/schedules`,\n    patch: (scheduleId: string) => `/api/schedules/${scheduleId}`,\n    delete: (scheduleId: string) => `/api/schedules/${scheduleId}`,\n  },\n  headers: {\n    'Content-Type': 'application/json',\n    Accept: 'application/json',\n  },\n\n  get: async (accessToken: string, startDateTime: string, endDateTime: string) => {\n    const response = await dallogApi.get<ScheduleResponseType>(\n      `${\n        scheduleApi.endpoint.get\n      }?startDateTime=${startDateTime}&endDateTime=${getDayOffsetDateTime(endDateTime, 1)}`,\n      {\n        headers: { ...scheduleApi.headers, Authorization: `Bearer ${accessToken}` },\n      }\n    );\n\n    return response;\n  },\n\n  post: async (\n    accessToken: string,\n    categoryId: number,\n    body: Omit<ScheduleType, 'id' | 'categoryId' | 'colorCode' | 'categoryType'>\n  ) => {\n    const response = await dallogApi.post(scheduleApi.endpoint.post(categoryId), body, {\n      headers: { ...scheduleApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  patch: async (\n    accessToken: string,\n    scheduleId: string,\n    body: Omit<ScheduleType, 'id' | 'categoryId' | 'colorCode' | 'categoryType'>\n  ) => {\n    const response = await dallogApi.patch(scheduleApi.endpoint.patch(scheduleId), body, {\n      headers: { ...scheduleApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  delete: async (accessToken: string, scheduleId: string) => {\n    const response = await dallogApi.delete(scheduleApi.endpoint.delete(scheduleId), {\n      headers: { Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n};\n\nexport default scheduleApi;\n"
  },
  {
    "path": "frontend/src/api/subscription.ts",
    "content": "import { AxiosResponse } from 'axios';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport dallogApi from './';\n\nconst subscriptionApi = {\n  endpoint: {\n    get: '/api/members/me/subscriptions',\n    post: (categoryId: number) => `/api/members/me/categories/${categoryId}/subscriptions`,\n    patch: (subscriptionId: number) => `/api/members/me/subscriptions/${subscriptionId}`,\n    delete: (subscriptionId: number) => `/api/members/me/subscriptions/${subscriptionId}`,\n  },\n\n  headers: {\n    'Content-Type': 'application/json',\n    Accept: 'application/json',\n  },\n\n  get: async (accessToken: string) => {\n    const response = await dallogApi.get<SubscriptionType[]>(subscriptionApi.endpoint.get, {\n      headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` },\n      transformResponse: (res) => {\n        return JSON.parse(res).subscriptions;\n      },\n    });\n\n    return response;\n  },\n\n  post: async (\n    accessToken: string,\n    categoryId: number,\n    body: Pick<SubscriptionType, 'colorCode'>\n  ) => {\n    const response = await dallogApi.post(subscriptionApi.endpoint.post(categoryId), body, {\n      headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  patch: async (\n    accessToken: string,\n    subscriptionId: number,\n    body: Pick<SubscriptionType, 'colorCode'> | Pick<SubscriptionType, 'checked'>\n  ) => {\n    const response = await dallogApi.patch(subscriptionApi.endpoint.patch(subscriptionId), body, {\n      headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n\n  delete: async (accessToken: string, subscriptionId: number): Promise<AxiosResponse<null>> => {\n    const response = await dallogApi.delete<null>(subscriptionApi.endpoint.delete(subscriptionId), {\n      headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` },\n    });\n\n    return response;\n  },\n};\n\nexport default subscriptionApi;\n"
  },
  {
    "path": "frontend/src/components/@common/Button/Button.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport Button from './Button';\n\nexport default {\n  title: 'Components/@Common/Button',\n  component: Button,\n} as ComponentMeta<typeof Button>;\n\nconst Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;\n\nexport const Primary = Template.bind({});\n\nPrimary.args = {\n  children: '기본 버튼입니다.',\n};\n"
  },
  {
    "path": "frontend/src/components/@common/Button/Button.styles.ts",
    "content": "import { css } from '@emotion/react';\n\nconst button = css`\n  border: none;\n\n  background: transparent;\n\n  font-family: inherit;\n  text-align: center;\n\n  cursor: pointer;\n\n  &:hover {\n    filter: brightness(1.1);\n  }\n\n  &:disabled {\n    cursor: not-allowed;\n    filter: grayscale(1);\n  }\n`;\n\nexport { button };\n"
  },
  {
    "path": "frontend/src/components/@common/Button/Button.test.tsx",
    "content": "/**\n * @jest-environment jsdom\n */\nimport { composeStories } from '@storybook/testing-react';\nimport { render, screen } from '@testing-library/react';\n\nimport * as stories from './Button.stories';\n\nconst { Primary } = composeStories(stories);\n\ntest('기본 버튼이 인자들과 함께 출력된다.', () => {\n  render(<Primary />);\n\n  const buttonElement = screen.getByText(/기본 버튼입니다./i);\n\n  expect(buttonElement).not.toBeNull();\n});\n\ntest('기본 버튼의 props를 overwrite할 수 있다.', () => {\n  render(<Primary>달록 버튼</Primary>);\n\n  const buttonElement = screen.getByText(/달록 버튼/i);\n\n  expect(buttonElement).not.toBeNull();\n});\n"
  },
  {
    "path": "frontend/src/components/@common/Button/Button.tsx",
    "content": "import { SerializedStyles, Theme } from '@emotion/react';\n\nimport { button } from './Button.styles';\n\ninterface ButtonProps {\n  type?: 'button' | 'submit' | 'reset';\n  cssProp?: SerializedStyles | (({ colors, flex }: Theme) => SerializedStyles);\n  onClick?: (e?: React.FormEvent) => void;\n  children?: string | JSX.Element | JSX.Element[];\n  disabled?: boolean;\n}\n\nfunction Button({\n  type = 'button',\n  cssProp,\n  onClick,\n  children,\n  disabled = false,\n  ...props\n}: ButtonProps) {\n  return (\n    <button {...props} type={type} css={[button, cssProp]} onClick={onClick} disabled={disabled}>\n      {children}\n    </button>\n  );\n}\n\nexport default Button;\n"
  },
  {
    "path": "frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx",
    "content": "import { Component } from 'react';\n\nimport ErrorPage from '@/pages/ErrorPage/ErrorPage';\n\ninterface Props {\n  children: JSX.Element | JSX.Element[];\n}\n\ninterface State {\n  hasError: boolean;\n}\n\nclass ErrorBoundary extends Component<Props, State> {\n  public state: State = {\n    hasError: false,\n  };\n\n  public static getDerivedStateFromError(): State {\n    return { hasError: true };\n  }\n\n  public render() {\n    if (this.state.hasError) return <ErrorPage />;\n\n    return this.props.children;\n  }\n}\n\nexport default ErrorBoundary;\n"
  },
  {
    "path": "frontend/src/components/@common/Fieldset/Fieldset.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport Fieldset from './Fieldset';\n\nexport default {\n  title: 'Components/@Common/Fieldset',\n  component: Fieldset,\n} as ComponentMeta<typeof Fieldset>;\n\nconst Template: ComponentStory<typeof Fieldset> = (args) => <Fieldset {...args} />;\n\nconst Primary = Template.bind({});\nPrimary.args = {\n  id: 'primary',\n  labelText: 'primary',\n  placeholder: '입력해주세요.',\n};\n\nconst DatePicker = Template.bind({});\nDatePicker.args = {\n  type: 'datetime-local',\n  id: 'date-time-picker',\n  labelText: '일정 시작',\n  placeholder: '입력해주세요.',\n};\n\nexport { Primary, DatePicker };\n"
  },
  {
    "path": "frontend/src/components/@common/Fieldset/Fieldset.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst fieldsetStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  position: relative;\n  align-items: flex-start;\n  gap: 2.5rem;\n\n  width: 100%;\n  height: auto;\n\n  font-size: 4rem;\n`;\n\nconst labelStyle = ({ colors }: Theme) => css`\n  padding: 0 1rem;\n\n  color: ${colors.GRAY_800};\n`;\n\nconst inputStyle = ({ colors }: Theme, isValid?: boolean) => css`\n  padding: 3rem;\n\n  width: 100%;\n  border-radius: 7px;\n  border: 1px solid ${isValid === false ? colors.RED_400 : colors.GRAY_400};\n\n  font-family: inherit;\n  font-size: inherit;\n\n  &:focus {\n    outline: none;\n    border-color: ${isValid === false ? colors.RED_400 : colors.YELLOW_500};\n    box-shadow: 0 0 2px ${isValid === false ? colors.RED_400 : colors.YELLOW_500};\n  }\n`;\n\nconst errorMessageStyle = ({ colors }: Theme, isValid?: boolean) => css`\n  display: ${isValid ? 'none' : 'block'};\n  position: absolute;\n  top: 110%;\n  left: 1%;\n\n  font-size: 3rem;\n  color: ${colors.RED_400};\n`;\n\nexport { errorMessageStyle, fieldsetStyle, labelStyle, inputStyle };\n"
  },
  {
    "path": "frontend/src/components/@common/Fieldset/Fieldset.test.tsx",
    "content": "/**\n * @jest-environment jsdom\n */\nimport { composeStories } from '@storybook/testing-react';\nimport { render, screen } from '@testing-library/react';\n\nimport * as stories from './Fieldset.stories';\n\nconst { Primary, DatePicker } = composeStories(stories);\n\ntest('기본 입력 필드가 출력된다.', () => {\n  render(<Primary />);\n\n  const buttonElement = screen.getByText(/primary/i);\n\n  expect(buttonElement).not.toBeNull();\n});\n\ntest('날짜 선택을 위한 입력 필드가 출력된다.', () => {\n  render(<DatePicker />);\n\n  const buttonElement = screen.getByText(/일정 시작/i);\n\n  expect(buttonElement).not.toBeNull();\n});\n"
  },
  {
    "path": "frontend/src/components/@common/Fieldset/Fieldset.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { FieldsetCssPropType } from '@/@types';\n\nimport { errorMessageStyle, fieldsetStyle, inputStyle, labelStyle } from './Fieldset.styles';\n\ninterface FieldsetProps extends React.HTMLAttributes<HTMLInputElement> {\n  type?: string;\n  value?: string;\n  defaultValue?: string;\n  cssProp?: FieldsetCssPropType;\n  labelText?: string;\n  autoFocus?: boolean;\n  refProp?: React.MutableRefObject<null | HTMLInputElement>;\n  disabled?: boolean;\n  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;\n  isValid?: boolean;\n  errorMessage?: string;\n  min?: string | number;\n  max?: string | number;\n}\n\nfunction Fieldset({\n  type = 'text',\n  id,\n  cssProp,\n  placeholder,\n  value,\n  defaultValue,\n  autoFocus,\n  refProp,\n  disabled,\n  onChange,\n  labelText,\n  isValid,\n  errorMessage,\n  min,\n  max,\n}: FieldsetProps) {\n  const theme = useTheme();\n\n  return (\n    <div css={[fieldsetStyle(theme), cssProp?.div]}>\n      {labelText && (\n        <label htmlFor={id} css={[labelStyle, cssProp?.label]}>\n          {labelText}\n        </label>\n      )}\n      <input\n        type={type}\n        id={id}\n        css={[inputStyle(theme, isValid), cssProp?.input]}\n        placeholder={placeholder}\n        value={value}\n        defaultValue={defaultValue}\n        autoFocus={autoFocus}\n        ref={refProp}\n        disabled={disabled}\n        onChange={onChange}\n        min={min}\n        max={max}\n      />\n      {errorMessage && <span css={errorMessageStyle(theme, isValid)}>{errorMessage}</span>}\n    </div>\n  );\n}\n\nexport default Fieldset;\n"
  },
  {
    "path": "frontend/src/components/@common/ModalPortal/ModalPortal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { TRANSPARENT } from '@/constants/style';\n\nconst dimmer = (\n  { colors, flex }: Theme,\n  isOpen: boolean,\n  dimmerBackground?: typeof TRANSPARENT\n) => css`\n  ${flex.row};\n\n  position: fixed;\n  top: 0;\n  left: 0;\n  z-index: 30;\n\n  width: 100%;\n  height: 100%;\n\n  background: ${dimmerBackground !== undefined\n    ? dimmerBackground\n    : isOpen\n    ? `${colors.BLACK}bb`\n    : 'transparent'};\n`;\n\nexport { dimmer };\n"
  },
  {
    "path": "frontend/src/components/@common/ModalPortal/ModalPortal.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport ReactDOM from 'react-dom';\n\nimport { TRANSPARENT } from '@/constants/style';\n\nimport { dimmer } from './ModalPortal.styles';\n\ninterface ModalPortalProps {\n  isOpen: boolean;\n  closeModal: () => void;\n  children: JSX.Element | JSX.Element[];\n  dimmerBackground?: typeof TRANSPARENT;\n}\n\nfunction ModalPortal({ isOpen, closeModal, children, dimmerBackground }: ModalPortalProps) {\n  const modalElement = document.getElementById('modal');\n\n  const theme = useTheme();\n\n  if (!(modalElement instanceof HTMLElement)) {\n    return <></>;\n  }\n\n  const handleClickDimmer = (e: React.MouseEvent) => {\n    if (e.target !== e.currentTarget) {\n      return;\n    }\n\n    closeModal();\n  };\n\n  const element = isOpen && (\n    <div css={dimmer(theme, isOpen, dimmerBackground)} onClick={handleClickDimmer}>\n      {children}\n    </div>\n  );\n\n  return ReactDOM.createPortal(element, modalElement);\n}\n\nexport default ModalPortal;\n"
  },
  {
    "path": "frontend/src/components/@common/PageLayout/PageLayout.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { ValueOf } from '@/@types/util';\n\nimport { PAGE_LAYOUT } from '@/constants/style';\n\nconst pageLayout = (\n  { mq }: Theme,\n  isSideBarOpen: boolean,\n  type: ValueOf<typeof PAGE_LAYOUT>\n) => css`\n  overflow-y: auto;\n  position: relative;\n\n  height: calc(100vh - 16rem);\n  margin-top: 16rem;\n\n  ${mq?.laptop} {\n    margin-left: ${type === PAGE_LAYOUT.DEFAULT ? '0' : isSideBarOpen ? '64rem' : '0'};\n\n    transition: margin-left 0.3s;\n  }\n`;\n\nexport { pageLayout };\n"
  },
  {
    "path": "frontend/src/components/@common/PageLayout/PageLayout.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useRecoilValue } from 'recoil';\n\nimport { ValueOf } from '@/@types/util';\n\nimport { sideBarState } from '@/recoil/atoms';\n\nimport { PAGE_LAYOUT } from '@/constants/style';\n\nimport { pageLayout } from './PageLayout.styles';\n\ninterface PageLayoutProps {\n  children: JSX.Element | JSX.Element[];\n  type?: ValueOf<typeof PAGE_LAYOUT>;\n}\n\nfunction PageLayout({ children, type = PAGE_LAYOUT.DEFAULT }: PageLayoutProps) {\n  const theme = useTheme();\n\n  const isSideBarOpen = useRecoilValue(sideBarState);\n\n  return <div css={pageLayout(theme, isSideBarOpen, type)}>{children}</div>;\n}\n\nexport default PageLayout;\n"
  },
  {
    "path": "frontend/src/components/@common/Responsive/Responsive.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst layoutStyle = ({ mq }: Theme, type: string) => css`\n  display: none;\n\n  width: 100%;\n  height: 100%;\n\n  ${type === 'laptop' && mq?.laptop} {\n    display: block;\n  }\n\n  ${type === 'tablet' && mq?.tablet} {\n    display: block;\n  }\n\n  ${type === 'mobile' && mq?.mobile} {\n    display: block;\n  }\n\n  ${type === 'all'} {\n    display: block;\n  }\n`;\n\nexport { layoutStyle };\n"
  },
  {
    "path": "frontend/src/components/@common/Responsive/Responsive.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { layoutStyle } from './Responsive.styles';\n\ninterface ResponsiveProps {\n  type: string;\n  children: JSX.Element | JSX.Element[];\n}\n\nfunction Responsive({ type = 'all', children }: ResponsiveProps) {\n  const theme = useTheme();\n\n  return <div css={layoutStyle(theme, type)}>{children}</div>;\n}\n\nexport default Responsive;\n"
  },
  {
    "path": "frontend/src/components/@common/Select/Select.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { OPTION_HEIGHT } from '@/constants/style';\n\nconst layoutStyle = css`\n  width: 100%;\n`;\n\nconst hiddenStyle = css`\n  display: none;\n`;\n\nconst dimmerStyle = (isSelectOpen: boolean) => css`\n  ${!isSelectOpen && hiddenStyle};\n\n  position: fixed;\n  width: 100%;\n  height: 100%;\n  top: 0;\n  left: 0;\n  background: transparent;\n`;\n\nconst selectStyle = ({ colors }: Theme) => css`\n  width: 100%;\n  height: 11.75rem;\n  border-radius: 7px;\n  border: 1px solid ${colors.GRAY_400};\n\n  font-size: 4rem;\n  text-align: center;\n  line-height: 12rem;\n\n  cursor: pointer;\n\n  &:focus {\n    outline: none;\n    border-color: ${colors.YELLOW_500};\n    box-shadow: 0 0 2px ${colors.YELLOW_500};\n  }\n`;\n\nconst optionLayoutStyle = ({ colors }: Theme, isSelectOpen: boolean) => css`\n  position: absolute;\n  overflow: overlay;\n\n  width: 100%;\n  max-height: ${isSelectOpen ? '50rem' : 0};\n  border: ${isSelectOpen && `1px solid ${colors.GRAY_400}`};\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n\n  &:focus {\n    outline: none;\n    border-color: ${colors.YELLOW_500};\n    box-shadow: 0 0 2px ${colors.YELLOW_500};\n  }\n`;\n\nconst optionStyle = ({ colors }: Theme, isSelected: boolean) => css`\n  height: ${OPTION_HEIGHT};\n\n  background: ${isSelected && colors.GRAY_200};\n\n  font-size: 4rem;\n\n  &:hover {\n    background: ${!isSelected && colors.GRAY_100};\n  }\n`;\n\nconst labelStyle = css`\n  display: block;\n\n  padding: 2.5rem 0;\n\n  text-align: center;\n`;\n\nconst relativeStyle = css`\n  position: relative;\n  z-index: 30;\n`;\n\nexport {\n  dimmerStyle,\n  labelStyle,\n  layoutStyle,\n  hiddenStyle,\n  selectStyle,\n  optionStyle,\n  optionLayoutStyle,\n  relativeStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/@common/Select/Select.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useEffect, useRef } from 'react';\n\nimport useToggle from '@/hooks/useToggle';\n\nimport { SelectCssPropType } from '@/@types';\n\nimport { OPTION_HEIGHT } from '@/constants/style';\n\nimport {\n  dimmerStyle,\n  hiddenStyle,\n  labelStyle,\n  layoutStyle,\n  optionLayoutStyle,\n  optionStyle,\n  relativeStyle,\n  selectStyle,\n} from './Select.styles';\n\ntype OptionsType = { id: number | string; name: number | string };\n\ninterface SelectProps {\n  options: Array<OptionsType>;\n  value: string;\n  onChange: ({\n    target,\n  }: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLSelectElement>) => void;\n  cssProp?: SelectCssPropType;\n  description?: string;\n}\n\nfunction Select({ options, value, onChange, cssProp, description = '옵션 선택' }: SelectProps) {\n  const theme = useTheme();\n\n  const ref = useRef<HTMLDivElement>(null);\n\n  const { state: isSelectOpen, toggleState: toggleSelectOpen } = useToggle(false);\n\n  const selectedPosition = options.findIndex((opt) => String(opt.id) === value);\n\n  useEffect(() => {\n    ref.current?.scrollTo(0, selectedPosition * OPTION_HEIGHT);\n  });\n\n  const handleClickDimmer = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    toggleSelectOpen();\n  };\n\n  return (\n    <div css={[layoutStyle, cssProp?.select]}>\n      <div css={dimmerStyle(isSelectOpen)} onClick={handleClickDimmer}></div>\n      <div css={selectStyle} onClick={toggleSelectOpen}>\n        {options.find((opt) => String(opt.id) === value)?.name || description}\n      </div>\n      <div css={relativeStyle}>\n        <div css={[optionLayoutStyle(theme, isSelectOpen), cssProp?.optionBox]} ref={ref}>\n          {isSelectOpen &&\n            options.map((opt, index) => (\n              <div key={index} css={optionStyle(theme, String(opt.id) === value)}>\n                <input\n                  type=\"radio\"\n                  id={`${opt.name}#${opt.id}`}\n                  value={opt.id}\n                  onChange={onChange}\n                  name=\"option-picker\"\n                  css={hiddenStyle}\n                  onClick={toggleSelectOpen}\n                />\n                <label htmlFor={`${opt.name}#${opt.id}`} css={[labelStyle, cssProp?.option]}>\n                  {opt.name}\n                </label>\n              </div>\n            ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default Select;\n"
  },
  {
    "path": "frontend/src/components/@common/Skeleton/Skeleton.stories.tsx",
    "content": "import { css } from '@emotion/react';\nimport { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport Skeleton from './Skeleton';\n\nexport default {\n  title: 'Components/@Common/Skeleton',\n  component: Skeleton,\n} as ComponentMeta<typeof Skeleton>;\n\nconst Template: ComponentStory<typeof Skeleton> = (args) => <Skeleton {...args} />;\n\nconst Primary = Template.bind({});\nPrimary.args = {\n  cssProp: css`\n    width: 100rem;\n    height: 20rem;\n  `,\n};\nexport { Primary };\n"
  },
  {
    "path": "frontend/src/components/@common/Skeleton/Skeleton.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst skeletonStyle = ({ colors }: Theme, width: string, height: string) => css`\n  @keyframes skeleton {\n    0% {\n      background-color: transparent;\n    }\n    25% {\n      background-color: ${colors.GRAY_100};\n    }\n    50% {\n      background-color: ${colors.GRAY_200};\n    }\n    75% {\n      background-color: ${colors.GRAY_300};\n    }\n    100% {\n      background-color: transparent;\n    }\n  }\n\n  display: inline-block;\n\n  width: ${width};\n  height: ${height};\n  border-radius: 7px;\n\n  animation: skeleton 2s infinite ease-out;\n`;\n\nexport { skeletonStyle };\n"
  },
  {
    "path": "frontend/src/components/@common/Skeleton/Skeleton.tsx",
    "content": "import { SerializedStyles, useTheme } from '@emotion/react';\n\nimport { skeletonStyle } from './Skeleton.styles';\n\ninterface SkeletonProps {\n  cssProp?: SerializedStyles;\n  width?: string;\n  height?: string;\n}\n\nfunction Skeleton({ cssProp, width = '100%', height = '100%' }: SkeletonProps) {\n  const theme = useTheme();\n\n  return (\n    <div css={cssProp}>\n      <div css={skeletonStyle(theme, width, height)}></div>\n    </div>\n  );\n}\n\nexport default Skeleton;\n"
  },
  {
    "path": "frontend/src/components/@common/Spinner/Spinner.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport Spinner from './Spinner';\n\nexport default {\n  title: 'Components/@Common/Spinner',\n  component: Spinner,\n} as ComponentMeta<typeof Spinner>;\n\nconst Template: ComponentStory<typeof Spinner> = () => <Spinner />;\n\nconst Primary = Template.bind({});\nPrimary.args = {};\n"
  },
  {
    "path": "frontend/src/components/@common/Spinner/Spinner.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst spinnerStyle = ({ colors }: Theme, size: number) => css`\n  position: relative;\n  display: inline-block;\n\n  width: ${size}rem;\n  height: ${size}rem;\n\n  & div {\n    position: absolute;\n    display: block;\n\n    width: ${size * 0.8}rem;\n    height: ${size * 0.8}rem;\n    margin: ${size * 0.1}rem;\n    border: ${size * 0.1}rem solid ${colors.GRAY_700};\n    box-sizing: border-box;\n    border-radius: 50%;\n    border-color: ${colors.YELLOW_500} transparent transparent transparent;\n\n    animation: loading 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;\n  }\n\n  & div:nth-of-type(1) {\n    animation-delay: -0.45s;\n  }\n  & div:nth-of-type(2) {\n    animation-delay: -0.3s;\n  }\n  & div:nth-of-type(3) {\n    animation-delay: -0.15s;\n  }\n\n  @keyframes loading {\n    0% {\n      transform: rotate(0deg);\n    }\n    100% {\n      transform: rotate(360deg);\n    }\n  }\n`;\n\nexport { spinnerStyle };\n"
  },
  {
    "path": "frontend/src/components/@common/Spinner/Spinner.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { spinnerStyle } from './Spinner.styles';\n\ninterface SpinnerProps {\n  size?: number;\n}\n\nfunction Spinner({ size = 5 }: SpinnerProps) {\n  const theme = useTheme();\n\n  return (\n    <div css={spinnerStyle(theme, size)}>\n      <div></div>\n      <div></div>\n      <div></div>\n      <div></div>\n    </div>\n  );\n}\n\nexport default Spinner;\n"
  },
  {
    "path": "frontend/src/components/AdminCategoryManageModal/AdminCategoryManageModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst closeModalButtonStyle = css`\n  position: absolute;\n  top: 6rem;\n  right: 6rem;\n\n  font-size: 6rem;\n`;\n\nconst deleteButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.RED_400};\n\n  font-size: 3rem;\n  color: ${colors.WHITE};\n`;\n\nconst errorMessageStyle = ({ colors }: Theme) => css`\n  color: ${colors.RED_400};\n`;\n\nconst headerStyle = css`\n  font-size: 6rem;\n`;\n\nconst forgiveButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.RED_400};\n\n  font-size: 3rem;\n  color: ${colors.WHITE};\n`;\n\nconst layoutStyle = ({ flex, colors }: Theme) => css`\n  ${flex.column};\n\n  justify-content: space-between;\n  gap: 10rem;\n  overflow: overlay;\n  position: relative;\n\n  max-height: 100vh;\n  padding: 12.5rem;\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n`;\n\nconst listBundleStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: space-between;\n  align-items: flex-start;\n  gap: 4rem;\n\n  width: 100%;\n`;\n\nconst listSectionStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  gap: 4rem;\n  justify-content: flex-start;\n  align-items: flex-start;\n\n  width: 60rem;\n`;\n\nconst renameButtonStyle = ({ colors }: Theme) => css`\n  height: 8rem;\n  padding: 2rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 3rem;\n  color: ${colors.WHITE};\n`;\n\nconst renameFieldSetStyle = {\n  div: css`\n    width: 40%;\n  `,\n  input: css`\n    height: 8rem;\n  `,\n};\n\nconst renameFormStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 20rem;\n\n  width: 100%;\n`;\n\nconst sectionStyle = css`\n  width: 100%;\n`;\n\nconst subscriberListStyle = ({ colors }: Theme) => css`\n  overflow: hidden;\n\n  width: 60rem;\n  max-height: 50rem;\n  padding-right: 2rem;\n  border: 1px solid ${colors.GRAY_100};\n\n  &:hover {\n    overflow: overlay;\n  }\n`;\n\nconst spaceBetweenStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 20rem;\n\n  width: 100%;\n`;\n\nconst titleStyle = css`\n  margin-bottom: 3rem;\n\n  font-size: 4rem;\n  font-weight: 700;\n`;\n\nexport {\n  closeModalButtonStyle,\n  deleteButtonStyle,\n  errorMessageStyle,\n  forgiveButtonStyle,\n  headerStyle,\n  layoutStyle,\n  listBundleStyle,\n  listSectionStyle,\n  renameButtonStyle,\n  renameFieldSetStyle,\n  renameFormStyle,\n  sectionStyle,\n  subscriberListStyle,\n  spaceBetweenStyle,\n  titleStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/AdminCategoryManageModal/AdminCategoryManageModal.tsx",
    "content": "import { useRecoilValue } from 'recoil';\n\nimport {\n  useDeleteCategory,\n  useGetSubscribers,\n  usePatchCategoryName,\n  usePatchCategoryRole,\n} from '@/hooks/@queries/category';\nimport { useDeleteSubscriptions } from '@/hooks/@queries/subscription';\nimport useValidateCategory from '@/hooks/useValidateCategory';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport { userState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\nimport Spinner from '@/components/@common/Spinner/Spinner';\nimport AdminItem from '@/components/AdminItem/AdminItem';\nimport SubscriberItem from '@/components/SubscriberItem/SubscriberItem';\n\nimport { ROLE } from '@/constants/category';\nimport { CONFIRM_MESSAGE } from '@/constants/message';\n\nimport { MdClose } from 'react-icons/md';\n\nimport {\n  closeModalButtonStyle,\n  deleteButtonStyle,\n  errorMessageStyle,\n  forgiveButtonStyle,\n  headerStyle,\n  layoutStyle,\n  listBundleStyle,\n  listSectionStyle,\n  renameButtonStyle,\n  renameFieldSetStyle,\n  renameFormStyle,\n  sectionStyle,\n  spaceBetweenStyle,\n  subscriberListStyle,\n  titleStyle,\n} from './AdminCategoryManageModal.styles';\n\ninterface AdminCategoryManageModalProps {\n  subscription: SubscriptionType;\n  closeModal: () => void;\n}\n\nfunction AdminCategoryManageModal({ subscription, closeModal }: AdminCategoryManageModalProps) {\n  const { id } = useRecoilValue(userState);\n\n  const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory(\n    subscription.category.name\n  );\n\n  const { isLoading, data } = useGetSubscribers({ categoryId: subscription.category.id });\n\n  const { mutate: patchCategoryName } = usePatchCategoryName({\n    categoryId: subscription.category.id,\n  });\n\n  const { mutate: deleteCategory } = useDeleteCategory({\n    categoryId: subscription.category.id,\n    onSuccess: closeModal,\n  });\n\n  const { mutate: patchRole } = usePatchCategoryRole({\n    categoryId: subscription.category.id,\n    memberId: Number(id),\n    onSuccess: () => {\n      if (!window.confirm(CONFIRM_MESSAGE.UNSUBSCRIBE)) return;\n\n      deleteSubscription();\n    },\n  });\n\n  const { mutate: deleteSubscription } = useDeleteSubscriptions({\n    subscriptionId: subscription.id,\n    onSuccess: closeModal,\n  });\n\n  if (isLoading || data === undefined) {\n    return <Spinner size={10} />;\n  }\n\n  const handleSubmitCategoryModifyForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    patchCategoryName({ name: categoryValue.inputValue });\n  };\n\n  const handleClickDeleteCategoryButton = () => {\n    window.confirm(CONFIRM_MESSAGE.DELETE) && deleteCategory();\n  };\n\n  const handleClickForgiveAdminButton = () => {\n    if (!window.confirm(CONFIRM_MESSAGE.FORGIVE_ADMIN)) return;\n\n    patchRole({\n      categoryRoleType: ROLE.NONE,\n    });\n  };\n\n  const admins = data.data.filter((member) => member.categoryRoleType === ROLE.ADMIN);\n  const subscribers = data.data.filter((member) => member.categoryRoleType === ROLE.NONE);\n\n  return (\n    <div css={layoutStyle}>\n      <h1 css={headerStyle}>{subscription.category.name} (관리)</h1>\n      <Button cssProp={closeModalButtonStyle} onClick={closeModal}>\n        <MdClose />\n      </Button>\n      <section css={sectionStyle}>\n        <h2 css={titleStyle}>카테고리 이름 수정</h2>\n        <form css={renameFormStyle} onSubmit={handleSubmitCategoryModifyForm}>\n          <Fieldset\n            placeholder={subscription.category.name}\n            value={categoryValue.inputValue}\n            autoFocus\n            onChange={categoryValue.onChangeValue}\n            isValid={isValidCategory}\n            errorMessage={getCategoryErrorMessage()}\n            cssProp={renameFieldSetStyle}\n          />\n          <Button type=\"submit\" disabled={!isValidCategory} cssProp={renameButtonStyle}>\n            수정\n          </Button>\n        </form>\n      </section>\n\n      <div css={listBundleStyle}>\n        <section css={listSectionStyle}>\n          <h2 css={titleStyle}>편집자 목록</h2>\n          <span>편집 권한을 해제할 수 있습니다.</span>\n          <div css={subscriberListStyle}>\n            {admins.map((admin) => {\n              return (\n                <AdminItem\n                  key={admin.member.id}\n                  categoryId={subscription.category.id}\n                  admin={admin.member}\n                />\n              );\n            })}\n          </div>\n        </section>\n        <section css={listSectionStyle}>\n          <h2 css={titleStyle}>구독자 목록</h2>\n          <span>편집 권한을 설정할 수 있습니다.</span>\n          {subscribers.length === 0 ? (\n            <span>하지만 편집자를 제외한 구독자가 아무도 없네요.</span>\n          ) : (\n            <div css={subscriberListStyle}>\n              {subscribers.map((subscriber) => {\n                return (\n                  <SubscriberItem\n                    key={subscriber.member.id}\n                    categoryId={subscription.category.id}\n                    subscriber={subscriber.member}\n                  />\n                );\n              })}\n            </div>\n          )}\n        </section>\n      </div>\n\n      <section css={sectionStyle}>\n        <h2 css={titleStyle}>카테고리 삭제</h2>\n        <div css={spaceBetweenStyle}>\n          <span>카테고리를 영구적으로 삭제합니다.</span>\n          <Button onClick={handleClickDeleteCategoryButton} cssProp={deleteButtonStyle}>\n            삭제\n          </Button>\n        </div>\n      </section>\n\n      <section css={sectionStyle}>\n        <h2 css={titleStyle}>관리 권한 포기</h2>\n        <div css={spaceBetweenStyle}>\n          <span>일정 추가/삭제/수정 및 카테고리 수정/삭제 권한을 포기합니다.</span>\n          <Button\n            cssProp={forgiveButtonStyle}\n            onClick={handleClickForgiveAdminButton}\n            disabled={admins.length === 1}\n          >\n            포기\n          </Button>\n        </div>\n        {admins.length === 1 && (\n          <span css={errorMessageStyle}>권한을 본인만 가지고 있다면 포기할 수 없습니다.</span>\n        )}\n      </section>\n    </div>\n  );\n}\n\nexport default AdminCategoryManageModal;\n"
  },
  {
    "path": "frontend/src/components/AdminItem/AdminItem.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst adminButtonStyle = css`\n  position: absolute;\n  right: 1rem;\n\n  font-size: 5rem;\n  line-height: 7rem;\n\n  &:hover {\n    transform: scale(1.1);\n  }\n`;\n\nconst adminItemStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: flex-start;\n  gap: 2rem;\n  position: relative;\n\n  height: 7rem;\n  padding: 6rem 2rem;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  font-size: 4rem;\n\n  &:hover {\n    background: ${colors.GRAY_100};\n  }\n`;\nconst displayNameStyle = css`\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`;\n\nconst profileImageStyle = css`\n  width: 7rem;\n  height: 7rem;\n  border-radius: 50%;\n`;\n\nexport { adminButtonStyle, adminItemStyle, displayNameStyle, profileImageStyle };\n"
  },
  {
    "path": "frontend/src/components/AdminItem/AdminItem.tsx",
    "content": "import { useRecoilValue } from 'recoil';\n\nimport { usePatchCategoryRole } from '@/hooks/@queries/category';\n\nimport { ProfileType } from '@/@types/profile';\n\nimport { userState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { ROLE } from '@/constants/category';\nimport { CONFIRM_MESSAGE } from '@/constants/message';\n\nimport { MdPersonOff } from 'react-icons/md';\n\nimport {\n  adminButtonStyle,\n  adminItemStyle,\n  displayNameStyle,\n  profileImageStyle,\n} from './AdminItem.styles';\n\ninterface AdminItemProps {\n  categoryId: number;\n  admin: ProfileType;\n}\n\nfunction AdminItem({ categoryId, admin }: AdminItemProps) {\n  const { id } = useRecoilValue(userState);\n\n  const { mutate: patchRole } = usePatchCategoryRole({\n    categoryId,\n    memberId: admin.id,\n  });\n\n  const handleClickDeleteRoleButton = () => {\n    window.confirm(CONFIRM_MESSAGE.DELETE_ADMIN) &&\n      patchRole({\n        categoryRoleType: ROLE.NONE,\n      });\n  };\n\n  return (\n    <div key={admin.id} css={adminItemStyle}>\n      <img src={admin.profileImageUrl} alt=\"프로필 이미지\" css={profileImageStyle} />\n      <span css={displayNameStyle}>{admin.displayName}</span>\n      {admin.id !== id && (\n        <Button cssProp={adminButtonStyle} onClick={handleClickDeleteRoleButton}>\n          <MdPersonOff />\n        </Button>\n      )}\n    </div>\n  );\n}\n\nexport default AdminItem;\n"
  },
  {
    "path": "frontend/src/components/Calendar/Calendar.fallback.tsx",
    "content": "import { CalendarControllerType } from '@/hooks/useCalendar';\nimport useRootFontSize from '@/hooks/useRootFontSize';\n\nimport theme from '@/styles/theme';\n\nimport Button from '@/components/@common/Button/Button';\nimport Responsive from '@/components/@common/Responsive/Responsive';\nimport Spinner from '@/components/@common/Spinner/Spinner';\nimport DateCell from '@/components/DateCell/DateCell';\n\nimport { DAYS } from '@/constants/date';\nimport { RESPONSIVE } from '@/constants/style';\n\nimport { MdKeyboardArrowLeft, MdKeyboardArrowRight } from 'react-icons/md';\n\nimport {\n  calendarGridStyle,\n  calendarHeaderStyle,\n  dayGridStyle,\n  dayStyle,\n  monthPickerStyle,\n  navButtonStyle,\n  navButtonTitleStyle,\n  navStyle,\n  spinnerStyle,\n  todayButtonStyle,\n} from './Calendar.styles';\n\ninterface CalendarFallbackProps {\n  calendarController: CalendarControllerType;\n  setDateInfo?: React.Dispatch<React.SetStateAction<string>>;\n  handleClickDateCell?: () => void;\n  categoryName?: string;\n  isLoading?: boolean;\n  readonly?: boolean;\n}\n\nfunction CalendarFallback({\n  calendarController,\n  setDateInfo,\n  handleClickDateCell,\n  categoryName,\n  isLoading = true,\n  readonly = false,\n}: CalendarFallbackProps) {\n  const rootFontSize = useRootFontSize();\n\n  const {\n    calendar,\n    currentMonth,\n    currentYear,\n    dateCellRef,\n    moveToBeforeMonth,\n    moveToNextMonth,\n    moveToToday,\n    rowCount,\n  } = calendarController;\n\n  return (\n    <>\n      <div css={calendarHeaderStyle}>\n        {`${currentYear}년 ${currentMonth}월${\n          categoryName ? ` \\u00A0☾\\u00A0 ${categoryName}` : ''\n        }`}\n        <div css={navStyle}>\n          {isLoading && (\n            <div css={spinnerStyle}>\n              <Spinner size={rootFontSize} />\n              <Responsive type={RESPONSIVE.LAPTOP.DEVICE}>\n                <span>일정을 가져오고 있습니다.</span>\n              </Responsive>\n            </div>\n          )}\n          <div css={monthPickerStyle}>\n            <Button cssProp={navButtonStyle} onClick={moveToBeforeMonth} aria-label=\"이전 달\">\n              <MdKeyboardArrowLeft />\n              <span css={navButtonTitleStyle}>전 달</span>\n            </Button>\n            <Button cssProp={todayButtonStyle} onClick={moveToToday} aria-label=\"이번 달\">\n              오늘\n            </Button>\n            <Button cssProp={navButtonStyle} onClick={moveToNextMonth} aria-label=\"다음 달\">\n              <MdKeyboardArrowRight />\n              <span css={navButtonTitleStyle}>다음 달</span>\n            </Button>\n          </div>\n        </div>\n      </div>\n      <div css={dayGridStyle}>\n        {DAYS.map((day) => (\n          <span key={`day#${day}`} css={dayStyle(theme, day)}>\n            {day}\n          </span>\n        ))}\n      </div>\n      <div css={calendarGridStyle(rowCount)}>\n        {calendar.map((dateTime) => {\n          return (\n            <DateCell\n              key={dateTime}\n              dateTime={dateTime}\n              currentMonth={currentMonth}\n              dateCellRef={dateCellRef}\n              setDateInfo={setDateInfo}\n              onClick={handleClickDateCell}\n              readonly={readonly}\n            />\n          );\n        })}\n      </div>\n    </>\n  );\n}\n\nexport default CalendarFallback;\n"
  },
  {
    "path": "frontend/src/components/Calendar/Calendar.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { DAYS } from '@/constants/date';\n\nconst calendarHeaderStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n  padding: 3rem 2rem;\n\n  font-size: 5rem;\n  font-weight: 500;\n  color: ${colors.GRAY_700};\n`;\n\nconst navStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  gap: 4rem;\n`;\n\nconst spinnerStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  gap: 2rem;\n\n  width: 100%;\n  height: 100%;\n\n  font-size: 3rem;\n`;\n\nconst monthPickerStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-around;\n`;\n\nconst todayButtonStyle = ({ colors }: Theme) => css`\n  width: 15rem;\n  height: 8rem;\n\n  padding: auto 0;\n\n  font-size: 4rem;\n  font-weight: 500;\n  color: ${colors.GRAY_700};\n  line-height: 4rem;\n`;\n\nconst navButtonStyle = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 8rem;\n  height: 8rem;\n  padding: 0;\n\n  font-size: 4rem;\n  line-height: 4rem;\n  color: ${colors.GRAY_600};\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst navButtonTitleStyle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nconst dayGridStyle = css`\n  display: grid;\n  grid-template-columns: repeat(7, calc(100% / 7));\n\n  height: 7rem;\n`;\n\nconst dayStyle = ({ colors }: Theme, day: string) => css`\n  padding: 2rem;\n  border-top: 1px solid ${colors.GRAY_300};\n  border-right: 1px solid ${colors.GRAY_300};\n  border-left: ${day === DAYS[0] && `1px solid ${colors.GRAY_300}`};\n\n  font-size: 3rem;\n  color: ${day === DAYS[0] && colors.RED_400};\n  text-align: right;\n`;\n\nconst calendarGridStyle = (rowNum: number) => css`\n  display: grid;\n  grid-template-columns: repeat(7, calc(100% / 7));\n  grid-auto-rows: calc(calc(100vh - 42rem) / ${rowNum});\n`;\n\nexport {\n  calendarGridStyle,\n  calendarHeaderStyle,\n  dayGridStyle,\n  dayStyle,\n  monthPickerStyle,\n  navButtonStyle,\n  navButtonTitleStyle,\n  navStyle,\n  spinnerStyle,\n  todayButtonStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/Calendar/Calendar.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { AxiosResponse } from 'axios';\n\nimport { CalendarControllerType } from '@/hooks/useCalendar';\n\nimport { ScheduleResponseType } from '@/@types/schedule';\n\nimport Button from '@/components/@common/Button/Button';\nimport DateCell from '@/components/DateCell/DateCell';\n\nimport { DAYS } from '@/constants/date';\n\nimport getSchedulePriority from '@/domains/schedule';\n\nimport { MdKeyboardArrowLeft, MdKeyboardArrowRight } from 'react-icons/md';\n\nimport {\n  calendarGridStyle,\n  calendarHeaderStyle,\n  dayGridStyle,\n  dayStyle,\n  monthPickerStyle,\n  navButtonStyle,\n  navButtonTitleStyle,\n  todayButtonStyle,\n} from './Calendar.styles';\n\ninterface CalendarProps {\n  calendarController: CalendarControllerType;\n  scheduleResponse: AxiosResponse<ScheduleResponseType>;\n  setDateInfo?: React.Dispatch<React.SetStateAction<string>>;\n  handleClickDateCell?: () => void;\n  categoryName?: string;\n  readonly?: boolean;\n}\n\nfunction Calendar({\n  calendarController,\n  scheduleResponse,\n  setDateInfo,\n  handleClickDateCell,\n  categoryName,\n  readonly,\n}: CalendarProps) {\n  const theme = useTheme();\n\n  const {\n    calendar,\n    currentMonth,\n    currentYear,\n    dateCellRef,\n    maxScheduleCount,\n    moveToBeforeMonth,\n    moveToNextMonth,\n    moveToToday,\n    rowCount,\n  } = calendarController;\n\n  const { calendarWithPriority, getLongTermSchedulesWithPriority, getSingleSchedulesWithPriority } =\n    getSchedulePriority(calendar);\n\n  const schedulesWithPriority = {\n    longTermSchedulesWithPriority: getLongTermSchedulesWithPriority(\n      scheduleResponse.data.longTerms\n    ),\n    allDaySchedulesWithPriority: getSingleSchedulesWithPriority(scheduleResponse.data.allDays),\n    fewHourSchedulesWithPriority: getSingleSchedulesWithPriority(scheduleResponse.data.fewHours),\n  };\n\n  return (\n    <>\n      <div css={calendarHeaderStyle}>\n        {`${currentYear}년 ${currentMonth}월${\n          categoryName ? ` \\u00A0☾\\u00A0 ${categoryName}` : ''\n        }`}\n        <div css={monthPickerStyle}>\n          <Button cssProp={navButtonStyle} onClick={moveToBeforeMonth} aria-label=\"이전 달\">\n            <MdKeyboardArrowLeft />\n            <span css={navButtonTitleStyle}>전 달</span>\n          </Button>\n          <Button cssProp={todayButtonStyle} onClick={moveToToday} aria-label=\"이번 달\">\n            오늘\n          </Button>\n          <Button cssProp={navButtonStyle} onClick={moveToNextMonth} aria-label=\"다음 달\">\n            <MdKeyboardArrowRight />\n            <span css={navButtonTitleStyle}>다음 달</span>\n          </Button>\n        </div>\n      </div>\n      <div css={dayGridStyle}>\n        {DAYS.map((day) => (\n          <span key={`${day}#day`} css={dayStyle(theme, day)}>\n            {day}\n          </span>\n        ))}\n      </div>\n      <div css={calendarGridStyle(rowCount)}>\n        {calendar.map((dateTime) => {\n          return (\n            <DateCell\n              key={dateTime}\n              dateTime={dateTime}\n              currentMonth={currentMonth}\n              dateCellRef={dateCellRef}\n              maxScheduleCount={maxScheduleCount}\n              calendarWithPriority={calendarWithPriority}\n              schedulesWithPriority={schedulesWithPriority}\n              setDateInfo={setDateInfo}\n              onClick={handleClickDateCell}\n              readonly={readonly}\n            />\n          );\n        })}\n      </div>\n    </>\n  );\n}\n\nexport default Calendar;\n"
  },
  {
    "path": "frontend/src/components/CategoryAddModal/CategoryAddModal.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport CategoryAddModal from './CategoryAddModal';\n\nexport default {\n  title: 'Components/CategoryAddModal',\n  component: CategoryAddModal,\n} as ComponentMeta<typeof CategoryAddModal>;\n\nconst Template: ComponentStory<typeof CategoryAddModal> = (args) => <CategoryAddModal {...args} />;\n\nexport const Primary = Template.bind({});\n"
  },
  {
    "path": "frontend/src/components/CategoryAddModal/CategoryAddModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst categoryAddModal = ({ colors, flex }: Theme) => css`\n  ${flex.column}\n\n  width: 120rem;\n  height: 90rem;\n  padding: 12.5rem;\n  border-radius: 7px;\n  justify-content: space-between;\n\n  background: ${colors.WHITE};\n`;\n\nconst title = ({ colors }: Theme) => css`\n  font-size: 8rem;\n  font-weight: bold;\n  color: ${colors.GRAY_700};\n`;\n\nconst form = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  width: 100%;\n  height: 100%;\n  justify-content: space-between;\n`;\n\nconst content = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  width: 100%;\n  height: 100%;\n\n  justify-content: center;\n`;\n\nconst controlButtons = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  align-self: flex-end;\n  gap: 5rem;\n`;\n\nconst cancelButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  box-sizing: border-box;\n  border: 1px solid ${colors.GRAY_500};\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.WHITE};\n\n  font-size: 4rem;\n  color: ${colors.GRAY_600};\n`;\n\nconst saveButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  box-sizing: border-box;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 4rem;\n  color: ${colors.WHITE};\n`;\n\nexport {\n  cancelButtonStyle,\n  categoryAddModal,\n  content,\n  controlButtons,\n  form,\n  saveButtonStyle,\n  title,\n};\n"
  },
  {
    "path": "frontend/src/components/CategoryAddModal/CategoryAddModal.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { usePostCategory } from '@/hooks/@queries/category';\nimport useValidateCategory from '@/hooks/useValidateCategory';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\n\nimport {\n  cancelButtonStyle,\n  categoryAddModal,\n  content,\n  controlButtons,\n  form,\n  saveButtonStyle,\n  title,\n} from './CategoryAddModal.styles';\n\ninterface CategoryAddModalProps {\n  closeModal: () => void;\n}\n\nfunction CategoryAddModal({ closeModal }: CategoryAddModalProps) {\n  const theme = useTheme();\n\n  const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory();\n\n  const { mutate } = usePostCategory({ onSuccess: closeModal });\n\n  const handleSubmitCategoryAddForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    mutate({ name: categoryValue.inputValue, categoryType: CATEGORY_TYPE.NORMAL });\n  };\n\n  return (\n    <div css={categoryAddModal}>\n      <h1 css={title}>새 카테고리 만들기</h1>\n      <form css={form} onSubmit={handleSubmitCategoryAddForm}>\n        <div css={content}>\n          <Fieldset\n            placeholder=\"이름\"\n            value={categoryValue.inputValue}\n            autoFocus={true}\n            onChange={categoryValue.onChangeValue}\n            isValid={isValidCategory}\n            errorMessage={getCategoryErrorMessage()}\n            labelText=\"카테고리 이름\"\n          />\n        </div>\n        <div css={controlButtons}>\n          <Button cssProp={cancelButtonStyle(theme)} onClick={closeModal}>\n            취소\n          </Button>\n          <Button type=\"submit\" cssProp={saveButtonStyle(theme)} disabled={!isValidCategory}>\n            완료\n          </Button>\n        </div>\n      </form>\n    </div>\n  );\n}\n\nexport default CategoryAddModal;\n"
  },
  {
    "path": "frontend/src/components/CategoryControl/CategoryControl.tsx",
    "content": "import { lazy, memo, Suspense, useRef, useState } from 'react';\n\nimport useRootFontSize from '@/hooks/useRootFontSize';\nimport useToggle from '@/hooks/useToggle';\n\nimport { CategoryType } from '@/@types/category';\n\nimport theme from '@/styles/theme';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport CategoryAddModal from '@/components/CategoryAddModal/CategoryAddModal';\nimport CategoryListFallback from '@/components/CategoryList/CategoryList.fallback';\n\nimport { MdSearch } from 'react-icons/md';\n\nimport {\n  buttonStyle,\n  categoryHeaderStyle,\n  categoryStyle,\n  controlStyle,\n  searchButtonStyle,\n  searchFieldsetStyle,\n  searchFormStyle,\n  searchInputStyle,\n} from './CategoryCotrol.styles';\n\nconst CategoryList = lazy(() => import('@/components/CategoryList/CategoryList'));\n\ninterface CategoryControlProps {\n  setCategory: React.Dispatch<React.SetStateAction<Pick<CategoryType, 'id' | 'name'>>>;\n}\n\nfunction CategoryControl({ setCategory }: CategoryControlProps) {\n  const keywordRef = useRef<HTMLInputElement>(null);\n\n  const [keyword, setKeyword] = useState('');\n\n  const rootFontSize = useRootFontSize();\n\n  const { state: isCategoryAddModalOpen, toggleState: toggleCategoryAddModalOpen } = useToggle();\n\n  const handleSubmitCategorySearchForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    if (!(keywordRef.current instanceof HTMLInputElement)) {\n      return;\n    }\n\n    setKeyword((keywordRef.current as HTMLInputElement).value);\n  };\n\n  const handleClickCategoryAddButton = () => {\n    toggleCategoryAddModalOpen();\n  };\n\n  return (\n    <div css={categoryStyle}>\n      <ModalPortal isOpen={isCategoryAddModalOpen} closeModal={toggleCategoryAddModalOpen}>\n        <CategoryAddModal closeModal={toggleCategoryAddModalOpen} />\n      </ModalPortal>\n      <h1 css={categoryHeaderStyle}>카테고리</h1>\n      <div css={controlStyle}>\n        <form css={searchFormStyle} onSubmit={handleSubmitCategorySearchForm}>\n          <Button type=\"submit\" cssProp={searchButtonStyle}>\n            <MdSearch size={rootFontSize * 5} />\n          </Button>\n          <Fieldset\n            placeholder=\"제목 찾기\"\n            cssProp={{ div: searchFieldsetStyle, input: searchInputStyle }}\n            refProp={keywordRef}\n          />\n        </form>\n        <Button cssProp={buttonStyle(theme)} onClick={handleClickCategoryAddButton}>\n          추가\n        </Button>\n      </div>\n      <Suspense fallback={<CategoryListFallback />}>\n        <CategoryList keyword={keyword} setCategory={setCategory} />\n      </Suspense>\n    </div>\n  );\n}\n\nexport default memo(CategoryControl);\n"
  },
  {
    "path": "frontend/src/components/CategoryControl/CategoryCotrol.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst categoryStyle = ({ mq }: Theme) => css`\n  align-self: center;\n\n  width: 30%;\n\n  ${mq?.tablet || mq?.mobile} {\n    width: 100%;\n  }\n`;\n\nconst categoryHeaderStyle = ({ colors }: Theme) => css`\n  padding: 1rem 3rem 4rem;\n\n  font-size: 6rem;\n  font-weight: 600;\n  color: ${colors.GRAY_700};\n`;\n\nconst controlStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  align-items: flex-start;\n  justify-content: center;\n  gap: 4rem;\n`;\n\nconst searchFormStyle = css`\n  position: relative;\n\n  width: 100%;\n  height: 12rem;\n  margin-bottom: 5rem;\n`;\n\nconst searchButtonStyle = css`\n  position: absolute;\n  z-index: 5;\n\n  top: 50%;\n  transform: translateY(-50%);\n\n  width: 10rem;\n`;\n\nconst searchFieldsetStyle = css`\n  height: 100%;\n`;\n\nconst searchInputStyle = css`\n  height: 100%;\n  padding-left: 10rem;\n\n  font-size: 4rem;\n`;\n\nconst buttonStyle = ({ colors }: Theme) => css`\n  width: 20rem;\n  height: 12rem;\n  border-radius: 7px;\n  border: 1px solid ${colors.GRAY_500};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 4rem;\n  font-weight: 600;\n  color: ${colors.WHITE};\n\n  &:hover {\n    box-shadow: none;\n  }\n`;\n\nexport {\n  buttonStyle,\n  categoryHeaderStyle,\n  categoryStyle,\n  controlStyle,\n  searchButtonStyle,\n  searchFieldsetStyle,\n  searchFormStyle,\n  searchInputStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/CategoryList/CategoryList.fallback.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport CategoryListFallback from './CategoryList.fallback';\n\nexport default {\n  title: 'Components/CategoryListFallback',\n  component: CategoryListFallback,\n} as ComponentMeta<typeof CategoryListFallback>;\n\nconst Template: ComponentStory<typeof CategoryListFallback> = () => <CategoryListFallback />;\n\nconst Primary = Template.bind({});\n\nexport { Primary };\n"
  },
  {
    "path": "frontend/src/components/CategoryList/CategoryList.fallback.tsx",
    "content": "import Skeleton from '@/components/@common/Skeleton/Skeleton';\nimport {\n  categoryItem,\n  item,\n} from '@/components/SubscribedCategoryItem/SubscribedCategoryItem.styles';\n\nimport { categoryTableHeaderStyle, categoryTableStyle, itemStyle } from './CategoryList.styles';\n\nfunction CategoryListFallback() {\n  return (\n    <div>\n      <div css={categoryTableHeaderStyle}>\n        <span css={itemStyle}>제목</span>\n        <span css={itemStyle}>개설자</span>\n        <span css={itemStyle}>구독</span>\n      </div>\n      <div css={categoryTableStyle}>\n        {new Array(10).fill(0).map((el, index) => (\n          <div css={categoryItem} key={index}>\n            <Skeleton cssProp={item} width=\"60%\" height=\"6rem\" />\n            <Skeleton cssProp={item} width=\"60%\" height=\"6rem\" />\n            <Skeleton cssProp={item} width=\"60%\" height=\"6rem\" />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default CategoryListFallback;\n"
  },
  {
    "path": "frontend/src/components/CategoryList/CategoryList.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport CategoryList from './CategoryList';\n\nexport default {\n  title: 'Components/CategoryList',\n  component: CategoryList,\n} as ComponentMeta<typeof CategoryList>;\n\nconst Template: ComponentStory<typeof CategoryList> = (args) => <CategoryList {...args} />;\n\nexport const Primary = Template.bind({});\nPrimary.args = {\n  keyword: '',\n};\n"
  },
  {
    "path": "frontend/src/components/CategoryList/CategoryList.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst categoryTableStyle = css`\n  overflow: hidden;\n\n  width: 100%;\n  height: calc(100vh - 66rem);\n\n  &:hover {\n    overflow-y: overlay;\n  }\n`;\n\nconst categoryTableHeaderStyle = ({ flex, colors }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-around;\n\n  width: 100%;\n  height: 12rem;\n  border-bottom: 2px solid ${colors.GRAY_400};\n\n  background: ${colors.GRAY_100};\n\n  font-size: 4rem;\n  font-weight: 700;\n`;\n\nconst itemStyle = css`\n  flex: 1 1 0;\n  text-align: center;\n`;\n\nexport { categoryTableHeaderStyle, categoryTableStyle, itemStyle };\n"
  },
  {
    "path": "frontend/src/components/CategoryList/CategoryList.tsx",
    "content": "import { Dispatch, SetStateAction } from 'react';\n\nimport { useGetEntireCategories } from '@/hooks/@queries/category';\nimport { useGetSubscriptions } from '@/hooks/@queries/subscription';\n\nimport { CategoryType } from '@/@types/category';\n\nimport SubscribedCategoryItem from '@/components/SubscribedCategoryItem/SubscribedCategoryItem';\nimport UnsubscribedCategoryItem from '@/components/UnsubscribedCategoryItem/UnsubscribedCategoryItem';\n\nimport { categoryTableHeaderStyle, categoryTableStyle, itemStyle } from './CategoryList.styles';\n\ninterface CategoryListProps {\n  keyword: string;\n  setCategory: Dispatch<SetStateAction<Pick<CategoryType, 'id' | 'name'>>>;\n}\n\nfunction CategoryList({ keyword, setCategory }: CategoryListProps) {\n  const { data: categoriesGetResponse } = useGetEntireCategories({ keyword });\n  const { data: subscriptionsGetResponse } = useGetSubscriptions({});\n\n  const subscriptionList = subscriptionsGetResponse?.data.map((el) => {\n    return {\n      subscriptionId: el.id,\n      categoryId: el.category.id,\n    };\n  });\n\n  const handleClickCategoryItem = (category: Pick<CategoryType, 'id' | 'name'>) => {\n    setCategory(category);\n  };\n\n  return (\n    <>\n      <div css={categoryTableHeaderStyle}>\n        <span css={itemStyle}>제목</span>\n        <span css={itemStyle}>개설자</span>\n        <span css={itemStyle}>구독</span>\n      </div>\n      <div css={categoryTableStyle}>\n        {categoriesGetResponse?.data.map((category) => {\n          const subscribedCategoryInfo = subscriptionList?.find(\n            (el) => el.categoryId === category.id\n          );\n\n          if (subscribedCategoryInfo === undefined) {\n            return (\n              <UnsubscribedCategoryItem\n                key={category.id}\n                category={category}\n                onClick={() => handleClickCategoryItem(category)}\n              />\n            );\n          }\n\n          return (\n            <SubscribedCategoryItem\n              key={category.id}\n              category={category}\n              subscriptionId={subscribedCategoryInfo.subscriptionId}\n              onClick={() => handleClickCategoryItem(category)}\n            />\n          );\n        })}\n      </div>\n    </>\n  );\n}\n\nexport default CategoryList;\n"
  },
  {
    "path": "frontend/src/components/CategoryModifyModal/CategoryModifyModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst modal = ({ colors, flex }: Theme) => css`\n  ${flex.column}\n\n  width: 120rem;\n  height: 90rem;\n  padding: 12.5rem;\n  border-radius: 7px;\n  justify-content: space-between;\n\n  background: ${colors.WHITE};\n`;\n\nconst title = ({ colors }: Theme) => css`\n  font-size: 8rem;\n  font-weight: bold;\n  color: ${colors.GRAY_700};\n`;\n\nconst form = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  width: 100%;\n  height: 100%;\n  justify-content: space-between;\n`;\n\nconst content = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  width: 100%;\n  height: 100%;\n\n  justify-content: center;\n`;\n\nconst controlButtons = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  align-self: flex-end;\n  gap: 5rem;\n`;\n\nconst cancelButton = ({ colors }: Theme) => css`\n  width: 22.5rem;\n  height: 10rem;\n  border: 2px solid ${colors.GRAY_500};\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.WHITE};\n\n  font-size: 5rem;\n  color: ${colors.GRAY_600};\n`;\n\nconst saveButton = ({ colors }: Theme) => css`\n  width: 22.5rem;\n  height: 10rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 5rem;\n  color: ${colors.WHITE};\n`;\n\nexport { cancelButton, content, controlButtons, form, modal, saveButton, title };\n"
  },
  {
    "path": "frontend/src/components/CategoryModifyModal/CategoryModifyModal.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { usePatchCategoryName } from '@/hooks/@queries/category';\nimport useValidateCategory from '@/hooks/useValidateCategory';\n\nimport { CategoryType } from '@/@types/category';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\n\nimport {\n  cancelButton,\n  content,\n  controlButtons,\n  form,\n  modal,\n  saveButton,\n  title,\n} from './CategoryModifyModal.styles';\n\ninterface CategoryModifyModalProps {\n  category: CategoryType;\n  closeModal: () => void;\n}\n\nfunction CategoryModifyModal({ category, closeModal }: CategoryModifyModalProps) {\n  const theme = useTheme();\n\n  const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory(\n    category.name\n  );\n\n  const { mutate } = usePatchCategoryName({ categoryId: category.id, onSuccess: closeModal });\n\n  const handleSubmitCategoryModifyForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    mutate({ name: categoryValue.inputValue });\n  };\n\n  return (\n    <div css={modal}>\n      <h1 css={title}>카테고리 이름 수정</h1>\n      <form css={form} onSubmit={handleSubmitCategoryModifyForm}>\n        <div css={content}>\n          <Fieldset\n            placeholder={category.name}\n            value={categoryValue.inputValue}\n            autoFocus\n            onChange={categoryValue.onChangeValue}\n            isValid={isValidCategory}\n            errorMessage={getCategoryErrorMessage()}\n            labelText=\"카테고리 이름\"\n          />\n        </div>\n        <div css={controlButtons}>\n          <Button cssProp={cancelButton(theme)} onClick={closeModal}>\n            취소\n          </Button>\n          <Button type=\"submit\" cssProp={saveButton(theme)} disabled={!isValidCategory}>\n            완료\n          </Button>\n        </div>\n      </form>\n    </div>\n  );\n}\n\nexport default CategoryModifyModal;\n"
  },
  {
    "path": "frontend/src/components/DateCell/DateCell.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { SCHEDULE } from '@/constants/style';\n\nconst dateCellStyle = ({ colors }: Theme, day: number, readonly: boolean) => css`\n  position: relative;\n\n  height: 100%;\n  border-right: 1px solid ${colors.GRAY_300};\n  border-bottom: 1px solid ${colors.GRAY_300};\n  border-left: ${day === 0 && `1px solid ${colors.GRAY_300}`};\n\n  ${!readonly &&\n  css`\n    &:hover {\n      background: ${colors.GRAY_000};\n    }\n  `}\n`;\n\nconst dateTextStyle = (\n  { colors }: Theme,\n  day: number,\n  isThisMonth: boolean,\n  isToday: boolean\n) => css`\n  position: absolute;\n  top: 1rem;\n  right: 1rem;\n\n  width: ${SCHEDULE.HEIGHT}rem;\n  height: ${SCHEDULE.HEIGHT}rem;\n  padding: 1rem;\n  border-radius: 50%;\n\n  background: ${isToday && colors.YELLOW_500};\n\n  font-size: 2.5rem;\n  font-weight: 500;\n  color: ${isToday\n    ? colors.WHITE\n    : day === 0\n    ? `${colors.RED_400}${isThisMonth ? '' : '80'}`\n    : `${colors.GRAY_700}${isThisMonth ? '' : '80'}`};\n  text-align: ${isToday ? 'center' : 'right'};\n  line-height: 3rem;\n`;\n\nconst moreStyle = ({ colors }: Theme) => css`\n  overflow: hidden;\n  position: absolute;\n  bottom: 0;\n\n  width: 100%;\n  height: ${SCHEDULE.HEIGHT}rem;\n  padding: 1rem;\n\n  font-size: 2.75rem;\n  font-weight: 200;\n  color: ${colors.GRAY_500};\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  line-height: 2.75rem;\n\n  cursor: pointer;\n\n  &:hover {\n    color: ${colors.BLACK};\n  }\n`;\n\nexport { dateCellStyle, dateTextStyle, moreStyle };\n"
  },
  {
    "path": "frontend/src/components/DateCell/DateCell.tsx",
    "content": "import useModalPosition from '@/hooks/useModalPosition';\n\nimport { ScheduleType } from '@/@types/schedule';\n\nimport theme from '@/styles/theme';\n\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport MoreScheduleModal from '@/components/MoreScheduleModal/MoreScheduleModal';\nimport Schedule from '@/components/Schedule/Schedule';\n\nimport { SCHEDULE } from '@/constants/schedule';\nimport { TRANSPARENT } from '@/constants/style';\n\nimport {\n  checkAllDay,\n  extractDateTime,\n  getDayOffsetDateTime,\n  getISODateString,\n  getToday,\n} from '@/utils/date';\n\nimport { dateCellStyle, dateTextStyle, moreStyle } from './DateCell.styles';\n\ninterface DateCellProps {\n  dateTime: string;\n  currentMonth: number;\n  dateCellRef: React.RefObject<HTMLDivElement>;\n  maxScheduleCount?: number;\n  calendarWithPriority?: Record<string, boolean[]>;\n  schedulesWithPriority?: Record<\n    | 'longTermSchedulesWithPriority'\n    | 'allDaySchedulesWithPriority'\n    | 'fewHourSchedulesWithPriority',\n    {\n      schedule: ScheduleType;\n      priority: null | number;\n    }[]\n  >;\n  setDateInfo?: React.Dispatch<React.SetStateAction<string>>;\n  onClick?: () => void;\n  readonly?: boolean;\n}\n\nfunction DateCell({\n  dateTime,\n  currentMonth,\n  dateCellRef,\n  maxScheduleCount,\n  calendarWithPriority,\n  schedulesWithPriority,\n  setDateInfo,\n  onClick,\n  readonly = false,\n}: DateCellProps) {\n  const moreScheduleModal = useModalPosition();\n\n  const { month, date, day } = extractDateTime(dateTime);\n\n  const isSchedulesLoaded = calendarWithPriority && schedulesWithPriority && maxScheduleCount;\n\n  const handleClickDateCell = (e: React.MouseEvent, info: string) => {\n    if (e.target !== e.currentTarget) {\n      return;\n    }\n\n    setDateInfo && setDateInfo(info);\n    onClick && onClick();\n  };\n\n  if (!isSchedulesLoaded) {\n    return (\n      <div\n        css={dateCellStyle(theme, day, readonly)}\n        ref={dateCellRef}\n        {...(!readonly && { onClick: (e) => handleClickDateCell(e, dateTime) })}\n      >\n        <span css={dateTextStyle(theme, day, currentMonth === month, dateTime === getToday())}>\n          {date}\n        </span>\n      </div>\n    );\n  }\n\n  const {\n    longTermSchedulesWithPriority,\n    allDaySchedulesWithPriority,\n    fewHourSchedulesWithPriority,\n  } = schedulesWithPriority;\n\n  const currentDate = getISODateString(dateTime);\n\n  const priorityPosition = calendarWithPriority[getISODateString(dateTime)].findIndex(\n    (priority) => !priority\n  );\n  const hasMoreSchedule = priorityPosition === -1 || priorityPosition + 1 > maxScheduleCount;\n\n  return (\n    <div\n      css={dateCellStyle(theme, day, readonly)}\n      ref={dateCellRef}\n      {...(!readonly && { onClick: (e) => handleClickDateCell(e, dateTime) })}\n    >\n      <span css={dateTextStyle(theme, day, currentMonth === month, dateTime === getToday())}>\n        {date}\n      </span>\n\n      {longTermSchedulesWithPriority.map(({ schedule, priority }) => {\n        const startDate = getISODateString(schedule.startDateTime);\n        const endDate = getISODateString(\n          checkAllDay(schedule.startDateTime, schedule.endDateTime)\n            ? getDayOffsetDateTime(schedule.endDateTime, -1)\n            : schedule.endDateTime\n        );\n        const { day: currentDay } = extractDateTime(dateTime);\n\n        if (!(startDate <= currentDate && currentDate <= endDate) || priority === null) return;\n\n        return (\n          <Schedule\n            key={`${SCHEDULE.RESPONSE_TYPE.LONG_TERMS}#${currentDate}#${schedule.id}`}\n            type={SCHEDULE.RESPONSE_TYPE.LONG_TERMS}\n            schedule={schedule}\n            priority={priority}\n            maxScheduleCount={maxScheduleCount}\n            isEndDate={currentDate === endDate}\n            isTitleVisible={startDate === currentDate || currentDay === 0}\n            readonly={readonly}\n          />\n        );\n      })}\n\n      {allDaySchedulesWithPriority.map(({ schedule, priority }) => {\n        const startDate = getISODateString(schedule.startDateTime);\n\n        if (startDate !== currentDate || priority === null) return;\n\n        return (\n          <Schedule\n            key={`${SCHEDULE.RESPONSE_TYPE.ALL_DAYS}#${currentDate}#${schedule.id}`}\n            type={SCHEDULE.RESPONSE_TYPE.ALL_DAYS}\n            schedule={schedule}\n            priority={priority}\n            maxScheduleCount={maxScheduleCount}\n            isEndDate={true}\n            readonly={readonly}\n          />\n        );\n      })}\n\n      {fewHourSchedulesWithPriority.map(({ schedule, priority }) => {\n        const startDate = getISODateString(schedule.startDateTime);\n\n        if (startDate !== currentDate || priority === null) return;\n\n        return (\n          <Schedule\n            key={`${SCHEDULE.RESPONSE_TYPE.FEW_HOURS}#${currentDate}#${schedule.id}`}\n            type={SCHEDULE.RESPONSE_TYPE.FEW_HOURS}\n            schedule={schedule}\n            priority={priority}\n            maxScheduleCount={maxScheduleCount}\n            isEndDate={false}\n            readonly={readonly}\n          />\n        );\n      })}\n\n      {hasMoreSchedule && (\n        <span css={moreStyle} onClick={moreScheduleModal.handleClickOpen}>\n          일정 더보기\n        </span>\n      )}\n\n      <ModalPortal\n        isOpen={moreScheduleModal.isModalOpen}\n        closeModal={moreScheduleModal.toggleModalOpen}\n        dimmerBackground={TRANSPARENT}\n      >\n        <MoreScheduleModal\n          moreScheduleModalPos={moreScheduleModal.modalPos}\n          moreScheduleDateTime={dateTime}\n          longTermSchedulesWithPriority={longTermSchedulesWithPriority}\n          allDaySchedulesWithPriority={allDaySchedulesWithPriority}\n          fewHourSchedulesWithPriority={fewHourSchedulesWithPriority}\n          readonly={readonly}\n        />\n      </ModalPortal>\n    </div>\n  );\n}\n\nexport default DateCell;\n"
  },
  {
    "path": "frontend/src/components/Footer/Footer.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst footerStyle = ({ colors, flex }: Theme) => css`\n  ${flex.column};\n\n  width: 100%;\n  height: 40rem;\n\n  color: ${colors.GRAY_600};\n  line-height: 150%;\n`;\n\nconst privacyPolicyButtonStyle = css`\n  margin: 1rem;\n\n  font-size: inherit;\n  color: inherit;\n`;\n\nexport { footerStyle, privacyPolicyButtonStyle };\n"
  },
  {
    "path": "frontend/src/components/Footer/Footer.tsx",
    "content": "import { useNavigate } from 'react-router-dom';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { PATH } from '@/constants';\n\nimport { footerStyle, privacyPolicyButtonStyle } from './Footer.styles';\n\nfunction Footer() {\n  const navigate = useNavigate();\n\n  const handleClickPrivacyPolicyButton = () => {\n    navigate(PATH.POLICY);\n  };\n\n  return (\n    <footer css={footerStyle}>\n      <p>우아한테크코스 4기 달록</p>\n      <p>서울특별시 송파구 올림픽로35다길 42, 14층 (한국루터회관)</p>\n      <p>Copyright © 2022 달록 - All rights reserved.</p>\n      <Button cssProp={privacyPolicyButtonStyle} onClick={handleClickPrivacyPolicyButton}>\n        개인정보처리방침\n      </Button>\n    </footer>\n  );\n}\n\nexport default Footer;\n"
  },
  {
    "path": "frontend/src/components/GoogleCategoryManageModal/GoogleCategoryManageModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst closeModalButtonStyle = css`\n  position: absolute;\n  top: 6rem;\n  right: 6rem;\n\n  font-size: 6rem;\n`;\n\nconst deleteButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.RED_400};\n\n  font-size: 3rem;\n  color: ${colors.WHITE};\n`;\n\nconst headerStyle = css`\n  font-size: 6rem;\n`;\n\nconst layoutStyle = ({ flex, colors }: Theme) => css`\n  ${flex.column};\n\n  justify-content: space-between;\n  gap: 10rem;\n  overflow: overlay;\n  position: relative;\n\n  max-height: 100vh;\n  padding: 12.5rem;\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n`;\n\nconst renameButtonStyle = ({ colors }: Theme) => css`\n  height: 8rem;\n  padding: 2rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 3rem;\n  color: ${colors.WHITE};\n`;\n\nconst renameFieldSetStyle = {\n  div: css`\n    width: 40%;\n  `,\n  input: css`\n    height: 8rem;\n  `,\n};\n\nconst renameFormStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 20rem;\n\n  width: 100%;\n`;\n\nconst sectionStyle = css`\n  width: 100%;\n`;\n\nconst spaceBetweenStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 20rem;\n\n  width: 100%;\n`;\n\nconst titleStyle = css`\n  margin-bottom: 3rem;\n\n  font-size: 4rem;\n  font-weight: 700;\n`;\n\nexport {\n  closeModalButtonStyle,\n  deleteButtonStyle,\n  headerStyle,\n  layoutStyle,\n  renameButtonStyle,\n  renameFieldSetStyle,\n  renameFormStyle,\n  sectionStyle,\n  spaceBetweenStyle,\n  titleStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/GoogleCategoryManageModal/GoogleCategoryManageModal.tsx",
    "content": "import { useDeleteCategory, usePatchCategoryName } from '@/hooks/@queries/category';\nimport useValidateCategory from '@/hooks/useValidateCategory';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\n\nimport { CONFIRM_MESSAGE } from '@/constants/message';\n\nimport { MdClose } from 'react-icons/md';\n\nimport {\n  closeModalButtonStyle,\n  deleteButtonStyle,\n  headerStyle,\n  layoutStyle,\n  renameButtonStyle,\n  renameFieldSetStyle,\n  renameFormStyle,\n  sectionStyle,\n  spaceBetweenStyle,\n  titleStyle,\n} from './GoogleCategoryManageModal.styles';\n\ninterface GoogleCategoryManageModalProps {\n  subscription: SubscriptionType;\n  closeModal: () => void;\n}\n\nfunction GoogleCategoryManageModal({ subscription, closeModal }: GoogleCategoryManageModalProps) {\n  const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory(\n    subscription.category.name\n  );\n\n  const { mutate: patchCategory } = usePatchCategoryName({\n    categoryId: subscription.category.id,\n    onSuccess: closeModal,\n  });\n\n  const { mutate: deleteCategory } = useDeleteCategory({\n    categoryId: subscription.category.id,\n    onSuccess: closeModal,\n  });\n\n  const handleSubmitCategoryModifyForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    patchCategory({ name: categoryValue.inputValue });\n  };\n\n  const handleClickDeleteCategoryButton = () => {\n    window.confirm(CONFIRM_MESSAGE.DELETE) && deleteCategory();\n  };\n\n  return (\n    <div css={layoutStyle}>\n      <h1 css={headerStyle}>{subscription.category.name} (관리)</h1>\n      <Button cssProp={closeModalButtonStyle} onClick={closeModal}>\n        <MdClose />\n      </Button>\n      <section css={sectionStyle}>\n        <h2 css={titleStyle}>카테고리 이름 수정</h2>\n        <form css={renameFormStyle} onSubmit={handleSubmitCategoryModifyForm}>\n          <Fieldset\n            placeholder={subscription.category.name}\n            value={categoryValue.inputValue}\n            autoFocus\n            onChange={categoryValue.onChangeValue}\n            isValid={isValidCategory}\n            errorMessage={getCategoryErrorMessage()}\n            cssProp={renameFieldSetStyle}\n          />\n          <Button type=\"submit\" disabled={!isValidCategory} cssProp={renameButtonStyle}>\n            수정\n          </Button>\n        </form>\n      </section>\n\n      <section css={sectionStyle}>\n        <h2 css={titleStyle}>카테고리 삭제</h2>\n        <div css={spaceBetweenStyle}>\n          <span>카테고리를 영구적으로 삭제합니다.</span>\n          <Button onClick={handleClickDeleteCategoryButton} cssProp={deleteButtonStyle}>\n            삭제\n          </Button>\n        </div>\n      </section>\n    </div>\n  );\n}\n\nexport default GoogleCategoryManageModal;\n"
  },
  {
    "path": "frontend/src/components/GoogleImportModal/GoogleImportModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst layoutStyle = ({ colors, flex }: Theme) => css`\n  ${flex.column};\n\n  align-items: flex-start;\n  justify-content: center;\n  gap: 10rem;\n\n  width: 120rem;\n  height: 120rem;\n  padding: 12.5rem;\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n\n  color: ${colors.GRAY_700};\n`;\n\nconst headerStyle = css`\n  font-size: 8rem;\n  font-weight: bold;\n  text-align: center;\n`;\n\nconst titleStyle = css`\n  padding: 0 1rem;\n  font-size: 4rem;\n`;\n\nconst googleSelectBoxStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  align-items: flex-start;\n  gap: 2rem;\n\n  width: 100%;\n\n  font-size: 4rem;\n`;\n\nconst formStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  align-items: flex-start;\n\n  width: 100%;\n  height: 100%;\n`;\n\nexport { formStyle, googleSelectBoxStyle, headerStyle, layoutStyle, titleStyle };\n"
  },
  {
    "path": "frontend/src/components/GoogleImportModal/GoogleImportModal.tsx",
    "content": "import { validateNotEmpty } from '@/validation';\nimport { useTheme } from '@emotion/react';\n\nimport {\n  useGetGoogleCalendar,\n  usePostGoogleCalendarCategory,\n} from '@/hooks/@queries/googleCalendar';\nimport useControlledInput from '@/hooks/useControlledInput';\nimport useValidateCategory from '@/hooks/useValidateCategory';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\nimport Select from '@/components/@common/Select/Select';\nimport Spinner from '@/components/@common/Spinner/Spinner';\nimport {\n  cancelButtonStyle,\n  content,\n  controlButtons,\n  saveButtonStyle,\n} from '@/components/CategoryAddModal/CategoryAddModal.styles';\n\nimport {\n  formStyle,\n  googleSelectBoxStyle,\n  headerStyle,\n  layoutStyle,\n  titleStyle,\n} from './GoogleImportModal.styles';\n\ninterface GoogleImportModal {\n  closeModal: () => void;\n}\n\nfunction GoogleImportModal({ closeModal }: GoogleImportModal) {\n  const theme = useTheme();\n\n  const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory();\n  const { inputValue: googleCalendarInputValue, onChangeValue: onChangeGoogleCalendarInputValue } =\n    useControlledInput();\n\n  const { isLoading, data } = useGetGoogleCalendar();\n  const { mutate } = usePostGoogleCalendarCategory({ onSuccess: closeModal });\n\n  const handleSubmitCategoryAddForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    mutate({ externalId: googleCalendarInputValue, name: categoryValue.inputValue });\n  };\n\n  if (isLoading || data === undefined) {\n    return <Spinner size={10} />;\n  }\n\n  const googleCalendars = data.data.externalCalendars.map((google) => {\n    return {\n      id: google.calendarId,\n      name: google.summary,\n    };\n  });\n\n  return (\n    <div css={layoutStyle}>\n      <div css={headerStyle}>구글 캘린더 가져오기</div>\n      <form css={formStyle} onSubmit={handleSubmitCategoryAddForm}>\n        <div css={googleSelectBoxStyle}>\n          <div css={titleStyle}>구글 캘린더 목록</div>\n          <Select\n            options={googleCalendars}\n            value={googleCalendarInputValue}\n            description=\"구글 캘린더 선택 (필수)\"\n            onChange={onChangeGoogleCalendarInputValue}\n          />\n        </div>\n\n        <div css={content}>\n          <Fieldset\n            placeholder=\"카테고리 이름\"\n            autoFocus={true}\n            onChange={categoryValue.onChangeValue}\n            errorMessage={getCategoryErrorMessage()}\n            isValid={isValidCategory}\n            labelText={'연동할 달록 카테고리 생성'}\n          />\n        </div>\n        <div css={controlButtons}>\n          <Button cssProp={cancelButtonStyle(theme)} onClick={closeModal}>\n            취소\n          </Button>\n          <Button\n            type=\"submit\"\n            cssProp={saveButtonStyle(theme)}\n            disabled={!isValidCategory || !validateNotEmpty(googleCalendarInputValue)}\n          >\n            완료\n          </Button>\n        </div>\n      </form>\n    </div>\n  );\n}\n\nexport default GoogleImportModal;\n"
  },
  {
    "path": "frontend/src/components/MoreScheduleModal/MoreScheduleModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { ModalPosType } from '@/@types';\n\nconst moreScheduleModalStyle = (\n  { colors, flex, mq }: Theme,\n  moreScheduleModalPos: ModalPosType\n) => css`\n  ${flex.column};\n\n  overflow-x: hidden;\n  justify-content: flex-start;\n  overflow-y: auto;\n  position: absolute;\n  top: ${moreScheduleModalPos.top ? `${moreScheduleModalPos.top + 20}px` : 'none'};\n  bottom: ${moreScheduleModalPos.bottom ? `${moreScheduleModalPos.bottom + 20}px` : 'none'};\n  gap: 1rem;\n\n  width: 50rem;\n  max-height: 50%;\n  padding: 4rem;\n  border-radius: 7px;\n  box-shadow: 0 0 5px ${colors.GRAY_500};\n\n  background: ${colors.WHITE};\n\n  ${mq?.laptop} {\n    right: ${moreScheduleModalPos.right ? `${moreScheduleModalPos.right + 20}px` : 'none'};\n    left: ${moreScheduleModalPos.left ? `${moreScheduleModalPos.left + 20}px` : 'none'};\n  }\n\n  ${mq?.tablet || mq?.mobile} {\n    left: 50%;\n    transform: translateX(-50%);\n  }\n`;\n\nconst headerStyle = ({ flex }: Theme) => css`\n  ${flex.column}\n\n  justify-content: flex-end;\n  gap: 1rem;\n\n  width: 100%;\n  margin-bottom: 2rem;\n`;\n\nconst dayTextStyle = ({ colors }: Theme, day: number) => css`\n  font-size: 3rem;\n  color: ${day === 0 && colors.RED_400};\n  text-align: right;\n`;\n\nconst dateTextStyle = ({ colors }: Theme, day: number, isToday: boolean) => css`\n  width: 5rem;\n  height: 5rem;\n  padding: 1rem;\n  border-radius: 50%;\n\n  background: ${isToday && colors.YELLOW_500};\n\n  font-size: 2.5rem;\n  font-weight: 500;\n  color: ${isToday ? colors.WHITE : day === 0 ? colors.RED_400 : colors.GRAY_700};\n  text-align: center;\n  line-height: 3rem;\n`;\n\nconst itemWithBackgroundStyle = ({ colors }: Theme, colorCode: string) => css`\n  overflow: hidden;\n\n  width: 100%;\n  height: 5rem;\n  padding: 1rem;\n\n  background: ${colorCode === '' ? colors.ORANGE_500 : colorCode};\n\n  font-size: 2.75rem;\n  color: white;\n  white-space: nowrap;\n  line-height: 2.75rem;\n  text-overflow: ellipsis;\n\n  &:hover {\n    cursor: pointer;\n    filter: brightness(0.95);\n  }\n`;\n\nconst itemWithoutBackgroundStyle = (theme: Theme, colorCode: string) => css`\n  ${itemWithBackgroundStyle(theme, colorCode)};\n\n  overflow: hidden;\n\n  border-left: 3px solid ${colorCode === '' ? theme.colors.ORANGE_500 : colorCode};\n\n  background: ${theme.colors.WHITE};\n\n  color: black;\n\n  &:hover {\n    background: ${theme.colors.GRAY_000};\n    filter: none;\n  }\n`;\n\nexport {\n  moreScheduleModalStyle,\n  dateTextStyle,\n  dayTextStyle,\n  headerStyle,\n  itemWithBackgroundStyle,\n  itemWithoutBackgroundStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/MoreScheduleModal/MoreScheduleModal.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useState } from 'react';\n\nimport useModalPosition from '@/hooks/useModalPosition';\nimport useToggle from '@/hooks/useToggle';\n\nimport { ModalPosType } from '@/@types';\nimport { ScheduleType } from '@/@types/schedule';\n\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport ScheduleModal from '@/components/ScheduleModal/ScheduleModal';\nimport ScheduleModifyModal from '@/components/ScheduleModifyModal/ScheduleModifyModal';\n\nimport { CALENDAR } from '@/constants';\nimport { DAYS } from '@/constants/date';\nimport { TRANSPARENT } from '@/constants/style';\n\nimport {\n  checkAllDay,\n  extractDateTime,\n  getDayOffsetDateTime,\n  getISODateString,\n  getThisDate,\n  getThisMonth,\n} from '@/utils/date';\n\nimport {\n  dateTextStyle,\n  dayTextStyle,\n  headerStyle,\n  itemWithBackgroundStyle,\n  itemWithoutBackgroundStyle,\n  moreScheduleModalStyle,\n} from './MoreScheduleModal.styles';\n\ninterface MoreScheduleModalProps {\n  moreScheduleModalPos: ModalPosType;\n  moreScheduleDateTime: string;\n  longTermSchedulesWithPriority: { schedule: ScheduleType; priority: number | null }[];\n  allDaySchedulesWithPriority: { schedule: ScheduleType; priority: number | null }[];\n  fewHourSchedulesWithPriority: { schedule: ScheduleType; priority: number | null }[];\n  readonly?: boolean;\n}\n\nfunction MoreScheduleModal({\n  moreScheduleModalPos,\n  moreScheduleDateTime,\n  longTermSchedulesWithPriority,\n  allDaySchedulesWithPriority,\n  fewHourSchedulesWithPriority,\n  readonly = false,\n}: MoreScheduleModalProps) {\n  const theme = useTheme();\n\n  const [scheduleInfo, setScheduleInfo] = useState<ScheduleType | null>(null);\n\n  const { state: isScheduleModifyModalOpen, toggleState: toggleScheduleModifyModalOpen } =\n    useToggle();\n\n  const scheduleModal = useModalPosition();\n\n  const { month, date, day } = extractDateTime(moreScheduleDateTime);\n  const nowDate = getISODateString(moreScheduleDateTime);\n\n  return (\n    <div css={moreScheduleModalStyle(theme, moreScheduleModalPos)}>\n      <div css={headerStyle}>\n        <span css={dayTextStyle(theme, day)}>{DAYS[day]}</span>\n        <span css={dateTextStyle(theme, day, getThisMonth() === month && getThisDate() === date)}>\n          {date}\n        </span>\n      </div>\n\n      {longTermSchedulesWithPriority.map(({ schedule }) => {\n        const startDate = getISODateString(schedule.startDateTime);\n        const endDate = getISODateString(\n          checkAllDay(schedule.startDateTime, schedule.endDateTime)\n            ? getDayOffsetDateTime(schedule.endDateTime, -1)\n            : schedule.endDateTime\n        );\n\n        return (\n          startDate <= nowDate &&\n          nowDate <= endDate && (\n            <div\n              key={`modal-${nowDate}#${schedule.id}`}\n              css={itemWithBackgroundStyle(theme, readonly ? '' : schedule.colorCode)}\n              onClick={(e) => scheduleModal.handleClickOpen(e, () => setScheduleInfo(schedule))}\n            >\n              {schedule.title.trim() || CALENDAR.EMPTY_TITLE}\n            </div>\n          )\n        );\n      })}\n\n      {allDaySchedulesWithPriority.map(({ schedule }) => {\n        const startDate = getISODateString(schedule.startDateTime);\n\n        return (\n          startDate === nowDate && (\n            <div\n              key={`modal-${nowDate}#${schedule.id}`}\n              css={itemWithBackgroundStyle(theme, readonly ? '' : schedule.colorCode)}\n              onClick={(e) => scheduleModal.handleClickOpen(e, () => setScheduleInfo(schedule))}\n            >\n              {schedule.title.trim() || CALENDAR.EMPTY_TITLE}\n            </div>\n          )\n        );\n      })}\n\n      {fewHourSchedulesWithPriority.map(({ schedule }) => {\n        const startDate = getISODateString(schedule.startDateTime);\n\n        return (\n          startDate === nowDate && (\n            <div\n              key={`modal-${nowDate}#${schedule.id}`}\n              css={itemWithoutBackgroundStyle(theme, readonly ? '' : schedule.colorCode)}\n              onClick={(e) => scheduleModal.handleClickOpen(e, () => setScheduleInfo(schedule))}\n            >\n              {schedule.title.trim() || CALENDAR.EMPTY_TITLE}\n            </div>\n          )\n        );\n      })}\n\n      {scheduleInfo && (\n        <>\n          <ModalPortal\n            isOpen={scheduleModal.isModalOpen}\n            closeModal={scheduleModal.toggleModalOpen}\n            dimmerBackground={TRANSPARENT}\n          >\n            <ScheduleModal\n              scheduleModalPos={scheduleModal.modalPos}\n              scheduleInfo={scheduleInfo}\n              toggleScheduleModifyModalOpen={toggleScheduleModifyModalOpen}\n              closeModal={scheduleModal.toggleModalOpen}\n              readonly={readonly}\n            />\n          </ModalPortal>\n          {!readonly && (\n            <ModalPortal\n              isOpen={isScheduleModifyModalOpen}\n              closeModal={toggleScheduleModifyModalOpen}\n            >\n              <ScheduleModifyModal\n                scheduleInfo={scheduleInfo}\n                closeModal={toggleScheduleModifyModalOpen}\n              />\n            </ModalPortal>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n\nexport default MoreScheduleModal;\n"
  },
  {
    "path": "frontend/src/components/NavBar/NavBar.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport NavBar from './NavBar';\n\nexport default {\n  title: 'Components/NavBar',\n  component: NavBar,\n} as ComponentMeta<typeof NavBar>;\n\nconst Template: ComponentStory<typeof NavBar> = () => <NavBar />;\n\nexport const Primary = Template.bind({});\n"
  },
  {
    "path": "frontend/src/components/NavBar/NavBar.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst navBar = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n  position: fixed;\n  top: 0;\n  left: 0;\n  z-index: 20;\n\n  width: 100%;\n  height: 16rem;\n  padding: 2rem 5rem 2rem 2rem;\n\n  background: ${colors.WHITE};\n\n  box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);\n`;\n\nconst menus = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  gap: 3rem;\n`;\n\nconst logo = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  position: relative;\n\n  background: transparent;\n\n  font-size: 5rem;\n  font-weight: bold;\n  color: ${colors.GRAY_700};\n`;\n\nconst logoImg = css`\n  width: 6rem;\n  height: 6rem;\n`;\n\nconst logoText = css`\n  margin-left: 2rem;\n`;\n\nconst menu = ({ colors, flex }: Theme) => css`\n  ${logo({ colors, flex })}\n\n  width: 11rem;\n  height: 11rem;\n\n  font-size: 7rem;\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst menuTitle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nexport { logo, logoImg, logoText, menu, menus, menuTitle, navBar };\n"
  },
  {
    "path": "frontend/src/components/NavBar/NavBar.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { lazy, Suspense } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilValue } from 'recoil';\n\nimport useToggle from '@/hooks/useToggle';\n\nimport { userState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport ProfileFallback from '@/components/Profile/Profile.fallback';\nimport SideBarButton from '@/components/SideBarButton/SideBarButton';\n\nimport { PATH } from '@/constants';\nimport { TRANSPARENT } from '@/constants/style';\n\nimport { MdCalendarToday, MdOutlineCategory, MdPersonOutline } from 'react-icons/md';\n\nimport BlackLogo from '../../assets/dallog_black.png';\nimport { logo, logoImg, logoText, menu, menus, menuTitle, navBar } from './NavBar.styles';\n\nconst Profile = lazy(() => import('@/components/Profile/Profile'));\n\nfunction NavBar() {\n  const { accessToken } = useRecoilValue(userState);\n\n  const theme = useTheme();\n  const navigate = useNavigate();\n\n  const { state: isProfileModalOpen, toggleState: toggleProfileModalOpen } = useToggle();\n\n  const handleClickMainButton = () => {\n    navigate(PATH.MAIN);\n  };\n\n  const handleClickCategoryMenuButton = () => {\n    navigate(PATH.CATEGORY);\n  };\n\n  const handleClickProfileMenuButton = () => {\n    toggleProfileModalOpen();\n  };\n\n  return (\n    <nav css={navBar}>\n      <div css={menus}>\n        {accessToken && <SideBarButton />}\n        <Button cssProp={logo(theme)} onClick={handleClickMainButton}>\n          <img src={BlackLogo} alt=\"logo\" css={logoImg} />\n          <span css={logoText}>달록</span>\n        </Button>\n      </div>\n      <div css={menus}>\n        {accessToken && (\n          <>\n            <Button cssProp={menu(theme)} onClick={handleClickMainButton} aria-label=\"달력 메뉴\">\n              <MdCalendarToday />\n              <span css={menuTitle}>달력</span>\n            </Button>\n            <Button\n              cssProp={menu(theme)}\n              onClick={handleClickCategoryMenuButton}\n              aria-label=\"카테고리 메뉴\"\n            >\n              <MdOutlineCategory />\n              <span css={menuTitle}>카테고리</span>\n            </Button>\n            <Button\n              cssProp={menu(theme)}\n              onClick={handleClickProfileMenuButton}\n              aria-label=\"프로필 메뉴\"\n              aria-expanded={isProfileModalOpen}\n            >\n              <MdPersonOutline />\n              <span css={menuTitle}>프로필</span>\n            </Button>\n            <ModalPortal\n              isOpen={isProfileModalOpen}\n              closeModal={toggleProfileModalOpen}\n              dimmerBackground={TRANSPARENT}\n            >\n              <Suspense fallback={<ProfileFallback />}>\n                <Profile />\n              </Suspense>\n            </ModalPortal>\n          </>\n        )}\n      </div>\n    </nav>\n  );\n}\n\nexport default NavBar;\n"
  },
  {
    "path": "frontend/src/components/Profile/Profile.fallback.stories.tsx",
    "content": "import { css } from '@emotion/react';\nimport { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport ProfileFallback from './Profile.fallback';\n\nexport default {\n  title: 'Components/ProfileFallback',\n  component: ProfileFallback,\n} as ComponentMeta<typeof ProfileFallback>;\n\nconst Template: ComponentStory<typeof ProfileFallback> = () => <ProfileFallback />;\n\nconst Primary = Template.bind({});\n\nexport { Primary };\n"
  },
  {
    "path": "frontend/src/components/Profile/Profile.fallback.tsx",
    "content": "import Skeleton from '@/components/@common/Skeleton/Skeleton';\n\nimport { imageStyle, layoutStyle, skeletonStyle } from './Profile.styles';\n\nfunction ProfileFallback() {\n  return (\n    <div css={layoutStyle}>\n      <Skeleton cssProp={imageStyle} />\n      <div css={skeletonStyle}>\n        <Skeleton width=\"30rem\" height=\"4rem\" />\n        <Skeleton width=\"30rem\" height=\"4rem\" />\n        <Skeleton width=\"20rem\" height=\"4rem\" />\n      </div>\n    </div>\n  );\n}\n\nexport default ProfileFallback;\n"
  },
  {
    "path": "frontend/src/components/Profile/Profile.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst contentStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  gap: 3rem;\n\n  width: 100%;\n\n  text-align: center;\n`;\n\nconst emailStyle = ({ colors }: Theme) => css`\n  font-size: 3rem;\n  color: ${colors.GRAY_500};\n`;\n\nconst imageStyle = css`\n  width: 35rem;\n  height: 35rem;\n  border-radius: 50%;\n`;\n\nconst inputStyle = {\n  input: css`\n    height: 3rem;\n\n    font-size: 3rem;\n  `,\n};\n\nconst layoutStyle = ({ flex, colors }: Theme) => css`\n  ${flex.column};\n\n  justify-content: space-around;\n  gap: 5rem;\n  position: absolute;\n  top: 15rem;\n  right: 2rem;\n\n  width: 60rem;\n  padding: 5rem;\n  box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.25);\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n\n  font-size: 4rem;\n`;\n\nconst logoutButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  border: 1px solid ${colors.GRAY_400};\n  border-radius: 7px;\n\n  font-size: 3rem;\n`;\n\nconst withdrawalButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  border: 1px solid ${colors.RED_400};\n  border-radius: 7px;\n\n  font-size: 3rem;\n  color: ${colors.RED_400};\n`;\n\nconst menu = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 9rem;\n  height: 9rem;\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst menuTitle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nconst nameButtonStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: flex-end;\n  gap: 2rem;\n\n  font-size: 3rem;\n`;\n\nconst nameStyle = css`\n  margin-left: 7rem;\n\n  font-size: 3.5rem;\n`;\n\nconst skeletonStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  gap: 3rem;\n`;\n\nexport {\n  contentStyle,\n  emailStyle,\n  imageStyle,\n  inputStyle,\n  layoutStyle,\n  logoutButtonStyle,\n  menu,\n  menuTitle,\n  nameStyle,\n  nameButtonStyle,\n  skeletonStyle,\n  withdrawalButtonStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/Profile/Profile.tsx",
    "content": "import { validateLength } from '@/validation';\nimport { useState } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilValue } from 'recoil';\n\nimport { usePatchProfile } from '@/hooks/@queries/profile';\nimport useControlledInput from '@/hooks/useControlledInput';\n\nimport { userState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\n\nimport { PATH } from '@/constants';\nimport { CONFIRM_MESSAGE } from '@/constants/message';\nimport { VALIDATION_MESSAGE, VALIDATION_SIZE } from '@/constants/validate';\n\nimport { removeAccessToken, removeRefreshToken } from '@/utils/storage';\n\nimport { MdOutlineCheck, MdOutlineModeEdit } from 'react-icons/md';\n\nimport {\n  contentStyle,\n  emailStyle,\n  imageStyle,\n  inputStyle,\n  layoutStyle,\n  logoutButtonStyle,\n  menu,\n  menuTitle,\n  nameButtonStyle,\n  nameStyle,\n} from './Profile.styles';\n\nfunction Profile() {\n  const navigate = useNavigate();\n\n  const user = useRecoilValue(userState);\n\n  const [isEditingName, setEditingName] = useState(false);\n\n  const editDisplayName = useControlledInput(user.displayName);\n\n  const { mutate } = usePatchProfile({ accessToken: user.accessToken });\n\n  const handleClickModifyButton = () => {\n    setEditingName(true);\n  };\n\n  const handleClickCompleteButton = () => {\n    mutate({\n      displayName: editDisplayName.inputValue.trim(),\n    });\n\n    setEditingName(false);\n  };\n\n  const handleClickLogoutButton = () => {\n    if (window.confirm(CONFIRM_MESSAGE.LOGOUT)) {\n      removeAccessToken();\n      removeRefreshToken();\n      navigate(PATH.MAIN);\n      location.reload();\n    }\n  };\n\n  return (\n    <div css={layoutStyle}>\n      <img src={user.profileImageUrl} css={imageStyle} alt=\"프로필 이미지\" />\n      <div css={contentStyle}>\n        {isEditingName ? (\n          <form css={nameButtonStyle}>\n            <Fieldset\n              placeholder={user.displayName}\n              value={editDisplayName.inputValue}\n              onChange={editDisplayName.onChangeValue}\n              cssProp={inputStyle}\n              autoFocus={true}\n              isValid={validateLength(\n                editDisplayName.inputValue.trim(),\n                VALIDATION_SIZE.MIN_LENGTH,\n                VALIDATION_SIZE.DISPLAY_NAME_MAX_LENGTH\n              )}\n              errorMessage={VALIDATION_MESSAGE.STRING_LENGTH(\n                VALIDATION_SIZE.MIN_LENGTH,\n                VALIDATION_SIZE.DISPLAY_NAME_MAX_LENGTH\n              )}\n            />\n            <Button\n              type=\"submit\"\n              cssProp={menu}\n              onClick={handleClickCompleteButton}\n              disabled={\n                !validateLength(\n                  editDisplayName.inputValue.trim(),\n                  VALIDATION_SIZE.MIN_LENGTH,\n                  VALIDATION_SIZE.DISPLAY_NAME_MAX_LENGTH\n                )\n              }\n            >\n              <MdOutlineCheck size={14} />\n              <span css={menuTitle}>완료</span>\n            </Button>\n          </form>\n        ) : (\n          <div>\n            <span css={nameStyle}>{user.displayName}</span>\n            <Button cssProp={menu} onClick={handleClickModifyButton}>\n              <MdOutlineModeEdit size={14} />\n              <span css={menuTitle}>수정</span>\n            </Button>\n          </div>\n        )}\n        <span css={emailStyle}>{user.email}</span>\n      </div>\n      <Button cssProp={logoutButtonStyle} onClick={handleClickLogoutButton}>\n        로그아웃\n      </Button>\n    </div>\n  );\n}\n\nexport default Profile;\n"
  },
  {
    "path": "frontend/src/components/ProtectRoute/ProtectRoute.tsx",
    "content": "import { lazy, Suspense } from 'react';\nimport { Outlet, useLocation } from 'react-router-dom';\n\nimport useUserValue from '@/hooks/useUserValue';\n\nimport SideBarFallback from '@/components/SideBar/SideBar.fallback';\nimport NotFoundPage from '@/pages/NotFoundPage/NotFoundPage';\nimport StartPage from '@/pages/StartPage/StartPage';\n\nimport { PATH } from '@/constants';\n\nconst SideBar = lazy(() => import('@/components/SideBar/SideBar'));\n\nfunction ProtectRoute() {\n  const { isAuthenticating, user } = useUserValue();\n  const { pathname } = useLocation();\n\n  if (isAuthenticating) {\n    return <></>;\n  }\n\n  if (!user.accessToken) {\n    return pathname === PATH.MAIN ? <StartPage /> : <NotFoundPage />;\n  }\n\n  return (\n    <>\n      <Suspense fallback={<SideBarFallback />}>\n        <SideBar />\n      </Suspense>\n      <Outlet />\n    </>\n  );\n}\n\nexport default ProtectRoute;\n"
  },
  {
    "path": "frontend/src/components/Schedule/Schedule.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { SCHEDULE } from '@/constants/style';\n\nconst itemWithBackgroundStyle = (\n  { colors }: Theme,\n  priority: number | null,\n  maxScheduleCount: number,\n  isEndDate: boolean,\n  isHovering: boolean,\n  colorCode: string\n) => css`\n  display: ${priority && priority >= maxScheduleCount ? 'none' : 'block'};\n  overflow: hidden;\n  position: absolute;\n  top: ${priority && priority * SCHEDULE.HEIGHT_WITH_MARGIN + 1}rem;\n\n  width: ${isEndDate ? '96%' : '100%'};\n  height: ${SCHEDULE.HEIGHT}rem;\n  padding: 1rem;\n  ${isEndDate &&\n  css`\n    border-top-right-radius: 4px;\n    border-bottom-right-radius: 4px;\n  `}\n\n  background: ${colorCode === '' ? colors.ORANGE_500 : colorCode};\n\n  font-size: 2.75rem;\n  color: white;\n  white-space: nowrap;\n  text-overflow: ellipsis;\n  line-height: 2.75rem;\n\n  cursor: pointer;\n  filter: ${isHovering && 'brightness(0.95)'};\n\n  transition: background-color 0.3s;\n`;\n\nconst itemWithoutBackgroundStyle = (\n  theme: Theme,\n  priority: number | null,\n  maxScheduleCount: number,\n  isEndDate: boolean,\n  isHovering: boolean,\n  colorCode: string\n) => css`\n  ${itemWithBackgroundStyle(theme, priority, maxScheduleCount, isEndDate, isHovering, colorCode)};\n\n  border-left: 3px solid ${colorCode === '' ? theme.colors.ORANGE_500 : colorCode};\n\n  background: ${isHovering ? theme.colors.GRAY_000 : theme.colors.WHITE};\n\n  color: black;\n\n  filter: none;\n\n  transition: background-color 0.3s;\n`;\n\nexport { itemWithBackgroundStyle, itemWithoutBackgroundStyle };\n"
  },
  {
    "path": "frontend/src/components/Schedule/Schedule.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useRecoilState } from 'recoil';\n\nimport useModalPosition from '@/hooks/useModalPosition';\nimport useToggle from '@/hooks/useToggle';\n\nimport { ScheduleResponseKeyType, ScheduleType } from '@/@types/schedule';\n\nimport { scheduleState } from '@/recoil/atoms';\n\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport ScheduleModal from '@/components/ScheduleModal/ScheduleModal';\nimport ScheduleModifyModal from '@/components/ScheduleModifyModal/ScheduleModifyModal';\n\nimport { CALENDAR } from '@/constants';\nimport { SCHEDULE } from '@/constants/schedule';\nimport { TRANSPARENT } from '@/constants/style';\n\nimport { itemWithBackgroundStyle, itemWithoutBackgroundStyle } from './Schedule.styles';\n\ninterface ScheduleProps {\n  type: ScheduleResponseKeyType;\n  schedule: ScheduleType;\n  priority: number;\n  maxScheduleCount: number;\n  isEndDate: boolean;\n  isTitleVisible?: boolean;\n  readonly?: boolean;\n}\n\nfunction Schedule({\n  type,\n  schedule,\n  priority,\n  maxScheduleCount,\n  isEndDate,\n  isTitleVisible = true,\n  readonly = false,\n}: ScheduleProps) {\n  const theme = useTheme();\n\n  const [hoveringId, setHoveringId] = useRecoilState(scheduleState);\n\n  const { state: isScheduleModifyModalOpen, toggleState: toggleScheduleModifyModalOpen } =\n    useToggle();\n\n  const scheduleModal = useModalPosition();\n\n  const handleMouseEnterSchedule = (scheduleId: string) => {\n    setHoveringId(scheduleId);\n  };\n\n  const handleMouseLeaveSchedule = () => {\n    setHoveringId('');\n  };\n\n  return (\n    <div>\n      <div\n        css={\n          type === SCHEDULE.RESPONSE_TYPE.FEW_HOURS\n            ? itemWithoutBackgroundStyle(\n                theme,\n                priority,\n                maxScheduleCount,\n                isEndDate,\n                hoveringId === schedule.id,\n                readonly ? '' : schedule.colorCode\n              )\n            : itemWithBackgroundStyle(\n                theme,\n                priority,\n                maxScheduleCount,\n                isEndDate,\n                hoveringId === schedule.id,\n                readonly ? '' : schedule.colorCode\n              )\n        }\n        onMouseEnter={() => handleMouseEnterSchedule(schedule.id)}\n        onClick={scheduleModal.handleClickOpen}\n        onMouseLeave={handleMouseLeaveSchedule}\n      >\n        {isTitleVisible && (schedule.title.trim() || CALENDAR.EMPTY_TITLE)}\n      </div>\n\n      <ModalPortal\n        isOpen={scheduleModal.isModalOpen}\n        closeModal={scheduleModal.toggleModalOpen}\n        dimmerBackground={TRANSPARENT}\n      >\n        <ScheduleModal\n          scheduleModalPos={scheduleModal.modalPos}\n          scheduleInfo={schedule}\n          toggleScheduleModifyModalOpen={toggleScheduleModifyModalOpen}\n          closeModal={scheduleModal.toggleModalOpen}\n          readonly={readonly}\n        />\n      </ModalPortal>\n\n      {!readonly && (\n        <ModalPortal isOpen={isScheduleModifyModalOpen} closeModal={toggleScheduleModifyModalOpen}>\n          <ScheduleModifyModal scheduleInfo={schedule} closeModal={toggleScheduleModifyModalOpen} />\n        </ModalPortal>\n      )}\n    </div>\n  );\n}\n\nexport default Schedule;\n"
  },
  {
    "path": "frontend/src/components/ScheduleAddButton/ScheduleAddButton.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport ScheduleAddButton from './ScheduleAddButton';\n\nexport default {\n  title: 'Components/ScheduleAddButton',\n  component: ScheduleAddButton,\n} as ComponentMeta<typeof ScheduleAddButton>;\n\nconst Template: ComponentStory<typeof ScheduleAddButton> = (args) => (\n  <ScheduleAddButton {...args} />\n);\n\nexport const Primary = Template.bind({});\nPrimary.args = {\n  onClick: () => void 0,\n};\n"
  },
  {
    "path": "frontend/src/components/ScheduleAddButton/ScheduleAddButton.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst scheduleAddButton = ({ colors, flex, mq }: Theme) => css`\n  ${flex.row};\n\n  position: fixed;\n  right: 7rem;\n  bottom: 7rem;\n\n  width: 13rem;\n  height: 13rem;\n  border-radius: 50%;\n  box-shadow: 0 0 4px rgba(0, 0, 0, 0.25);\n\n  background: ${colors.WHITE};\n  opacity: 0.8;\n\n  font-size: 7rem;\n  line-height: 7rem;\n  color: ${colors.YELLOW_500};\n\n  &:hover {\n    opacity: 1;\n  }\n\n  ${mq?.mobile} {\n    right: 4rem;\n\n    width: 10rem;\n    height: 10rem;\n\n    font-size: 6rem;\n    line-height: 6rem;\n  }\n`;\n\nexport { scheduleAddButton };\n"
  },
  {
    "path": "frontend/src/components/ScheduleAddButton/ScheduleAddButton.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { MdEditCalendar } from 'react-icons/md';\n\nimport { scheduleAddButton } from './ScheduleAddButton.styles';\n\ninterface ScheduleAddButtonProps {\n  onClick: () => void;\n}\n\nfunction ScheduleAddButton({ onClick }: ScheduleAddButtonProps) {\n  const theme = useTheme();\n\n  return (\n    <Button cssProp={scheduleAddButton(theme)} onClick={onClick} aria-label=\"일정 추가\">\n      <MdEditCalendar />\n    </Button>\n  );\n}\n\nexport default ScheduleAddButton;\n"
  },
  {
    "path": "frontend/src/components/ScheduleAddModal/ScheduleAddModal.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport ScheduleAddModal from './ScheduleAddModal';\n\nexport default {\n  title: 'Components/ScheduleAddModal',\n  component: ScheduleAddModal,\n} as ComponentMeta<typeof ScheduleAddModal>;\n\nconst Template: ComponentStory<typeof ScheduleAddModal> = (args) => <ScheduleAddModal {...args} />;\n\nexport const Primary = Template.bind({});\nPrimary.args = {\n  dateInfo: '2022-08-02T00:00',\n};\n"
  },
  {
    "path": "frontend/src/components/ScheduleAddModal/ScheduleAddModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst scheduleAddModal = ({ colors }: Theme) => css`\n  width: 120rem;\n  padding: 12.5rem;\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n`;\n\nconst form = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  gap: 8rem;\n\n  height: 100%;\n`;\n\nconst dateTime = ({ flex }: Theme) => css`\n  ${flex.column}\n\n  position: relative;\n  gap: 2.5rem;\n\n  width: 100%;\n`;\n\nconst checkboxStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  position: absolute;\n  top: 0;\n  right: 1rem;\n  gap: 2rem;\n  z-index: 10;\n\n  font-size: 4rem;\n  color: ${colors.GRAY_700};\n\n  input + label {\n    position: relative;\n\n    width: 4rem;\n    height: 4rem;\n    border: 1px solid ${colors.YELLOW_500};\n    border-radius: 7px;\n\n    &:hover {\n      cursor: pointer;\n    }\n  }\n\n  input:checked + label::after {\n    content: '✓';\n\n    position: absolute;\n    top: -1px;\n    left: -1px;\n\n    width: 4rem;\n    height: 4rem;\n    border-radius: 7px;\n\n    background: ${colors.YELLOW_500};\n\n    font-weight: 600;\n    color: white;\n    text-align: center;\n  }\n\n  input {\n    display: none;\n  }\n`;\n\nconst arrow = ({ colors }: Theme) => css`\n  font-size: 6rem;\n  font-weight: bold;\n  color: ${colors.GRAY_500};\n`;\n\nconst selectBoxStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  align-items: flex-start;\n  gap: 2.5rem;\n\n  width: 100%;\n`;\n\nconst controlButtons = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  align-self: flex-end;\n  gap: 5rem;\n`;\n\nconst cancelButton = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  box-sizing: border-box;\n  border: 1px solid ${colors.GRAY_500};\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.WHITE};\n\n  font-size: 4rem;\n  color: ${colors.GRAY_600};\n`;\n\nconst saveButton = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  box-sizing: border-box;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 4rem;\n  color: ${colors.WHITE};\n`;\n\nconst labelStyle = ({ colors }: Theme) => css`\n  padding: 0 1rem;\n\n  font-size: 4rem;\n  color: ${colors.GRAY_800};\n`;\n\nconst dateTimePickerStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: space-between;\n  align-items: flex-end;\n\n  width: 100%;\n`;\n\nconst dateFieldsetStyle = (isAllDay: boolean) => {\n  return {\n    div: css`\n      width: ${isAllDay ? '100%' : '45%'};\n    `,\n    input: css`\n      height: 11.75rem;\n    `,\n  };\n};\n\nconst selectTimeStyle = {\n  select: css`\n    width: 45%;\n  `,\n};\n\nexport {\n  arrow,\n  cancelButton,\n  checkboxStyle,\n  controlButtons,\n  dateFieldsetStyle,\n  dateTime,\n  dateTimePickerStyle,\n  form,\n  labelStyle,\n  saveButton,\n  scheduleAddModal,\n  selectBoxStyle,\n  selectTimeStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/ScheduleAddModal/ScheduleAddModal.tsx",
    "content": "import { validateLength } from '@/validation';\nimport { useTheme } from '@emotion/react';\nimport { useState } from 'react';\n\nimport { useGetEditableCategories } from '@/hooks/@queries/category';\nimport { usePostSchedule } from '@/hooks/@queries/schedule';\nimport useControlledInput from '@/hooks/useControlledInput';\nimport useValidateSchedule from '@/hooks/useValidateSchedule';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\nimport Select from '@/components/@common/Select/Select';\nimport Spinner from '@/components/@common/Spinner/Spinner';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\nimport { DATE_TIME, TIMES } from '@/constants/date';\nimport { VALIDATION_MESSAGE, VALIDATION_SIZE } from '@/constants/validate';\n\nimport { getDayOffsetDateTime, getEndTime, getISODateString, getStartTime } from '@/utils/date';\n\nimport {\n  arrow,\n  cancelButton,\n  checkboxStyle,\n  controlButtons,\n  dateFieldsetStyle,\n  dateTime,\n  dateTimePickerStyle,\n  form,\n  labelStyle,\n  saveButton,\n  scheduleAddModal,\n  selectBoxStyle,\n  selectTimeStyle,\n} from './ScheduleAddModal.styles';\n\ninterface ScheduleAddModalProps {\n  dateInfo: string;\n  closeModal: () => void;\n}\n\nfunction ScheduleAddModal({ dateInfo, closeModal }: ScheduleAddModalProps) {\n  const theme = useTheme();\n\n  const [isAllDay, setAllDay] = useState(true);\n\n  const { isLoading, data } = useGetEditableCategories({});\n\n  const categoryId = useControlledInput(String(data?.data[0].id));\n\n  const { mutate: postSchedule } = usePostSchedule({\n    categoryId: categoryId.inputValue,\n    onSuccess: () => closeModal(),\n  });\n\n  const validationSchedule = useValidateSchedule({\n    initialStartDate: getISODateString(dateInfo),\n    initialStartTime: isAllDay ? DATE_TIME.START : getStartTime(),\n    initialEndDate: getISODateString(dateInfo),\n    initialEndTime: isAllDay ? DATE_TIME.END : getEndTime(),\n  });\n\n  const handleClickAllDayButton = () => {\n    setAllDay((prev) => !prev);\n  };\n\n  const handleSubmitScheduleAddForm = (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    const body = {\n      title: validationSchedule.title.inputValue,\n      startDateTime: `${validationSchedule.startDate.inputValue}T${validationSchedule.startTime.inputValue}`,\n      endDateTime: `${\n        isAllDay\n          ? getISODateString(getDayOffsetDateTime(validationSchedule.endDate.inputValue, 1))\n          : validationSchedule.endDate.inputValue\n      }T${validationSchedule.endTime.inputValue}`,\n      memo: validationSchedule.memo.inputValue,\n    };\n\n    postSchedule(body);\n  };\n\n  if (isLoading || data === undefined) {\n    return <Spinner size={10} />;\n  }\n\n  const categories = data.data\n    .filter((category) => category.categoryType !== CATEGORY_TYPE.GOOGLE)\n    .map((category) => {\n      return {\n        id: category.id,\n        name: category.name,\n      };\n    });\n\n  const selectTimes = TIMES.map((time) => {\n    return {\n      id: time,\n      name: time,\n    };\n  });\n\n  return (\n    <div css={scheduleAddModal}>\n      <form css={form} onSubmit={handleSubmitScheduleAddForm}>\n        <Fieldset\n          placeholder=\"제목을 입력하세요.\"\n          value={validationSchedule.title.inputValue}\n          onChange={validationSchedule.title.onChangeValue}\n          isValid={validateLength(\n            validationSchedule.title.inputValue,\n            VALIDATION_SIZE.MIN_LENGTH,\n            VALIDATION_SIZE.SCHEDULE_TITLE_MAX_LENGTH\n          )}\n          errorMessage={VALIDATION_MESSAGE.STRING_LENGTH(\n            VALIDATION_SIZE.MIN_LENGTH,\n            VALIDATION_SIZE.SCHEDULE_TITLE_MAX_LENGTH\n          )}\n          autoFocus\n          labelText=\"제목\"\n        />\n        <div css={dateTime}>\n          <div css={checkboxStyle}>\n            <input\n              type=\"checkbox\"\n              id=\"allDay\"\n              checked={isAllDay}\n              onClick={handleClickAllDayButton}\n              readOnly\n            />\n            <label htmlFor=\"allDay\" />\n            <label htmlFor=\"allDay\">종일</label>\n          </div>\n          <div css={dateTimePickerStyle}>\n            <Fieldset\n              type=\"date\"\n              value={validationSchedule.startDate.inputValue}\n              onChange={validationSchedule.startDate.onChangeValue}\n              labelText={isAllDay ? '날짜' : '날짜 / 시간'}\n              cssProp={dateFieldsetStyle(isAllDay)}\n            />\n            {!isAllDay && (\n              <Select\n                options={selectTimes}\n                value={validationSchedule.startTime.inputValue}\n                onChange={validationSchedule.startTime.onChangeValue}\n                cssProp={selectTimeStyle}\n              />\n            )}\n          </div>\n          <p css={arrow} aria-hidden>\n            ↓\n          </p>\n          <div css={dateTimePickerStyle}>\n            <Fieldset\n              type=\"date\"\n              value={validationSchedule.endDate.inputValue}\n              onChange={validationSchedule.endDate.onChangeValue}\n              cssProp={dateFieldsetStyle(isAllDay)}\n              min={validationSchedule.startDate.inputValue}\n            />\n            {!isAllDay && (\n              <Select\n                options={selectTimes}\n                value={validationSchedule.endTime.inputValue}\n                onChange={validationSchedule.endTime.onChangeValue}\n                cssProp={selectTimeStyle}\n              />\n            )}\n          </div>\n        </div>\n        <div css={selectBoxStyle}>\n          <span css={labelStyle}>카테고리</span>\n          <Select\n            options={categories}\n            value={categoryId.inputValue}\n            onChange={categoryId.onChangeValue}\n          />\n        </div>\n        <Fieldset\n          placeholder=\"메모를 추가하세요.\"\n          value={validationSchedule.memo.inputValue}\n          onChange={validationSchedule.memo.onChangeValue}\n          isValid={validateLength(\n            validationSchedule.memo.inputValue,\n            0,\n            VALIDATION_SIZE.SCHEDULE_MEMO_MAX_LENGTH\n          )}\n          errorMessage={VALIDATION_MESSAGE.STRING_LENGTH(\n            0,\n            VALIDATION_SIZE.SCHEDULE_MEMO_MAX_LENGTH\n          )}\n          labelText=\"메모 (선택)\"\n        />\n        <div css={controlButtons}>\n          <Button cssProp={cancelButton(theme)} onClick={closeModal}>\n            취소\n          </Button>\n          <Button\n            type=\"submit\"\n            cssProp={saveButton(theme)}\n            disabled={!validationSchedule.isValidSchedule}\n          >\n            저장\n          </Button>\n        </div>\n      </form>\n    </div>\n  );\n}\n\nexport default ScheduleAddModal;\n"
  },
  {
    "path": "frontend/src/components/ScheduleModal/ScheduleModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { ModalPosType } from '@/@types';\n\nconst scheduleModalStyle = ({ colors, mq }: Theme, scheduleModalPos: ModalPosType) => css`\n  position: absolute;\n  top: ${scheduleModalPos.top ? `${scheduleModalPos.top + 20}px` : 'none'};\n  bottom: ${scheduleModalPos.bottom ? `${scheduleModalPos.bottom + 20}px` : 'none'};\n\n  padding: 5rem 5rem 10rem 10rem;\n  border-radius: 7px;\n  box-shadow: 0 0 30px ${colors.GRAY_500};\n\n  background: ${colors.WHITE};\n\n  ${mq?.laptop} {\n    right: ${scheduleModalPos.right ? `${scheduleModalPos.right + 20}px` : 'none'};\n    left: ${scheduleModalPos.left ? `${scheduleModalPos.left + 20}px` : 'none'};\n  }\n\n  ${mq?.tablet || mq?.mobile} {\n    left: 50%;\n    transform: translateX(-50%);\n  }\n`;\n\nconst headerStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: flex-end;\n`;\n\nconst buttonStyle = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 11rem;\n  height: 11rem;\n\n  font-size: 5rem;\n  color: ${colors.GRAY_700};\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst buttonTitleStyle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nconst contentStyle = ({ flex }: Theme) => css`\n  ${flex.column}\n\n  gap: 10rem;\n`;\n\nconst contentBlockStyle = ({ colors }: Theme) => css`\n  display: flex;\n  gap: 3rem;\n\n  width: 90rem;\n\n  font-size: 4rem;\n  color: ${colors.GRAY_700};\n`;\n\nconst scheduleIconStyle = css`\n  margin-top: 1rem;\n`;\n\nconst scheduleInfoStyle = ({ flex }: Theme) => css`\n  ${flex.column}\n\n  align-items: flex-start;\n  gap: 3rem;\n\n  word-wrap: break-word;\n  word-break: break-all;\n`;\n\nconst scheduleTitleStyle = css`\n  font-size: 6rem;\n`;\n\nconst colorStyle = ({ colors }: Theme, colorCode: string) => css`\n  width: 4rem;\n  height: 4rem;\n  border-radius: 25%;\n\n  background: ${colorCode === '' ? colors.ORANGE_500 : colorCode};\n`;\n\nconst grayTextStyle = ({ colors }: Theme) => css`\n  color: ${colors.GRAY_600};\n`;\n\nexport {\n  buttonStyle,\n  buttonTitleStyle,\n  colorStyle,\n  contentBlockStyle,\n  contentStyle,\n  grayTextStyle,\n  headerStyle,\n  scheduleIconStyle,\n  scheduleInfoStyle,\n  scheduleModalStyle,\n  scheduleTitleStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/ScheduleModal/ScheduleModal.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { useGetEditableCategories, useGetSingleCategory } from '@/hooks/@queries/category';\nimport { useDeleteSchedule } from '@/hooks/@queries/schedule';\n\nimport { ModalPosType } from '@/@types';\nimport { ScheduleType } from '@/@types/schedule';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\nimport { CONFIRM_MESSAGE } from '@/constants/message';\n\nimport {\n  MdClose,\n  MdDeleteOutline,\n  MdOutlineCalendarToday,\n  MdOutlineModeEdit,\n} from 'react-icons/md';\n\nimport {\n  buttonStyle,\n  buttonTitleStyle,\n  colorStyle,\n  contentBlockStyle,\n  contentStyle,\n  grayTextStyle,\n  headerStyle,\n  scheduleIconStyle,\n  scheduleInfoStyle,\n  scheduleModalStyle,\n  scheduleTitleStyle,\n} from './ScheduleModal.styles';\n\ninterface ScheduleModalProps {\n  scheduleModalPos: ModalPosType;\n  scheduleInfo: ScheduleType;\n  toggleScheduleModifyModalOpen: () => void;\n  closeModal: () => void;\n  readonly?: boolean;\n}\n\nfunction ScheduleModal({\n  scheduleModalPos,\n  scheduleInfo,\n  toggleScheduleModifyModalOpen,\n  closeModal,\n  readonly = false,\n}: ScheduleModalProps) {\n  const theme = useTheme();\n\n  const { data: categoryGetResponse } = useGetSingleCategory({\n    categoryId: scheduleInfo.categoryId,\n  });\n\n  const { data: editableCategoryGetResponse } = useGetEditableCategories({});\n\n  const { mutate } = useDeleteSchedule({\n    scheduleId: scheduleInfo.id,\n    onSuccess: () => closeModal(),\n  });\n\n  const handleClickModifyButton = () => {\n    closeModal();\n    toggleScheduleModifyModalOpen();\n  };\n\n  const handleClickDeleteButton = () => {\n    if (confirm(CONFIRM_MESSAGE.DELETE)) {\n      mutate();\n    }\n  };\n\n  const formatDateTime = (dateTime: string | undefined) => {\n    if (dateTime === undefined) {\n      return;\n    }\n\n    return dateTime.replace('T', ' ').slice(0, -3);\n  };\n\n  const canEditSchedule =\n    !readonly &&\n    editableCategoryGetResponse?.data.some(\n      (category) =>\n        category.id === scheduleInfo.categoryId && category.categoryType !== CATEGORY_TYPE.GOOGLE\n    );\n\n  return (\n    <div css={scheduleModalStyle(theme, scheduleModalPos)}>\n      <div css={headerStyle}>\n        {canEditSchedule && (\n          <>\n            <Button cssProp={buttonStyle} onClick={handleClickModifyButton}>\n              <MdOutlineModeEdit />\n              <span css={buttonTitleStyle}>일정 수정</span>\n            </Button>\n            <Button cssProp={buttonStyle} onClick={handleClickDeleteButton}>\n              <MdDeleteOutline />\n              <span css={buttonTitleStyle}>일정 삭제</span>\n            </Button>\n          </>\n        )}\n        <Button cssProp={buttonStyle} onClick={closeModal}>\n          <MdClose />\n          <span css={buttonTitleStyle}>닫기</span>\n        </Button>\n      </div>\n      <div css={contentStyle}>\n        <div css={contentBlockStyle}>\n          <MdOutlineCalendarToday css={scheduleIconStyle} />\n          <div css={scheduleInfoStyle}>\n            <p css={scheduleTitleStyle}>{scheduleInfo.title}</p>\n            <p>\n              <span>{formatDateTime(scheduleInfo.startDateTime)}</span>\n              &nbsp;&nbsp;→&nbsp;&nbsp;\n              <span>{formatDateTime(scheduleInfo.endDateTime)}</span>\n            </p>\n            {scheduleInfo.memo && <p>{scheduleInfo.memo}</p>}\n          </div>\n        </div>\n        <div css={contentBlockStyle}>\n          <div css={colorStyle(theme, readonly ? '' : scheduleInfo.colorCode)} />\n          <span>\n            {categoryGetResponse?.data.name}\n            <span css={grayTextStyle}>\n              {scheduleInfo.categoryType === CATEGORY_TYPE.GOOGLE && ' (구글)'}\n              {scheduleInfo.categoryType === CATEGORY_TYPE.PERSONAL && ' (기본)'}\n            </span>\n          </span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default ScheduleModal;\n"
  },
  {
    "path": "frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst modalStyle = ({ colors }: Theme) => css`\n  width: 120rem;\n  padding: 12.5rem;\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n`;\n\nconst formStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  gap: 8rem;\n`;\n\nconst dateFieldsetStyle = (isAllDay: boolean) => {\n  return {\n    div: css`\n      width: ${isAllDay ? '100%' : '45%'};\n    `,\n    input: css`\n      height: 11.75rem;\n    `,\n  };\n};\n\nconst dateTimePickerStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: space-between;\n  align-items: flex-end;\n\n  width: 100%;\n`;\n\nconst dateTimeStyle = ({ flex }: Theme) => css`\n  ${flex.column}\n\n  position: relative;\n  gap: 2.5rem;\n\n  width: 100%;\n`;\n\nconst checkboxStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  position: absolute;\n  top: 0;\n  right: 1rem;\n  gap: 2rem;\n  z-index: 10;\n\n  font-size: 4rem;\n  color: ${colors.GRAY_700};\n\n  input + label {\n    position: relative;\n\n    width: 4rem;\n    height: 4rem;\n    border: 1px solid ${colors.YELLOW_500};\n    border-radius: 7px;\n\n    &:hover {\n      cursor: pointer;\n    }\n  }\n\n  input:checked + label::after {\n    content: '✓';\n\n    position: absolute;\n    top: -1px;\n    left: -1px;\n\n    width: 4rem;\n    height: 4rem;\n    border-radius: 7px;\n\n    background: ${colors.YELLOW_500};\n\n    font-weight: 600;\n    color: white;\n    text-align: center;\n  }\n\n  input {\n    display: none;\n  }\n`;\n\nconst arrowStyle = ({ colors }: Theme) => css`\n  font-size: 6rem;\n  font-weight: bold;\n  color: ${colors.GRAY_500};\n`;\n\nconst controlButtonsStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  align-self: flex-end;\n  gap: 5rem;\n`;\n\nconst cancelButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  box-sizing: border-box;\n  border: 1px solid ${colors.GRAY_500};\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.WHITE};\n\n  font-size: 4rem;\n  color: ${colors.GRAY_600};\n`;\n\nconst saveButtonStyle = ({ colors }: Theme) => css`\n  padding: 2rem 3rem;\n  box-sizing: border-box;\n  border-radius: 7px;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.YELLOW_500};\n\n  font-size: 4rem;\n  color: ${colors.WHITE};\n`;\n\nconst labelStyle = ({ colors }: Theme) => css`\n  padding: 0 1rem;\n\n  font-size: 4rem;\n  color: ${colors.GRAY_800};\n`;\n\nconst categoryBoxStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  align-items: flex-start;\n  gap: 2.5rem;\n\n  width: 100%;\n`;\n\nconst selectTimeStyle = {\n  select: css`\n    width: 45%;\n  `,\n};\n\nexport {\n  arrowStyle,\n  cancelButtonStyle,\n  categoryBoxStyle,\n  checkboxStyle,\n  controlButtonsStyle,\n  dateFieldsetStyle,\n  dateTimePickerStyle,\n  dateTimeStyle,\n  formStyle,\n  labelStyle,\n  modalStyle,\n  saveButtonStyle,\n  selectTimeStyle,\n};\n"
  },
  {
    "path": "frontend/src/components/ScheduleModifyModal/ScheduleModifyModal.tsx",
    "content": "import { validateLength } from '@/validation';\nimport { useTheme } from '@emotion/react';\nimport { useState } from 'react';\n\nimport { useGetEditableCategories } from '@/hooks/@queries/category';\nimport { usePatchSchedule } from '@/hooks/@queries/schedule';\nimport useControlledInput from '@/hooks/useControlledInput';\nimport useValidateSchedule from '@/hooks/useValidateSchedule';\n\nimport { ScheduleType } from '@/@types/schedule';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\nimport Select from '@/components/@common/Select/Select';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\nimport { DATE_TIME, TIMES } from '@/constants/date';\nimport { VALIDATION_MESSAGE, VALIDATION_SIZE } from '@/constants/validate';\n\nimport {\n  checkAllDay,\n  getDayOffsetDateTime,\n  getISODateString,\n  getISOTimeString,\n} from '@/utils/date';\n\nimport {\n  arrowStyle,\n  cancelButtonStyle,\n  categoryBoxStyle,\n  checkboxStyle,\n  controlButtonsStyle,\n  dateFieldsetStyle,\n  dateTimePickerStyle,\n  dateTimeStyle,\n  formStyle,\n  labelStyle,\n  modalStyle,\n  saveButtonStyle,\n  selectTimeStyle,\n} from './ScheduleModifyModal.styles';\n\ninterface ScheduleModifyModalProps {\n  scheduleInfo: ScheduleType;\n  closeModal: () => void;\n}\n\nfunction ScheduleModifyModal({ scheduleInfo, closeModal }: ScheduleModifyModalProps) {\n  const theme = useTheme();\n\n  const [isAllDay, setAllDay] = useState(\n    checkAllDay(scheduleInfo.startDateTime, scheduleInfo.endDateTime)\n  );\n\n  const { data } = useGetEditableCategories({});\n\n  const { mutate } = usePatchSchedule({\n    scheduleId: scheduleInfo.id,\n    onSuccess: () => closeModal(),\n  });\n\n  const categoryId = useControlledInput(\n    String(data?.data.find((category) => category.id === scheduleInfo.categoryId)?.id)\n  );\n\n  const validationSchedule = useValidateSchedule({\n    initialTitle: scheduleInfo.title,\n    initialStartDate: getISODateString(scheduleInfo.startDateTime),\n    initialStartTime: getISOTimeString(scheduleInfo.startDateTime).slice(0, 5),\n    initialEndDate: getISODateString(\n      isAllDay ? getDayOffsetDateTime(scheduleInfo.endDateTime, -1) : scheduleInfo.endDateTime\n    ),\n    initialEndTime: getISOTimeString(scheduleInfo.endDateTime).slice(0, 5),\n    initialMemo: scheduleInfo.memo,\n  });\n\n  const handleSubmitScheduleModifyForm = (e: React.FormEvent) => {\n    e.preventDefault();\n\n    const body = {\n      title: validationSchedule.title.inputValue,\n      startDateTime: `${validationSchedule.startDate.inputValue}T${\n        isAllDay ? DATE_TIME.START : validationSchedule.startTime.inputValue\n      }`,\n      endDateTime: `${\n        isAllDay\n          ? `${getISODateString(getDayOffsetDateTime(validationSchedule.endDate.inputValue, 1))}T${\n              DATE_TIME.END\n            }`\n          : `${validationSchedule.endDate.inputValue}T${validationSchedule.endTime.inputValue}`\n      }`,\n      memo: validationSchedule.memo.inputValue,\n      categoryId:\n        data?.data.find((category) => category.id === Number(categoryId.inputValue))?.id ||\n        scheduleInfo.categoryId,\n    };\n\n    mutate(body);\n  };\n\n  const handleClickAllDayButton = () => {\n    setAllDay((prev) => !prev);\n  };\n\n  const categories = data?.data\n    .filter((category) => category.categoryType !== CATEGORY_TYPE.GOOGLE)\n    .map((category) => {\n      return {\n        id: category.id,\n        name: category.name,\n      };\n    });\n\n  const selectTimes = TIMES.map((time) => {\n    return {\n      id: time,\n      name: time,\n    };\n  });\n\n  return (\n    <div css={modalStyle}>\n      <form css={formStyle} onSubmit={handleSubmitScheduleModifyForm}>\n        <Fieldset\n          placeholder=\"제목을 입력하세요.\"\n          value={validationSchedule.title.inputValue}\n          onChange={validationSchedule.title.onChangeValue}\n          isValid={validateLength(\n            validationSchedule.title.inputValue,\n            VALIDATION_SIZE.MIN_LENGTH,\n            VALIDATION_SIZE.SCHEDULE_TITLE_MAX_LENGTH\n          )}\n          errorMessage={VALIDATION_MESSAGE.STRING_LENGTH(\n            VALIDATION_SIZE.MIN_LENGTH,\n            VALIDATION_SIZE.SCHEDULE_TITLE_MAX_LENGTH\n          )}\n          labelText=\"제목\"\n        />\n        <div css={dateTimeStyle}>\n          <div css={checkboxStyle}>\n            <input\n              type=\"checkbox\"\n              id=\"allDay\"\n              checked={isAllDay}\n              onClick={handleClickAllDayButton}\n              readOnly\n            />\n            <label htmlFor=\"allDay\" />\n            <label htmlFor=\"allDay\">종일</label>\n          </div>\n          <div css={dateTimePickerStyle}>\n            <Fieldset\n              type=\"date\"\n              value={validationSchedule.startDate.inputValue}\n              onChange={validationSchedule.startDate.onChangeValue}\n              labelText={isAllDay ? '날짜' : '날짜 / 시간'}\n              cssProp={dateFieldsetStyle(isAllDay)}\n            />\n            {!isAllDay && (\n              <Select\n                options={selectTimes}\n                value={validationSchedule.startTime.inputValue}\n                onChange={validationSchedule.startTime.onChangeValue}\n                cssProp={selectTimeStyle}\n              />\n            )}\n          </div>\n\n          <p css={arrowStyle}>↓</p>\n          <div css={dateTimePickerStyle}>\n            <Fieldset\n              type=\"date\"\n              value={validationSchedule.endDate.inputValue}\n              onChange={validationSchedule.endDate.onChangeValue}\n              cssProp={dateFieldsetStyle(isAllDay)}\n              min={validationSchedule.startDate.inputValue}\n            />\n            {!isAllDay && (\n              <Select\n                options={selectTimes}\n                value={validationSchedule.endTime.inputValue}\n                onChange={validationSchedule.endTime.onChangeValue}\n                cssProp={selectTimeStyle}\n              />\n            )}\n          </div>\n        </div>\n        {categories && (\n          <div css={categoryBoxStyle}>\n            <div css={labelStyle}>카테고리</div>\n            <Select\n              options={categories}\n              value={categoryId.inputValue}\n              onChange={categoryId.onChangeValue}\n            />\n          </div>\n        )}\n        <Fieldset\n          placeholder=\"메모를 추가하세요.\"\n          value={validationSchedule.memo.inputValue}\n          onChange={validationSchedule.memo.onChangeValue}\n          isValid={validateLength(\n            validationSchedule.memo.inputValue,\n            0,\n            VALIDATION_SIZE.SCHEDULE_MEMO_MAX_LENGTH\n          )}\n          errorMessage={VALIDATION_MESSAGE.STRING_LENGTH(\n            0,\n            VALIDATION_SIZE.SCHEDULE_MEMO_MAX_LENGTH\n          )}\n          labelText=\"메모 (선택)\"\n        />\n        <div css={controlButtonsStyle}>\n          <Button cssProp={cancelButtonStyle(theme)} onClick={closeModal}>\n            취소\n          </Button>\n          <Button\n            type=\"submit\"\n            cssProp={saveButtonStyle(theme)}\n            disabled={!validationSchedule.isValidSchedule}\n          >\n            저장\n          </Button>\n        </div>\n      </form>\n    </div>\n  );\n}\n\nexport default ScheduleModifyModal;\n"
  },
  {
    "path": "frontend/src/components/SideAdminList/SideAdminList.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css`\n  ${flex.column}\n\n  display: ${isSideBarOpen ? 'flex' : 'none'};\n  justify-content: flex-start;\n\n  width: 54rem;\n\n  font-size: 4rem;\n`;\n\nconst headerLayoutStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n`;\n\nconst headerStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n  height: 8rem;\n\n  font-weight: bold;\n\n  cursor: pointer;\n`;\n\nconst contentStyle = ({ flex }: Theme, isListOpen: boolean, listLength: number) => css`\n  ${flex.column};\n\n  gap: 2rem;\n  overflow: hidden;\n\n  width: 100%;\n  height: ${isListOpen ? `${9 * listLength}rem` : 0};\n  margin-bottom: 5rem;\n\n  transition: height 0.3s ease-in-out;\n`;\n\nconst menuStyle = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 9rem;\n  height: 9rem;\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst menuTitleStyle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nexport { contentStyle, headerLayoutStyle, headerStyle, listStyle, menuStyle, menuTitleStyle };\n"
  },
  {
    "path": "frontend/src/components/SideAdminList/SideAdminList.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useRecoilValue } from 'recoil';\n\nimport useRootFontSize from '@/hooks/useRootFontSize';\nimport useToggle from '@/hooks/useToggle';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport { sideBarState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport CategoryAddModal from '@/components/CategoryAddModal/CategoryAddModal';\nimport SideItem from '@/components/SideItem/SideItem';\n\nimport { MdAdd, MdKeyboardArrowDown, MdKeyboardArrowUp } from 'react-icons/md';\n\nimport {\n  contentStyle,\n  headerLayoutStyle,\n  headerStyle,\n  listStyle,\n  menuStyle,\n  menuTitleStyle,\n} from './SideAdminList.styles';\n\ninterface SideAdminListProps {\n  categories: SubscriptionType[];\n}\n\nfunction SideAdminList({ categories }: SideAdminListProps) {\n  const isSideBarOpen = useRecoilValue(sideBarState);\n\n  const theme = useTheme();\n\n  const rootFontSize = useRootFontSize();\n\n  const { state: isMyListOpen, toggleState: toggleMyListOpen } = useToggle(true);\n\n  const { state: isCategoryAddModalOpen, toggleState: toggleCategoryAddModalOpen } = useToggle();\n\n  const handleClickCategoryAddButton = () => {\n    toggleCategoryAddModalOpen();\n  };\n\n  return (\n    <div css={listStyle(theme, isSideBarOpen)}>\n      <div css={headerLayoutStyle}>\n        <span css={headerStyle} onClick={toggleMyListOpen}>\n          관리 카테고리\n        </span>\n        <Button cssProp={menuStyle}>\n          <MdAdd size={rootFontSize * 5} onClick={handleClickCategoryAddButton} />\n          <span css={menuTitleStyle}>카테고리 추가</span>\n        </Button>\n        <Button onClick={toggleMyListOpen}>\n          {isMyListOpen ? (\n            <MdKeyboardArrowUp size={rootFontSize * 5} />\n          ) : (\n            <MdKeyboardArrowDown size={rootFontSize * 5} />\n          )}\n        </Button>\n      </div>\n\n      <div css={contentStyle(theme, isMyListOpen, categories.length)}>\n        {categories.map((el) => {\n          return <SideItem key={el.category.id} subscription={el} />;\n        })}\n      </div>\n      <ModalPortal isOpen={isCategoryAddModalOpen} closeModal={toggleCategoryAddModalOpen}>\n        <CategoryAddModal closeModal={toggleCategoryAddModalOpen} />\n      </ModalPortal>\n    </div>\n  );\n}\n\nexport default SideAdminList;\n"
  },
  {
    "path": "frontend/src/components/SideBar/SideBar.fallback.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport SideBarFallback from './SideBar.fallback';\n\nexport default {\n  title: 'Components/SideBarFallback',\n  component: SideBarFallback,\n} as ComponentMeta<typeof SideBarFallback>;\n\nconst Template: ComponentStory<typeof SideBarFallback> = () => <SideBarFallback />;\n\nconst Primary = Template.bind({});\n\nexport { Primary };\n"
  },
  {
    "path": "frontend/src/components/SideBar/SideBar.fallback.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useRecoilValue } from 'recoil';\n\nimport { sideBarState } from '@/recoil/atoms';\n\nimport Skeleton from '@/components/@common/Skeleton/Skeleton';\n\nimport { sideBar, skeletonItemStyle } from './SideBar.styles';\n\nfunction SideBarFallback() {\n  const theme = useTheme();\n\n  const isSideBarOpen = useRecoilValue(sideBarState);\n\n  return (\n    <div css={sideBar(theme, isSideBarOpen)}>\n      {new Array(3).fill(0).map((el, index) => (\n        <section key={index}>\n          <Skeleton cssProp={skeletonItemStyle} width=\"100%\" height=\"7rem\" />\n          <Skeleton cssProp={skeletonItemStyle} width=\"70%\" height=\"7rem\" />\n          <Skeleton cssProp={skeletonItemStyle} width=\"70%\" height=\"7rem\" />\n          <Skeleton cssProp={skeletonItemStyle} width=\"70%\" height=\"7rem\" />\n          <Skeleton cssProp={skeletonItemStyle} width=\"70%\" height=\"7rem\" />\n          <Skeleton cssProp={skeletonItemStyle} width=\"70%\" height=\"7rem\" />\n        </section>\n      ))}\n    </div>\n  );\n}\n\nexport default SideBarFallback;\n"
  },
  {
    "path": "frontend/src/components/SideBar/SideBar.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst sideBar = ({ colors }: Theme, isSideBarOpen: boolean) => css`\n  overflow: hidden;\n  position: fixed;\n  z-index: 10;\n\n  width: ${isSideBarOpen ? '64rem' : '0'};\n  height: calc(100vh - 16rem);\n  padding: ${isSideBarOpen ? '4rem' : '0'};\n  border: 1px solid ${colors.GRAY_400};\n\n  background: ${colors.WHITE};\n\n  transition: width 0.3s;\n\n  &:hover {\n    overflow-y: overlay;\n  }\n`;\n\nconst skeletonItemStyle = css`\n  margin: 3rem;\n`;\n\nexport { skeletonItemStyle, sideBar };\n"
  },
  {
    "path": "frontend/src/components/SideBar/SideBar.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useRecoilValue } from 'recoil';\n\nimport { useGetEditableCategories } from '@/hooks/@queries/category';\nimport { useGetSubscriptions } from '@/hooks/@queries/subscription';\n\nimport { sideBarState, userState } from '@/recoil/atoms';\n\nimport SideAdminList from '@/components/SideAdminList/SideAdminList';\nimport SideGoogleList from '@/components/SideGoogleList/SideGoogleList';\nimport SideSubscribedList from '@/components/SideSubscribedList/SideSubscribedList';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\n\nimport { sideBar } from './SideBar.styles';\n\nfunction SideBar() {\n  const user = useRecoilValue(userState);\n  const isSideBarOpen = useRecoilValue(sideBarState);\n\n  const theme = useTheme();\n\n  const { data: getEditableCategoriesResponse } = useGetEditableCategories({\n    enabled: isSideBarOpen && !!user.accessToken,\n  });\n\n  const { data: getSubscriptionsResponse } = useGetSubscriptions({\n    enabled: isSideBarOpen && !!user.accessToken,\n  });\n\n  const canEditCategories = getEditableCategoriesResponse?.data.map((category) => category.id);\n\n  const adminList = getSubscriptionsResponse?.data.filter(\n    (el) =>\n      (canEditCategories?.includes(el.category.id) &&\n        el.category.categoryType !== CATEGORY_TYPE.GOOGLE) ||\n      el.category.categoryType === CATEGORY_TYPE.PERSONAL\n  );\n\n  const subscribedList = getSubscriptionsResponse?.data.filter(\n    (el) =>\n      !canEditCategories?.includes(el.category.id) &&\n      el.category.categoryType === CATEGORY_TYPE.NORMAL\n  );\n\n  const googleList = getSubscriptionsResponse?.data.filter(\n    (el) => el.category.categoryType === CATEGORY_TYPE.GOOGLE\n  );\n\n  return (\n    <div css={sideBar(theme, isSideBarOpen)} tabIndex={10}>\n      {adminList && <SideAdminList categories={adminList} />}\n      {subscribedList && <SideSubscribedList categories={subscribedList} />}\n      {googleList && <SideGoogleList categories={googleList} />}\n    </div>\n  );\n}\n\nexport default SideBar;\n"
  },
  {
    "path": "frontend/src/components/SideBarButton/SideBarButton.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst menu = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  position: relative;\n\n  width: 11rem;\n  height: 11rem;\n\n  background: transparent;\n\n  font-size: 7rem;\n  font-weight: bold;\n  color: ${colors.GRAY_700};\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst menuTitle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nexport { menu, menuTitle };\n"
  },
  {
    "path": "frontend/src/components/SideBarButton/SideBarButton.tsx",
    "content": "import { useRecoilState } from 'recoil';\n\nimport { sideBarSelector } from '@/recoil/selectors';\n\nimport theme from '@/styles/theme';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { MdMenu, MdMenuOpen } from 'react-icons/md';\n\nimport { menu, menuTitle } from './SideBarButton.styles';\n\nfunction SideBarButton() {\n  const [isSideBarOpen, toggleSideBarOpen] = useRecoilState(sideBarSelector);\n\n  const handleClickSideBarButton = () => {\n    toggleSideBarOpen(isSideBarOpen);\n  };\n\n  return (\n    <Button\n      cssProp={menu(theme)}\n      onClick={handleClickSideBarButton}\n      aria-label={isSideBarOpen ? '사이드바 닫기' : '사이드바 열기'}\n    >\n      {isSideBarOpen ? <MdMenuOpen size={28} /> : <MdMenu size={28} />}\n      <span css={menuTitle}>메뉴</span>\n    </Button>\n  );\n}\n\nexport default SideBarButton;\n"
  },
  {
    "path": "frontend/src/components/SideGoogleList/SideGoogleList.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css`\n  ${flex.column}\n\n  display: ${isSideBarOpen ? 'flex' : 'none'};\n  justify-content: flex-start;\n\n  width: 54rem;\n\n  font-size: 4rem;\n`;\n\nconst headerLayoutStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n`;\n\nconst headerStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n  height: 8rem;\n\n  font-weight: bold;\n\n  cursor: pointer;\n`;\n\nconst contentStyle = ({ flex }: Theme, isListOpen: boolean, listLength: number) => css`\n  ${flex.column};\n\n  justify-content: flex-start;\n  align-items: flex-start;\n  gap: 2rem;\n  overflow: hidden;\n\n  width: 100%;\n  height: ${isListOpen ? `${9 * listLength}rem` : 0};\n  margin-bottom: 5rem;\n\n  transition: height 0.3s ease-in-out;\n`;\n\nconst menuStyle = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 9rem;\n  height: 9rem;\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst menuTitleStyle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\n\nexport { contentStyle, headerLayoutStyle, headerStyle, listStyle, menuStyle, menuTitleStyle };\n"
  },
  {
    "path": "frontend/src/components/SideGoogleList/SideGoogleList.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useRecoilValue } from 'recoil';\n\nimport useRootFontSize from '@/hooks/useRootFontSize';\nimport useToggle from '@/hooks/useToggle';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport { sideBarState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport GoogleImportModal from '@/components/GoogleImportModal/GoogleImportModal';\nimport SideItem from '@/components/SideItem/SideItem';\n\nimport { MdAdd, MdKeyboardArrowDown, MdKeyboardArrowUp } from 'react-icons/md';\n\nimport {\n  contentStyle,\n  headerLayoutStyle,\n  headerStyle,\n  listStyle,\n  menuStyle,\n  menuTitleStyle,\n} from './SideGoogleList.styles';\n\ninterface SideGoogleListProps {\n  categories: SubscriptionType[];\n}\n\nfunction SideGoogleList({ categories }: SideGoogleListProps) {\n  const isSideBarOpen = useRecoilValue(sideBarState);\n\n  const theme = useTheme();\n\n  const rootFontSize = useRootFontSize();\n\n  const { state: isGoogleListOpen, toggleState: toggleGoogleListOpen } = useToggle(true);\n  const { state: isGoogleImportModalOpen, toggleState: toggleGoogleImportModalOpen } = useToggle();\n\n  const handleClickGoogleImportButton = () => {\n    toggleGoogleImportModalOpen();\n  };\n\n  return (\n    <div css={listStyle(theme, isSideBarOpen)}>\n      <div css={headerLayoutStyle}>\n        <span css={headerStyle} onClick={toggleGoogleListOpen}>\n          구글 카테고리\n        </span>\n        <Button cssProp={menuStyle}>\n          <MdAdd size={rootFontSize * 5} onClick={handleClickGoogleImportButton} />\n          <span css={menuTitleStyle}>구글 카테고리 추가</span>\n        </Button>\n        <Button onClick={toggleGoogleListOpen}>\n          {isGoogleListOpen ? (\n            <MdKeyboardArrowUp size={rootFontSize * 5} />\n          ) : (\n            <MdKeyboardArrowDown size={rootFontSize * 5} />\n          )}\n        </Button>\n      </div>\n      <div\n        css={contentStyle(theme, isGoogleListOpen, categories.length === 0 ? 1 : categories.length)}\n      >\n        {categories.map((el) => {\n          return <SideItem key={el.category.id} subscription={el} />;\n        })}\n        {categories.length === 0 && <span>카테고리를 추가해주세요.</span>}\n      </div>\n      <ModalPortal isOpen={isGoogleImportModalOpen} closeModal={toggleGoogleImportModalOpen}>\n        <GoogleImportModal closeModal={toggleGoogleImportModalOpen} />\n      </ModalPortal>\n    </div>\n  );\n}\n\nexport default SideGoogleList;\n"
  },
  {
    "path": "frontend/src/components/SideItem/SideItem.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst itemStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: space-between;\n\n  width: 100%;\n  height: 7rem;\n\n  &:hover {\n    background-color: ${colors.GRAY_100};\n\n    button {\n      visibility: visible;\n    }\n  }\n`;\n\nconst checkBoxNameStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  gap: 1rem;\n\n  &:hover {\n    cursor: pointer;\n  }\n`;\n\nconst nameStyle = css`\n  overflow: hidden;\n  position: relative;\n\n  width: 32rem;\n\n  white-space: nowrap;\n  text-overflow: ellipsis;\n`;\n\nconst headerStyle = css`\n  padding: 2rem;\n\n  font-size: 5rem;\n`;\n\nconst iconStyle = css`\n  visibility: hidden;\n`;\n\nconst modalLayoutStyle = css`\n  position: relative;\n`;\n\nexport { itemStyle, checkBoxNameStyle, headerStyle, iconStyle, nameStyle, modalLayoutStyle };\n"
  },
  {
    "path": "frontend/src/components/SideItem/SideItem.tsx",
    "content": "import { usePatchSubscription } from '@/hooks/@queries/subscription';\nimport useModalPosition from '@/hooks/useModalPosition';\nimport useRootFontSize from '@/hooks/useRootFontSize';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport Button from '@/components/@common/Button/Button';\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport Spinner from '@/components/@common/Spinner/Spinner';\nimport SubscriptionModifyModal from '@/components/SubscriptionModifyModal/SubscriptionModifyModal';\n\nimport { TRANSPARENT } from '@/constants/style';\n\nimport { MdCheckBox, MdCheckBoxOutlineBlank, MdMoreVert } from 'react-icons/md';\n\nimport {\n  checkBoxNameStyle,\n  iconStyle,\n  itemStyle,\n  modalLayoutStyle,\n  nameStyle,\n} from './SideItem.styles';\n\ninterface SideItemProps {\n  subscription: SubscriptionType;\n}\n\nfunction SideItem({ subscription }: SideItemProps) {\n  const { isLoading, mutate: patchSubscription } = usePatchSubscription({\n    subscriptionId: subscription.id,\n  });\n\n  const rootFontSize = useRootFontSize();\n\n  const { isModalOpen, toggleModalOpen, handleClickOpen, modalPos } = useModalPosition();\n\n  const handleClickCategoryItem = (checked: boolean, colorCode: string) => {\n    patchSubscription({\n      checked: !checked,\n      colorCode,\n    });\n  };\n\n  return (\n    <div css={itemStyle}>\n      <div css={checkBoxNameStyle}>\n        {isLoading ? (\n          <Spinner />\n        ) : subscription.checked ? (\n          <MdCheckBox\n            size={rootFontSize * 5}\n            color={subscription.colorCode}\n            onClick={() => {\n              handleClickCategoryItem(subscription.checked, subscription.colorCode);\n            }}\n          />\n        ) : (\n          <MdCheckBoxOutlineBlank\n            size={rootFontSize * 5}\n            color={subscription.colorCode}\n            onClick={() => {\n              handleClickCategoryItem(subscription.checked, subscription.colorCode);\n            }}\n          />\n        )}\n        <span\n          css={nameStyle}\n          onClick={() => {\n            handleClickCategoryItem(subscription.checked, subscription.colorCode);\n          }}\n        >\n          {subscription.category.name}\n        </span>\n      </div>\n      <div css={modalLayoutStyle}>\n        <Button cssProp={iconStyle}>\n          <MdMoreVert size={rootFontSize * 5} onClick={handleClickOpen} />\n        </Button>\n        {isModalOpen && (\n          <ModalPortal\n            isOpen={isModalOpen}\n            closeModal={toggleModalOpen}\n            dimmerBackground={TRANSPARENT}\n          >\n            <SubscriptionModifyModal\n              toggleModalOpen={toggleModalOpen}\n              modalPos={modalPos}\n              subscription={subscription}\n              patchSubscription={patchSubscription}\n            />\n          </ModalPortal>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default SideItem;\n"
  },
  {
    "path": "frontend/src/components/SideSubscribedList/SideSubscribedList.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst listStyle = ({ flex }: Theme, isSideBarOpen: boolean) => css`\n  ${flex.column}\n\n  display: ${isSideBarOpen ? 'flex' : 'none'};\n  justify-content: flex-start;\n\n  width: 54rem;\n\n  font-size: 4rem;\n`;\n\nconst headerLayoutStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n`;\n\nconst headerStyle = ({ flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-between;\n\n  width: 100%;\n  height: 8rem;\n\n  font-weight: bold;\n\n  cursor: pointer;\n`;\n\nconst contentStyle = ({ flex }: Theme, isListOpen: boolean, listLength: number) => css`\n  ${flex.column};\n\n  justify-content: flex-start;\n  align-items: flex-start;\n  gap: 2rem;\n  overflow: hidden;\n\n  width: 100%;\n  height: ${isListOpen ? `${9 * listLength}rem` : 0};\n  margin-bottom: 5rem;\n\n  transition: height 0.3s ease-in-out;\n`;\n\nconst menuStyle = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 9rem;\n  height: 9rem;\n\n  &:hover {\n    border-radius: 50%;\n\n    background: ${colors.GRAY_100};\n\n    filter: none;\n  }\n\n  &:hover span {\n    visibility: visible;\n  }\n`;\n\nconst menuTitleStyle = ({ colors }: Theme) => css`\n  visibility: hidden;\n  position: absolute;\n  top: 120%;\n  left: 50%;\n  transform: translateX(-50%);\n\n  padding: 2rem 3rem;\n\n  background: ${colors.GRAY_700}ee;\n\n  font-size: 3rem;\n  font-weight: normal;\n  color: ${colors.WHITE};\n  white-space: nowrap;\n`;\nexport { contentStyle, headerLayoutStyle, headerStyle, listStyle, menuStyle, menuTitleStyle };\n"
  },
  {
    "path": "frontend/src/components/SideSubscribedList/SideSubscribedList.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilValue } from 'recoil';\n\nimport useRootFontSize from '@/hooks/useRootFontSize';\nimport useToggle from '@/hooks/useToggle';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport { sideBarState } from '@/recoil/atoms';\n\nimport Button from '@/components/@common/Button/Button';\nimport SideItem from '@/components/SideItem/SideItem';\n\nimport { PATH } from '@/constants';\n\nimport { MdAdd, MdKeyboardArrowDown, MdKeyboardArrowUp } from 'react-icons/md';\n\nimport {\n  contentStyle,\n  headerLayoutStyle,\n  headerStyle,\n  listStyle,\n  menuStyle,\n  menuTitleStyle,\n} from './SideSubscribedList.styles';\n\ninterface SideSubscribedListProps {\n  categories: SubscriptionType[];\n}\n\nfunction SideSubscribedList({ categories }: SideSubscribedListProps) {\n  const rootFontSize = useRootFontSize();\n\n  const isSideBarOpen = useRecoilValue(sideBarState);\n\n  const { state: isSubscribedListOpen, toggleState: toggleSubscribedListOpen } = useToggle(true);\n\n  const theme = useTheme();\n\n  const navigate = useNavigate();\n\n  const handleClickCategoryAddButton = () => navigate(PATH.CATEGORY);\n\n  return (\n    <div css={listStyle(theme, isSideBarOpen)}>\n      <div css={headerLayoutStyle}>\n        <span css={headerStyle} onClick={toggleSubscribedListOpen}>\n          구독 카테고리\n        </span>\n        <Button cssProp={menuStyle}>\n          <MdAdd size={rootFontSize * 5} onClick={handleClickCategoryAddButton} />\n          <span css={menuTitleStyle}>카테고리 구독</span>\n        </Button>\n        <Button onClick={toggleSubscribedListOpen}>\n          {isSubscribedListOpen ? (\n            <MdKeyboardArrowUp size={rootFontSize * 5} />\n          ) : (\n            <MdKeyboardArrowDown size={rootFontSize * 5} />\n          )}\n        </Button>\n      </div>\n      <div\n        css={contentStyle(\n          theme,\n          isSubscribedListOpen,\n          categories.length === 0 ? 1 : categories.length\n        )}\n      >\n        {categories.map((el) => {\n          return <SideItem key={el.category.id} subscription={el} />;\n        })}\n        {categories.length === 0 && <span>카테고리를 구독해주세요.</span>}\n      </div>\n    </div>\n  );\n}\n\nexport default SideSubscribedList;\n"
  },
  {
    "path": "frontend/src/components/SnackBar/SnackBar.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst snackBarStyle = ({ colors }: Theme, isOpen: boolean) => css`\n  ${isOpen\n    ? css`\n        z-index: 50;\n        position: fixed;\n        bottom: 5rem;\n        left: 50%;\n        transform: translateX(-50%);\n\n        padding: 4rem;\n        border-radius: 7px;\n\n        background: ${colors.YELLOW_500};\n        opacity: 0;\n\n        color: ${colors.WHITE};\n        font-size: 3.5rem;\n        line-height: 3.5rem;\n        text-align: center;\n\n        @keyframes show {\n          0% {\n            opacity: 0;\n          }\n\n          50% {\n            opacity: 1;\n          }\n\n          75% {\n            opacity: 1;\n          }\n\n          100% {\n            opacity: 0;\n          }\n        }\n\n        animation: show 2.5s;\n      `\n    : css`\n        display: none;\n      `}\n`;\n\nexport { snackBarStyle };\n"
  },
  {
    "path": "frontend/src/components/SnackBar/SnackBar.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useEffect, useState } from 'react';\nimport { useRecoilValue } from 'recoil';\n\nimport { snackBarState } from '@/recoil/atoms';\n\nimport { snackBarStyle } from './SnackBar.styles';\n\nfunction SnackBar() {\n  const theme = useTheme();\n\n  const snackBarInfo = useRecoilValue(snackBarState);\n\n  const [timer, setTimer] = useState<null | NodeJS.Timeout>(null);\n\n  useEffect(() => {\n    if (snackBarInfo.text === '' || timer) {\n      return;\n    }\n\n    const newTimer = setTimeout(() => {\n      setTimer(null);\n    }, 2500);\n\n    setTimer(newTimer);\n  }, [snackBarInfo]);\n\n  const isOpen = timer !== null;\n\n  return <div css={snackBarStyle(theme, isOpen)}>{snackBarInfo.text}</div>;\n}\n\nexport default SnackBar;\n"
  },
  {
    "path": "frontend/src/components/SubscribedCategoryItem/SubscribedCategoryItem.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst categoryItem = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-around;\n  position: relative;\n\n  height: 20rem;\n  border-bottom: 1px solid ${colors.GRAY_400};\n\n  font-size: 4rem;\n\n  &:hover {\n    background: ${colors.GRAY_100};\n\n    cursor: pointer;\n  }\n`;\n\nconst item = css`\n  flex: 1 1 0;\n\n  text-align: center;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`;\n\nconst unsubscribeButton = ({ colors }: Theme) => css`\n  position: relative;\n\n  width: 18rem;\n  height: 8rem;\n  border-radius: 7px;\n\n  background-color: ${colors.GRAY_500};\n\n  font-size: 3.5rem;\n  font-weight: 700;\n  line-height: 3.5rem;\n  color: ${colors.WHITE};\n\n  &:hover {\n    filter: none;\n  }\n`;\n\nconst detailStyle = ({ colors }: Theme, hoveringUpside: boolean) => css`\n  position: absolute;\n  top: ${hoveringUpside && '120%'};\n  bottom: ${!hoveringUpside && '120%'};\n  z-index: 10;\n\n  width: max-content;\n  padding: 4rem 6rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 5px ${colors.GRAY_500};\n\n  background: ${colors.BLUE_500};\n\n  color: ${colors.WHITE};\n`;\n\nexport { categoryItem, detailStyle, item, unsubscribeButton };\n"
  },
  {
    "path": "frontend/src/components/SubscribedCategoryItem/SubscribedCategoryItem.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { useGetEditableCategories, useGetSingleCategory } from '@/hooks/@queries/category';\nimport { useDeleteSubscriptions } from '@/hooks/@queries/subscription';\nimport useHoverCategoryItem from '@/hooks/useHoverCategoryItem';\n\nimport { CategoryType } from '@/@types/category';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { CONFIRM_MESSAGE } from '@/constants/message';\n\nimport { getISODateString } from '@/utils/date';\n\nimport {\n  categoryItem,\n  detailStyle,\n  item,\n  unsubscribeButton,\n} from './SubscribedCategoryItem.styles';\n\ninterface SubscribedCategoryItemProps {\n  category: CategoryType;\n  subscriptionId: number;\n  onClick: () => void;\n}\n\nfunction SubscribedCategoryItem({\n  category,\n  subscriptionId,\n  onClick,\n}: SubscribedCategoryItemProps) {\n  const theme = useTheme();\n\n  const { hoveringPosY, handleHoverCategoryItem } = useHoverCategoryItem();\n\n  const { data: getSingleCategoryResponse } = useGetSingleCategory({\n    categoryId: category.id,\n    enabled: !!hoveringPosY,\n  });\n\n  const { data: getEditableCategoriesResponse } = useGetEditableCategories({});\n\n  const { mutate } = useDeleteSubscriptions({ subscriptionId });\n\n  const handleClickUnsubscribeButton = () => {\n    if (window.confirm(CONFIRM_MESSAGE.UNSUBSCRIBE)) {\n      mutate();\n    }\n  };\n\n  const canUnsubscribeCategory = !getEditableCategoriesResponse?.data.some(\n    (el) => el.id === category.id\n  );\n\n  return (\n    <div\n      css={categoryItem}\n      onClick={onClick}\n      onMouseEnter={handleHoverCategoryItem}\n      onMouseLeave={handleHoverCategoryItem}\n    >\n      <span css={item}>{category.name}</span>\n      <span css={item}>{category.creator.displayName}</span>\n      <div css={item}>\n        <Button\n          cssProp={unsubscribeButton(theme)}\n          onClick={handleClickUnsubscribeButton}\n          disabled={!canUnsubscribeCategory}\n        >\n          구독중\n        </Button>\n      </div>\n      {hoveringPosY !== null && (\n        <div css={detailStyle(theme, hoveringPosY < innerHeight / 2)}>\n          {`구독자 ${\n            getSingleCategoryResponse?.data.subscriberCount ?? '-'\n          }명 • 개설일 ${getISODateString(category.createdAt)}`}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default SubscribedCategoryItem;\n"
  },
  {
    "path": "frontend/src/components/SubscriberItem/SubscriberItem.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst adminButtonStyle = css`\n  position: absolute;\n  right: 1rem;\n\n  font-size: 5rem;\n  line-height: 7rem;\n\n  &:hover {\n    transform: scale(1.1);\n  }\n`;\n\nconst displayNameStyle = css`\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`;\n\nconst profileImageStyle = css`\n  width: 7rem;\n  height: 7rem;\n  border-radius: 50%;\n`;\n\nconst subscriberItemStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: flex-start;\n  gap: 2rem;\n  position: relative;\n\n  height: 7rem;\n  padding: 6rem 2rem;\n  box-shadow: 0 2px 2px ${colors.GRAY_400};\n\n  font-size: 4rem;\n\n  &:hover {\n    background: ${colors.GRAY_100};\n  }\n`;\n\nexport { adminButtonStyle, displayNameStyle, profileImageStyle, subscriberItemStyle };\n"
  },
  {
    "path": "frontend/src/components/SubscriberItem/SubscriberItem.tsx",
    "content": "import { usePatchCategoryRole } from '@/hooks/@queries/category';\n\nimport { ProfileType } from '@/@types/profile';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { ROLE } from '@/constants/category';\nimport { CONFIRM_MESSAGE } from '@/constants/message';\n\nimport { MdOutlineAdminPanelSettings } from 'react-icons/md';\n\nimport {\n  adminButtonStyle,\n  displayNameStyle,\n  profileImageStyle,\n  subscriberItemStyle,\n} from './SubscriberItem.styles';\n\ninterface SubscriberItemProps {\n  categoryId: number;\n  subscriber: ProfileType;\n}\n\nfunction SubscriberItem({ categoryId, subscriber }: SubscriberItemProps) {\n  const { mutate: patchRole } = usePatchCategoryRole({\n    categoryId,\n    memberId: subscriber.id,\n  });\n\n  const handleClickAddRoleButton = () => {\n    window.confirm(CONFIRM_MESSAGE.ADD_ADMIN) &&\n      patchRole({\n        categoryRoleType: ROLE.ADMIN,\n      });\n  };\n\n  return (\n    <div key={subscriber.id} css={subscriberItemStyle}>\n      <img src={subscriber.profileImageUrl} alt=\"프로필 이미지\" css={profileImageStyle} />\n      <span css={displayNameStyle}>{subscriber.displayName}</span>\n      <Button cssProp={adminButtonStyle} onClick={handleClickAddRoleButton}>\n        <MdOutlineAdminPanelSettings />\n      </Button>\n    </div>\n  );\n}\n\nexport default SubscriberItem;\n"
  },
  {
    "path": "frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { ModalPosType } from '@/@types';\n\nconst controlButtonStyle = ({ colors, flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: flex-start;\n  gap: 1rem;\n\n  width: 100%;\n  padding: 2rem;\n  border-bottom: 1px solid ${colors.GRAY_300};\n  box-sizing: contain;\n\n  &:hover {\n    filter: none;\n    background-color: ${colors.GRAY_100};\n  }\n`;\n\nconst colorStyle = (color: string) => css`\n  width: 5rem;\n  height: 5rem;\n  border-radius: 50%;\n\n  background: ${color};\n\n  &:hover {\n    filter: none;\n    transform: scale(1.2);\n  }\n`;\n\nconst modalPosStyle = ({ colors, flex }: Theme, modalPos: ModalPosType) => css`\n  ${flex.column};\n\n  align-items: flex-start;\n  position: absolute;\n  top: ${modalPos.top ? `${modalPos.top}px` : 'none'};\n  right: ${modalPos.right ? `${modalPos.right}px` : 'none'};\n  bottom: ${modalPos.bottom ? `${modalPos.bottom}px` : 'none'};\n  left: ${modalPos.left ? `${modalPos.left}px` : 'none'};\n\n  border: 1px solid ${colors.GRAY_300};\n  border-radius: 7px;\n\n  background: ${colors.WHITE};\n`;\n\nconst paletteStyle = css`\n  display: grid;\n  grid-template-columns: repeat(4, 1fr);\n  place-items: center;\n  gap: 2rem;\n\n  width: 35rem;\n  padding: 2rem;\n`;\n\nconst outerStyle = css`\n  position: fixed;\n  left: 0;\n  top: 16rem;\n\n  width: 100%;\n  height: 100%;\n\n  background-color: transparent;\n`;\n\nexport { colorStyle, controlButtonStyle, modalPosStyle, outerStyle, paletteStyle };\n"
  },
  {
    "path": "frontend/src/components/SubscriptionModifyModal/SubscriptionModifyModal.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { AxiosResponse } from 'axios';\nimport { UseMutateFunction } from 'react-query';\n\nimport { useGetEditableCategories } from '@/hooks/@queries/category';\nimport { useDeleteSubscriptions } from '@/hooks/@queries/subscription';\nimport useRootFontSize from '@/hooks/useRootFontSize';\nimport useToggle from '@/hooks/useToggle';\n\nimport { ModalPosType } from '@/@types';\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport Button from '@/components/@common/Button/Button';\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport AdminCategoryManageModal from '@/components/AdminCategoryManageModal/AdminCategoryManageModal';\nimport GoogleCategoryManageModal from '@/components/GoogleCategoryManageModal/GoogleCategoryManageModal';\n\nimport { CATEGORY_TYPE } from '@/constants/category';\nimport { CONFIRM_MESSAGE } from '@/constants/message';\nimport { PALETTE } from '@/constants/style';\n\nimport { MdOutlineDelete, MdSettings } from 'react-icons/md';\n\nimport {\n  colorStyle,\n  controlButtonStyle,\n  modalPosStyle,\n  outerStyle,\n  paletteStyle,\n} from './SubscriptionModifyModal.styles';\n\ninterface SubscriptionModifyModalProps {\n  toggleModalOpen: () => void;\n  modalPos: ModalPosType;\n  subscription: SubscriptionType;\n  patchSubscription: UseMutateFunction<\n    AxiosResponse<null>,\n    unknown,\n    Pick<SubscriptionType, 'colorCode'> | Pick<SubscriptionType, 'checked'>,\n    unknown\n  >;\n}\n\nfunction SubscriptionModifyModal({\n  toggleModalOpen,\n  modalPos,\n  subscription,\n  patchSubscription,\n}: SubscriptionModifyModalProps) {\n  const theme = useTheme();\n\n  const rootFontSize = useRootFontSize();\n\n  const { state: isCategoryManageModalOpen, toggleState: toggleCategoryManageModalOpen } =\n    useToggle();\n\n  const { isLoading, data } = useGetEditableCategories({});\n\n  const { mutate } = useDeleteSubscriptions({\n    subscriptionId: subscription.id,\n    onSuccess: toggleModalOpen,\n  });\n\n  if (isLoading || !data) {\n    return <></>;\n  }\n\n  const handleClickManageButton = () => {\n    toggleCategoryManageModalOpen();\n  };\n\n  const handleClickPalette = (checked: boolean, colorCode: string) => {\n    patchSubscription({ checked, colorCode });\n    toggleModalOpen();\n  };\n\n  const handleClickDeleteSubscription = () => {\n    if (window.confirm(CONFIRM_MESSAGE.UNSUBSCRIBE)) mutate();\n  };\n\n  const closeModals = () => {\n    toggleCategoryManageModalOpen();\n    toggleModalOpen();\n  };\n\n  const canEditCategories = data.data\n    .filter((category) => category.categoryType !== CATEGORY_TYPE.PERSONAL)\n    .map((category) => category.id);\n\n  const canEditSubscription = canEditCategories.includes(subscription.category.id);\n\n  const canDeleteSubscription =\n    !canEditCategories.includes(subscription.category.id) &&\n    subscription.category.categoryType !== CATEGORY_TYPE.PERSONAL;\n\n  return (\n    <>\n      <div css={outerStyle} onClick={toggleModalOpen} />\n      <div css={modalPosStyle(theme, modalPos)}>\n        {canEditSubscription && (\n          <Button cssProp={controlButtonStyle} onClick={handleClickManageButton}>\n            <MdSettings size={rootFontSize * 5} />\n            <span>관리</span>\n          </Button>\n        )}\n        {canDeleteSubscription && (\n          <Button cssProp={controlButtonStyle} onClick={handleClickDeleteSubscription}>\n            <MdOutlineDelete size={rootFontSize * 5} />\n            <span>구독 해제</span>\n          </Button>\n        )}\n        <div css={paletteStyle}>\n          {PALETTE.map((color) => {\n            return (\n              <Button\n                key={color}\n                cssProp={colorStyle(color)}\n                onClick={() => {\n                  handleClickPalette(subscription.checked, color);\n                }}\n              ></Button>\n            );\n          })}\n        </div>\n      </div>\n\n      <ModalPortal isOpen={isCategoryManageModalOpen} closeModal={closeModals}>\n        {subscription.category.categoryType === CATEGORY_TYPE.GOOGLE ? (\n          <GoogleCategoryManageModal subscription={subscription} closeModal={closeModals} />\n        ) : (\n          <AdminCategoryManageModal subscription={subscription} closeModal={closeModals} />\n        )}\n      </ModalPortal>\n    </>\n  );\n}\n\nexport default SubscriptionModifyModal;\n"
  },
  {
    "path": "frontend/src/components/UnsubscribedCategoryItem/UnsubscribedCategoryItem.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst categoryItem = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  justify-content: space-around;\n  position: relative;\n\n  height: 20rem;\n  border-bottom: 1px solid ${colors.GRAY_400};\n\n  font-size: 4rem;\n\n  &:hover {\n    background: ${colors.GRAY_100};\n\n    cursor: pointer;\n  }\n`;\n\nconst item = css`\n  flex: 1 1 0;\n\n  text-align: center;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n`;\n\nconst subscribeButton = ({ colors }: Theme) => css`\n  width: 18rem;\n  height: 8rem;\n  border-radius: 7px;\n\n  background-color: ${colors.YELLOW_500};\n\n  font-size: 3.5rem;\n  font-weight: 700;\n  line-height: 3.5rem;\n  color: ${colors.WHITE};\n`;\n\nconst detailStyle = ({ colors }: Theme, hoveringUpside: boolean) => css`\n  position: absolute;\n  top: ${hoveringUpside && '120%'};\n  bottom: ${!hoveringUpside && '120%'};\n  z-index: 10;\n\n  width: max-content;\n  padding: 4rem 6rem;\n  border-radius: 7px;\n  box-shadow: 0 2px 5px ${colors.GRAY_500};\n\n  background: ${colors.BLUE_500};\n\n  color: ${colors.WHITE};\n\n  &::after {\n    position: absolute;\n    top: ${hoveringUpside ? '-40%' : '100%'};\n    left: 50%;\n\n    margin-left: -10px;\n    border-width: 10px;\n    border-style: solid;\n    border-color: ${hoveringUpside\n      ? `transparent transparent ${colors.BLUE_500}`\n      : `${colors.BLUE_500} transparent transparent`};\n\n    content: '';\n  }\n`;\n\nexport { categoryItem, detailStyle, item, subscribeButton };\n"
  },
  {
    "path": "frontend/src/components/UnsubscribedCategoryItem/UnsubscribedCategoryItem.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { useGetSingleCategory } from '@/hooks/@queries/category';\nimport { usePostSubscription } from '@/hooks/@queries/subscription';\nimport useHoverCategoryItem from '@/hooks/useHoverCategoryItem';\n\nimport { CategoryType } from '@/@types/category';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { PALETTE } from '@/constants/style';\n\nimport { getRandomNumber } from '@/utils';\nimport { getISODateString } from '@/utils/date';\n\nimport {\n  categoryItem,\n  detailStyle,\n  item,\n  subscribeButton,\n} from './UnsubscribedCategoryItem.styles';\n\ninterface UnsubscribedCategoryItemProps {\n  category: CategoryType;\n  onClick: () => void;\n}\n\nfunction UnsubscribedCategoryItem({ category, onClick }: UnsubscribedCategoryItemProps) {\n  const theme = useTheme();\n\n  const { hoveringPosY, handleHoverCategoryItem } = useHoverCategoryItem();\n\n  const { data } = useGetSingleCategory({\n    categoryId: category.id,\n    enabled: !!hoveringPosY,\n  });\n\n  const body = {\n    colorCode: PALETTE[getRandomNumber(0, PALETTE.length)],\n  };\n\n  const { mutate } = usePostSubscription({ categoryId: category.id });\n\n  const handleClickSubscribeButton = () => {\n    mutate(body);\n  };\n\n  return (\n    <div\n      css={categoryItem}\n      onClick={onClick}\n      onMouseEnter={handleHoverCategoryItem}\n      onMouseLeave={handleHoverCategoryItem}\n    >\n      <span css={item}>{category.name}</span>\n      <span css={item}>{category.creator.displayName}</span>\n      <div css={item}>\n        <Button cssProp={subscribeButton(theme)} onClick={handleClickSubscribeButton}>\n          구독\n        </Button>\n      </div>\n      {hoveringPosY !== null && (\n        <div css={detailStyle(theme, hoveringPosY < innerHeight / 2)}>\n          {`구독자 ${data?.data.subscriberCount ?? '-'}명 • 개설일 ${getISODateString(\n            category.createdAt\n          )}`}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default UnsubscribedCategoryItem;\n"
  },
  {
    "path": "frontend/src/components/WithdrawalModal/WithdrawalModal.stories.tsx",
    "content": "import { ComponentMeta, ComponentStory } from '@storybook/react';\n\nimport WithdrawalModal from './WithdrawalModal';\n\nexport default {\n  title: 'Components/WithdrawalModal',\n  component: WithdrawalModal,\n} as ComponentMeta<typeof WithdrawalModal>;\n\nconst Template: ComponentStory<typeof WithdrawalModal> = (args) => <WithdrawalModal {...args} />;\n\nexport const Primary = Template.bind({});\n"
  },
  {
    "path": "frontend/src/components/WithdrawalModal/WithdrawalModal.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst headerStyle = css`\n  width: 100%;\n\n  padding: 0 auto;\n\n  font-size: 8rem;\n  font-weight: bold;\n  text-align: center;\n`;\n\nconst layoutStyle = ({ colors, flex }: Theme) => css`\n  ${flex.column};\n  align-items: flex-start;\n\n  gap: 6rem;\n\n  width: 100rem;\n  padding: 12.5rem;\n  border-radius: 7px;\n\n  font-size: 4rem;\n  line-height: 6rem;\n\n  background: ${colors.WHITE};\n`;\n\nconst withdrawalButtonStyle = ({ colors }: Theme) => css`\n  width: 80%;\n  margin: 0 auto;\n\n  padding: 2rem 3rem;\n  border: 1px solid ${colors.RED_400};\n  border-radius: 7px;\n\n  font-size: 3rem;\n  color: ${colors.RED_400};\n`;\n\nconst withdrawalConditionTextStyle = ({ colors }: Theme) => css`\n  color: ${colors.RED_400};\n  font-weight: 700;\n`;\n\nexport { headerStyle, layoutStyle, withdrawalButtonStyle, withdrawalConditionTextStyle };\n"
  },
  {
    "path": "frontend/src/components/WithdrawalModal/WithdrawalModal.tsx",
    "content": "import { validateWithdrawalCondition } from '@/validation';\n\nimport { useDeleteProfile } from '@/hooks/@queries/profile';\nimport useControlledInput from '@/hooks/useControlledInput';\n\nimport Button from '@/components/@common/Button/Button';\nimport Fieldset from '@/components/@common/Fieldset/Fieldset';\n\nimport { CONFIRM_MESSAGE } from '@/constants/message';\nimport { VALIDATION_STRING } from '@/constants/validate';\n\nimport {\n  headerStyle,\n  layoutStyle,\n  withdrawalButtonStyle,\n  withdrawalConditionTextStyle,\n} from './WithdrawalModal.styles';\n\ninterface WithdrawalModalProps {\n  closeModal: () => void;\n}\n\nfunction WithdrawalModal({ closeModal }: WithdrawalModalProps) {\n  const { inputValue, onChangeValue } = useControlledInput();\n\n  const { mutate } = useDeleteProfile({ onSuccess: closeModal });\n\n  const handleClickWithdrawalButton = () => {\n    if (window.confirm(CONFIRM_MESSAGE.WITHDRAWAL)) {\n      mutate();\n    }\n  };\n\n  return (\n    <div css={layoutStyle}>\n      <h1 css={headerStyle}>달록 탈퇴</h1>\n      <p>탈퇴를 진행하면 일정과 카테고리를 비롯한 모든 정보가 영구적으로 삭제됩니다.</p>\n      <p>그래도 탈퇴하시겠습니까?</p>\n      <p>\n        탈퇴를 원하시면{' '}\n        <span css={withdrawalConditionTextStyle}>{VALIDATION_STRING.WITHDRAWAL}</span>를\n        입력해주세요.\n      </p>\n      <Fieldset\n        placeholder={VALIDATION_STRING.WITHDRAWAL}\n        onChange={onChangeValue}\n        value={inputValue}\n      />\n      <Button\n        cssProp={withdrawalButtonStyle}\n        onClick={handleClickWithdrawalButton}\n        disabled={!validateWithdrawalCondition(inputValue)}\n      >\n        내용을 확인했으며, 탈퇴하겠습니다.\n      </Button>\n    </div>\n  );\n}\n\nexport default WithdrawalModal;\n"
  },
  {
    "path": "frontend/src/constants/api.ts",
    "content": "const API = {\n  AUTH_CODE_KEY: 'code',\n  CATEGORY_GET_SIZE: 4,\n};\n\nconst API_URL = process.env.API_URL;\n\nconst CACHE_KEY = {\n  CATEGORIES: 'categories',\n  CATEGORY: 'category',\n  ENTER: 'enter',\n  GOOGLE_CALENDAR: 'googleCalendar',\n  MY_CATEGORIES: 'myCategories',\n  PROFILE: 'profile',\n  SCHEDULE: 'schedule',\n  SCHEDULES: 'schedules',\n  SUBSCRIPTIONS: 'subscriptions',\n  SUBSCRIBERS: 'subscribers',\n  VALIDATE: 'validate',\n  ADMIN_CATEGORIES: 'adminCategories',\n  EDITABLE_CATEGORIES: 'editableCategories',\n  LOGIN_AGAIN: 'loginAgain',\n};\n\nconst RESPONSE = {\n  STATUS: {\n    UNAUTHORIZED: 401,\n  },\n};\n\nexport { API, API_URL, CACHE_KEY, RESPONSE };\n"
  },
  {
    "path": "frontend/src/constants/category.ts",
    "content": "const CATEGORY_TYPE = {\n  GOOGLE: 'GOOGLE',\n  NORMAL: 'NORMAL',\n  PERSONAL: 'PERSONAL',\n};\n\nconst ROLE = {\n  ADMIN: 'ADMIN',\n  NONE: 'NONE',\n};\n\nexport { CATEGORY_TYPE, ROLE };\n"
  },
  {
    "path": "frontend/src/constants/date.ts",
    "content": "import { zeroFill } from '@/utils';\n\nconst DATE_TIME = {\n  START_INDEX: 11,\n  START: '00:00',\n  END: '00:00',\n};\n\nconst DAYS = ['일', '월', '화', '수', '목', '금', '토'];\n\nconst TIMES = new Array(48)\n  .fill(0)\n  .map((_, arrIdx) => Math.floor(arrIdx / 2).toString())\n  .map((hour, hourIdx) => (hourIdx % 2 === 0 ? `${zeroFill(hour)}:00` : `${zeroFill(hour)}:30`));\n\nexport { DATE_TIME, DAYS, TIMES };\n"
  },
  {
    "path": "frontend/src/constants/index.ts",
    "content": "const ATOM_KEY = {\n  SCHEDULE: 'scheduleState',\n  SIDE_BAR: 'sideBarState',\n  SNACK_BAR: 'snackBarState',\n  USER: 'userState',\n};\n\nconst CALENDAR = {\n  MAX_SCHEDULE_COUNT: 20,\n  EMPTY_TITLE: '(제목 없음)',\n};\n\nconst EVENT = {\n  MOUSE_ENTER: 'mouseenter',\n};\n\nconst SELECTOR_KEY = {\n  SIDE_BAR: 'sideBarSelector',\n};\n\nconst STORAGE_KEY = {\n  ACCESS_TOKEN: 'accessToken',\n  REFRESH_TOKEN: 'refreshToken',\n};\n\nconst PATH = {\n  MAIN: '/',\n  AUTH: '/oauth',\n  CATEGORY: '/category',\n  POLICY: '/policy',\n};\n\nexport { ATOM_KEY, CALENDAR, EVENT, SELECTOR_KEY, STORAGE_KEY, PATH };\n"
  },
  {
    "path": "frontend/src/constants/message.ts",
    "content": "const CONFIRM_MESSAGE = {\n  DELETE: '정말 삭제하시겠습니까?',\n  UNSUBSCRIBE: '구독을 해제하시겠습니까?',\n  LOGOUT: '로그아웃하시겠습니까?',\n  WITHDRAWAL: '정말 탈퇴하시겠습니까?',\n  ADD_ADMIN: '카테고리 편집 권한을 부여하시겠습니까?',\n  DELETE_ADMIN: '카테고리 편집 권한을 삭제하시겠습니까?',\n  FORGIVE_ADMIN: '정말 카테고리 편집 권한을 포기하시겠습니까?',\n};\n\nconst ERROR_MESSAGE = {\n  DEFAULT: '에러가 발생했습니다. 잠시 후에 다시 시도해주세요.',\n};\n\nconst SUCCESS_MESSAGE = {\n  DELETE_CATEGORY: '카테고리를 삭제했습니다.',\n  DELETE_PROFILE: '회원 탈퇴되었습니다.',\n  DELETE_SCHEDULE: '일정을 삭제했습니다.',\n  PATCH_CATEGORY_NAME: '카테고리 이름이 변경되었습니다.',\n  PATCH_CATEGORY_ROLE: '편집 권한을 변경하였습니다.',\n  PATCH_PROFILE_NAME: '이름을 변경하였습니다.',\n  PATCH_SCHEDULE: '일정을 변경하였습니다.',\n\n  POST_CATEGORY: '카테고리를 생성하였습니다.',\n  POST_LOGIN: '로그인에 성공했습니다.',\n  POST_SCHEDULE: '일정을 생성했습니다.',\n  POST_LOGIN_AGAIN: '다시 로그인했습니다.',\n};\n\nconst TOOLTIP_MESSAGE = {\n  CANNOT_UNSUBSCRIBE_EDITABLE_CATEGORY: '편집 권한이 있는 카테고리는 구독을 해제할 수 없습니다.',\n  CANNOT_EDIT_DELETE_DEFAULT_CATEGORY: '기본 카테고리는 수정/삭제가 불가능합니다.',\n};\n\nexport { CONFIRM_MESSAGE, ERROR_MESSAGE, SUCCESS_MESSAGE, TOOLTIP_MESSAGE };\n"
  },
  {
    "path": "frontend/src/constants/schedule.ts",
    "content": "const SCHEDULE = {\n  RESPONSE_TYPE: {\n    LONG_TERMS: 'longTerms',\n    ALL_DAYS: 'allDays',\n    FEW_HOURS: 'fewHours',\n  },\n};\n\nexport { SCHEDULE };\n"
  },
  {
    "path": "frontend/src/constants/style.ts",
    "content": "const OPTION_HEIGHT = 36;\n\nconst PAGE_LAYOUT = {\n  DEFAULT: 'default',\n  SIDEBAR: 'sidebar',\n};\n\nconst PALETTE = [\n  '#AD1457',\n  '#D81B60',\n  '#D50000',\n  '#E67C73',\n  '#F4511E',\n  '#EF6C00',\n  '#F09300',\n  '#F6BF26',\n  '#E4C441',\n  '#C0CA33',\n  '#7CB342',\n  '#33B679',\n  '#0B8043',\n  '#009688',\n  '#039BE5',\n  '#4285F4',\n  '#3F51B5',\n  '#7986CB',\n  '#B39DDB',\n  '#9E69AF',\n  '#8E24AA',\n  '#795548',\n  '#616161',\n  '#A79B8E',\n];\n\nconst RESPONSIVE = {\n  LAPTOP: {\n    DEVICE: 'laptop',\n    FONT_SIZE: 4,\n    MIN_WIDTH: 1024,\n  },\n  TABLET: {\n    DEVICE: 'tablet',\n    FONT_SIZE: 4,\n    MAX_WIDTH: 1023,\n  },\n  MOBILE: {\n    DEVICE: 'mobile',\n    FONT_SIZE: 3.5,\n    MAX_WIDTH: 767,\n  },\n};\n\nconst SCHEDULE = {\n  HEIGHT: 5,\n  HEIGHT_WITH_MARGIN: 5.5,\n};\n\nconst TRANSPARENT = 'transparent';\n\nexport { OPTION_HEIGHT, PAGE_LAYOUT, PALETTE, RESPONSIVE, SCHEDULE, TRANSPARENT };\n"
  },
  {
    "path": "frontend/src/constants/validate.ts",
    "content": "const VALIDATION_SIZE = {\n  MIN_LENGTH: 1,\n  SCHEDULE_MEMO_MAX_LENGTH: 255,\n  SCHEDULE_TITLE_MAX_LENGTH: 50,\n  CATEGORY_NAME_MAX_LENGTH: 20,\n  DISPLAY_NAME_MAX_LENGTH: 100,\n};\n\nconst VALIDATION_STRING = {\n  CATEGORY: '내 일정',\n  WITHDRAWAL: '달록 탈퇴',\n};\n\nconst VALIDATION_MESSAGE = {\n  STRING_LENGTH: (min: number, max: number) => `${min}자 ~ ${max}자로 입력해주세요.`,\n  INVALID_CATEGORY_NAME: `\"${VALIDATION_STRING.CATEGORY}\"을 카테고리 이름으로 지정할 수 없습니다.`,\n};\n\nexport { VALIDATION_MESSAGE, VALIDATION_STRING, VALIDATION_SIZE };\n"
  },
  {
    "path": "frontend/src/domains/schedule.ts",
    "content": "import { ScheduleType } from '@/@types/schedule';\n\nimport { CALENDAR } from '@/constants';\n\nimport { checkAllDay, getDayOffsetDateTime, getISODateString } from '@/utils/date';\n\nfunction getSchedulePriority(calendar: string[]) {\n  const calendarWithPriority = calendar.reduce((acc: Record<string, boolean[]>, cur) => {\n    acc[getISODateString(cur)] = new Array(CALENDAR.MAX_SCHEDULE_COUNT).fill(false);\n\n    return acc;\n  }, {});\n\n  const getLongTermSchedulesWithPriority = (longTerms: Array<ScheduleType>) =>\n    longTerms.map((schedule) => {\n      const startDate = getISODateString(schedule.startDateTime);\n      const endDate = getISODateString(\n        checkAllDay(schedule.startDateTime, schedule.endDateTime)\n          ? getDayOffsetDateTime(schedule.endDateTime, -1)\n          : schedule.endDateTime\n      );\n\n      const calendarStartDate = calendar.find((el) => startDate <= el && el <= endDate);\n\n      if (\n        !calendarStartDate ||\n        !calendarWithPriority.hasOwnProperty(getISODateString(calendarStartDate))\n      ) {\n        return {\n          schedule,\n          priority: null,\n        };\n      }\n\n      const priority = calendarWithPriority[getISODateString(calendarStartDate)].findIndex(\n        (el) => !el\n      );\n\n      if (priority === -1) {\n        return {\n          schedule,\n          priority: null,\n        };\n      }\n\n      const scheduleRange = calendar\n        .filter((dateTime) => {\n          const date = getISODateString(dateTime);\n\n          return startDate <= date && date <= endDate;\n        })\n        .map((dateTime) => dateTime.split('T')[0]);\n\n      scheduleRange.forEach((dateTime) => {\n        if (calendarWithPriority.hasOwnProperty(dateTime)) {\n          calendarWithPriority[dateTime][priority] = true;\n        }\n      });\n\n      return {\n        schedule,\n        priority: priority + 1,\n      };\n    });\n\n  const getSingleSchedulesWithPriority = (singleSchedules: Array<ScheduleType>) =>\n    singleSchedules.map((schedule) => {\n      const startDate = getISODateString(schedule.startDateTime);\n\n      if (!calendarWithPriority.hasOwnProperty(startDate)) {\n        return {\n          schedule,\n          priority: null,\n        };\n      }\n\n      const priority = calendarWithPriority[startDate].findIndex((el) => !el);\n\n      if (priority === -1) {\n        return {\n          schedule,\n          priority: null,\n        };\n      }\n\n      calendarWithPriority[startDate][priority] = true;\n\n      return {\n        schedule,\n        priority: priority + 1,\n      };\n    });\n\n  return {\n    calendarWithPriority,\n    getLongTermSchedulesWithPriority,\n    getSingleSchedulesWithPriority,\n  };\n}\n\nexport default getSchedulePriority;\n"
  },
  {
    "path": "frontend/src/hooks/@queries/category.ts",
    "content": "import { AxiosError, AxiosResponse } from 'axios';\nimport { QueryKey, useMutation, useQuery, useQueryClient, UseQueryOptions } from 'react-query';\nimport { useRecoilValue } from 'recoil';\n\nimport useSnackBar from '@/hooks/useSnackBar';\n\nimport {\n  CategoryRoleType,\n  CategorySubscriberType,\n  CategoryType,\n  SingleCategoryType,\n} from '@/@types/category';\n\nimport { userState } from '@/recoil/atoms';\n\nimport { CACHE_KEY } from '@/constants/api';\nimport { SUCCESS_MESSAGE } from '@/constants/message';\n\nimport categoryApi from '@/api/category';\n\ninterface UseDeleteCategoryParams {\n  categoryId: number;\n  onSuccess?: () => void;\n}\n\ninterface UseGetAdminCategoriesParams {\n  enabled?: boolean;\n}\n\ninterface UseGetEditableCategoriesParams {\n  enabled?: boolean;\n}\n\ninterface UseGetEntireCategoriesParams {\n  keyword: string;\n}\n\ninterface UseGetSchedulesWithCategoryParams {\n  categoryId: number;\n  startDateTime: string;\n  endDateTime: string;\n}\n\ninterface UseGetSingleCategoryParams\n  extends Omit<\n    UseQueryOptions<\n      AxiosResponse<SingleCategoryType>,\n      AxiosError<unknown>,\n      AxiosResponse<SingleCategoryType>,\n      QueryKey\n    >,\n    'queryKey' | 'queryFn'\n  > {\n  categoryId: number;\n}\n\ninterface UseGetSubscribersParams {\n  categoryId: number;\n}\n\ninterface UsePatchCategoryRoleParams {\n  categoryId: number;\n  memberId: number;\n  onSuccess?: () => void;\n}\n\ninterface UsePatchCategoryNameParams {\n  categoryId: number;\n  onSuccess?: () => void;\n}\n\ninterface UsePostCategoryParams {\n  onSuccess?: () => void;\n}\n\nfunction useDeleteCategory({ categoryId, onSuccess }: UseDeleteCategoryParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation(() => categoryApi.delete(accessToken, categoryId), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.MY_CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n\n      openSnackBar(SUCCESS_MESSAGE.DELETE_CATEGORY);\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nfunction useGetAdminCategories({ enabled }: UseGetAdminCategoriesParams) {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, data } = useQuery<AxiosResponse<CategoryType[]>, AxiosError>(\n    CACHE_KEY.ADMIN_CATEGORIES,\n    () => categoryApi.getAdmin(accessToken),\n    {\n      enabled,\n    }\n  );\n\n  return { isLoading, data };\n}\n\nfunction useGetEditableCategories({ enabled }: UseGetEditableCategoriesParams) {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, data } = useQuery<AxiosResponse<CategoryType[]>, AxiosError>(\n    CACHE_KEY.EDITABLE_CATEGORIES,\n    () => categoryApi.getEditable(accessToken),\n    {\n      enabled,\n      suspense: true,\n    }\n  );\n\n  return { isLoading, data };\n}\n\nfunction useGetEntireCategories({ keyword }: UseGetEntireCategoriesParams) {\n  const { data } = useQuery<AxiosResponse<CategoryType[]>, AxiosError>(\n    [CACHE_KEY.CATEGORIES, keyword],\n    () => categoryApi.getEntire(keyword),\n    {\n      suspense: true,\n    }\n  );\n\n  return { data };\n}\n\nfunction useGetMyCategories() {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, data } = useQuery<AxiosResponse<CategoryType[]>, AxiosError>(\n    CACHE_KEY.MY_CATEGORIES,\n    () => categoryApi.getMy(accessToken)\n  );\n\n  return { isLoading, data };\n}\n\nfunction useGetSchedulesWithCategory({\n  categoryId,\n  startDateTime,\n  endDateTime,\n}: UseGetSchedulesWithCategoryParams) {\n  const { isLoading, data } = useQuery(\n    [CACHE_KEY.SCHEDULES, categoryId, startDateTime, endDateTime],\n    () => categoryApi.getSchedules(categoryId, startDateTime, endDateTime),\n    {\n      enabled: !!categoryId,\n    }\n  );\n\n  return { isLoading, data };\n}\n\nfunction useGetSingleCategory({ categoryId, ...options }: UseGetSingleCategoryParams) {\n  const { data } = useQuery<AxiosResponse<SingleCategoryType>, AxiosError>(\n    [CACHE_KEY.CATEGORY, categoryId],\n    () => categoryApi.getSingle(categoryId),\n    { ...options }\n  );\n\n  return { data };\n}\n\nfunction useGetSubscribers({ categoryId }: UseGetSubscribersParams) {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, data } = useQuery<AxiosResponse<CategorySubscriberType[]>, AxiosError>(\n    [CACHE_KEY.SUBSCRIBERS, categoryId],\n    () => categoryApi.getSubscribers(accessToken, categoryId)\n  );\n\n  return { isLoading, data };\n}\n\nfunction usePatchCategoryName({ categoryId, onSuccess }: UsePatchCategoryNameParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<\n    AxiosResponse<Pick<CategoryType, 'name'>>,\n    AxiosError,\n    Pick<CategoryType, 'name'>,\n    unknown\n  >((body) => categoryApi.patch(accessToken, categoryId, body), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.MY_CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n\n      openSnackBar(SUCCESS_MESSAGE.PATCH_CATEGORY_NAME);\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nfunction usePatchCategoryRole({ categoryId, memberId, onSuccess }: UsePatchCategoryRoleParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<\n    AxiosResponse<{ categoryRoleType: CategoryRoleType }>,\n    AxiosError,\n    { categoryRoleType: CategoryRoleType },\n    unknown\n  >((body) => categoryApi.patchRole(accessToken, categoryId, memberId, body), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIBERS);\n      queryClient.invalidateQueries(CACHE_KEY.EDITABLE_CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n\n      openSnackBar(SUCCESS_MESSAGE.PATCH_CATEGORY_ROLE);\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nfunction usePostCategory({ onSuccess }: UsePostCategoryParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<\n    AxiosResponse<CategoryType>,\n    AxiosError,\n    Pick<CategoryType, 'name' | 'categoryType'>,\n    unknown\n  >((body) => categoryApi.post(accessToken, body), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.MY_CATEGORIES);\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n      queryClient.invalidateQueries(CACHE_KEY.EDITABLE_CATEGORIES);\n\n      openSnackBar(SUCCESS_MESSAGE.POST_CATEGORY);\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nexport {\n  useDeleteCategory,\n  useGetAdminCategories,\n  useGetEditableCategories,\n  useGetEntireCategories,\n  useGetMyCategories,\n  useGetSchedulesWithCategory,\n  useGetSingleCategory,\n  useGetSubscribers,\n  usePatchCategoryName,\n  usePatchCategoryRole,\n  usePostCategory,\n};\n"
  },
  {
    "path": "frontend/src/hooks/@queries/googleCalendar.ts",
    "content": "import { AxiosError, AxiosResponse } from 'axios';\nimport { useMutation, useQuery, useQueryClient } from 'react-query';\nimport { useRecoilValue } from 'recoil';\n\nimport useSnackBar from '@/hooks/useSnackBar';\n\nimport { GoogleCalendarGetResponseType, GoogleCalendarPostBodyType } from '@/@types/googleCalendar';\n\nimport { userState } from '@/recoil/atoms';\n\nimport { CACHE_KEY } from '@/constants/api';\nimport { SUCCESS_MESSAGE } from '@/constants/message';\n\nimport googleCalendarApi from '@/api/googleCalendar';\n\ninterface UsePostGoogleCalendarCategoryParams {\n  onSuccess?: () => void;\n}\n\nfunction useGetGoogleCalendar() {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, data } = useQuery<AxiosResponse<GoogleCalendarGetResponseType>, AxiosError>(\n    CACHE_KEY.GOOGLE_CALENDAR,\n    () => googleCalendarApi.get(accessToken)\n  );\n\n  return { isLoading, data };\n}\n\nfunction usePostGoogleCalendarCategory({ onSuccess }: UsePostGoogleCalendarCategoryParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation(\n    (body: GoogleCalendarPostBodyType) => googleCalendarApi.post(accessToken, body),\n    {\n      onSuccess: () => {\n        queryClient.invalidateQueries(CACHE_KEY.CATEGORIES);\n        queryClient.invalidateQueries(CACHE_KEY.MY_CATEGORIES);\n        queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n        queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n        queryClient.invalidateQueries(CACHE_KEY.EDITABLE_CATEGORIES);\n\n        openSnackBar(SUCCESS_MESSAGE.POST_CATEGORY);\n        onSuccess && onSuccess();\n      },\n    }\n  );\n\n  return { mutate };\n}\n\nexport { useGetGoogleCalendar, usePostGoogleCalendarCategory };\n"
  },
  {
    "path": "frontend/src/hooks/@queries/login.ts",
    "content": "import { AxiosError, AxiosResponse } from 'axios';\nimport { useMutation, useQuery } from 'react-query';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilState, useSetRecoilState } from 'recoil';\n\nimport useSnackBar from '@/hooks/useSnackBar';\n\nimport { sideBarState, userState, UserStateType } from '@/recoil/atoms';\n\nimport { PATH } from '@/constants';\nimport { CACHE_KEY, RESPONSE } from '@/constants/api';\nimport { SUCCESS_MESSAGE } from '@/constants/message';\n\nimport {\n  removeAccessToken,\n  removeRefreshToken,\n  setAccessToken,\n  setRefreshToken,\n} from '@/utils/storage';\n\nimport loginApi from '@/api/login';\n\nfunction useAuth(code: string | null) {\n  const [user, setUser] = useRecoilState(userState);\n  const navigate = useNavigate();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<UserStateType, AxiosError>(() => loginApi.auth(code), {\n    retry: 0,\n    onError: () => onErrorAuth(),\n    onSuccess: ({ accessToken, refreshToken }) => {\n      onSuccessAuth(accessToken, refreshToken);\n      openSnackBar(SUCCESS_MESSAGE.POST_LOGIN);\n    },\n  });\n\n  const onErrorAuth = () => {\n    navigate(PATH.MAIN);\n  };\n\n  const onSuccessAuth = (accessToken: string, refreshToken: string) => {\n    setUser({ ...user, accessToken, refreshToken });\n    setAccessToken(accessToken);\n    setRefreshToken(refreshToken);\n\n    navigate(PATH.MAIN);\n  };\n\n  return {\n    mutate,\n  };\n}\n\nfunction useGetLoginUrl() {\n  const { error, refetch } = useQuery<string>(CACHE_KEY.ENTER, loginApi.getUrl, {\n    enabled: false,\n    onSuccess: (data) => onSuccessGetLoginUrl(data),\n  });\n\n  const onSuccessGetLoginUrl = (loginUrl: string) => {\n    location.href = loginUrl;\n  };\n\n  return {\n    error,\n    refetch,\n  };\n}\n\nfunction useLoginAgain() {\n  const [user, setUser] = useRecoilState(userState);\n  const { openSnackBar } = useSnackBar();\n\n  const navigate = useNavigate();\n\n  const { mutate } = useMutation<string, AxiosError>(() => loginApi.relogin(user.refreshToken), {\n    mutationKey: CACHE_KEY.LOGIN_AGAIN,\n    onSuccess: (data) => {\n      setAccessToken(data);\n      setUser({ ...user, accessToken: data });\n\n      openSnackBar(SUCCESS_MESSAGE.POST_LOGIN_AGAIN);\n    },\n    onError: () => {\n      removeAccessToken();\n      removeRefreshToken();\n      navigate(PATH.MAIN);\n      location.reload();\n    },\n  });\n\n  return {\n    mutate,\n  };\n}\n\nfunction useLoginValidate() {\n  const [user, setUser] = useRecoilState(userState);\n  const setSideBarOpen = useSetRecoilState(sideBarState);\n\n  const { mutate } = useLoginAgain();\n\n  const { isLoading, isSuccess } = useQuery<AxiosResponse, AxiosError>(\n    [CACHE_KEY.VALIDATE, user.accessToken],\n    () => loginApi.validate(user.accessToken),\n    {\n      onError: (error: unknown) => onErrorValidate(error),\n      retry: false,\n      useErrorBoundary: false,\n      enabled: !!user.accessToken,\n    }\n  );\n\n  const onErrorValidate = (error: unknown) => {\n    if (error instanceof AxiosError && error.response?.status === RESPONSE.STATUS.UNAUTHORIZED) {\n      mutate();\n\n      return;\n    }\n\n    setUser({ accessToken: '', refreshToken: '' });\n    setSideBarOpen(false);\n    removeAccessToken();\n    removeRefreshToken();\n  };\n\n  return {\n    isLoading,\n    isSuccess,\n  };\n}\n\nexport { useAuth, useGetLoginUrl, useLoginAgain, useLoginValidate };\n"
  },
  {
    "path": "frontend/src/hooks/@queries/profile.ts",
    "content": "import { useMutation, useQueryClient } from 'react-query';\nimport { useNavigate } from 'react-router-dom';\nimport { useRecoilValue } from 'recoil';\n\nimport useSnackBar from '@/hooks/useSnackBar';\n\nimport { userState } from '@/recoil/atoms';\n\nimport { PATH } from '@/constants';\nimport { CACHE_KEY } from '@/constants/api';\nimport { SUCCESS_MESSAGE } from '@/constants/message';\n\nimport { removeAccessToken, removeRefreshToken } from '@/utils/storage';\n\nimport profileApi from '@/api/profile';\n\ninterface UseDeleteProfileParams {\n  onSuccess?: () => void;\n}\n\ninterface UsePatchProfileParams {\n  accessToken: string;\n}\n\nfunction useDeleteProfile({ onSuccess }: UseDeleteProfileParams) {\n  const navigate = useNavigate();\n  const { accessToken } = useRecoilValue(userState);\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation(() => profileApi.delete(accessToken), {\n    onSuccess: () => {\n      onSuccess && onSuccess();\n      removeAccessToken();\n      removeRefreshToken();\n      navigate(PATH.MAIN);\n      location.reload();\n      openSnackBar(SUCCESS_MESSAGE.DELETE_PROFILE);\n    },\n  });\n\n  return { mutate };\n}\n\nfunction usePatchProfile({ accessToken }: UsePatchProfileParams) {\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation(\n    (body: { displayName: string }) => profileApi.patch(accessToken, body),\n    {\n      onSuccess: () => {\n        queryClient.invalidateQueries(CACHE_KEY.PROFILE);\n        queryClient.invalidateQueries(CACHE_KEY.CATEGORIES);\n\n        openSnackBar(SUCCESS_MESSAGE.PATCH_PROFILE_NAME);\n      },\n    }\n  );\n\n  return { mutate };\n}\n\nexport { useDeleteProfile, usePatchProfile };\n"
  },
  {
    "path": "frontend/src/hooks/@queries/schedule.ts",
    "content": "import { AxiosError, AxiosResponse } from 'axios';\nimport { useMutation, useQuery, useQueryClient } from 'react-query';\nimport { useRecoilValue } from 'recoil';\n\nimport useSnackBar from '@/hooks/useSnackBar';\n\nimport { ScheduleResponseType, ScheduleType } from '@/@types/schedule';\n\nimport { userState } from '@/recoil/atoms';\n\nimport { CACHE_KEY } from '@/constants/api';\nimport { SUCCESS_MESSAGE } from '@/constants/message';\n\nimport scheduleApi from '@/api/schedule';\n\ninterface UseDeleteScheduleParams {\n  scheduleId: string;\n  onSuccess?: () => void;\n}\n\ninterface UseGetSchedulesParams {\n  startDateTime: string;\n  endDateTime: string;\n}\n\ninterface UsePatchScheduleParams {\n  scheduleId: string;\n  onSuccess?: () => void;\n}\n\ninterface UsePostScheduleParams {\n  categoryId: string;\n  onSuccess?: () => void;\n}\n\nfunction useDeleteSchedule({ scheduleId, onSuccess }: UseDeleteScheduleParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<AxiosResponse, AxiosError>(\n    () => scheduleApi.delete(accessToken, scheduleId),\n    {\n      onSuccess: () => {\n        queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n\n        openSnackBar(SUCCESS_MESSAGE.DELETE_SCHEDULE);\n        onSuccess && onSuccess();\n      },\n    }\n  );\n\n  return { mutate };\n}\n\nfunction useGetSchedules({ startDateTime, endDateTime }: UseGetSchedulesParams) {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, data } = useQuery<AxiosResponse<ScheduleResponseType>, AxiosError>(\n    [CACHE_KEY.SCHEDULES, startDateTime, endDateTime],\n    () => scheduleApi.get(accessToken, startDateTime, endDateTime)\n  );\n\n  return {\n    isLoading,\n    data,\n  };\n}\n\nfunction usePatchSchedule({ scheduleId, onSuccess }: UsePatchScheduleParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<\n    AxiosResponse,\n    AxiosError,\n    Omit<ScheduleType, 'id' | 'categoryId' | 'colorCode' | 'categoryType'>,\n    unknown\n  >(CACHE_KEY.SCHEDULE, (body) => scheduleApi.patch(accessToken, scheduleId, body), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n\n      openSnackBar(SUCCESS_MESSAGE.PATCH_SCHEDULE);\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nfunction usePostSchedule({ categoryId, onSuccess }: UsePostScheduleParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n  const { openSnackBar } = useSnackBar();\n\n  const { mutate } = useMutation<\n    AxiosResponse<{ schedules: ScheduleType[] }>,\n    AxiosError,\n    Omit<ScheduleType, 'id' | 'categoryId' | 'colorCode' | 'categoryType'>,\n    unknown\n  >((body) => scheduleApi.post(accessToken, Number(categoryId), body), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n\n      openSnackBar(SUCCESS_MESSAGE.POST_SCHEDULE);\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nexport { useDeleteSchedule, useGetSchedules, usePatchSchedule, usePostSchedule };\n"
  },
  {
    "path": "frontend/src/hooks/@queries/subscription.ts",
    "content": "import { AxiosError, AxiosResponse } from 'axios';\nimport { useMutation, useQuery, useQueryClient } from 'react-query';\nimport { useRecoilValue } from 'recoil';\n\nimport { SubscriptionType } from '@/@types/subscription';\n\nimport { userState } from '@/recoil/atoms';\n\nimport { CACHE_KEY } from '@/constants/api';\n\nimport subscriptionApi from '@/api/subscription';\n\ninterface UseDeleteSubscriptionParams {\n  subscriptionId: number;\n  onSuccess?: () => void;\n}\n\ninterface UseGetSubscriptionsParams {\n  enabled?: boolean;\n}\n\ninterface UsePatchSubscriptionParams {\n  subscriptionId: number;\n  onSuccess?: () => void;\n}\n\ninterface UsePostSubscriptionParams {\n  categoryId: number;\n  onSuccess?: () => void;\n}\n\nfunction useDeleteSubscriptions({ subscriptionId, onSuccess }: UseDeleteSubscriptionParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n\n  const { mutate } = useMutation(() => subscriptionApi.delete(accessToken, subscriptionId), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n      queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n      queryClient.invalidateQueries(CACHE_KEY.CATEGORY);\n\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nfunction useGetSubscriptions({ enabled }: UseGetSubscriptionsParams) {\n  const { accessToken } = useRecoilValue(userState);\n\n  const { isLoading, error, data } = useQuery<AxiosResponse<SubscriptionType[]>, AxiosError>(\n    CACHE_KEY.SUBSCRIPTIONS,\n    () => subscriptionApi.get(accessToken),\n    {\n      enabled,\n      suspense: true,\n    }\n  );\n\n  return { isLoading, error, data };\n}\n\nfunction usePatchSubscription({ subscriptionId, onSuccess }: UsePatchSubscriptionParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n\n  const { isLoading, mutate } = useMutation(\n    (body: Pick<SubscriptionType, 'colorCode'> | Pick<SubscriptionType, 'checked'>) =>\n      subscriptionApi.patch(accessToken, subscriptionId, body),\n    {\n      onSuccess: async () => {\n        await queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n        await queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n\n        onSuccess && onSuccess();\n      },\n    }\n  );\n\n  return { isLoading, mutate };\n}\n\nfunction usePostSubscription({ categoryId, onSuccess }: UsePostSubscriptionParams) {\n  const { accessToken } = useRecoilValue(userState);\n  const queryClient = useQueryClient();\n\n  const { mutate } = useMutation<\n    AxiosResponse<Pick<SubscriptionType, 'colorCode'>>,\n    AxiosError,\n    Pick<SubscriptionType, 'colorCode'>,\n    unknown\n  >((body) => subscriptionApi.post(accessToken, categoryId, body), {\n    onSuccess: () => {\n      queryClient.invalidateQueries(CACHE_KEY.SUBSCRIPTIONS);\n      queryClient.invalidateQueries(CACHE_KEY.SCHEDULES);\n      queryClient.invalidateQueries([CACHE_KEY.CATEGORY, categoryId]);\n\n      onSuccess && onSuccess();\n    },\n  });\n\n  return { mutate };\n}\n\nexport { useDeleteSubscriptions, useGetSubscriptions, usePatchSubscription, usePostSubscription };\n"
  },
  {
    "path": "frontend/src/hooks/useCalendar.ts",
    "content": "import { useLayoutEffect, useRef, useState } from 'react';\n\nimport { SCHEDULE } from '@/constants/style';\n\nimport {\n  extractDateTime,\n  getCurrentCalendar,\n  getMonthOffsetDateTime,\n  getToday,\n} from '@/utils/date';\n\nimport useRootFontSize from './useRootFontSize';\n\ninterface CalendarControllerType {\n  calendar: string[];\n  currentDateTime: string;\n  currentDay: number;\n  currentMonth: number;\n  currentYear: number;\n  dateCellRef: React.RefObject<HTMLDivElement>;\n  endDateTime: string;\n  maxScheduleCount: number;\n  moveToBeforeMonth: () => void;\n  moveToNextMonth: () => void;\n  moveToToday: () => void;\n  rowCount: number;\n  startDateTime: string;\n}\n\nfunction useCalendar() {\n  const dateCellRef = useRef<HTMLDivElement>(null);\n\n  const [currentDateTime, setCurrentDateTime] = useState(getToday());\n  const [calendar, setCalendar] = useState(getCurrentCalendar(currentDateTime));\n  const [maxScheduleCount, setMaxScheduleCount] = useState(0);\n\n  const rootFontSize = useRootFontSize();\n\n  const startDateTime = calendar[0];\n  const endDateTime = calendar[calendar.length - 1];\n\n  useLayoutEffect(() => {\n    if (!(dateCellRef.current instanceof HTMLDivElement)) return;\n\n    setMaxScheduleCount(\n      Math.floor(\n        (Math.floor(dateCellRef.current.clientHeight / 10) * 10 - SCHEDULE.HEIGHT * rootFontSize) /\n          (SCHEDULE.HEIGHT_WITH_MARGIN * rootFontSize)\n      )\n    );\n  }, [startDateTime, rootFontSize]);\n\n  const {\n    year: currentYear,\n    month: currentMonth,\n    day: currentDay,\n  } = extractDateTime(currentDateTime);\n\n  const rowCount = Math.ceil(calendar.length / 7);\n\n  const moveToBeforeMonth = () => {\n    const beforeMonthDateTime = getMonthOffsetDateTime(currentDateTime, -1);\n\n    setCurrentDateTime(beforeMonthDateTime);\n    setCalendar(getCurrentCalendar(beforeMonthDateTime));\n  };\n\n  const moveToToday = () => {\n    const today = getToday();\n\n    setCurrentDateTime(today);\n    setCalendar(getCurrentCalendar(today));\n  };\n\n  const moveToNextMonth = () => {\n    const afterMonthDateTime = getMonthOffsetDateTime(currentDateTime, 1);\n\n    setCurrentDateTime(afterMonthDateTime);\n    setCalendar(getCurrentCalendar(afterMonthDateTime));\n  };\n\n  return {\n    calendar,\n    currentDateTime,\n    currentDay,\n    currentMonth,\n    currentYear,\n    dateCellRef,\n    endDateTime,\n    maxScheduleCount,\n    moveToBeforeMonth,\n    moveToNextMonth,\n    moveToToday,\n    rowCount,\n    startDateTime,\n  };\n}\n\nexport default useCalendar;\n\nexport { CalendarControllerType };\n"
  },
  {
    "path": "frontend/src/hooks/useControlledInput.ts",
    "content": "import { useEffect, useState } from 'react';\n\nfunction useControlledInput(initialInputValue?: string) {\n  const [inputValue, setInputValue] = useState(initialInputValue ?? '');\n\n  const onChangeValue = ({\n    target,\n  }: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLSelectElement>) => {\n    if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement) {\n      setInputValue(target.value);\n    }\n  };\n\n  useEffect(() => {\n    setInputValue(initialInputValue ?? '');\n  }, [initialInputValue]);\n\n  return { inputValue, setInputValue, onChangeValue };\n}\n\nexport default useControlledInput;\n"
  },
  {
    "path": "frontend/src/hooks/useHoverCategoryItem.ts",
    "content": "import { useState } from 'react';\n\nimport { EVENT } from '@/constants';\n\nfunction useHoverCategoryItem() {\n  const [hoveringPosY, setHoveringPosY] = useState<number | null>(null);\n\n  const handleHoverCategoryItem = (e: React.MouseEvent) => {\n    if (e.type === EVENT.MOUSE_ENTER) {\n      setHoveringPosY(e.clientY);\n\n      return;\n    }\n\n    setHoveringPosY(null);\n  };\n\n  return { hoveringPosY, handleHoverCategoryItem };\n}\n\nexport default useHoverCategoryItem;\n"
  },
  {
    "path": "frontend/src/hooks/useIntersect.ts",
    "content": "import { useCallback, useEffect, useRef } from 'react';\n\ntype IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;\n\nfunction useIntersect(onIntersect: IntersectHandler, options?: IntersectionObserverInit) {\n  const ref = useRef<HTMLDivElement>(null);\n  const callback = useCallback(\n    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {\n      entries.forEach((entry) => {\n        if (entry.isIntersecting) {\n          onIntersect(entry, observer);\n        }\n      });\n    },\n    [onIntersect]\n  );\n\n  useEffect(() => {\n    if (!ref.current) {\n      return;\n    }\n\n    const observer = new IntersectionObserver(callback, options);\n\n    observer.observe(ref.current);\n\n    return () => {\n      observer.disconnect();\n    };\n  }, [ref, options, callback]);\n\n  return ref;\n}\n\nexport default useIntersect;\n"
  },
  {
    "path": "frontend/src/hooks/useModalPosition.ts",
    "content": "import { useState } from 'react';\n\nimport { ModalPosType } from '@/@types';\n\nimport useToggle from './useToggle';\n\nfunction useModalPosition() {\n  const [modalPos, setModalPos] = useState<ModalPosType>({});\n\n  const { state: isModalOpen, toggleState: toggleModalOpen } = useToggle();\n\n  const handleClickOpen = (e: React.MouseEvent, callback?: () => void) => {\n    if (e.target !== e.currentTarget) {\n      return;\n    }\n\n    setModalPos(calculateModalPos(e.clientX, e.clientY));\n    callback && callback();\n    toggleModalOpen();\n  };\n\n  const calculateModalPos = (clickX: number, clickY: number) => {\n    const position = { top: clickY, right: 0, bottom: 0, left: clickX };\n\n    if (clickX > innerWidth / 2) {\n      position.right = innerWidth - clickX;\n      position.left = 0;\n    }\n\n    if (clickY > innerHeight / 2) {\n      position.bottom = innerHeight - clickY;\n      position.top = 0;\n    }\n\n    return position;\n  };\n\n  return { isModalOpen, toggleModalOpen, handleClickOpen, modalPos };\n}\n\nexport default useModalPosition;\n"
  },
  {
    "path": "frontend/src/hooks/useRootFontSize.ts",
    "content": "import { useEffect, useState } from 'react';\n\nimport { RESPONSIVE } from '@/constants/style';\n\nimport { debounce } from '@/utils';\n\nfunction useRootFontSize() {\n  const getRootFontSize = () => {\n    if (innerWidth >= RESPONSIVE.LAPTOP.MIN_WIDTH) return RESPONSIVE.LAPTOP.FONT_SIZE;\n\n    if (innerWidth > RESPONSIVE.MOBILE.MAX_WIDTH) return RESPONSIVE.TABLET.FONT_SIZE;\n\n    return RESPONSIVE.MOBILE.FONT_SIZE;\n  };\n\n  const [rootFontSize, setRootFontSize] = useState(getRootFontSize());\n\n  useEffect(() => {\n    const handleResizeWindow = debounce(() => setRootFontSize(getRootFontSize()));\n\n    window.addEventListener('resize', handleResizeWindow);\n\n    return () => window.removeEventListener('resize', handleResizeWindow);\n  }, []);\n\n  return rootFontSize;\n}\n\nexport default useRootFontSize;\n"
  },
  {
    "path": "frontend/src/hooks/useSnackBar.ts",
    "content": "import { useSetRecoilState } from 'recoil';\n\nimport { snackBarState } from '@/recoil/atoms';\n\nfunction useSnackBar() {\n  const setText = useSetRecoilState(snackBarState);\n\n  const openSnackBar = (text: string) => {\n    setText({ text });\n  };\n\n  return { openSnackBar };\n}\n\nexport default useSnackBar;\n"
  },
  {
    "path": "frontend/src/hooks/useToggle.ts",
    "content": "import { useState } from 'react';\n\nfunction useToggle(initialState = false) {\n  const [state, setState] = useState(initialState);\n\n  const toggleState = () => {\n    setState((prev) => !prev);\n  };\n\n  return { state, toggleState };\n}\n\nexport default useToggle;\n"
  },
  {
    "path": "frontend/src/hooks/useUserValue.ts",
    "content": "import { AxiosError, AxiosResponse } from 'axios';\nimport { useQuery } from 'react-query';\nimport { useRecoilState, useSetRecoilState } from 'recoil';\n\nimport { useLoginValidate } from '@/hooks/@queries/login';\n\nimport { ProfileType } from '@/@types/profile';\n\nimport { sideBarState, userState } from '@/recoil/atoms';\n\nimport { CACHE_KEY } from '@/constants/api';\n\nimport { removeAccessToken, removeRefreshToken } from '@/utils/storage';\n\nimport profileApi from '@/api/profile';\n\nfunction useUserValue() {\n  const [user, setUser] = useRecoilState(userState);\n  const setSideBarOpen = useSetRecoilState(sideBarState);\n\n  const { isLoading, isSuccess } = useLoginValidate();\n\n  useQuery<AxiosResponse<ProfileType>, AxiosError>(\n    CACHE_KEY.PROFILE,\n    () => profileApi.get(user.accessToken),\n    {\n      onError: () => onErrorValidate(),\n      onSuccess: ({ data }) => setUser({ ...user, ...data }),\n      retry: false,\n      useErrorBoundary: false,\n      enabled: isSuccess,\n      staleTime: 1 * 60 * 1000,\n    }\n  );\n\n  const onErrorValidate = () => {\n    setUser({ accessToken: '', refreshToken: '' });\n    setSideBarOpen(false);\n    removeAccessToken();\n    removeRefreshToken();\n  };\n\n  return { isAuthenticating: isLoading, user };\n}\n\nexport default useUserValue;\n"
  },
  {
    "path": "frontend/src/hooks/useValidateCategory.ts",
    "content": "import { validateLength, validateNotEqualString } from '@/validation';\n\nimport { VALIDATION_MESSAGE, VALIDATION_SIZE, VALIDATION_STRING } from '@/constants/validate';\n\nimport useControlledInput from './useControlledInput';\n\nfunction useValidateCategory(initialCategory?: string) {\n  const categoryValue = useControlledInput(initialCategory);\n\n  const getCategoryErrorMessage = () => {\n    if (\n      !validateLength(\n        categoryValue.inputValue,\n        VALIDATION_SIZE.MIN_LENGTH,\n        VALIDATION_SIZE.CATEGORY_NAME_MAX_LENGTH\n      )\n    ) {\n      return VALIDATION_MESSAGE.STRING_LENGTH(\n        VALIDATION_SIZE.MIN_LENGTH,\n        VALIDATION_SIZE.CATEGORY_NAME_MAX_LENGTH\n      );\n    }\n\n    if (!validateNotEqualString(categoryValue.inputValue, VALIDATION_STRING.CATEGORY)) {\n      return VALIDATION_MESSAGE.INVALID_CATEGORY_NAME;\n    }\n\n    return undefined;\n  };\n\n  const isValidCategory =\n    validateLength(\n      categoryValue.inputValue,\n      VALIDATION_SIZE.MIN_LENGTH,\n      VALIDATION_SIZE.CATEGORY_NAME_MAX_LENGTH\n    ) && validateNotEqualString(categoryValue.inputValue, VALIDATION_STRING.CATEGORY);\n\n  return { categoryValue, getCategoryErrorMessage, isValidCategory };\n}\n\nexport default useValidateCategory;\n"
  },
  {
    "path": "frontend/src/hooks/useValidateSchedule.ts",
    "content": "import { validateLength, validateNotEmpty, validateStartEndDateTime } from '@/validation';\nimport { useEffect } from 'react';\n\nimport { DATE_TIME } from '@/constants/date';\nimport { VALIDATION_SIZE } from '@/constants/validate';\n\nimport { getDayOffsetDateTime, getEndTime, getISODateString } from '@/utils/date';\n\nimport useControlledInput from './useControlledInput';\n\ninterface useValidateScheduleParametersType {\n  initialTitle?: string;\n  initialStartDate?: string;\n  initialStartTime?: string;\n  initialEndDate?: string;\n  initialEndTime?: string;\n  initialMemo?: string;\n}\n\nfunction useValidateSchedule({\n  initialTitle,\n  initialStartDate,\n  initialStartTime,\n  initialEndDate,\n  initialEndTime,\n  initialMemo,\n}: useValidateScheduleParametersType) {\n  const title = useControlledInput(initialTitle);\n  const startDate = useControlledInput(initialStartDate);\n  const startTime = useControlledInput(initialStartTime || DATE_TIME.START);\n  const endDate = useControlledInput(initialEndDate);\n  const endTime = useControlledInput(initialEndTime || DATE_TIME.END);\n  const memo = useControlledInput(initialMemo);\n\n  const isValidSchedule =\n    validateLength(\n      title.inputValue,\n      VALIDATION_SIZE.MIN_LENGTH,\n      VALIDATION_SIZE.SCHEDULE_TITLE_MAX_LENGTH\n    ) &&\n    validateStartEndDateTime(\n      `${startDate.inputValue}T${startTime}`,\n      `${endDate.inputValue}T${endTime}`\n    ) &&\n    validateLength(memo.inputValue, 0, VALIDATION_SIZE.SCHEDULE_MEMO_MAX_LENGTH) &&\n    validateNotEmpty(startDate.inputValue) &&\n    validateNotEmpty(endDate.inputValue);\n\n  useEffect(() => {\n    if (startDate.inputValue + startTime.inputValue <= endDate.inputValue + endTime.inputValue)\n      return;\n\n    if (startDate.inputValue > endDate.inputValue) {\n      endDate.setInputValue(startDate.inputValue);\n      endTime.setInputValue(startTime.inputValue);\n\n      return;\n    }\n\n    if (startTime.inputValue >= '23:00') {\n      const nextDate = getISODateString(getDayOffsetDateTime(startDate.inputValue, 1));\n\n      endDate.setInputValue(nextDate);\n    }\n\n    endTime.setInputValue(getEndTime(startTime.inputValue));\n  }, [startDate, startTime]);\n\n  return {\n    title,\n    startDate,\n    startTime,\n    endDate,\n    endTime,\n    memo,\n    isValidSchedule,\n  };\n}\n\nexport default useValidateSchedule;\n"
  },
  {
    "path": "frontend/src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"ko\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <link\n      rel=\"stylesheet\"\n      as=\"style\"\n      crossorigin\n      href=\"https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.5/dist/web/static/pretendard-dynamic-subset.css\"\n    />\n    <title>달록</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <div id=\"modal\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "frontend/src/index.tsx",
    "content": "import { ThemeProvider } from '@emotion/react';\nimport { createRoot } from 'react-dom/client';\nimport { QueryClient, QueryClientProvider } from 'react-query';\nimport { ReactQueryDevtools } from 'react-query/devtools';\nimport { BrowserRouter as Router } from 'react-router-dom';\nimport { RecoilRoot } from 'recoil';\n\nimport GlobalStyle from '@/styles/GlobalStyle';\nimport theme from '@/styles/theme';\n\nimport ErrorBoundary from '@/components/@common/ErrorBoundary/ErrorBoundary';\n\nimport App from './App';\n\nconst root = document.getElementById('root') as HTMLElement;\n\nconst rootElement = createRoot(root);\n\nconst queryClient = new QueryClient();\n\nrootElement.render(\n  <RecoilRoot>\n    <ThemeProvider theme={theme}>\n      <GlobalStyle />\n      <Router>\n        <ErrorBoundary>\n          <QueryClientProvider client={queryClient}>\n            <App />\n            <ReactQueryDevtools initialIsOpen={false} />\n          </QueryClientProvider>\n        </ErrorBoundary>\n      </Router>\n    </ThemeProvider>\n  </RecoilRoot>\n);\n"
  },
  {
    "path": "frontend/src/pages/AuthPage/AuthPage.tsx",
    "content": "import { useEffect } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport { useAuth } from '@/hooks/@queries/login';\n\nimport { PATH } from '@/constants';\nimport { API } from '@/constants/api';\n\nimport { getSearchParam } from '@/utils';\n\nfunction AuthPage() {\n  const navigate = useNavigate();\n\n  const code = getSearchParam(API.AUTH_CODE_KEY);\n\n  const { mutate } = useAuth(code);\n\n  useEffect(() => {\n    code && mutate();\n    !code && navigate(PATH.MAIN);\n  }, []);\n\n  return <div></div>;\n}\n\nexport default AuthPage;\n"
  },
  {
    "path": "frontend/src/pages/CalendarPage/CalendarPage.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst calendarPageStyle = ({ mq }: Theme) => css`\n  padding: 0 5rem 5rem;\n\n  ${mq?.mobile} {\n    padding: 0 2rem;\n  }\n`;\n\nexport { calendarPageStyle };\n"
  },
  {
    "path": "frontend/src/pages/CalendarPage/CalendarPage.tsx",
    "content": "import { useState } from 'react';\n\nimport { useGetSchedules } from '@/hooks/@queries/schedule';\nimport useCalendar from '@/hooks/useCalendar';\nimport useToggle from '@/hooks/useToggle';\n\nimport ModalPortal from '@/components/@common/ModalPortal/ModalPortal';\nimport PageLayout from '@/components/@common/PageLayout/PageLayout';\nimport Calendar from '@/components/Calendar/Calendar';\nimport CalendarFallback from '@/components/Calendar/Calendar.fallback';\nimport ScheduleAddButton from '@/components/ScheduleAddButton/ScheduleAddButton';\nimport ScheduleAddModal from '@/components/ScheduleAddModal/ScheduleAddModal';\n\nimport { PAGE_LAYOUT } from '@/constants/style';\n\nimport { getToday } from '@/utils/date';\n\nimport { calendarPageStyle } from './CalendarPage.styles';\n\nfunction CalendarPage() {\n  const [dateInfo, setDateInfo] = useState('');\n\n  const calendarController = useCalendar();\n  const { startDateTime, endDateTime } = calendarController;\n\n  const { state: isScheduleAddModalOpen, toggleState: toggleScheduleAddModalOpen } = useToggle();\n\n  const { isLoading, data } = useGetSchedules({ startDateTime, endDateTime });\n\n  const handleClickScheduleAddButton = () => {\n    setDateInfo(getToday());\n    toggleScheduleAddModalOpen();\n  };\n\n  return (\n    <PageLayout type={PAGE_LAYOUT.SIDEBAR}>\n      <div css={calendarPageStyle}>\n        {isLoading && (\n          <CalendarFallback\n            calendarController={calendarController}\n            setDateInfo={setDateInfo}\n            handleClickDateCell={toggleScheduleAddModalOpen}\n          />\n        )}\n        {data && (\n          <Calendar\n            calendarController={calendarController}\n            scheduleResponse={data}\n            setDateInfo={setDateInfo}\n            handleClickDateCell={toggleScheduleAddModalOpen}\n          />\n        )}\n        {dateInfo && (\n          <ModalPortal isOpen={isScheduleAddModalOpen} closeModal={toggleScheduleAddModalOpen}>\n            <ScheduleAddModal dateInfo={dateInfo} closeModal={toggleScheduleAddModalOpen} />\n          </ModalPortal>\n        )}\n        <ScheduleAddButton onClick={handleClickScheduleAddButton} />\n      </div>\n    </PageLayout>\n  );\n}\n\nexport default CalendarPage;\n"
  },
  {
    "path": "frontend/src/pages/CategoryPage/CategoryPage.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst categoryPageStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  justify-content: space-around;\n  align-items: flex-start;\n\n  height: 100%;\n`;\n\nconst calendarStyle = ({ mq }: Theme) => css`\n  position: relative;\n\n  width: 65%;\n\n  ${mq?.tablet || mq?.mobile} {\n    display: none;\n  }\n`;\n\nconst hintStyle = ({ colors }: Theme) => css`\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%);\n  z-index: 10;\n\n  padding: 4rem 6rem;\n  border-radius: 7px;\n\n  background: ${colors.ORANGE_500};\n\n  font-size: 4rem;\n  font-weight: 500;\n  color: ${colors.WHITE};\n`;\n\nexport { calendarStyle, categoryPageStyle, hintStyle };\n"
  },
  {
    "path": "frontend/src/pages/CategoryPage/CategoryPage.tsx",
    "content": "import { useState } from 'react';\n\nimport { useGetSchedulesWithCategory } from '@/hooks/@queries/category';\nimport useCalendar from '@/hooks/useCalendar';\n\nimport { CategoryType } from '@/@types/category';\n\nimport PageLayout from '@/components/@common/PageLayout/PageLayout';\nimport Calendar from '@/components/Calendar/Calendar';\nimport CalendarFallback from '@/components/Calendar/Calendar.fallback';\nimport CategoryControl from '@/components/CategoryControl/CategoryControl';\n\nimport { PAGE_LAYOUT } from '@/constants/style';\n\nimport { calendarStyle, categoryPageStyle, hintStyle } from './CategoryPage.styles';\n\nfunction CategoryPage() {\n  const [category, setCategory] = useState<Pick<CategoryType, 'id' | 'name'>>({ id: 0, name: '' });\n\n  const calendarController = useCalendar();\n  const { startDateTime, endDateTime } = calendarController;\n\n  const { isLoading, data } = useGetSchedulesWithCategory({\n    categoryId: category.id,\n    startDateTime,\n    endDateTime,\n  });\n\n  if (category.id === 0) {\n    return (\n      <PageLayout type={PAGE_LAYOUT.SIDEBAR}>\n        <div css={categoryPageStyle}>\n          <CategoryControl setCategory={setCategory} />\n          <div css={calendarStyle}>\n            <div css={hintStyle}>클릭한 카테고리의 일정을 확인할 수 있어요</div>\n            <CalendarFallback calendarController={calendarController} isLoading={false} readonly />\n          </div>\n        </div>\n      </PageLayout>\n    );\n  }\n\n  return (\n    <PageLayout type={PAGE_LAYOUT.SIDEBAR}>\n      <div css={categoryPageStyle}>\n        <CategoryControl setCategory={setCategory} />\n        <div css={calendarStyle}>\n          {isLoading && (\n            <CalendarFallback\n              calendarController={calendarController}\n              categoryName={category.name}\n              readonly\n            />\n          )}\n          {data && (\n            <Calendar\n              calendarController={calendarController}\n              scheduleResponse={data}\n              categoryName={category.name}\n              readonly\n            />\n          )}\n        </div>\n      </div>\n    </PageLayout>\n  );\n}\n\nexport default CategoryPage;\n"
  },
  {
    "path": "frontend/src/pages/ErrorPage/ErrorPage.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst layoutStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n  justify-content: center;\n  align-items: center;\n  gap: 15rem;\n\n  height: 100vh;\n`;\n\nconst buttonStyle = ({ colors }: Theme) => css`\n  padding: 3rem 6rem;\n  border: 1px solid ${colors.GRAY_800};\n  border-radius: 7px;\n  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25);\n\n  font-size: 5rem;\n  color: ${colors.GRAY_800};\n\n  &:hover {\n    box-shadow: none;\n  }\n`;\n\nconst textStyle = ({ colors }: Theme, fontSize: string) => css`\n  font-size: ${fontSize};\n  font-weight: 400;\n  text-align: center;\n  line-height: 150%;\n  color: ${colors.GRAY_800};\n`;\n\nexport { buttonStyle, layoutStyle, textStyle };\n"
  },
  {
    "path": "frontend/src/pages/ErrorPage/ErrorPage.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useNavigate } from 'react-router-dom';\n\nimport Button from '@/components/@common/Button/Button';\n\nimport { PATH } from '@/constants';\nimport { ERROR_MESSAGE } from '@/constants/message';\n\nimport { buttonStyle, layoutStyle, textStyle } from './ErrorPage.styles';\n\nfunction ErrorPage() {\n  const theme = useTheme();\n\n  const navigation = useNavigate();\n\n  const handleClickReturnButton = () => {\n    navigation(PATH.MAIN);\n    location.reload();\n  };\n\n  return (\n    <div css={layoutStyle}>\n      <span css={textStyle(theme, '40rem')}>((⊙_⊙);)</span>\n      <span css={textStyle(theme, '10rem')}>{ERROR_MESSAGE.DEFAULT}</span>\n\n      <Button cssProp={buttonStyle(theme)} onClick={handleClickReturnButton}>\n        달록 홈페이지로 돌아가기\n      </Button>\n    </div>\n  );\n}\n\nexport default ErrorPage;\n"
  },
  {
    "path": "frontend/src/pages/NotFoundPage/NotFoundPage.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst layoutStyle = ({ flex }: Theme) => css`\n  ${flex.column};\n\n  gap: 15rem;\n\n  height: calc(100% - 16rem);\n`;\n\nconst buttonStyle = ({ colors }: Theme) => css`\n  padding: 3rem 6rem;\n  border: 1px solid ${colors.GRAY_800};\n  border-radius: 7px;\n  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25);\n\n  font-size: 5rem;\n  color: ${colors.GRAY_800};\n\n  &:hover {\n    box-shadow: none;\n  }\n`;\n\nconst textStyle = ({ colors }: Theme, fontSize: string) => css`\n  font-size: ${fontSize};\n  font-weight: 400;\n  text-align: center;\n  line-height: 150%;\n  color: ${colors.GRAY_800};\n`;\n\nexport { buttonStyle, layoutStyle, textStyle };\n"
  },
  {
    "path": "frontend/src/pages/NotFoundPage/NotFoundPage.tsx",
    "content": "import { useTheme } from '@emotion/react';\nimport { useEffect } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useSetRecoilState } from 'recoil';\n\nimport { sideBarSelector } from '@/recoil/selectors';\n\nimport Button from '@/components/@common/Button/Button';\nimport PageLayout from '@/components/@common/PageLayout/PageLayout';\n\nimport { PATH } from '@/constants';\n\nimport { buttonStyle, layoutStyle, textStyle } from './NotFoundPage.styles';\n\nfunction NotFoundPage() {\n  const theme = useTheme();\n\n  const toggleSideBarOpen = useSetRecoilState(sideBarSelector);\n\n  const navigation = useNavigate();\n\n  const handleClickReturnButton = () => {\n    navigation(PATH.MAIN);\n  };\n\n  useEffect(() => {\n    toggleSideBarOpen(false);\n  }, []);\n\n  return (\n    <PageLayout>\n      <div css={layoutStyle}>\n        <span css={textStyle(theme, '40rem')}>(⊙_⊙?)</span>\n        <span css={textStyle(theme, '10rem')}>\n          죄송합니다. <br /> 요청하신 페이지를 찾을 수가 없어요.\n        </span>\n\n        <Button cssProp={buttonStyle(theme)} onClick={handleClickReturnButton}>\n          달록 홈페이지로 돌아가기\n        </Button>\n      </div>\n    </PageLayout>\n  );\n}\n\nexport default NotFoundPage;\n"
  },
  {
    "path": "frontend/src/pages/PrivacyPolicyPage/PrivacyPolicyPage.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst privacyPolicyStyle = ({ flex }: Theme) => css`\n  ${flex.column}\n\n  align-items: flex-start;\n\n  margin: 5% 10%;\n`;\n\nconst headerStyle = css`\n  margin-bottom: 10rem;\n\n  font-size: 5rem;\n  font-weight: bold;\n`;\n\nconst contentStyle = css`\n  white-space: pre-wrap;\n`;\n\nexport { contentStyle, headerStyle, privacyPolicyStyle };\n"
  },
  {
    "path": "frontend/src/pages/PrivacyPolicyPage/PrivacyPolicyPage.tsx",
    "content": "import PageLayout from '@/components/@common/PageLayout/PageLayout';\n\nimport { contentStyle, headerStyle, privacyPolicyStyle } from './PrivacyPolicyPage.styles';\n\nfunction PrivacyPolicyPage() {\n  return (\n    <PageLayout>\n      <div css={privacyPolicyStyle}>\n        <h1 css={headerStyle}>개인정보처리방침</h1>\n        <p css={contentStyle}>\n          {`\n< dallog >('https://dallog.me'이하 'dallog')은(는) 「개인정보 보호법」 제30조에 따라 정보주체의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다.\n\n○ 이 개인정보처리방침은 2022년 8월 17부터 적용됩니다.\n\n\n제1조(개인정보의 처리 목적)\n\n< dallog >('https://dallog.me'이하 'dallog')은(는) 다음의 목적을 위하여 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.\n\n1. 홈페이지 회원가입 및 관리\n\n회원자격 유지·관리 목적으로 개인정보를 처리합니다.\n\n\n2. 재화 또는 서비스 제공\n\n서비스 제공을 목적으로 개인정보를 처리합니다.\n\n\n\n\n제2조(개인정보의 처리 및 보유 기간)\n\n① < dallog >은(는) 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.\n\n② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다.\n\n1.<홈페이지 회원가입 및 관리>\n<홈페이지 회원가입 및 관리>와 관련한 개인정보는 수집.이용에 관한 동의일로부터<회원탈퇴 시 까지>까지 위 이용목적을 위하여 보유.이용됩니다.\n보유근거 : 회원 가입의사 확인, 회원자격 유지․관리, 서비스 부정이용 방지 목적으로 개인정보를 처리합니다.\n관련법령 :\n예외사유 :\n\n\n제3조(처리하는 개인정보의 항목)\n\n① < dallog >은(는) 다음의 개인정보 항목을 처리하고 있습니다.\n\n1< 홈페이지 회원가입 및 관리 >\n필수항목 : 이메일, 유저네임, 프로필이미지URL\n선택항목 :\n\n\n제4조(개인정보의 파기절차 및 파기방법)\n\n\n① < dallog > 은(는) 개인정보 보유기간의 경과, 처리목적 달성 등 개인정보가 불필요하게 되었을 때에는 지체없이 해당 개인정보를 파기합니다.\n\n② 정보주체로부터 동의받은 개인정보 보유기간이 경과하거나 처리목적이 달성되었음에도 불구하고 다른 법령에 따라 개인정보를 계속 보존하여야 하는 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관장소를 달리하여 보존합니다.\n\n③ 개인정보 파기의 절차 및 방법은 다음과 같습니다.\n1. 파기절차\n< dallog > 은(는) 파기 사유가 발생한 개인정보를 선정하고, < dallog > 의 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.\n\n\n\n제5조(정보주체와 법정대리인의 권리·의무 및 그 행사방법에 관한 사항)\n\n\n\n① 정보주체는 dallog에 대해 언제든지 개인정보 열람·정정·삭제·처리정지 요구 등의 권리를 행사할 수 있습니다.\n\n② 제1항에 따른 권리 행사는 dallog에 대해 「개인정보 보호법」 시행령 제41조제1항에 따라 서면, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 dallog은(는) 이에 대해 지체 없이 조치하겠습니다.\n\n③ 제1항에 따른 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자 등 대리인을 통하여 하실 수 있습니다.이 경우 “개인정보 처리 방법에 관한 고시(제2020-7호)” 별지 제11호 서식에 따른 위임장을 제출하셔야 합니다.\n\n④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 의하여 정보주체의 권리가 제한 될 수 있습니다.\n\n⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에서 그 개인정보가 수집 대상으로 명시되어 있는 경우에는 그 삭제를 요구할 수 없습니다.\n\n⑥ dallog은(는) 정보주체 권리에 따른 열람의 요구, 정정·삭제의 요구, 처리정지의 요구 시 열람 등 요구를 한 자가 본인이거나 정당한 대리인인지를 확인합니다.\n\n\n\n제6조(개인정보의 안전성 확보조치에 관한 사항)\n\n< dallog >은(는) 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다.\n\n1. 해킹 등에 대비한 기술적 대책\n<dallog>('dallog')은 해킹이나 컴퓨터 바이러스 등에 의한 개인정보 유출 및 훼손을 막기 위하여 보안프로그램을 설치하고 주기적인 갱신·점검을 하며 외부로부터 접근이 통제된 구역에 시스템을 설치하고 기술적/물리적으로 감시 및 차단하고 있습니다.\n\n2. 개인정보에 대한 접근 제한\n개인정보를 처리하는 데이터베이스시스템에 대한 접근권한의 부여,변경,말소를 통하여 개인정보에 대한 접근통제를 위하여 필요한 조치를 하고 있으며 침입차단시스템을 이용하여 외부로부터의 무단 접근을 통제하고 있습니다.\n\n\n\n\n제7조(개인정보를 자동으로 수집하는 장치의 설치·운영 및 그 거부에 관한 사항)\n\n\n\ndallog 은(는) 정보주체의 이용정보를 저장하고 수시로 불러오는 ‘쿠키(cookie)’를 사용하지 않습니다.\n\n제8조 (개인정보 보호책임자에 관한 사항)\n\n① dallog 은(는) 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.\n\n▶ 개인정보 보호책임자\n성명 :구동희\n직책 :팀원\n직급 :팀원\n연락처 :010-3160-2953, gudonghee2000@gmail.com,\n※ 개인정보 보호 담당부서로 연결됩니다.\n\n▶ 개인정보 보호 담당부서\n부서명 :\n담당자 :\n연락처 :, ,\n② 정보주체께서는 dallog 의 서비스(또는 사업)을 이용하시면서 발생한 모든 개인정보 보호 관련 문의, 불만처리, 피해구제 등에 관한 사항을 개인정보 보호책임자 및 담당부서로 문의하실 수 있습니다. dallog 은(는) 정보주체의 문의에 대해 지체 없이 답변 및 처리해드릴 것입니다.\n\n제9조(개인정보의 열람청구를 접수·처리하는 부서)\n정보주체는 ｢개인정보 보호법｣ 제35조에 따른 개인정보의 열람 청구를 아래의 부서에 할 수 있습니다.\n< dallog >은(는) 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.\n\n▶ 개인정보 열람청구 접수·처리 부서\n부서명 :\n담당자 :\n연락처 : , ,\n\n\n제10조(정보주체의 권익침해에 대한 구제방법)\n\n\n\n정보주체는 개인정보침해로 인한 구제를 받기 위하여 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보침해신고센터 등에 분쟁해결이나 상담 등을 신청할 수 있습니다. 이 밖에 기타 개인정보침해의 신고, 상담에 대하여는 아래의 기관에 문의하시기 바랍니다.\n\n1. 개인정보분쟁조정위원회 : (국번없이) 1833-6972 (www.kopico.go.kr)\n2. 개인정보침해신고센터 : (국번없이) 118 (privacy.kisa.or.kr)\n3. 대검찰청 : (국번없이) 1301 (www.spo.go.kr)\n4. 경찰청 : (국번없이) 182 (ecrm.cyber.go.kr)\n\n「개인정보보호법」제35조(개인정보의 열람), 제36조(개인정보의 정정·삭제), 제37조(개인정보의 처리정지 등)의 규정에 의한 요구에 대 하여 공공기관의 장이 행한 처분 또는 부작위로 인하여 권리 또는 이익의 침해를 받은 자는 행정심판법이 정하는 바에 따라 행정심판을 청구할 수 있습니다.\n\n※ 행정심판에 대해 자세한 사항은 중앙행정심판위원회(www.simpan.go.kr) 홈페이지를 참고하시기 바랍니다.\n\n제11조(개인정보 처리방침 변경)\n\n\n① 이 개인정보처리방침은 2022년 8월 17부터 적용됩니다.\n          `}\n        </p>\n      </div>\n    </PageLayout>\n  );\n}\n\nexport default PrivacyPolicyPage;\n"
  },
  {
    "path": "frontend/src/pages/StartPage/StartPage.styles.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nconst contentStyle = ({ flex }: Theme) => css`\n  ${flex.row};\n\n  width: 100%;\n  height: 100%;\n  padding: 0 25rem;\n`;\n\nconst calendarStyle = ({ flex, colors }: Theme) => css`\n  ${flex.column};\n\n  gap: 2rem;\n  position: relative;\n  left: -20%;\n  top: -10rem;\n\n  width: 200rem;\n  border: 1px solid ${colors.GRAY_400};\n  box-sizing: content-box;\n  box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.25);\n\n  opacity: 0;\n\n  transform: rotate(10deg);\n\n  @keyframes fadeIn {\n    from {\n      opacity: 0;\n    }\n\n    to {\n      opacity: 1;\n    }\n  }\n\n  animation-name: fadeIn;\n  animation-duration: 1s;\n  animation-timing-function: ease-in-out;\n  animation-fill-mode: forwards;\n`;\n\nconst dateItemStyle = ({ colors }: Theme) => css`\n  width: 200rem;\n  height: 30rem;\n  padding: 6rem 6rem 4rem;\n\n  background: ${colors.WHITE};\n\n  font-size: 20rem;\n  text-align: right;\n`;\n\nconst itemStyle = css`\n  position: relative;\n  transform: translateX(-100%);\n\n  width: 200rem;\n  height: 50rem;\n  padding: 4rem;\n\n  font-size: 10rem;\n  font-weight: 600;\n  text-align: right;\n\n  opacity: 0;\n\n  @keyframes slideIn {\n    from {\n      opacity: 0;\n      transform: translateX(-100%);\n    }\n\n    to {\n      transform: translateX(0);\n      opacity: 1;\n    }\n  }\n\n  animation-name: slideIn;\n  animation-duration: 2s;\n  animation-timing-function: ease-in-out;\n  animation-fill-mode: forwards;\n`;\n\nconst firstItemStyle = ({ colors }: Theme) => css`\n  ${itemStyle};\n\n  background: ${colors.YELLOW_500};\n\n  animation-delay: 0.5s;\n`;\n\nconst secondItemStyle = ({ colors }: Theme) => css`\n  ${itemStyle};\n\n  background: ${colors.ORANGE_500};\n\n  animation-delay: 1s;\n`;\n\nconst thirdItemStyle = ({ colors }: Theme) => css`\n  ${itemStyle};\n\n  background: ${colors.BLUE_500};\n\n  animation-delay: 1.5s;\n`;\n\nconst introductionStyle = ({ flex, mq }: Theme) => css`\n  ${flex.column}\n\n  align-items: flex-start;\n  gap: 15rem;\n\n  width: 100%;\n\n  ${mq?.laptop} {\n    align-items: flex-end;\n    gap: 6rem;\n\n    text-align: right;\n  }\n`;\n\nconst blackTextStyle = ({ colors }: Theme) => css`\n  width: 100%;\n\n  font-size: 20rem;\n  font-weight: 700;\n  line-height: 25rem;\n  color: ${colors.BLACK};\n  text-shadow: 5px 5px 10px rgba(0, 0, 0, 0.25);\n`;\n\nconst whiteTextStyle = ({ colors }: Theme) => css`\n  width: 100%;\n\n  font-size: 20rem;\n  font-weight: 700;\n  line-height: 25rem;\n  color: ${colors.WHITE};\n  text-shadow: 5px 5px 10px rgba(0, 0, 0, 0.25);\n`;\n\nconst detailTextStyle = ({ colors }: Theme) => css`\n  font-size: 4rem;\n  color: ${colors.GRAY_500};\n  line-height: 120%;\n`;\n\nconst loginText = css`\n  width: 100%;\n`;\n\nconst googleLoginButton = ({ colors, flex }: Theme) => css`\n  ${flex.row}\n\n  position: relative;\n  justify-content: flex-start;\n\n  width: 75rem;\n  height: 15rem;\n  padding: 4rem;\n  border-radius: 7px;\n  border: 1px solid ${colors.GRAY_400};\n  box-shadow: 2px 2px 2px ${colors.GRAY_400};\n\n  background: ${colors.WHITE};\n\n  font-size: 4rem;\n  font-weight: 500;\n  color: ${colors.BLACK}8a;\n\n  &:hover {\n    box-shadow: 3px 3px 3px ${colors.GRAY_500};\n  }\n`;\n\nconst iconStyle = css`\n  font-size: 5rem;\n`;\n\nconst secondSectionStyle = ({ flex, colors }: Theme) => css`\n  ${flex.column};\n\n  align-items: center;\n  justify-content: flex-start;\n\n  width: 100%;\n  height: 100%;\n  margin-top: 20rem;\n  padding: 20rem auto;\n  gap: 10rem;\n\n  background: ${colors.GRAY_200};\n`;\n\nexport {\n  blackTextStyle,\n  calendarStyle,\n  contentStyle,\n  detailTextStyle,\n  dateItemStyle,\n  firstItemStyle,\n  googleLoginButton,\n  iconStyle,\n  introductionStyle,\n  loginText,\n  secondItemStyle,\n  secondSectionStyle,\n  thirdItemStyle,\n  whiteTextStyle,\n};\n"
  },
  {
    "path": "frontend/src/pages/StartPage/StartPage.tsx",
    "content": "import { useTheme } from '@emotion/react';\n\nimport { useGetLoginUrl } from '@/hooks/@queries/login';\n\nimport Button from '@/components/@common/Button/Button';\nimport PageLayout from '@/components/@common/PageLayout/PageLayout';\nimport Responsive from '@/components/@common/Responsive/Responsive';\nimport Footer from '@/components/Footer/Footer';\n\nimport { getThisDate } from '@/utils/date';\n\nimport { FcGoogle } from 'react-icons/fc';\n\nimport {\n  blackTextStyle,\n  calendarStyle,\n  contentStyle,\n  dateItemStyle,\n  detailTextStyle,\n  firstItemStyle,\n  googleLoginButton,\n  iconStyle,\n  introductionStyle,\n  loginText,\n  secondItemStyle,\n  thirdItemStyle,\n  whiteTextStyle,\n} from './StartPage.styles';\n\nfunction StartPage() {\n  const theme = useTheme();\n\n  const { error, refetch } = useGetLoginUrl();\n\n  const handleClickGoogleLoginButton = () => {\n    refetch();\n  };\n\n  if (error) {\n    return <>Error</>;\n  }\n\n  return (\n    <PageLayout>\n      <section css={contentStyle}>\n        <Responsive type=\"laptop\">\n          <div css={calendarStyle}>\n            <div css={dateItemStyle}>{getThisDate()}</div>\n            <div css={firstItemStyle}>운동 일정</div>\n            <div css={secondItemStyle}>스터디 일정</div>\n            <div css={thirdItemStyle}>동아리 일정</div>\n          </div>\n        </Responsive>\n\n        <div css={introductionStyle}>\n          <section>\n            <span css={blackTextStyle}>달력</span>\n            <span css={whiteTextStyle}>이</span>\n            <br />\n            <span css={blackTextStyle}>기록</span>\n            <span css={whiteTextStyle}>을</span>\n            <br />\n            <span css={whiteTextStyle}>공유할때</span>\n            <br />\n            <span css={blackTextStyle}>달록</span>\n            <br />\n          </section>\n          <span css={detailTextStyle}>\n            공유 캘린더 플랫폼 달록은 달력과 카테고리를 <br />\n            이용하여 누구나 자신의 일정을 공유할 수 있습니다.\n          </span>\n          <Button cssProp={googleLoginButton(theme)} onClick={handleClickGoogleLoginButton}>\n            <FcGoogle css={iconStyle} />\n            <p css={loginText}>Google 계정으로 로그인하기</p>\n          </Button>\n        </div>\n      </section>\n      <Footer />\n    </PageLayout>\n  );\n}\n\nexport default StartPage;\n"
  },
  {
    "path": "frontend/src/recoil/atoms/index.ts",
    "content": "import { atom } from 'recoil';\n\nimport { ProfileType } from '@/@types/profile';\n\nimport { ATOM_KEY } from '@/constants';\n\nimport { getAccessToken, getRefreshToken } from '@/utils/storage';\n\ninterface UserStateType extends Partial<ProfileType> {\n  accessToken: string;\n  refreshToken: string;\n}\n\nconst scheduleState = atom({\n  key: ATOM_KEY.SCHEDULE,\n  default: '',\n});\n\nconst sideBarState = atom({\n  key: ATOM_KEY.SIDE_BAR,\n  default: true,\n});\n\nconst snackBarState = atom({\n  key: ATOM_KEY.SNACK_BAR,\n  default: {\n    text: '',\n  },\n});\n\nconst userState = atom<UserStateType>({\n  key: ATOM_KEY.USER,\n  default: {\n    accessToken: getAccessToken() ?? '',\n    refreshToken: getRefreshToken() ?? '',\n  },\n});\n\nexport { scheduleState, sideBarState, snackBarState, userState, UserStateType };\n"
  },
  {
    "path": "frontend/src/recoil/selectors/index.ts",
    "content": "import { selector } from 'recoil';\n\nimport { sideBarState } from '@/recoil/atoms';\n\nimport { SELECTOR_KEY } from '@/constants';\n\nconst sideBarSelector = selector({\n  key: SELECTOR_KEY.SIDE_BAR,\n  get: ({ get }) => get(sideBarState),\n  set: ({ set }) => set(sideBarState, (prev) => !prev),\n});\n\nexport { sideBarSelector };\n"
  },
  {
    "path": "frontend/src/styles/GlobalStyle.tsx",
    "content": "import { css, Global, Theme } from '@emotion/react';\nimport emotionReset from 'emotion-reset';\n\nconst global = ({ colors, mq }: Theme) => css`\n  ${emotionReset};\n\n  *,\n  *::after,\n  *::before {\n    margin: 0;\n\n    box-sizing: border-box;\n  }\n\n  html {\n    font-size: 4px;\n\n    ${mq?.mobile} {\n      font-size: 3.5px;\n    }\n  }\n\n  body {\n    overflow: overlay;\n\n    font-family: Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue',\n      'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji',\n      'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;\n    font-size: 3rem;\n\n    *::-webkit-scrollbar {\n      width: 1rem;\n    }\n\n    *::-webkit-scrollbar-thumb {\n      border-radius: 7px;\n      background-clip: padding-box;\n      border: 1px solid transparent;\n\n      background: ${colors.GRAY_400};\n    }\n\n    *::-webkit-scrollbar-track {\n      border-radius: 7px;\n      box-shadow: inset 0 0 5px white;\n    }\n  }\n`;\n\nfunction GlobalStyle() {\n  return <Global styles={global} />;\n}\n\nexport default GlobalStyle;\n"
  },
  {
    "path": "frontend/src/styles/theme.ts",
    "content": "import { css, Theme } from '@emotion/react';\n\nimport { RESPONSIVE } from '../constants/style';\n\nconst colors = {\n  YELLOW_000: '#fff9db',\n  YELLOW_100: '#fff3bf',\n  YELLOW_200: '#ffec99',\n  YELLOW_300: '#ffe066',\n  YELLOW_400: '#fee500',\n  YELLOW_500: '#F4BD68',\n  YELLOW_600: '#fab005',\n  YELLOW_700: '#f59f00',\n  YELLOW_800: '#f08c00',\n  YELLOW_900: '#e67700',\n  GREEN_500: '#03c75a',\n  WHITE: '#ffffff',\n  GRAY_000: '#f8f9fa',\n  GRAY_100: '#f1f3f5',\n  GRAY_200: '#e9ecef',\n  GRAY_300: '#dee2e6',\n  GRAY_400: '#ced4da',\n  GRAY_500: '#adb5bd',\n  GRAY_600: '#868e96',\n  GRAY_700: '#495057',\n  GRAY_800: '#343a40',\n  GRAY_900: '#212529',\n  BLACK: '#000000',\n  RED_000: '#fff5f5',\n  RED_100: '#ffe3e3',\n  RED_200: '#ffc9c9',\n  RED_300: '#ffa8a8',\n  RED_400: '#ff8787',\n  RED_500: '#ff6b6b',\n  RED_600: '#fa5252',\n  RED_700: '#f03e3e',\n  RED_800: '#e03131',\n  RED_900: '#c92a2a',\n  ORANGE_500: '#EE6C4C',\n  BLUE_500: '#88B6B9',\n};\n\nconst flex = {\n  row: css`\n    display: flex;\n    flex-direction: row;\n    justify-content: center;\n    align-items: center;\n  `,\n  column: css`\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n  `,\n};\n\nconst mq = {\n  laptop: `@media screen and (min-width: ${RESPONSIVE.LAPTOP.MIN_WIDTH}px)`,\n  tablet: `@media screen and (max-width: ${RESPONSIVE.TABLET.MAX_WIDTH}px)`,\n  mobile: `@media screen and (max-width: ${RESPONSIVE.MOBILE.MAX_WIDTH}px)`,\n};\n\nconst theme: Theme = {\n  colors,\n  flex,\n  mq,\n};\n\nexport default theme;\n"
  },
  {
    "path": "frontend/src/utils/date.ts",
    "content": "import { DATE_TIME } from '@/constants/date';\n\nimport { zeroFill } from '.';\n\nconst checkAllDay = (startDateTime: string, endDateTime: string) =>\n  startDateTime < endDateTime && getISOTimeString(endDateTime).startsWith(DATE_TIME.END);\n\nconst getStartTime = () => {\n  const nowDateTime = new Date();\n  const nowHour = nowDateTime.getHours();\n  const nowMinute = nowDateTime.getMinutes();\n\n  if (nowMinute === 0 || nowMinute === 30) return `${zeroFill(nowHour)}:${zeroFill(nowMinute)}`;\n\n  if (nowMinute < 30) return `${zeroFill(nowHour)}:30`;\n\n  if (nowHour >= 23) return '00:00';\n\n  return `${zeroFill(+nowHour + 1)}:00`;\n};\n\nconst getEndTime = (startTime?: string) => {\n  const [nowHour, nowMinute] =\n    startTime === undefined ? getStartTime().split(':') : startTime.split(':');\n\n  return nowHour < '23'\n    ? `${zeroFill(+nowHour + 1)}:${zeroFill(nowMinute)}`\n    : `00:${zeroFill(nowMinute)}`;\n};\n\nconst getThisDate = () => new Date().getDate();\n\nconst getThisMonth = () => new Date().getMonth() + 1;\n\nconst extractDateTime = (dateTime: string) => {\n  const dateTimeObject = new Date(dateTime);\n\n  return {\n    year: dateTimeObject.getFullYear(),\n    month: dateTimeObject.getMonth() + 1,\n    date: dateTimeObject.getDate(),\n    day: dateTimeObject.getDay(),\n  };\n};\n\nconst getCurrentCalendar = (currentDateTime: string) => {\n  const firstDateTime = new Date(new Date(currentDateTime).setDate(1));\n  const dateTime = new Date(new Date(currentDateTime).setDate(1));\n  const calendarInfo: string[] = [];\n\n  while (extractDateTime(getISOString(dateTime)).month === extractDateTime(currentDateTime).month) {\n    calendarInfo.push(getISOString(dateTime));\n    dateTime.setDate(extractDateTime(getISOString(dateTime)).date + 1);\n  }\n\n  const firstDay = extractDateTime(calendarInfo[0]).day;\n  const lastDay = extractDateTime(calendarInfo[calendarInfo.length - 1]).day;\n\n  if (firstDay !== 0) {\n    Array(firstDay)\n      .fill(0)\n      .forEach((_, idx) => {\n        calendarInfo.unshift(getDayOffsetDateTime(getISOString(firstDateTime), -(idx + 1)));\n      });\n  }\n\n  if (lastDay !== 6) {\n    Array(6 - lastDay)\n      .fill(0)\n      .forEach((_, idx) => {\n        calendarInfo.push(getDayOffsetDateTime(getISOString(dateTime), idx));\n      });\n  }\n\n  return calendarInfo;\n};\n\nconst getDayOffsetDateTime = (dateTime: string, offset: number) =>\n  getISOString(new Date(new Date(dateTime).setDate(extractDateTime(dateTime).date + offset)));\n\nconst getISODateString = (ISOString: string) => ISOString.split('T')[0];\n\nconst getISOString = (date: Date) => {\n  const offset = 1000 * 60 * new Date().getTimezoneOffset();\n  const localeDateTime = new Date(date.getTime() - offset);\n\n  return localeDateTime.toISOString().split('.')[0].slice(0, -3);\n};\n\nconst getISOTimeString = (ISOString: string) => ISOString.split('T')[1];\n\nconst getMonthOffsetDateTime = (dateTime: string, offset: number) =>\n  getISOString(new Date(new Date(dateTime).setMonth(extractDateTime(dateTime).month + offset - 1)));\n\nconst getToday = () => `${getISODateString(getISOString(new Date()))}T${DATE_TIME.START}`;\n\nexport {\n  checkAllDay,\n  extractDateTime,\n  getCurrentCalendar,\n  getDayOffsetDateTime,\n  getEndTime,\n  getISODateString,\n  getISOString,\n  getISOTimeString,\n  getMonthOffsetDateTime,\n  getStartTime,\n  getThisDate,\n  getThisMonth,\n  getToday,\n};\n"
  },
  {
    "path": "frontend/src/utils/index.ts",
    "content": "const debounce = <F extends (...params: any[]) => void>(callback: F, delay = 100) => {\n  let timer: NodeJS.Timeout;\n\n  return function (...args: any[]) {\n    if (timer) clearTimeout(timer);\n\n    timer = setTimeout(() => callback.call(args), delay);\n  } as F;\n};\n\nconst getRandomNumber = (min: number, max: number) => {\n  return Math.floor(Math.random() * (max - min)) + min;\n};\n\nconst getSearchParam = (key: string) => {\n  return new URLSearchParams(location.search).get(key);\n};\n\nconst zeroFill = (str: string | number) => {\n  return str.toString().padStart(2, '0');\n};\n\nexport { debounce, getRandomNumber, getSearchParam, zeroFill };\n"
  },
  {
    "path": "frontend/src/utils/storage.ts",
    "content": "import { STORAGE_KEY } from '@/constants';\n\nconst getAccessToken = () => {\n  return localStorage.getItem(STORAGE_KEY.ACCESS_TOKEN);\n};\n\nconst getRefreshToken = () => {\n  return localStorage.getItem(STORAGE_KEY.REFRESH_TOKEN);\n};\n\nconst removeAccessToken = () => {\n  localStorage.removeItem(STORAGE_KEY.ACCESS_TOKEN);\n};\n\nconst removeRefreshToken = () => {\n  localStorage.removeItem(STORAGE_KEY.REFRESH_TOKEN);\n};\n\nconst setAccessToken = (accessToken: string) => {\n  localStorage.setItem(STORAGE_KEY.ACCESS_TOKEN, accessToken);\n};\n\nconst setRefreshToken = (refreshToken: string) => {\n  localStorage.setItem(STORAGE_KEY.REFRESH_TOKEN, refreshToken);\n};\n\nexport {\n  getAccessToken,\n  getRefreshToken,\n  removeAccessToken,\n  removeRefreshToken,\n  setAccessToken,\n  setRefreshToken,\n};\n"
  },
  {
    "path": "frontend/src/validation/index.ts",
    "content": "import { VALIDATION_STRING } from '@/constants/validate';\n\nconst validateLength = (target: string, min: number, max: number) =>\n  min <= target.length && target.length <= max;\n\nconst validateNotEmpty = (target: string) => target.length > 0;\n\nconst validateNotEqualString = (target: string, comparisonTarget: string) =>\n  target.trim() !== comparisonTarget;\n\nconst validateStartEndDateTime = (startDate: string, endDate: string) => startDate <= endDate;\n\nconst validateWithdrawalCondition = (value: string) => value === VALIDATION_STRING.WITHDRAWAL;\n\nexport {\n  validateLength,\n  validateNotEmpty,\n  validateNotEqualString,\n  validateStartEndDateTime,\n  validateWithdrawalCondition,\n};\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"esnext\",\n    \"module\": \"ESNext\",\n    \"jsx\": \"react-jsx\",\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"jsxImportSource\": \"@emotion/react\",\n    \"moduleResolution\": \"node\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"src/*\"]\n    }\n  },\n  \"exclude\": [\"node_modules\"],\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "frontend/webpack.config.js",
    "content": "const webpack = require('webpack');\nconst path = require('path');\nconst Dotenv = require('dotenv-webpack');\n\nconst HtmlWebpackPlugin = require('html-webpack-plugin');\nconst CssMinimizerPlugin = require('css-minimizer-webpack-plugin');\nconst MiniCssExtractPlugin = require('mini-css-extract-plugin');\nconst BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;\nconst CompressionPlugin = require('compression-webpack-plugin');\nconst ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');\n\nconst prod = process.env.NODE_ENV === 'production';\n\nmodule.exports = {\n  mode: prod ? 'production' : 'development',\n  devtool: prod ? 'none' : 'eval',\n  entry: './src/index.tsx',\n  resolve: {\n    alias: {\n      '@': path.resolve(__dirname, './src/'),\n    },\n    extensions: ['.js', '.ts', '.tsx'],\n  },\n  module: {\n    rules: [\n      {\n        test: /\\.tsx?$/,\n        use: 'babel-loader',\n      },\n      {\n        test: /\\\\.css$/,\n        use: [prod ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader'],\n      },\n      {\n        test: /\\.(png|jp(e*)g|gif)$/,\n        use: [\n          {\n            loader: 'file-loader',\n            options: {\n              name: 'images/[contenthash]-[name].[ext]',\n            },\n          },\n        ],\n      },\n    ],\n  },\n  output: {\n    path: path.join(__dirname, '/dist'),\n    filename: 'bundle.[contenthash].js',\n  },\n  plugins: [\n    new webpack.ProvidePlugin({\n      React: 'react',\n    }),\n    new HtmlWebpackPlugin({\n      template: './src/index.html',\n      favicon: './src/assets/dallog_color.png',\n    }),\n    new Dotenv(),\n    new MiniCssExtractPlugin(),\n    new BundleAnalyzerPlugin({\n      analyzerMode: 'disabled',\n      generateStatsFile: true,\n    }),\n    new CompressionPlugin(),\n    new ForkTsCheckerWebpackPlugin(),\n  ],\n  devServer: {\n    historyApiFallback: true,\n    port: 3000,\n    open: true,\n    hot: true,\n  },\n  optimization: {\n    minimizer: ['...', new CssMinimizerPlugin()],\n  },\n};\n"
  },
  {
    "path": "jenkins/backend-dev.jenkinsfile",
    "content": "pipeline {\n    agent {\n        label 'Backend'\n    }\n    \n    stages {\n        stage('Github') {\n            steps {\n                git branch: 'develop', url: 'https://github.com/woowacourse-teams/2022-dallog.git'\n                withCredentials([GitUsernamePassword(credentialsId: 'github-access-token', gitToolName: 'Default')]) {\n                    sh 'git submodule update --init --recursive'\n                }\n            }\n        }\n        stage('SonarQube analysis') {\n            steps {\n                dir('backend') {\n                    withSonarQubeEnv('SonarServer') {\n                        sh './gradlew test sonarqube'\n                    }\n                }\n            }\n        }\n        stage(\"Quality Gate\") {\n            steps {\n                timeout(time: 1, unit: 'HOURS') {\n                   waitForQualityGate abortPipeline: true\n                }\n            }\n        }\n        stage('Build') {\n            steps {\n                dir('backend') {\n                    sh \"./gradlew bootJar\"\n                }\n            }\n        }\n        stage('Deploy') {\n            steps {\n                dir('backend/build/libs') {\n                    sshagent(credentials: ['key-dallog']) {\n                        sh '''#!/bin/bash\n                        if curl -s \"http://${BACKEND_DEV_PUBLIC_IP}:${BLUE_PORT}\" > /dev/null\n                        then\n                            target_port=$GREEN_PORT\n                        else\n                            target_port=$BLUE_PORT\n                        fi\n\n                        scp -o StrictHostKeyChecking=no backend-0.0.1-SNAPSHOT.jar ubuntu@${BACKEND_DEV_IP}:/home/ubuntu\n                        ssh ubuntu@${BACKEND_DEV_IP} \"sh run.sh ${target_port}\" &\n\n                        for retry_count in \\$(seq 10)\n                        do\n                            if curl -s \"http://${BACKEND_DEV_PUBLIC_IP}:${target_port}\" > /dev/null\n                            then\n                                echo \"Health check success ✅ port number: ${target_port}\"\n                                break\n                            fi\n\n                            if [ $retry_count -eq 10 ]\n                            then\n                                echo \"Health check failed ❌ port number: ${target_port}\"\n                                exit 1\n                            fi\n\n                            echo \"The server is not alive yet. Retry health check in 20 seconds... retry count: ${retry_count}\"\n                            sleep 20\n                        done\n\n                        ssh ubuntu@${BACKEND_DEV_IP} \"echo 'set \\\\\\$service_port ${target_port};' | sudo tee /etc/nginx/conf.d/service-port.inc && sudo service nginx reload\"\n                        echo \"Switch the reverse proxy direction of nginx to ${target_port} 🔄\"\n\n                        if [ \"${target_port}\" == \"${BLUE_PORT}\" ]\n                        then\n                            ssh ubuntu@${BACKEND_DEV_IP} \"fuser -s -k ${GREEN_PORT}/tcp\"\n                        else\n                            ssh ubuntu@${BACKEND_DEV_IP} \"fuser -s -k ${BLUE_PORT}/tcp\"\n                        fi\n\n                        echo \"Kill the process on the opposite server. 🔪\"\n                        '''\n                    }\n                }\n            }\n        }\n    }\n    post {\n        success {\n            discordSend title: \"백엔드 개발 서버 배포에 성공하였습니다 ✨\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult,\n                webhookURL: env.DISCORD_BACKEND_DEV_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n        failure {\n            discordSend title: \"백엔드 개발 서버 배포에 실패하였습니다 ❌\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult,\n                webhookURL: env.DISCORD_BACKEND_DEV_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n    }\n}\n"
  },
  {
    "path": "jenkins/backend-prod.jenkinsfile",
    "content": "pipeline {\n    agent {\n        label 'Backend'\n    }\n    \n    stages {\n        stage('Github') {\n            steps {\n                git branch: 'main', url: 'https://github.com/woowacourse-teams/2022-dallog.git'\n                withCredentials([GitUsernamePassword(credentialsId: 'github-access-token', gitToolName: 'Default')]) {\n                    sh 'git submodule update --init --recursive'\n                }\n            }\n        }\n        stage('Build') {\n            steps {\n                dir('backend') {\n                    sh \"./gradlew bootJar\"\n                }\n            }\n        }\n        stage('Deploy') {\n            steps {\n                dir('backend/build/libs') {\n                    sshagent(credentials: ['key-dallog']) {\n                        sh '''#!/bin/bash\n                        if curl -s \"http://${BACKEND_PROD_PUBLIC_IP}:${BLUE_PORT}\" > /dev/null\n                        then\n                            target_port=$GREEN_PORT\n                        else\n                            target_port=$BLUE_PORT\n                        fi\n\n                        scp -o StrictHostKeyChecking=no backend-0.0.1-SNAPSHOT.jar ubuntu@${BACKEND_PROD_IP}:/home/ubuntu\n                        ssh ubuntu@${BACKEND_PROD_IP} \"sh run.sh ${target_port}\" &\n\n                        for retry_count in \\$(seq 10)\n                        do\n                            if curl -s \"http://${BACKEND_PROD_PUBLIC_IP}:${target_port}\" > /dev/null\n                            then\n                                echo \"Health check success ✅ port number: ${target_port}\"\n                                break\n                            fi\n\n                            if [ $retry_count -eq 10 ]\n                            then\n                                echo \"Health check failed ❌ port number: ${target_port}\"\n                                exit 1\n                            fi\n\n                            echo \"The server is not alive yet. Retry health check in 20 seconds... retry count: ${retry_count}\"\n                            sleep 20\n                        done\n\n                        ssh -o StrictHostKeyChecking=no ubuntu@${BACKEND_WS_IP} \"echo 'set \\\\\\$service_port ${target_port};' | sudo tee /etc/nginx/conf.d/service-port.inc && sudo service nginx reload\"\n                        echo \"Switch the reverse proxy direction of nginx to ${target_port} 🔄\"\n\n                        if [ \"${target_port}\" == \"${BLUE_PORT}\" ]\n                        then\n                            ssh ubuntu@${BACKEND_PROD_IP} \"fuser -s -k ${GREEN_PORT}/tcp\"\n                        else\n                            ssh ubuntu@${BACKEND_PROD_IP} \"fuser -s -k ${BLUE_PORT}/tcp\"\n                        fi\n\n                        echo \"Kill the process on the opposite server. 🔪\"\n                        '''\n                    }\n                }\n            }\n        }\n    }\n    post {\n        success {\n            discordSend title: \"백엔드 프로덕션 서버 배포에 성공하였습니다 ✨\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult, \n                webhookURL: env.DISCORD_BACKEND_PROD_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n        failure {\n            discordSend title: \"백엔드 프로덕션 서버 배포에 실패하였습니다 ❌\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult, \n                webhookURL: env.DISCORD_BACKEND_PROD_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n    }\n}\n"
  },
  {
    "path": "jenkins/frontend-dev.jenkinsfile",
    "content": "API_URL = \"https://dev-api.dallog.me\"\n\npipeline {\n    agent {\n        label 'Frontend'\n    }\n    \n    stages {\n        stage('Github') {\n            steps {\n                git branch: 'develop', url: 'https://github.com/woowacourse-teams/2022-dallog.git'\n            }\n        }\n        stage('Build') {\n            steps {\n                dir('frontend') {\n                    sh \"echo 'API_URL = ${API_URL}' > .env\"\n                    nodejs(nodeJSInstallationName: 'NodeJS 16.14.0') {\n                        sh \"npm install -g yarn\"\n                        sh \"yarn\"\n                        sh \"yarn dev-build\"\n                    }\n                }\n            }\n        }\n        stage('Deploy') {\n            steps {\n                dir('frontend/dist') {\n                    sshagent(credentials: ['key-dallog']) {\n                        sh \"scp -o StrictHostKeyChecking=no -r ./* ubuntu@${env.FRONTEND_DEV_IP}:/home/ubuntu/\"\n                        sh \"ssh -o StrictHostKeyChecking=no ubuntu@${env.FRONTEND_DEV_IP} 'sudo cp -r ./* /usr/share/nginx/html/ && sudo rm -rf ./*'\"\n                    }\n                }\n            }\n        }\n    }\n    post {\n        success {\n            discordSend title: \"프론트엔드 개발 서버 배포에 성공하였습니다 ✨\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult, \n                webhookURL: env.DISCORD_FRONTEND_DEV_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n        failure {\n            discordSend title: \"프론트엔드 개발 서버 배포에 실패하였습니다 ❌\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult, \n                webhookURL: env.DISCORD_FRONTEND_DEV_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n    }\n}\n"
  },
  {
    "path": "jenkins/frontend-prod.jenkinsfile",
    "content": "API_URL = \"https://api.dallog.me\"\n\npipeline {\n    agent {\n        label 'Frontend'\n    }\n    \n    stages {\n        stage('Github') {\n            steps {\n                git branch: 'main', url: 'https://github.com/woowacourse-teams/2022-dallog.git'\n            }\n        }\n        stage('Build') {\n            steps {\n                dir('frontend') {\n                    sh \"echo 'API_URL = ${API_URL}' > .env\"\n                    nodejs(nodeJSInstallationName: 'NodeJS 16.14.0') {\n                        sh \"npm install -g yarn\"\n                        sh \"yarn\"\n                        sh \"yarn prod-build\"\n                    }\n                }\n            }\n        }\n        stage('Deploy') {\n            steps {\n                dir('frontend/dist') {\n                    sshagent(credentials: ['key-dallog']) {\n                        sh \"scp -o StrictHostKeyChecking=no -r ./* ubuntu@${env.FRONTEND_PROD_IP}:/home/ubuntu/\"\n                        sh \"ssh -o StrictHostKeyChecking=no ubuntu@${env.FRONTEND_PROD_IP} 'sudo cp -r ./* /usr/share/nginx/html/ && sudo rm -rf ./*'\"\n                    }\n                }\n            }\n        }\n    }\n    post {\n        success {\n            discordSend title: \"프론트엔드 프로덕션 서버 배포에 성공하였습니다 ✨\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult, \n                webhookURL: env.DISCORD_FRONTEND_PROD_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n        failure {\n            discordSend title: \"프론트엔드 프로덕션 서버 배포에 실패하였습니다 ❌\",\n                description: \"빌드 번호 #${env.BUILD_NUMBER}\",\n                link: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\",\n                result: currentBuild.currentResult, \n                webhookURL: env.DISCORD_FRONTEND_PROD_WEBHOOK,\n                footer: \"http://jenkins.dallog.me:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}\"\n        }\n    }\n}\n"
  }
]