diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3f8d27c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,42 @@ +name: Deploy on Merge to main + +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + deploy: + name: Deploy to EC2 + if: github.event.pull_request.merged == true # PR이 머지된 경우만 실행 + runs-on: ubuntu-latest + concurrency: + group: deploy + cancel-in-progress: true # 이전 실행 중인 워크플로를 취소 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "${{ vars.EC2_HOST }}" >> ~/.ssh/known_hosts + + - name: Execute Deployment Script on EC2 + run: | + ssh ec2-user@${{ vars.EC2_HOST }} << 'EOF' + cd /home/ec2-user/starchive/scripts + + git checkout main + # 스프링 배포 스크립트 실행 + ./spring-deploy.sh + + # nestjs 배포 스크립트 실행 + ./nestjs-deploy.sh + + EOF diff --git a/.github/workflows/nestjs-deploy.yml b/.github/workflows/nestjs-deploy.yml new file mode 100644 index 0000000..28bcbd7 --- /dev/null +++ b/.github/workflows/nestjs-deploy.yml @@ -0,0 +1,42 @@ +name: NestJS Deploy on Merge to develop + +on: + pull_request: + types: + - closed + branches: + - main + paths: + - 'BACK/nest-app/**' + +jobs: + deploy: + name: Deploy to EC2 + if: github.event.pull_request.merged == true # PR이 머지된 경우만 실행 + runs-on: ubuntu-latest + concurrency: + group: deploy-nestjs + cancel-in-progress: true # 이전 실행 중인 워크플로를 취소 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "${{ vars.EC2_HOST }}" >> ~/.ssh/known_hosts + + - name: Execute Deployment Script on EC2 + run: | + ssh ec2-user@${{ vars.EC2_HOST }} << 'EOF' + cd /home/ec2-user/starchive/scripts + + git checkout develop + + # nestjs 배포 스크립트 실행 + ./nestjs-deploy.sh + + EOF diff --git a/.github/workflows/nestjs.yml b/.github/workflows/nestjs.yml new file mode 100644 index 0000000..cef1c93 --- /dev/null +++ b/.github/workflows/nestjs.yml @@ -0,0 +1,45 @@ +name: NestJS CI + +on: + pull_request: + branches: + - develop + paths: + - 'BACK/nest-app/**' + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + # 1. 코드 체크아웃 + - uses: actions/checkout@v4 + + # 2. Node.js 설정 + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '22' # 사용하려는 Node.js 버전 + cache: 'npm' + cache-dependency-path: 'BACK/nest-app/package-lock.json' + + # 3. 의존성 설치 + - name: Install dependencies + run: | + cd BACK/nest-app/ + npm install + + # 4. NestJS 빌드 + - name: Build the project + run: | + cd BACK/nest-app/ + npm run build + + # 5. 테스트 실행 + #- name: Run tests + # run: | + # cd BACK/nest-app/ + # npm run test diff --git a/.github/workflows/spring-delploy.yml b/.github/workflows/spring-delploy.yml new file mode 100644 index 0000000..ef48c90 --- /dev/null +++ b/.github/workflows/spring-delploy.yml @@ -0,0 +1,53 @@ +name: Spring Deploy on Merge to develop + +on: + pull_request: + types: + - closed + branches: + - develop + paths: + - 'BACK/spring-app/**' + +jobs: + deploy: + name: Deploy to EC2 + if: github.event.pull_request.merged == true # PR이 머지된 경우만 실행 + runs-on: ubuntu-latest + concurrency: + group: deploy-spring + cancel-in-progress: true # 이전 실행 중인 워크플로를 취소 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create application properties files + run: | + mkdir -p BACK/spring-app/src/main/resources + echo "${{ secrets.APPLICATION_PROPERTIES_FOR_TEST }}" > BACK/spring-app/src/main/resources/application-test.properties + echo "${{ secrets.APPLICATION_PROPERTIES_FOR_PROD }}" > BACK/spring-app/src/main/resources/application-prod.properties + + - name: Set up SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.EC2_SSH_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "${{ vars.EC2_HOST }}" >> ~/.ssh/known_hosts + + - name: Transfer properties files to EC2 + run: | + scp BACK/spring-app/src/main/resources/application-test.properties ec2-user@${{ vars.EC2_HOST }}:/home/ec2-user/starchive/BACK/spring-app/src/main/resources/ + scp BACK/spring-app/src/main/resources/application-prod.properties ec2-user@${{ vars.EC2_HOST }}:/home/ec2-user/starchive/BACK/spring-app/src/main/resources/ + + + - name: Execute Deployment Script on EC2 + run: | + ssh ec2-user@${{ vars.EC2_HOST }} << 'EOF' + cd /home/ec2-user/starchive/scripts + + git checkout develop + # 스프링 배포 스크립트 실행 + ./spring-deploy.sh + + EOF diff --git a/.github/workflows/spring-integration.yml b/.github/workflows/spring-integration.yml new file mode 100644 index 0000000..b627fdd --- /dev/null +++ b/.github/workflows/spring-integration.yml @@ -0,0 +1,41 @@ +name: Spring CI + +on: + pull_request: + branches: + - develop + paths: + - 'BACK/spring-app/**' + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # Configure Gradle for optimal use in GitHub Actions, including caching of downloaded dependencies. + # See: https://github.com/gradle/actions/blob/main/setup-gradle/README.md + - name: Setup Gradle + uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + + - name: Set application-test.properties + run: | + mkdir -p BACK/spring-app/src/main/resources + cat < BACK/spring-app/src/main/resources/application-test.properties + ${{ secrets.APPLICATION_PROPERTIES_FOR_TEST }} + EOF + + - name: Build with Gradle + run: | + cd BACK/spring-app/ + chmod +x ./gradlew + ./gradlew build diff --git a/BACK/nest-app/prisma/schema.prisma b/BACK/nest-app/prisma/schema.prisma index 8a66323..c0fe701 100644 --- a/BACK/nest-app/prisma/schema.prisma +++ b/BACK/nest-app/prisma/schema.prisma @@ -14,15 +14,17 @@ datasource db { } model Category { - categoryId BigInt @id @default(autoincrement()) // 기본 키 + categoryId BigInt @id @default(autoincrement()) name String @db.VarChar(100) - parentId BigInt? // 상위 카테고리 (nullable) + parentId BigInt? - @@map("Categories") // 실제 테이블 이름과 매핑 + posts Post[] + + @@map("Categorys") } model Post { - postId BigInt @id @default(autoincrement()) // 기본 키 + postId BigInt @id @default(autoincrement()) categoryId BigInt title String @db.VarChar(64) content String @db.Text @@ -30,5 +32,28 @@ model Post { password String @db.VarChar(128) createAt DateTime - @@map("Posts") // 실제 테이블 이름과 매핑 + category Category @relation(fields: [categoryId], references: [categoryId]) + postHashTags PostHashTag[] + + @@map("Posts") +} + +model PostHashTag { + postHashTagId BigInt @id @default(autoincrement()) + postId BigInt + hashTagId BigInt + + post Post @relation(fields: [postId], references: [postId]) + hashTag HashTag @relation(fields: [hashTagId], references: [hashTagId]) + + @@map("PostHashTag") +} + +model HashTag { + hashTagId BigInt @id @default(autoincrement()) + name String @db.VarChar(32) + + postHashTags PostHashTag[] + + @@map("HashTags") } diff --git a/BACK/nest-app/src/post/post.controller.ts b/BACK/nest-app/src/post/post.controller.ts index c3a304c..cc4d9d4 100644 --- a/BACK/nest-app/src/post/post.controller.ts +++ b/BACK/nest-app/src/post/post.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { PostService } from './post.service'; @Controller('posts') @@ -6,12 +6,17 @@ export class PostController { constructor(private readonly postService: PostService) {} @Get() - async getPostsByCategory(@Query('category') categoryId: string) { - // 카테고리 ID가 전달되지 않은 경우 전체 조회 - if (!categoryId) { - return this.postService.getAllPosts(); - } - - return this.postService.getPostsByCategory(BigInt(categoryId)); + async getPostsByCategory( + @Query('category') categoryId: string, + @Query('tag') hashTagId: string, + @Query('page') page: string = '1', + @Query('pageSize') pageSize: string = '10', + ) { + return this.postService.getPosts( + categoryId ? BigInt(categoryId) : null, + hashTagId ? BigInt(hashTagId) : null, + parseInt(page, 10), + parseInt(pageSize, 10), + ); } } diff --git a/BACK/nest-app/src/post/post.service.ts b/BACK/nest-app/src/post/post.service.ts index fce55e5..e431691 100644 --- a/BACK/nest-app/src/post/post.service.ts +++ b/BACK/nest-app/src/post/post.service.ts @@ -5,32 +5,70 @@ import { PrismaService } from 'src/prisma/prisma.service'; export class PostService { constructor(private readonly prisma: PrismaService) {} - async getPostsByCategory(categoryId: bigint) { + async getPosts( + categoryId: bigint | null, + hashTagId: bigint | null, + page: number = 1, + pageSize: number = 10, + ) { const categoryTreeMap = await this.makeCategoryTreeMap(); - const searchCategory: CategoryTree = categoryTreeMap.get(categoryId); + let queryingObject = { + include: { + postHashTags: { + include: { + hashTag: true, + }, + }, + }, + where: {}, + skip: (page - 1) * pageSize, + take: pageSize, + }; + + if (categoryId) { + const searchCategory: CategoryTree = categoryTreeMap.get(categoryId); - const posts = await this.prisma.post.findMany({ - where: { + queryingObject.where = { + ...queryingObject.where, categoryId: { in: searchCategory?.getAllDescendantIds() ?? [], }, - }, - }); - - return this.toResponse(posts, categoryTreeMap); - } + }; + } - public async getAllPosts() { - const categoryTreeMap = await this.makeCategoryTreeMap(); + if (hashTagId) { + queryingObject.where = { + ...queryingObject.where, + postHashTags: { + some: { + hashTagId: hashTagId, + }, + }, + }; + } - const posts = await this.prisma.post.findMany(); + const [posts, totalCount] = await Promise.all([ + this.prisma.post.findMany(queryingObject), + this.prisma.post.count({ where: queryingObject.where }), + ]); - return this.toResponse(posts, categoryTreeMap); + return this.toResponse(posts, categoryTreeMap, page, pageSize, totalCount); } private toResponse( - posts: { + posts: ({ + postHashTags: ({ + hashTag: { + name: string; + hashTagId: bigint; + }; + } & { + postId: bigint; + postHashTagId: bigint; + hashTagId: bigint; + })[]; + } & { postId: bigint; categoryId: bigint; title: string; @@ -38,23 +76,37 @@ export class PostService { author: string; password: string; createAt: Date; - }[], + })[], categoryTreeMap: Map, + page: number, + pageSize: number, + totalCount: number, ) { - return posts.map((post) => { - const categoryTree: CategoryTree = categoryTreeMap.get(post.categoryId); - - const hierList: categoryResponse[] = categoryTree.getHierList(); - - return { - categoryHier: hierList, - postId: Number(post.postId), - author: post.author, - title: post.title, - content: post.content.slice(0, 350), - createdAt: post.createAt, - }; - }); + const totalPages = Math.ceil(totalCount / pageSize); + + return { + currentPage: page, + totalPages: totalPages, + totalCount: totalCount, + posts: posts.map((post) => { + const categoryTree: CategoryTree = categoryTreeMap.get(post.categoryId); + + const hierList: categoryResponse[] = categoryTree.getHierList(); + + return { + categoryHier: hierList, + postId: Number(post.postId), + author: post.author, + title: post.title, + content: post.content.slice(0, 350), + createdAt: post.createAt, + hashTags: post.postHashTags.map(({ hashTag }) => ({ + hashTagId: Number(hashTag.hashTagId), + name: hashTag.name, + })), + }; + }), + }; } private async makeCategoryTreeMap() { @@ -98,7 +150,6 @@ class CategoryTree { private name: string; private children: CategoryTree[] = []; private parent: CategoryTree; - private hierList: categoryResponse[] = []; public fillNode(id: bigint, name: string) { this.id = id; @@ -114,29 +165,20 @@ class CategoryTree { } public getAllDescendantIds(): bigint[] { - const idList: bigint[] = [this.id]; - - this.children.forEach((child: CategoryTree) => { - idList.push(...child.getAllDescendantIds()); - }); - - return idList; + return [ + this.id, + ...this.children.flatMap((child) => child.getAllDescendantIds()), + ]; } public getHierList(): categoryResponse[] { - if (this.hierList.length !== 0) { - return this.hierList; - } - - if (!!this.parent) { - this.hierList.push(...this.parent.getHierList()); - } - - this.hierList.push({ - categoryId: Number(this.id), - categoryName: this.name, - }); - - return this.hierList; + const list = this.parent ? this.parent.getHierList() : []; + return [ + ...list, + { + categoryId: Number(this.id), + categoryName: this.name, + }, + ]; } } diff --git a/BACK/spring-app/build.gradle b/BACK/spring-app/build.gradle index c9afb3a..0d8ec50 100644 --- a/BACK/spring-app/build.gradle +++ b/BACK/spring-app/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' compileOnly 'org.projectlombok:lombok' @@ -38,6 +39,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.findify:s3mock_2.13:0.2.6' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -45,3 +48,9 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +tasks.withType(Test) { + systemProperty "spring.profiles.active", "test" +} + + diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java index e813457..85e4938 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/controller/CategoryController.java @@ -1,11 +1,17 @@ package com.starchive.springapp.category.controller; -import com.starchive.springapp.category.dto.CategoryListTreeResponse; +import com.starchive.springapp.category.dto.CategoryDto; import com.starchive.springapp.category.service.CategoryService; +import com.starchive.springapp.global.dto.ResponseDto; +import com.starchive.springapp.hashtag.dto.HashTagDto; +import com.starchive.springapp.hashtag.service.HashTagService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; @RestController @@ -13,10 +19,21 @@ @Tag(name = "카테고리") public class CategoryController { private final CategoryService categoryService; + private final HashTagService hashTagService; - @GetMapping("/categorys") + @GetMapping("/categories") @Operation(summary = "카테고리 목록 전체 조회") - public CategoryListTreeResponse showCategories() { - return categoryService.findAll(); + public ResponseEntity>> showCategories() { + List categories = categoryService.findAll(); + ResponseDto> listResponseDto = new ResponseDto<>(categories); + return ResponseEntity.ok(listResponseDto); + } + + @GetMapping("/categories/{categoryId}/hashtags") + @Operation(summary = "특정 카테고리에 포함되는 해쉬태그 목록 조회") + public ResponseEntity>> showHashTags(@PathVariable("categoryId") Long categoryId) { + List categories = hashTagService.findManyByCategory(categoryId); + ResponseDto> listResponseDto = new ResponseDto<>(categories); + return ResponseEntity.ok(listResponseDto); } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java index 928ce05..b4c63e8 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/domain/Category.java @@ -1,8 +1,9 @@ package com.starchive.springapp.category.domain; +import static jakarta.persistence.FetchType.LAZY; + import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -18,21 +19,21 @@ @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Table(name = "Categorys") +@Table(name = "Categories") public class Category { @Id @GeneratedValue @Column(name = "categoryId") private Long id; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = LAZY) @JoinColumn(name = "parentId") private Category parent; @Column(length = 100) String name; - @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY) + @OneToMany(mappedBy = "parent", fetch = LAZY) private List children = new ArrayList<>(); public Category(String name, Category parent) { diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/dto/CategoryListTreeResponse.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/dto/CategoryListTreeResponse.java deleted file mode 100644 index 2bcccdb..0000000 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/dto/CategoryListTreeResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.starchive.springapp.category.dto; - -import com.starchive.springapp.category.domain.Category; -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class CategoryListTreeResponse { - @Schema(description = "최상위 카테고리 목록") - List roots; - - public static CategoryListTreeResponse from(List roots) { - CategoryListTreeResponse categoryListTreeResponse = new CategoryListTreeResponse(); - categoryListTreeResponse.roots = roots.stream().map(CategoryDto::from).toList(); - return categoryListTreeResponse; - } - -} - diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java new file mode 100644 index 0000000..0e60a2d --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/exception/CategoryNotFoundException.java @@ -0,0 +1,13 @@ +package com.starchive.springapp.category.exception; + +import static com.starchive.springapp.global.ErrorMessage.CATEGORY_NOT_FOUND; + +public class CategoryNotFoundException extends RuntimeException { + public CategoryNotFoundException() { + super(CATEGORY_NOT_FOUND); + } + + public CategoryNotFoundException(String message) { + super(message); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java index 1be0f3f..2357e4b 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/category/service/CategoryService.java @@ -1,7 +1,8 @@ package com.starchive.springapp.category.service; import com.starchive.springapp.category.domain.Category; -import com.starchive.springapp.category.dto.CategoryListTreeResponse; +import com.starchive.springapp.category.dto.CategoryDto; +import com.starchive.springapp.category.exception.CategoryNotFoundException; import com.starchive.springapp.category.repository.CategoryRepository; import java.util.List; import lombok.RequiredArgsConstructor; @@ -12,9 +13,13 @@ public class CategoryService { private final CategoryRepository categoryRepository; - public CategoryListTreeResponse findAll() { + public List findAll() { List rootCateGories = categoryRepository.findRootCategoriesWithChildren(); - return CategoryListTreeResponse.from(rootCateGories); + return rootCateGories.stream().map(CategoryDto::from).toList(); + } + + public Category findOne(Long id) { + return categoryRepository.findById(id).orElseThrow(CategoryNotFoundException::new); } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java new file mode 100644 index 0000000..4499ff2 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/ErrorMessage.java @@ -0,0 +1,9 @@ +package com.starchive.springapp.global; + +public class ErrorMessage { + final public static String FAIL_UPLOAD = "이미지 업로드 실패"; + final public static String NOT_IMAGE_EXTENSION = "올바르지 않은 이미지 확장자(허용 확장자: \"jpg\", \"png\", \"gif\", \"jpeg\""; + final public static String INVALID_FILE_SIZE = "파일 크기가 너무 큽니다. 최대 이미지 크기: 5MB"; + final public static String CATEGORY_NOT_FOUND = "카테고리가 존재하지 않습니다."; + final public static String HASHTAG_NOT_FOUND = "카테고리가 존재하지 않습니다."; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java new file mode 100644 index 0000000..d90bf10 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package com.starchive.springapp.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SwaggerConfig.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SwaggerConfig.java new file mode 100644 index 0000000..2c931ca --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/SwaggerConfig.java @@ -0,0 +1,11 @@ +package com.starchive.springapp.global.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition(servers = {@Server(url = "https://starchive-back.store", description = "기본 서버 주소") + , @Server(url = "http://localhost:8080", description = "로컬 주소")}) +public class SwaggerConfig { +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/WebConfig.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/WebConfig.java new file mode 100644 index 0000000..77fcc1c --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/config/WebConfig.java @@ -0,0 +1,28 @@ +package com.starchive.springapp.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Slf4j +@Configuration +public class WebConfig implements WebMvcConfigurer { + private static final int ONE_HOUR = 3600; + + @Override + public void addCorsMappings(CorsRegistry registry) { + log.info("Adding CORS mapping"); + registry.addMapping("/**") + .allowedOrigins( + "https://starchive.vercel.app", + "https://starchive-back.store", + "http://localhost:5173" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // OPTIONS 추가 + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(ONE_HOUR); + } +} + diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java b/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java index 28f3b81..89c3465 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/global/exception/GlobalExceptionHandler.java @@ -1,7 +1,12 @@ package com.starchive.springapp.global.exception; +import static com.starchive.springapp.global.ErrorMessage.INVALID_FILE_SIZE; + +import com.starchive.springapp.category.exception.CategoryNotFoundException; +import com.starchive.springapp.hashtag.exception.HashTagNotFoundException; import java.util.HashMap; import java.util.Map; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -21,4 +26,16 @@ public ResponseEntity> handleValidationExceptions(MethodArgu return ResponseEntity.badRequest().body(errors); } + + @ExceptionHandler(CategoryNotFoundException.class) + public ResponseEntity handleMaxSizeException(CategoryNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ex.getMessage()); + } + + @ExceptionHandler(HashTagNotFoundException.class) + public ResponseEntity handleMaxSizeException(HashTagNotFoundException ex) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(INVALID_FILE_SIZE); + } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/controller/HashTagController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/controller/HashTagController.java index cabc14d..f51da1c 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/controller/HashTagController.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/controller/HashTagController.java @@ -1,8 +1,9 @@ package com.starchive.springapp.hashtag.controller; import com.starchive.springapp.global.dto.ResponseDto; +import com.starchive.springapp.hashtag.dto.HashTagCheckRequest; import com.starchive.springapp.hashtag.dto.HashTagDto; -import com.starchive.springapp.hashtag.dto.HashTagRequest; +import com.starchive.springapp.hashtag.dto.HashTagUpdateRequest; import com.starchive.springapp.hashtag.service.HashTagService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -10,9 +11,12 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -31,8 +35,22 @@ public ResponseEntity>> showAllHashTags() { @PostMapping("/hashtag") @Operation(summary = "해쉬태그 조회 및 저장", description = "이름을 갖은 해쉬태그가 없으면 생성 및 저장 후 반환합니다.") - public ResponseEntity> checkHashTag(@Valid @RequestBody HashTagRequest request) { + public ResponseEntity> checkHashTag(@Valid @RequestBody HashTagCheckRequest request) { HashTagDto hashTagDto = hashTagService.findOneOrSave(request.getName()); return ResponseEntity.ok(new ResponseDto<>(hashTagDto)); } + + @PutMapping("/hashtag") + @Operation(summary = "해쉬태그 이름 수정") + public ResponseEntity> updateHashTag(@Valid @RequestBody HashTagUpdateRequest request) { + HashTagDto hashTagDto = hashTagService.updateName(request.getId(), request.getName()); + return ResponseEntity.ok(new ResponseDto<>(hashTagDto)); + } + + @DeleteMapping("/hashtag") + @Operation(summary = "해시태그 삭제") + public ResponseEntity> deleteHashTag(@RequestParam("hashTagId") Long hashTagId) { + hashTagService.delete(hashTagId); + return ResponseEntity.noContent().build(); + } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java index 8ac4fb7..2be4d8f 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/domain/HashTag.java @@ -6,14 +6,12 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Table(name = "HashTags") @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor @Getter public class HashTag { @Id @@ -27,4 +25,8 @@ public class HashTag { public HashTag(String name) { this.name = name; } + + public void changeName(String name) { + this.name = name; + } } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagRequest.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagCheckRequest.java similarity index 94% rename from BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagRequest.java rename to BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagCheckRequest.java index ce3ea5b..e218e39 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagRequest.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagCheckRequest.java @@ -12,7 +12,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public class HashTagRequest { +public class HashTagCheckRequest { @Schema(description = "해시태그 이름", example = "Spring", maxLength = 32) @NotEmpty(message = "해쉬태그이름은 1글자 이상이어야 합니다.") @Size(max = 32, message = "해쉬태그이름은 32자 보다 작거나 같아야 합니다.") diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagUpdateRequest.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagUpdateRequest.java new file mode 100644 index 0000000..acb2388 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/dto/HashTagUpdateRequest.java @@ -0,0 +1,23 @@ +package com.starchive.springapp.hashtag.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class HashTagUpdateRequest { + @Schema(description = "해시태그 식별 id", example = "1") + @NotNull(message = "ID는 필수입니다.") // Long 타입에는 @NotNull 사용 + private long id; + + @Schema(description = "해시태그 이름", example = "Spring", maxLength = 32) + @NotEmpty(message = "해쉬태그이름은 1글자 이상이어야 합니다.") + @Size(max = 32, message = "해쉬태그이름은 32자 보다 작거나 같아야 합니다.") + private String name; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java index 86c47d4..fd8f36c 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/exception/HashTagNotFoundException.java @@ -1,8 +1,10 @@ package com.starchive.springapp.hashtag.exception; +import static com.starchive.springapp.global.ErrorMessage.HASHTAG_NOT_FOUND; + public class HashTagNotFoundException extends RuntimeException { public HashTagNotFoundException() { - super("해쉬 태그가 존재 하지 않습니다."); + super(HASHTAG_NOT_FOUND); } public HashTagNotFoundException(String message) { diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java index f2eb792..37583c7 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/repository/HashTagRepository.java @@ -1,11 +1,24 @@ package com.starchive.springapp.hashtag.repository; import com.starchive.springapp.hashtag.domain.HashTag; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface HashTagRepository extends JpaRepository { - public Optional findByName(String name); + Optional findByName(String name); + + @Query("select distinct h from HashTag h " + + "join fetch PostHashTag ph on h.id = ph.hashTag.id " + + "join fetch Post p on ph.post.id = p.id " + + "join fetch Category c on p.category.id = c.id " + + "where c.id = :categoryId") + List findAllByCategoryId(@Param("categoryId") Long categoryId); + + + List findManyByIdIn(@Param("ids") List ids); } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java index 1997602..dcb8f18 100644 --- a/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/hashtag/service/HashTagService.java @@ -1,9 +1,13 @@ package com.starchive.springapp.hashtag.service; +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.category.exception.CategoryNotFoundException; +import com.starchive.springapp.category.repository.CategoryRepository; import com.starchive.springapp.hashtag.domain.HashTag; import com.starchive.springapp.hashtag.dto.HashTagDto; import com.starchive.springapp.hashtag.exception.HashTagNotFoundException; import com.starchive.springapp.hashtag.repository.HashTagRepository; +import com.starchive.springapp.posthashtag.repository.PostHashTagRepository; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,12 +18,18 @@ @RequiredArgsConstructor public class HashTagService { private final HashTagRepository hashTagRepository; + private final CategoryRepository categoryRepository; + private final PostHashTagRepository postHashTagRepository; public HashTag save(String name) { HashTag hashTag = new HashTag(name); return hashTagRepository.save(hashTag); } + public List findManyByIds(List ids) { + return hashTagRepository.findManyByIdIn(ids); + } + public HashTag findOne(String name) { return hashTagRepository.findByName(name).orElseThrow(HashTagNotFoundException::new); } @@ -38,4 +48,21 @@ public HashTagDto findOneOrSave(String name) { return HashTagDto.from(hashTag); } + public List findManyByCategory(Long CategoryId) { + Category category = categoryRepository.findById(CategoryId).orElseThrow(CategoryNotFoundException::new); + + return hashTagRepository.findAllByCategoryId(category.getId()).stream().map(HashTagDto::from).toList(); + } + + public HashTagDto updateName(Long hashTagId, String newName) { + HashTag hashTag = hashTagRepository.findById(hashTagId).orElseThrow(HashTagNotFoundException::new); + hashTag.changeName(newName); + return HashTagDto.from(hashTag); + } + + public void delete(Long hashTagId) { + postHashTagRepository.deleteAllByHashTagId(hashTagId); + hashTagRepository.deleteById(hashTagId); + } + } diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java new file mode 100644 index 0000000..407eef8 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/controller/PostImageController.java @@ -0,0 +1,29 @@ +package com.starchive.springapp.image.controller; + +import com.starchive.springapp.global.dto.ResponseDto; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.service.PostImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "게시글 이미지") +@RestController +@RequiredArgsConstructor +public class PostImageController { + private final PostImageService postImageService; + + @Operation(summary = "게시글에 포함될 이미지 업로드") + @PostMapping(value = "/postImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> imageUpload(@RequestParam("image") MultipartFile image) { + PostImageDto postImageDto = postImageService.uploadImage(image); + + return ResponseEntity.ok(new ResponseDto<>(postImageDto)); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java new file mode 100644 index 0000000..17b1aab --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/domain/PostImage.java @@ -0,0 +1,41 @@ +package com.starchive.springapp.image.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.starchive.springapp.post.domain.Post; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class PostImage { + @Id + @GeneratedValue + private Long id; + + @Lob + String imagePath; + + LocalDateTime uploadDate; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "postId") + Post post; + + public PostImage(String imagePath) { + this.imagePath = imagePath; + this.uploadDate = LocalDateTime.now(); + } + + public void setPost(Post post) { + this.post = post; + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java new file mode 100644 index 0000000..91a47e2 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/dto/PostImageDto.java @@ -0,0 +1,11 @@ +package com.starchive.springapp.image.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class PostImageDto { + Long id; + String imagePath; +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java new file mode 100644 index 0000000..5ce6f0f --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/repository/PostImageRepository.java @@ -0,0 +1,20 @@ +package com.starchive.springapp.image.repository; + +import com.starchive.springapp.image.domain.PostImage; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PostImageRepository extends JpaRepository { + @Query("SELECT p FROM PostImage p WHERE p.post IS NULL AND p.uploadDate < :cutoffDate") + List findOldOrphanedPostImages(@Param("cutoffDate") LocalDateTime cutoffDate); + + @Modifying + @Query("DELETE FROM PostImage p WHERE p.id IN :ids") + void deleteByIds(@Param("ids") List ids); + + List findManyByIdIn(@Param("ids") List ids); +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java new file mode 100644 index 0000000..8ff698c --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/image/service/PostImageService.java @@ -0,0 +1,60 @@ +package com.starchive.springapp.image.service; + +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.s3.S3Service; +import java.net.URI; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PostImageService { + private final S3Service s3Service; + private final PostImageRepository postImageRepository; + + public PostImageDto uploadImage(MultipartFile image) { + String imagePath = s3Service.saveFile(image); + PostImage postImage = new PostImage(imagePath); + + postImageRepository.save(postImage); + + return new PostImageDto(postImage.getId(), postImage.getImagePath()); + } + + public void setPost(List imageIds, Post post) { + List postImages = postImageRepository.findManyByIdIn(imageIds); + postImages.forEach(postImage -> { + postImage.setPost(post); + }); + } + + @Scheduled(cron = "0 0 2 * * ?") + public void deleteOldOrphanedPostImages() { + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(1); // 하루 전 + + List oldOrphanedPostImages = postImageRepository.findOldOrphanedPostImages(cutoffDate); + oldOrphanedPostImages.forEach(postImage -> { + s3Service.deleteObject(extractKeyFromUrl(postImage.getImagePath())); + postImageRepository.delete(postImage); + log.info("Deleted old orphaned PostImages: {}", postImage.getImagePath()); + }); + + } + + private String extractKeyFromUrl(String url) { + URI uri = URI.create(url); + return uri.getPath().substring(1); // 첫 번째 '/' 제거 + } + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/controller/PostController.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/controller/PostController.java new file mode 100644 index 0000000..cbc626a --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/controller/PostController.java @@ -0,0 +1,28 @@ +package com.starchive.springapp.post.controller; + +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@Tag(name = "게시글") +public class PostController { + private final PostService postService; + + @PostMapping("/post") + @Operation(summary = "게시글 작성") + public ResponseEntity post(@Valid @RequestBody PostCreateRequest request) { + + postService.createPost(request); + + return ResponseEntity.status(201).build(); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java new file mode 100644 index 0000000..896ac78 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/domain/Post.java @@ -0,0 +1,66 @@ +package com.starchive.springapp.post.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.post.dto.PostCreateRequest; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor +@Table(name = "Posts") +public class Post { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "postId") + private Long id; + + @Column(nullable = false, length = 64) + private String title; + + @Lob + private String content; + + @Column(nullable = false, length = 32) + private String author; + + @Column(nullable = false, length = 128) + private String password; + + @Column(nullable = false, name = "datetime") + private LocalDateTime createAt; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "categoryId") + private Category category; + + public static Post of(PostCreateRequest request, Category category) { + Post post = new Post(); + post.title = request.getTitle(); + post.content = request.getContent(); + post.author = request.getAuthor(); + post.password = request.getPassword(); + post.createAt = LocalDateTime.now(); + post.category = category; + + return post; + } + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/dto/PostCreateRequest.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..924a049 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/dto/PostCreateRequest.java @@ -0,0 +1,48 @@ +package com.starchive.springapp.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class PostCreateRequest { + @NotEmpty + @Size(max = 64) + @Schema(description = "게시글 제목", example = "게시글 제목 예시") + private String title; + + @NotEmpty + @Schema(description = "게시글 내용", example = "게시글 내용 예시") + private String content; + + @NotEmpty + @Size(max = 32) + @Schema(description = "작성자 이름", example = "홍길동") + private String author; + + @NotEmpty + @Size(max = 128) + @Schema(description = "비밀번호", example = "1234") + private String password; + + @NotNull + @Schema(description = "카테고리 ID", example = "1") + private Long categoryId; + + @Nullable + @Schema(description = "해쉬 태그 ID", example = "[1,2,3]") + private List hashTagIds; + + @Nullable + @Schema(description = "첨부 이미지 ID", example = "[1,2]") + private List imageIds; + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/repository/PostRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/repository/PostRepository.java new file mode 100644 index 0000000..50ef03b --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/repository/PostRepository.java @@ -0,0 +1,7 @@ +package com.starchive.springapp.post.repository; + +import com.starchive.springapp.post.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/post/service/PostService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/post/service/PostService.java new file mode 100644 index 0000000..57cf878 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/post/service/PostService.java @@ -0,0 +1,35 @@ +package com.starchive.springapp.post.service; + +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.category.service.CategoryService; +import com.starchive.springapp.image.service.PostImageService; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.repository.PostRepository; +import com.starchive.springapp.posthashtag.service.PostHashTagService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostService { + private final PostRepository postRepository; + private final PostHashTagService postHashTagService; + private final CategoryService categoryService; + private final PostImageService postImageService; + + public void createPost(PostCreateRequest request) { + Category category = categoryService.findOne(request.getCategoryId()); + Post post = Post.of(request, category); + + postRepository.save(post); + + postHashTagService.storePostHashTag(request.getHashTagIds(), post); + + postImageService.setPost(request.getImageIds(), post); + + } + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java new file mode 100644 index 0000000..b76e73e --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/domain/PostHashTag.java @@ -0,0 +1,41 @@ +package com.starchive.springapp.posthashtag.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.post.domain.Post; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "PostHashTag") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostHashTag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "postHashTagId") + private Long id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "postId") + private Post post; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "hashTagId") + private HashTag hashTag; + + public PostHashTag(Post post, HashTag hashTag) { + this.post = post; + this.hashTag = hashTag; + } + + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/repository/PostHashTagRepository.java b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/repository/PostHashTagRepository.java new file mode 100644 index 0000000..0660627 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/repository/PostHashTagRepository.java @@ -0,0 +1,17 @@ +package com.starchive.springapp.posthashtag.repository; + +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PostHashTagRepository extends JpaRepository { + @Modifying + @Query("DELETE FROM PostHashTag p WHERE p.hashTag.id = :hashTagId") + void deleteAllByHashTagId(@Param("hashTagId") Long hasTagId); + + @Query("select p from PostHashTag p WHERE p.hashTag.id = :hashTagId") + List findAllByHashTagId(@Param("hashTagId") Long hasTagId); +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/service/PostHashTagService.java b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/service/PostHashTagService.java new file mode 100644 index 0000000..ca0f9e4 --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/posthashtag/service/PostHashTagService.java @@ -0,0 +1,37 @@ +package com.starchive.springapp.posthashtag.service; + +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.hashtag.service.HashTagService; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import com.starchive.springapp.posthashtag.repository.PostHashTagRepository; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class PostHashTagService { + private final PostHashTagRepository postHashTagRepository; + private final HashTagService hashTagService; + + public void storePostHashTag(List hashTagsIds, Post post) { + + if (hashTagsIds == null || hashTagsIds.isEmpty()) { + return; + } + + ArrayList postHashTags = new ArrayList<>(); + List hasTags = hashTagService.findManyByIds(hashTagsIds); + + hasTags.stream().forEach(hasTag -> { + PostHashTag postHashTag = new PostHashTag(post, hasTag); + postHashTags.add(postHashTag); + }); + + postHashTagRepository.saveAll(postHashTags); + } +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java new file mode 100644 index 0000000..51ecfec --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Config.java @@ -0,0 +1,37 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + @Primary + public AmazonS3 amazonS3Client() { + AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } + + +} diff --git a/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java new file mode 100644 index 0000000..15b560c --- /dev/null +++ b/BACK/spring-app/src/main/java/com/starchive/springapp/s3/S3Service.java @@ -0,0 +1,77 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.starchive.springapp.global.ErrorMessage; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + // 단일 파일 저장 + public String saveFile(MultipartFile file) { + String randomFilename = generateRandomFilename(file); + log.info("File upload started: {}, bucketName: {}", randomFilename, bucket); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + try { + amazonS3.putObject(bucket, randomFilename, file.getInputStream(), metadata); + } catch (AmazonS3Exception e) { + log.error("Amazon S3 error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } catch (SdkClientException e) { + log.error("AWS SDK client error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } catch (IOException e) { + log.error("IO error while uploading file: " + e.getMessage()); + throw new RuntimeException(ErrorMessage.FAIL_UPLOAD); + } + + log.info("File upload completed: " + randomFilename); + + return amazonS3.getUrl(bucket, randomFilename).toString(); + } + + public void deleteObject(String key) { + amazonS3.deleteObject(bucket, key); + log.info("Deleted S3 Object: {}", key); + } + + // 랜덤파일명 생성 (파일명 중복 방지) + private String generateRandomFilename(MultipartFile multipartFile) { + String originalFilename = multipartFile.getOriginalFilename(); + String fileExtension = validateFileExtension(originalFilename); + String randomFilename = UUID.randomUUID() + "." + fileExtension; + return randomFilename; + } + + // 파일 확장자 체크 + private String validateFileExtension(String originalFilename) { + String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); + List allowedExtensions = Arrays.asList("jpg", "png", "gif", "jpeg"); + + if (!allowedExtensions.contains(fileExtension)) { + throw new RuntimeException(ErrorMessage.NOT_IMAGE_EXTENSION); + } + return fileExtension; + } +} diff --git a/BACK/spring-app/src/main/resources/application.properties b/BACK/spring-app/src/main/resources/application.properties deleted file mode 100644 index 0a68850..0000000 --- a/BACK/spring-app/src/main/resources/application.properties +++ /dev/null @@ -1,7 +0,0 @@ -spring.application.name=spring-app - -spring.datasource.url=jdbc:h2:tcp://localhost/~/starchive -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= -spring.h2.console.enabled=true \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/SpringAppApplicationTests.java b/BACK/spring-app/src/test/java/com/starchive/springapp/SpringAppApplicationTests.java deleted file mode 100644 index 3ffc070..0000000 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/SpringAppApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.starchive.springapp; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class SpringAppApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java index 2802411..0caa249 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/controller/CategoryControllerTest.java @@ -6,7 +6,11 @@ import com.starchive.springapp.category.domain.Category; import com.starchive.springapp.category.repository.CategoryRepository; -import org.junit.jupiter.api.BeforeEach; +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -25,8 +29,12 @@ class CategoryControllerTest { @Autowired private MockMvc mockMvc; - @BeforeEach - void init() { + @Autowired + EntityManager entityManager; + + + @Test + public void 목록_전체_조회_테스트() throws Exception { //given Category parent = new Category("알고리즘", null); Category child1 = new Category("자료구조", parent); @@ -38,21 +46,56 @@ void init() { categoryRepository.save(child2); categoryRepository.save(parent1); categoryRepository.save(child3); - } - @Test - public void 목록_전체_조회_테스트() throws Exception { // when - mockMvc.perform(get("/categorys") + mockMvc.perform(get("/categories") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.roots").isArray()) - .andExpect(jsonPath("$.roots[0].name").value("알고리즘")) - .andExpect(jsonPath("$.roots[0].children").isArray()) - .andExpect(jsonPath("$.roots[0].children[0].name").value("자료구조")) - .andExpect(jsonPath("$.roots[0].children[1].name").value("다이나믹프로그래밍")) - .andExpect(jsonPath("$.roots[1].name").value("프로젝트")) - .andExpect(jsonPath("$.roots[1].children[0].name").value("요구사항")); + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].name").value("알고리즘")) + .andExpect(jsonPath("$.data[0].children").isArray()) + .andExpect(jsonPath("$.data[0].children[0].name").value("자료구조")) + .andExpect(jsonPath("$.data[0].children[1].name").value("다이나믹프로그래밍")) + .andExpect(jsonPath("$.data[1].name").value("프로젝트")) + .andExpect(jsonPath("$.data[1].children[0].name").value("요구사항")); + + } + + @Test + public void 특정_카테고리에_포함되는_해쉬태그_목록_조회_통합_테스트() throws Exception { + // Given + Category category = new Category("알고리즘", null); + entityManager.persist(category); + + Post post = Post.builder() + .title("알고리즘 기초") + .content("알고리즘을 학습합시다.") + .author("홍길동") + .password("1234") + .createAt(LocalDateTime.now()) + .category(category) + .build(); + entityManager.persist(post); + + HashTag hashTag1 = new HashTag("자료구조"); + HashTag hashTag2 = new HashTag("다이나믹 프로그래밍"); + entityManager.persist(hashTag1); + entityManager.persist(hashTag2); + PostHashTag postHashTag1 = new PostHashTag(post, hashTag1); + entityManager.persist(postHashTag1); + + PostHashTag postHashTag2 = new PostHashTag(post, hashTag2); + entityManager.persist(postHashTag2); + + // When & Then + mockMvc.perform(get("/categories/{categoryId}/hashtags", category.getId()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].id").value(hashTag1.getId())) + .andExpect(jsonPath("$.data[0].name").value(hashTag1.getName())) + .andExpect(jsonPath("$.data[1].id").value(hashTag2.getId())) + .andExpect(jsonPath("$.data[1].name").value(hashTag2.getName())); } } \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java index 42c8444..c733d82 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryDtoTest.java @@ -4,6 +4,7 @@ import com.starchive.springapp.category.repository.CategoryRepository; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import java.util.List; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -44,4 +45,42 @@ class CategoryDtoTest { Assertions.assertThat(categoryDto.getChildren().size()).isEqualTo(2); } + @Test + void DTO_트리_변환_테스트() { + //given + Category parent1 = new Category("알고리즘", null); + Category child1 = new Category("자료구조", parent1); + Category child2 = new Category("다이나믹프로그래밍", parent1); + Category child4 = new Category("그리디", parent1); + Category parent2 = new Category("프로젝트", null); + Category child3 = new Category("요구사항", parent2); + Category parent3 = new Category("회고", null); + categoryRepository.save(parent1); + categoryRepository.save(child1); + categoryRepository.save(child2); + categoryRepository.save(child4); + categoryRepository.save(parent2); + categoryRepository.save(child3); + categoryRepository.save(parent3); + System.out.println("---------------------쿼리1"); + // 트랜잭션 종료 (영속성 컨텍스트 초기화) + entityManager.flush(); + entityManager.clear(); + System.out.println("---------------------쿼리2"); + + //when + List rootCategoriesWithChildren = categoryRepository.findRootCategoriesWithChildren(); + System.out.println("---------------------쿼리3"); + + for (Category rootCategories : rootCategoriesWithChildren) { + System.out.println("root " + rootCategories.getName()); + for (Category child : rootCategories.getChildren()) { + System.out.print(child.getName() + " "); + } + System.out.println(); + } + //then + Assertions.assertThat(rootCategoriesWithChildren.size()).isEqualTo(3); + } + } \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryListTreeResponseTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryListTreeResponseTest.java deleted file mode 100644 index 8b0fe3f..0000000 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/dto/CategoryListTreeResponseTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.starchive.springapp.category.dto; - -import com.starchive.springapp.category.domain.Category; -import com.starchive.springapp.category.repository.CategoryRepository; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -@SpringBootTest -@Transactional -class CategoryListTreeResponseTest { - @Autowired - CategoryRepository categoryRepository; - - @PersistenceContext - private EntityManager entityManager; - - @Test - void DTO_트리_변환_테스트() { - //given - Category parent1 = new Category("알고리즘", null); - Category child1 = new Category("자료구조", parent1); - Category child2 = new Category("다이나믹프로그래밍", parent1); - Category child4 = new Category("그리디", parent1); - Category parent2 = new Category("프로젝트", null); - Category child3 = new Category("요구사항", parent2); - Category parent3 = new Category("회고", null); - categoryRepository.save(parent1); - categoryRepository.save(child1); - categoryRepository.save(child2); - categoryRepository.save(child4); - categoryRepository.save(parent2); - categoryRepository.save(child3); - categoryRepository.save(parent3); - System.out.println("---------------------쿼리1"); - // 트랜잭션 종료 (영속성 컨텍스트 초기화) - entityManager.flush(); - entityManager.clear(); - System.out.println("---------------------쿼리2"); - - //when - List rootCategoriesWithChildren = categoryRepository.findRootCategoriesWithChildren(); - System.out.println("---------------------쿼리3"); - - for (Category rootCategories : rootCategoriesWithChildren) { - System.out.println("root " + rootCategories.getName()); - for (Category child : rootCategories.getChildren()) { - System.out.print(child.getName() + " "); - } - System.out.println(); - } - //then - Assertions.assertThat(rootCategoriesWithChildren.size()).isEqualTo(3); - } - -} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java index 29ba4e4..492c85c 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/category/service/CategoryServiceTest.java @@ -3,8 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import com.starchive.springapp.category.domain.Category; -import com.starchive.springapp.category.dto.CategoryListTreeResponse; +import com.starchive.springapp.category.dto.CategoryDto; import com.starchive.springapp.category.repository.CategoryRepository; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -40,16 +41,16 @@ void init() { @Test void 전체_목록_조회_테스트() { // when - CategoryListTreeResponse response = categoryService.findAll(); + List response = categoryService.findAll(); // then - assertThat(response.getRoots()).hasSize(3); - assertThat(response.getRoots().get(0).getName()).isEqualTo("알고리즘"); - assertThat(response.getRoots().get(0).getChildren()).hasSize(2); - assertThat(response.getRoots().get(0).getChildren().get(0).getName()).isEqualTo("자료구조"); - assertThat(response.getRoots().get(0).getChildren().get(1).getName()).isEqualTo("다이나믹프로그래밍"); - assertThat(response.getRoots().get(1).getName()).isEqualTo("프로젝트"); - assertThat(response.getRoots().get(1).getChildren().get(0).getName()).isEqualTo("요구사항"); + assertThat(response).hasSize(3); + assertThat(response.get(0).getName()).isEqualTo("알고리즘"); + assertThat(response.get(0).getChildren()).hasSize(2); + assertThat(response.get(0).getChildren().get(0).getName()).isEqualTo("자료구조"); + assertThat(response.get(0).getChildren().get(1).getName()).isEqualTo("다이나믹프로그래밍"); + assertThat(response.get(1).getName()).isEqualTo("프로젝트"); + assertThat(response.get(1).getChildren().get(0).getName()).isEqualTo("요구사항"); } } \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java index 2a29b57..baf93a0 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/controller/HashTagControllerTest.java @@ -1,16 +1,21 @@ package com.starchive.springapp.hashtag.controller; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.hashtag.dto.HashTagCheckRequest; import com.starchive.springapp.hashtag.dto.HashTagDto; -import com.starchive.springapp.hashtag.dto.HashTagRequest; +import com.starchive.springapp.hashtag.dto.HashTagUpdateRequest; import com.starchive.springapp.hashtag.service.HashTagService; import java.util.Arrays; import java.util.List; @@ -36,8 +41,8 @@ class HashTagControllerTest { public void 전체_해쉬태그_목록_반환_테스트() throws Exception { //given List mockTags = Arrays.asList( - new HashTag(1L, "Spring"), - new HashTag(2L, "Java") + new HashTag("Spring"), + new HashTag("Java") ).stream().map(HashTagDto::from).toList(); when(hashTagService.findAll()).thenReturn(mockTags); @@ -56,7 +61,7 @@ class HashTagControllerTest { HashTagDto mockTag = HashTagDto.from(new HashTag("Spring")); when(hashTagService.findOneOrSave(anyString())).thenReturn(mockTag); - HashTagRequest request = new HashTagRequest("Spring"); + HashTagCheckRequest request = new HashTagCheckRequest("Spring"); // when & then mockMvc.perform(post("/hashtag") @@ -69,7 +74,7 @@ class HashTagControllerTest { @Test public void 해쉬태그_이름이_빈문자열인경우_예외발생() throws Exception { // Given - HashTagRequest request = new HashTagRequest(""); // name이 빈 문자열 + HashTagCheckRequest request = new HashTagCheckRequest(""); // name이 빈 문자열 // When & Then mockMvc.perform(post("/hashtag") @@ -83,7 +88,7 @@ class HashTagControllerTest { public void 해쉬태그_이름이_길이가_32자를_초과한경우_예외발생() throws Exception { // Given String longName = "a".repeat(33); // 33글자 문자열 - HashTagRequest request = new HashTagRequest(longName); + HashTagCheckRequest request = new HashTagCheckRequest(longName); // When & Then mockMvc.perform(post("/hashtag") @@ -92,4 +97,37 @@ class HashTagControllerTest { .andExpect(status().isBadRequest()) // 400 Bad Request .andExpect(jsonPath("$.name").value("해쉬태그이름은 32자 보다 작거나 같아야 합니다.")); // 에러 메시지 검증 } + + @Test + void 해시태그_이름_수정_API_테스트() throws Exception { + // Given + Long hashTagId = 1L; + String updatedName = "UpdatedName"; + HashTagDto mockResponse = new HashTagDto(hashTagId, updatedName); + + HashTagUpdateRequest request = new HashTagUpdateRequest(hashTagId, updatedName); + + when(hashTagService.updateName(eq(hashTagId), eq(updatedName))).thenReturn(mockResponse); + + /// When & Then + mockMvc.perform(put("/hashtag") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) // 요청 데이터 변환 + .andExpect(status().isOk()) // 200 상태 코드 확인 + .andExpect(jsonPath("$.data.id").value(hashTagId)) // 응답 ID 확인 + .andExpect(jsonPath("$.data.name").value(updatedName)); // 응답 이름 확인 + } + + @Test + void 해시태그_삭제_API_테스트() throws Exception { + // Given + Long hashTagId = 1L; + + doNothing().when(hashTagService).delete(eq(hashTagId)); + + // When & Then + mockMvc.perform(delete("/hashtag") + .param("hashTagId", hashTagId.toString())) + .andExpect(status().isNoContent()); // 204 상태 코드 확인 + } } \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java new file mode 100644 index 0000000..5393a81 --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/repository/HashTagRepositoryTest.java @@ -0,0 +1,60 @@ +package com.starchive.springapp.hashtag.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DataJpaTest +class HashTagRepositoryTest { + @Autowired + private HashTagRepository hashTagRepository; + + @Autowired + private EntityManager entityManager; + + @Test + void 카테고리에_포함되는_해쉬태그_조회_테스트() { + // Given + Category category = new Category("알고리즘", null); + entityManager.persist(category); + + Post post = Post.builder() + .title("알고리즘 기초") + .content("알고리즘을 학습합시다.") + .author("홍길동") + .password("1234") + .createAt(LocalDateTime.now()) + .category(category) + .build(); + entityManager.persist(post); + + HashTag hashTag1 = new HashTag("자료구조"); + HashTag hashTag2 = new HashTag("다이나믹 프로그래밍"); + entityManager.persist(hashTag1); + entityManager.persist(hashTag2); + + PostHashTag postHashTag1 = new PostHashTag(post, hashTag1); + entityManager.persist(postHashTag1); + + PostHashTag postHashTag2 = new PostHashTag(post, hashTag2); + entityManager.persist(postHashTag2); + + // When + List result = hashTagRepository.findAllByCategoryId(category.getId()); + + // Then + assertThat(result) + .hasSize(2) + .extracting(HashTag::getName) + .containsExactlyInAnyOrder("자료구조", "다이나믹 프로그래밍"); + } +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java index a3bcff7..008f0a5 100644 --- a/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/hashtag/service/HashTagServiceTest.java @@ -7,6 +7,12 @@ import com.starchive.springapp.hashtag.domain.HashTag; import com.starchive.springapp.hashtag.dto.HashTagDto; import com.starchive.springapp.hashtag.exception.HashTagNotFoundException; +import com.starchive.springapp.hashtag.repository.HashTagRepository; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import com.starchive.springapp.posthashtag.repository.PostHashTagRepository; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -18,6 +24,14 @@ class HashTagServiceTest { @Autowired HashTagService hashTagService; + @Autowired + EntityManager em; + @Autowired + private HashTagRepository hashTagRepository; + + @Autowired + private PostHashTagRepository postHashTagRepository; + @Test public void 해쉬태그_저장_테스트() throws Exception { //given @@ -40,4 +54,44 @@ class HashTagServiceTest { HashTagDto hashTagDto = hashTagService.findOneOrSave("DP"); assertThat(hashTagDto.getName()).isEqualTo("DP"); } + + @Test + public void 해쉬태그_이름_수정_테스트() throws Exception { + //given + HashTag hashTag = hashTagService.save("DP"); + + //when + hashTagService.updateName(hashTag.getId(), "다이나믹 프로그래밍"); + HashTag findOne = hashTagService.findOne("다이나믹 프로그래밍"); + + //then + assertThat(findOne.getId()).isEqualTo(hashTag.getId()); + } + + @Test + public void 해쉬태그_삭제_테스트() throws Exception { + //given + Post post1 = Post.builder().title("타이틀1").author("content").password("1234").createAt(LocalDateTime.now()) + .build(); + Post post2 = Post.builder().title("타이틀2").author("content").password("1234").createAt(LocalDateTime.now()) + .build(); + HashTag hashTag1 = hashTagService.save("DP"); + + PostHashTag postHashTag1 = new PostHashTag(post1, hashTag1); + PostHashTag postHashTag2 = new PostHashTag(post2, hashTag1); + em.persist(post1); + em.persist(post2); + em.persist(postHashTag1); + em.persist(postHashTag2); + em.flush(); + em.clear(); + + //when + hashTagService.delete(hashTag1.getId()); + + //then + assertThat(hashTagRepository.findByName("DP")).isEmpty(); + assertThat(postHashTagRepository.findAllByHashTagId(hashTag1.getId())).isEmpty(); + + } } \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java new file mode 100644 index 0000000..4dbfb6a --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/image/controller/PostImageControllerTest.java @@ -0,0 +1,54 @@ +package com.starchive.springapp.image.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.service.PostImageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(PostImageController.class) +@ExtendWith(MockitoExtension.class) +class PostImageControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private PostImageService postImageService; + + @InjectMocks + private PostImageController postImageController; + + @Test + public void 이미지_업로드_컨트롤러_단위_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String content = "테스트 내용"; + + MockMultipartFile file = new MockMultipartFile("image", path, contentType, content.getBytes()); + + Mockito.when(postImageService.uploadImage(Mockito.any(MockMultipartFile.class))) + .thenReturn(new PostImageDto(1L, "Https://" + path)); + //when + //then + mockMvc.perform(multipart("/postImage") + .file(file) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.id").value(1L)) + .andExpect(jsonPath("$.data.imagePath").value("Https://" + path)); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java new file mode 100644 index 0000000..d7fb81a --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/image/service/PostImageServiceTest.java @@ -0,0 +1,59 @@ +package com.starchive.springapp.image.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.dto.PostImageDto; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.s3.S3Service; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +@ExtendWith(MockitoExtension.class) +class PostImageServiceTest { + @Mock + private S3Service s3Service; + + @Mock + private PostImageRepository postImageRepository; + + @InjectMocks + private PostImageService postImageService; + + @Test + public void 이미지_업로드_서비스로직_단위_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String content = "테스트 내용"; + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, content.getBytes()); + + when(s3Service.saveFile(Mockito.any(MockMultipartFile.class))).thenReturn("https://test.png"); + when(postImageRepository.save(Mockito.any())).then(invocation -> { + PostImage postImage = invocation.getArgument(0); + Field field = postImage.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(postImage, 1L); + return postImage; + }); + + //when + PostImageDto postImageDto = postImageService.uploadImage(file); + + //then + verify(s3Service).saveFile(Mockito.any(MockMultipartFile.class)); + verify(postImageRepository).save(Mockito.any()); + assertThat(postImageDto.getId()).isEqualTo(1L); + assertThat(postImageDto.getImagePath()).contains("test.png"); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/post/controller/PostControllerTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/post/controller/PostControllerTest.java new file mode 100644 index 0000000..18c8e61 --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/post/controller/PostControllerTest.java @@ -0,0 +1,90 @@ +package com.starchive.springapp.post.controller; + + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.service.PostService; +import java.util.Arrays; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(PostController.class) +class PostControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private PostService postService; + + @Test + @DisplayName("게시물 생성 성공") + void createPost_Success() throws Exception { + // Given + PostCreateRequest request = new PostCreateRequest( + "Test Title", + "Test Content", + "Author Name", + "password123", + 1L, + Arrays.asList(101L, 102L), + Arrays.asList(201L, 202L) + ); + + // Mock PostService behavior + Mockito.doNothing().when(postService).createPost(Mockito.any(PostCreateRequest.class)); + + // When & Then) + mockMvc.perform(post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("게시물 생성 실패 - 필수 필드 누락") + void createPost_Failure_MissingFields() throws Exception { + // Given + PostCreateRequest request = new PostCreateRequest(); // 빈 객체로 필수 값 누락 + + // When & Then + mockMvc.perform(post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("게시물 생성 실패 - 제목 길이 초과") + void createPost_Failure_TitleTooLong() throws Exception { + // Given + String longTitle = "a".repeat(65); // 제목 길이를 명시적으로 초과시킴 (65자) + PostCreateRequest request = new PostCreateRequest( + longTitle, + "Test Content", + "Author Name", + "password123", + 1L, + null, + null + ); + + // When & Then + mockMvc.perform(post("/post") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/post/service/PostServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/post/service/PostServiceTest.java new file mode 100644 index 0000000..afee606 --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/post/service/PostServiceTest.java @@ -0,0 +1,78 @@ +package com.starchive.springapp.post.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.starchive.springapp.category.domain.Category; +import com.starchive.springapp.category.repository.CategoryRepository; +import com.starchive.springapp.hashtag.domain.HashTag; +import com.starchive.springapp.hashtag.repository.HashTagRepository; +import com.starchive.springapp.image.domain.PostImage; +import com.starchive.springapp.image.repository.PostImageRepository; +import com.starchive.springapp.post.domain.Post; +import com.starchive.springapp.post.dto.PostCreateRequest; +import com.starchive.springapp.post.repository.PostRepository; +import com.starchive.springapp.posthashtag.domain.PostHashTag; +import com.starchive.springapp.posthashtag.repository.PostHashTagRepository; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class PostServiceTest { + @Autowired + HashTagRepository hashTagRepository; + + @Autowired + CategoryRepository categoryRepository; + + @Autowired + PostImageRepository postImageRepository; + + @Autowired + PostHashTagRepository postHashTagRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + PostService postService; + + @Test + public void 게시글_작성_통합_테스트() throws Exception { + //given + HashTag hashTag = new HashTag("tag1"); + hashTagRepository.save(hashTag); + HashTag hashTag2 = new HashTag("tag2"); + hashTagRepository.save(hashTag2); + + PostImage postImage = new PostImage("imagePath"); + postImageRepository.save(postImage); + + Category category = new Category("예시카테고리", null); + categoryRepository.save(category); + + List hashTagIds = new ArrayList<>(List.of(hashTag.getId(), hashTag2.getId())); + List postImageIds = new ArrayList<>(List.of(postImage.getId())); + + PostCreateRequest postCreateRequest = + new PostCreateRequest("title", "content", "author", "password" + , category.getId(), hashTagIds, postImageIds); + + //when + postService.createPost(postCreateRequest); + + Post createdPost = postRepository.findAll().getFirst(); + List postHashTags = postHashTagRepository.findAll(); + + //then + assertThat(createdPost.getCategory().getId()).isEqualTo(category.getId()); + assertThat(postImage.getPost().getId()).isEqualTo(createdPost.getId()); + assertThat(postHashTags.size()).isEqualTo(2); + + } + +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java new file mode 100644 index 0000000..f01c3fa --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3MockConfig.java @@ -0,0 +1,48 @@ +package com.starchive.springapp.s3; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.AnonymousAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import io.findify.s3mock.S3Mock; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +class S3MockConfig { + + private String testBucketName = "test-bucket-in-memory"; + + private String testRegion = "na-east-1"; + + @Bean + public S3Mock s3Mock() { + return new S3Mock.Builder() + .withPort(8001) + .withInMemoryBackend() + .build(); + } + + @Bean(name = "MockS3Client") + public AmazonS3 amazonS3(S3Mock s3Mock) { + s3Mock.start(); + AwsClientBuilder.EndpointConfiguration endpoint = new AwsClientBuilder.EndpointConfiguration( + "http://localhost:8001", testRegion); + + AmazonS3 client = AmazonS3ClientBuilder + .standard() + .withPathStyleAccessEnabled(true) + .withEndpointConfiguration(endpoint) + .withCredentials(new AWSStaticCredentialsProvider(new AnonymousAWSCredentials())) + .build(); + + client.createBucket(testBucketName); + + return client; + } + + public String getTestBucketName() { + return testBucketName; + } +} \ No newline at end of file diff --git a/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java new file mode 100644 index 0000000..3d0e3cb --- /dev/null +++ b/BACK/spring-app/src/test/java/com/starchive/springapp/s3/S3ServiceTest.java @@ -0,0 +1,64 @@ +package com.starchive.springapp.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.amazonaws.services.s3.AmazonS3; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; + + +@SpringBootTest +@Import(S3MockConfig.class) +class S3ServiceTest { + + @Autowired + @Qualifier("MockS3Client") + private AmazonS3 amazonS3; + + @Autowired + S3MockConfig s3MockConfig; + + @Autowired + private S3Service s3Service; + + @Test + public void 이미지_업로드_테스트() throws Exception { + //given + String path = "test.png"; + String contentType = "image/png"; + String bucket = s3MockConfig.getTestBucketName(); + + MockMultipartFile file = new MockMultipartFile("test", path, contentType, "test".getBytes()); + //Reflection s3Service + Field reflectionFieldFor_amazonS3 = s3Service.getClass().getDeclaredField("amazonS3"); + reflectionFieldFor_amazonS3.setAccessible(true); + reflectionFieldFor_amazonS3.set(s3Service, amazonS3); + + Field reflectionFieldFor_bucket = s3Service.getClass().getDeclaredField("bucket"); + reflectionFieldFor_bucket.setAccessible(true); + reflectionFieldFor_bucket.set(s3Service, bucket); + + //when + String urlPath = s3Service.saveFile(file); + + //then + assertThat(urlPath).contains(bucket); + + amazonS3.listBuckets().forEach(System.out::println); + amazonS3.listObjects(bucket).getObjectSummaries().forEach(System.out::println); + + assertThat(amazonS3.listObjects(bucket).getObjectSummaries().size()).isEqualTo(1); + + String key = urlPath.substring(urlPath.lastIndexOf("/") + 1); + + assertThat(amazonS3.listObjects(bucket).getObjectSummaries().get(0).getKey()) + .isEqualTo(key); + + } + +} \ No newline at end of file diff --git a/FRONT/.gitignore b/FRONT/.gitignore index a547bf3..438657a 100644 --- a/FRONT/.gitignore +++ b/FRONT/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/FRONT/package-lock.json b/FRONT/package-lock.json index 0ad5d39..d393695 100644 --- a/FRONT/package-lock.json +++ b/FRONT/package-lock.json @@ -12,7 +12,9 @@ "react": "^18.3.1", "react-cookie": "^7.2.2", "react-dom": "^18.3.1", + "react-markdown": "^8.0.7", "react-router-dom": "^6.28.0", + "remark-gfm": "^3.0.1", "styled-components": "^6.1.13", "vitest": "^2.1.5", "zustand": "^5.0.1" @@ -21,6 +23,7 @@ "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@types/prismjs": "^1.26.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -30,6 +33,7 @@ "globals": "^15.11.0", "jsdom": "^25.0.1", "msw": "^2.6.4", + "prismjs": "^1.29.0", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", "vite": "^5.4.10" @@ -1603,12 +1607,30 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", @@ -1626,6 +1648,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", @@ -1637,6 +1674,13 @@ "undici-types": "~6.19.8" } }, + "node_modules/@types/prismjs": { + "version": "1.26.5", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", + "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -1683,6 +1727,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.13.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.13.0.tgz", @@ -2192,6 +2242,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2305,6 +2365,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", @@ -2423,6 +2493,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2545,6 +2625,29 @@ "devOptional": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decode-named-character-reference/node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2575,12 +2678,20 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2879,6 +2990,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3106,6 +3223,16 @@ "node": ">=8" } }, + "node_modules/hast-util-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", + "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -3223,6 +3350,35 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3273,6 +3429,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3404,6 +3572,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3448,6 +3625,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3496,128 +3683,938 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/mdast-util-definitions": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", + "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "unist-util-visit": "^4.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "devOptional": true, + "node_modules/mdast-util-find-and-replace": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz", + "integrity": "sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^5.0.0", + "unist-util-visit-parents": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "devOptional": true, + "node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/mdast-util-gfm": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz", + "integrity": "sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==", "license": "MIT", - "engines": { - "node": ">=4" + "dependencies": { + "mdast-util-from-markdown": "^1.0.0", + "mdast-util-gfm-autolink-literal": "^1.0.0", + "mdast-util-gfm-footnote": "^1.0.0", + "mdast-util-gfm-strikethrough": "^1.0.0", + "mdast-util-gfm-table": "^1.0.0", + "mdast-util-gfm-task-list-item": "^1.0.0", + "mdast-util-to-markdown": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz", + "integrity": "sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/mdast": "^3.0.0", + "ccount": "^2.0.0", + "mdast-util-find-and-replace": "^2.0.0", + "micromark-util-character": "^1.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/mdast-util-gfm-footnote": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz", + "integrity": "sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.3.0", + "micromark-util-normalize-identifier": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/msw": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.6.4.tgz", - "integrity": "sha512-Pm4LmWQeytDsNCR+A7gt39XAdtH6zQb6jnIKRig0FlvYOn8eksn3s1nXxUfz5KYUjbckof7Z4p2ewzgffPoCbg==", - "devOptional": true, - "hasInstallScript": true, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz", + "integrity": "sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==", "license": "MIT", "dependencies": { - "@bundled-es-modules/cookie": "^2.0.1", - "@bundled-es-modules/statuses": "^1.0.1", - "@bundled-es-modules/tough-cookie": "^0.1.6", - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.36.5", - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/until": "^2.1.0", - "@types/cookie": "^0.6.0", - "@types/statuses": "^2.0.4", - "chalk": "^4.1.2", - "graphql": "^16.8.1", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "strict-event-emitter": "^0.5.1", - "type-fest": "^4.26.1", - "yargs": "^17.7.2" + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.3.0" }, - "bin": { - "msw": "cli/index.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz", + "integrity": "sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^1.0.0", + "mdast-util-to-markdown": "^1.3.0" }, - "engines": { - "node": ">=18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz", + "integrity": "sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-markdown": "^1.3.0" }, "funding": { - "url": "https://github.com/sponsors/mswjs" + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "unist-util-is": "^5.0.0" }, - "peerDependencies": { - "typescript": ">= 4.8.x" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz", + "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/mdast": "^3.0.0", + "mdast-util-definitions": "^5.0.0", + "micromark-util-sanitize-uri": "^1.1.0", + "trim-lines": "^3.0.0", + "unist-util-generated": "^2.0.0", + "unist-util-position": "^4.0.0", + "unist-util-visit": "^4.0.0" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mute-stream": { + "node_modules/mdast-util-to-markdown": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-1.5.0.tgz", + "integrity": "sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^3.0.0", + "mdast-util-to-string": "^3.0.0", + "micromark-util-decode-string": "^1.0.0", + "unist-util-visit": "^4.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-factory-destination": "^1.0.0", + "micromark-factory-label": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-factory-title": "^1.0.0", + "micromark-factory-whitespace": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-html-tag-name": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz", + "integrity": "sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^1.0.0", + "micromark-extension-gfm-footnote": "^1.0.0", + "micromark-extension-gfm-strikethrough": "^1.0.0", + "micromark-extension-gfm-table": "^1.0.0", + "micromark-extension-gfm-tagfilter": "^1.0.0", + "micromark-extension-gfm-task-list-item": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-types": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz", + "integrity": "sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz", + "integrity": "sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==", + "license": "MIT", + "dependencies": { + "micromark-core-commonmark": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz", + "integrity": "sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==", + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz", + "integrity": "sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==", + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz", + "integrity": "sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz", + "integrity": "sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==", + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.6.4.tgz", + "integrity": "sha512-Pm4LmWQeytDsNCR+A7gt39XAdtH6zQb6jnIKRig0FlvYOn8eksn3s1nXxUfz5KYUjbckof7Z4p2ewzgffPoCbg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.36.5", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "chalk": "^4.1.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", @@ -3666,6 +4663,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3892,6 +4898,37 @@ "license": "MIT", "peer": true }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/psl": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.10.0.tgz", @@ -3985,6 +5022,43 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.7.tgz", + "integrity": "sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/prop-types": "^15.0.0", + "@types/unist": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^2.0.0", + "prop-types": "^15.0.0", + "property-information": "^6.0.0", + "react-is": "^18.0.0", + "remark-parse": "^10.0.0", + "remark-rehype": "^10.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^0.4.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/react-markdown/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -4048,6 +5122,53 @@ "dev": true, "license": "MIT" }, + "node_modules/remark-gfm": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-3.0.1.tgz", + "integrity": "sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-gfm": "^2.0.0", + "micromark-extension-gfm": "^2.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", + "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-from-markdown": "^1.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-10.1.0.tgz", + "integrity": "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/mdast": "^3.0.0", + "mdast-util-to-hast": "^12.1.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4154,6 +5275,18 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4250,6 +5383,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4333,6 +5476,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-object": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", + "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, "node_modules/styled-components": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.13.tgz", @@ -4520,6 +5672,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", @@ -4611,6 +5783,103 @@ "license": "MIT", "peer": true }, + "node_modules/unified": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", + "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "bail": "^2.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", + "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", + "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", + "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", + "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0", + "unist-util-visit-parents": "^5.1.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", + "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universal-cookie": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", @@ -4683,6 +5952,54 @@ "requires-port": "^1.0.0" } }, + "node_modules/uvu": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0", + "diff": "^5.0.0", + "kleur": "^4.0.3", + "sade": "^1.7.3" + }, + "bin": { + "uvu": "bin.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/vfile": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", + "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^3.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", + "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.10", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", @@ -5085,6 +6402,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/FRONT/package.json b/FRONT/package.json index 2c40a1e..3d81dd8 100644 --- a/FRONT/package.json +++ b/FRONT/package.json @@ -15,7 +15,9 @@ "react": "^18.3.1", "react-cookie": "^7.2.2", "react-dom": "^18.3.1", + "react-markdown": "^8.0.7", "react-router-dom": "^6.28.0", + "remark-gfm": "^3.0.1", "styled-components": "^6.1.13", "vitest": "^2.1.5", "zustand": "^5.0.1" @@ -24,6 +26,7 @@ "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", + "@types/prismjs": "^1.26.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -33,6 +36,7 @@ "globals": "^15.11.0", "jsdom": "^25.0.1", "msw": "^2.6.4", + "prismjs": "^1.29.0", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", "vite": "^5.4.10" diff --git a/FRONT/src/components/Aside/category.example.json b/FRONT/public/mock/category.example.json similarity index 100% rename from FRONT/src/components/Aside/category.example.json rename to FRONT/public/mock/category.example.json diff --git a/FRONT/public/mock/posts b/FRONT/public/mock/posts new file mode 100644 index 0000000..b09fdf8 --- /dev/null +++ b/FRONT/public/mock/posts @@ -0,0 +1,172 @@ +[ + { + "categoryHier": [ + { + "categoryId": 1, + "categoryName": "기술" + }, + { + "categoryId": 101, + "categoryName": "프로그래밍" + } + ], + "postId": 1001, + "title": "자바스크립트 입문", + "author": "홍길동", + "createdAt": "2024-11-01", + "content": "자바스크립트는 웹 개발에서 가장 인기 있는 프로그래밍 언어 중 하나입니다. 이 글에서는 기본 개념과 모범 사례를 소개합니다." + }, + { + "categoryHier": [ + { + "categoryId": 2, + "categoryName": "라이프스타일" + }, + { + "categoryId": 201, + "categoryName": "건강" + } + ], + "postId": 1002, + "title": "숙면을 위한 10가지 팁", + "author": "김민지", + "createdAt": "2024-11-02", + "content": "질 좋은 수면은 건강에 필수적입니다. 이 글에서는 수면 개선을 위한 10가지 팁을 공유합니다. 밤 시간 루틴부터 침실 환경 최적화까지 다룹니다." + }, + { + "categoryHier": [ + { + "categoryId": 3, + "categoryName": "엔터테인먼트" + }, + { + "categoryId": 301, + "categoryName": "영화" + } + ], + "postId": 1003, + "title": "2024년 최고의 SF 영화 5선", + "author": "이철수", + "createdAt": "2024-11-03", + "content": "SF 영화는 오랜 시간 동안 관객을 매료시켜 왔습니다. 올해 개봉된 최고의 SF 영화 5편을 소개하고, 그 매력을 살펴봅니다." + }, + { + "categoryHier": [ + { + "categoryId": 4, + "categoryName": "교육" + }, + { + "categoryId": 401, + "categoryName": "온라인 학습" + } + ], + "postId": 1004, + "title": "최고의 온라인 학습 플랫폼", + "author": "박지연", + "createdAt": "2024-11-04", + "content": "온라인 학습은 빠르게 성장하고 있습니다. 이 글에서는 코딩부터 자기계발까지 다양한 주제를 다루는 최고의 플랫폼을 리뷰합니다." + }, + { + "categoryHier": [ + { + "categoryId": 5, + "categoryName": "여행" + }, + { + "categoryId": 501, + "categoryName": "모험" + } + ], + "postId": 1005, + "title": "알프스 탐험 가이드", + "author": "최영수", + "createdAt": "2024-11-05", + "content": "알프스는 숨막히는 풍경과 짜릿한 모험을 제공합니다. 이 글에서는 꼭 방문해야 할 명소와 탐험 팁을 공유합니다." + }, + { + "categoryHier": [ + { + "categoryId": 1, + "categoryName": "기술" + }, + { + "categoryId": 102, + "categoryName": "AI와 머신러닝" + } + ], + "postId": 1006, + "title": "헬스케어에서의 AI의 미래", + "author": "정우성", + "createdAt": "2024-11-06", + "content": "AI는 헬스케어를 혁신적인 방향으로 변화시키고 있습니다. 이 글에서는 현재의 활용 사례와 AI 혁신의 미래를 살펴봅니다." + }, + { + "categoryHier": [ + { + "categoryId": 3, + "categoryName": "엔터테인먼트" + }, + { + "categoryId": 302, + "categoryName": "음악" + } + ], + "postId": 1007, + "title": "2024년 최고의 앨범 10선", + "author": "이지현", + "createdAt": "2024-11-07", + "content": "음악 팬들을 위한 2024년을 정의한 최고의 앨범 10개를 소개합니다. 다양한 장르와 아티스트를 만나보세요." + }, + { + "categoryHier": [ + { + "categoryId": 4, + "categoryName": "교육" + }, + { + "categoryId": 402, + "categoryName": "공부 팁" + } + ], + "postId": 1008, + "title": "시험에서 좋은 성적 받는 법", + "author": "한동훈", + "createdAt": "2024-11-08", + "content": "효과적으로 공부하는 것은 시험 성적에 큰 차이를 만듭니다. 이 글에서는 시험 준비와 스트레스 관리 전략을 공유합니다." + }, + { + "categoryHier": [ + { + "categoryId": 5, + "categoryName": "여행" + }, + { + "categoryId": 502, + "categoryName": "문화 체험" + } + ], + "postId": 1009, + "title": "일본 문화 속으로", + "author": "윤지영", + "createdAt": "2024-11-09", + "content": "일본은 독특한 문화 체험을 제공합니다. 이 글에서는 다도부터 스모 경기까지 일본 전통에 몰입하는 방법을 소개합니다." + }, + { + "categoryHier": [ + { + "categoryId": 2, + "categoryName": "라이프스타일" + }, + { + "categoryId": 202, + "categoryName": "마음챙김" + } + ], + "postId": 1010, + "title": "마음챙김의 예술", + "author": "배수진", + "createdAt": "2024-11-10", + "content": "마음챙김은 현재 순간에 집중하는 삶의 방식입니다. 일상에 마음챙김을 도입하는 간단한 방법들을 이 글에서 확인해보세요." + } +] diff --git a/FRONT/src/App.css b/FRONT/src/App.css index 660c2a1..00041fd 100644 --- a/FRONT/src/App.css +++ b/FRONT/src/App.css @@ -3,6 +3,7 @@ html, body { padding: 0; font-size: 14px; color: var(--text-color); + background-color: var(--background-color); } h1, h2, h3, h4, p { diff --git a/FRONT/src/App.tsx b/FRONT/src/App.tsx index 8323f2b..5f3214b 100644 --- a/FRONT/src/App.tsx +++ b/FRONT/src/App.tsx @@ -1,19 +1,25 @@ import { Route, Routes } from 'react-router-dom' import './App.css' import Home from './pages/Home/Home' +import CreatePost from './pages/CreatePost/CreatePost' import Navbar from '@_components/Navbar/Navbar' import Footer from '@_components/Footer/Footer' -import Aside from './components/Aside/Aside' +import Aside from '@_components/Aside/Aside' +import useLoadingStore from './store/useLoadingStore'; +import LoadingModal from '@_components/LoadingModal/LoadingModal' function App() { + const { isLoading } = useLoadingStore(); return ( <>