diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d38e96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + branches: [develop] + +permissions: + contents: read + +concurrency: + group: "ci-${{ github.ref }}" + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 97328b7..eea7c5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Test + run: npm test + - name: Build run: npm run build diff --git a/content/posts/hello-world.md b/content/posts/hello-world.md index e9e59aa..36532fa 100644 --- a/content/posts/hello-world.md +++ b/content/posts/hello-world.md @@ -2,8 +2,7 @@ title: "Hello World - 첫 번째 블로그 포스트" date: "2026-01-13" description: "Next.js와 TypeScript로 만든 블로그의 첫 번째 포스트입니다." -category: "개발" -subcategory: "Next.js" +category: "블로그" tags: ["tutorial", "beginner"] thumbnail: "/images/thumbnails/nextjs" --- diff --git a/content/posts/pgjdbc-jsonb-typehandler.md b/content/posts/pgjdbc-jsonb-typehandler.md index f4cc481..924433a 100644 --- a/content/posts/pgjdbc-jsonb-typehandler.md +++ b/content/posts/pgjdbc-jsonb-typehandler.md @@ -3,9 +3,9 @@ title: "pgjdbc 42.7.11 JSONB 반환 타입 변경과 TypeHandler 대응" date: "2026-05-06" description: "pgjdbc 42.7.11에서 JSONB 컬럼의 반환 타입이 String에서 PGobject로 변경되었습니다. MyBatis 커스텀 TypeHandler를 글로벌 등록하여 기존 코드 변경 없이 호환성을 유지하는 방법을 정리합니다." category: "개발" -subcategory: "Java" +subcategory: "Spring" tags: ["guide", "intermediate"] -thumbnail: "/images/thumbnails/java" +thumbnail: "/images/thumbnails/springboot" glossary: - id: "pgobject" term: "PGobject" diff --git a/content/posts/spring-boot-i18next-architecture.md b/content/posts/spring-boot-i18next-architecture.md index d590a6e..48ca944 100644 --- a/content/posts/spring-boot-i18next-architecture.md +++ b/content/posts/spring-boot-i18next-architecture.md @@ -3,7 +3,7 @@ title: "Spring Boot에서 i18next로 다국어 구현하기 - localStorage SWR date: "2026-02-27" description: "Spring Boot + Thymeleaf 환경에서 i18next와 localStorage SWR 캐싱을 결합한 서버-클라이언트 하이브리드 다국어 아키텍처를 설계합니다. 기존 jquery.i18n.properties.js의 한계를 극복하고, 캐시 히트 시 네트워크 요청 0회를 달성하는 구조를 다룹니다." category: "개발" -subcategory: "Spring Boot" +subcategory: "Spring" tags: ["guide", "intermediate"] relatedSlugs: - localstorage-swr-caching-pattern diff --git a/src/components/__tests__/ImageHoverPreview.test.tsx b/src/components/__tests__/ImageHoverPreview.test.tsx deleted file mode 100644 index f6afba0..0000000 --- a/src/components/__tests__/ImageHoverPreview.test.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { render, screen, act, fireEvent } from "@testing-library/react"; -import ImageHoverPreview from "../ImageHoverPreview"; - -/** - * ImageHoverPreview 통합 테스트. - * - * document 레벨 mouseover/mouseout 이벤트 위임으로 .prose 내 img를 감지하고, - * createPortal로 document.body에 확대 프리뷰를 렌더한다. - */ - -/** .prose 래퍼 안에 이미지가 있는 페이지를 렌더한다. */ -function renderWithProseImage(alt = "sample image") { - return render( -
-
- {alt} -
-
- outside -
- -
- ); -} - -/** matchMedia를 hover 지원 환경으로 모킹한다. */ -function mockHoverSupported(supported: boolean) { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: jest.fn().mockImplementation((query: string) => ({ - matches: supported && query === "(hover: hover)", - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); -} - -describe("ImageHoverPreview 통합 테스트", () => { - beforeEach(() => { - mockHoverSupported(true); - }); - - // ───────────────────────────────────────────────────── - // 1. 이미지 hover 시 프리뷰 표시 - // ───────────────────────────────────────────────────── - describe("프리뷰 표시", () => { - test("prose 내 이미지에 마우스를 올리면 프리뷰가 표시된다", () => { - renderWithProseImage("테스트 이미지"); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - const preview = document.querySelector(".image-hover-preview"); - expect(preview).toBeInTheDocument(); - - const previewImg = preview?.querySelector("img"); - expect(previewImg?.getAttribute("src")).toContain("test.png"); - expect(previewImg).toHaveAttribute("alt", "테스트 이미지"); - }); - - test("마우스를 이미지에서 벗어나면 프리뷰가 사라진다", () => { - renderWithProseImage(); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).toBeInTheDocument(); - - act(() => { - fireEvent.mouseOut(image); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - }); - - // ───────────────────────────────────────────────────── - // 2. 필터링 - prose 외부 이미지 - // ───────────────────────────────────────────────────── - describe("필터링", () => { - test("prose 외부 이미지에서는 프리뷰가 표시되지 않는다", () => { - renderWithProseImage(); - - const outsideImage = screen.getByTestId("outside-image"); - - act(() => { - fireEvent.mouseOver(outsideImage, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - - test("Lightbox가 열려있을 때 hover 프리뷰가 표시되지 않는다", () => { - renderWithProseImage(); - - // Lightbox 오버레이가 존재하는 상태 시뮬레이션 - const overlay = document.createElement("div"); - overlay.className = "lightbox-overlay"; - document.body.appendChild(overlay); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - - document.body.removeChild(overlay); - }); - }); - - // ───────────────────────────────────────────────────── - // 3. 비정상 프로토콜 이미지 필터링 - // ───────────────────────────────────────────────────── - describe("프로토콜 검증", () => { - test("data: URI 이미지에서는 프리뷰가 표시되지 않는다", () => { - render( -
-
- data uri -
- -
- ); - - const image = screen.getByTestId("data-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - - test("blob: URI 이미지에서는 프리뷰가 표시되지 않는다", () => { - render( -
-
- blob uri -
- -
- ); - - const image = screen.getByTestId("blob-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - }); - - // ───────────────────────────────────────────────────── - // 4. 프리뷰 해제 - scroll, Escape, visibilitychange - // ───────────────────────────────────────────────────── - describe("프리뷰 해제", () => { - test("스크롤 시 프리뷰가 사라진다", () => { - renderWithProseImage(); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).toBeInTheDocument(); - - act(() => { - fireEvent.scroll(window); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - - test("Escape 키 입력 시 프리뷰가 사라진다", () => { - renderWithProseImage(); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).toBeInTheDocument(); - - act(() => { - fireEvent.keyDown(document, { key: "Escape" }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - - test("탭 전환(visibilitychange) 시 프리뷰가 사라진다", () => { - renderWithProseImage(); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).toBeInTheDocument(); - - act(() => { - Object.defineProperty(document, "hidden", { - writable: true, - value: true, - }); - document.dispatchEvent(new Event("visibilitychange")); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - - // cleanup - Object.defineProperty(document, "hidden", { - writable: true, - value: false, - }); - }); - }); - - // ───────────────────────────────────────────────────── - // 5. 모바일 환경 비활성화 - // ───────────────────────────────────────────────────── - describe("모바일 환경", () => { - test("hover 미지원 환경에서는 프리뷰가 표시되지 않는다", () => { - mockHoverSupported(false); - renderWithProseImage(); - - const image = screen.getByTestId("prose-image"); - - act(() => { - fireEvent.mouseOver(image, { clientX: 200, clientY: 300 }); - }); - - expect( - document.querySelector(".image-hover-preview") - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/hooks/__tests__/useImageHoverPreview.test.ts b/src/hooks/__tests__/useImageHoverPreview.test.ts deleted file mode 100644 index bcc61dc..0000000 --- a/src/hooks/__tests__/useImageHoverPreview.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { useImageHoverPreview } from "../useImageHoverPreview"; - -describe("useImageHoverPreview", () => { - // ───────────────────────────────────────────────────── - // 1. 초기 상태 - // ───────────────────────────────────────────────────── - describe("초기 상태", () => { - test("초기 상태에서 프리뷰가 보이지 않는다", () => { - const { result } = renderHook(() => useImageHoverPreview()); - - expect(result.current.isVisible).toBe(false); - expect(result.current.imageSrc).toBeNull(); - expect(result.current.imageAlt).toBe(""); - }); - }); - - // ───────────────────────────────────────────────────── - // 2. showPreview 호출 - // ───────────────────────────────────────────────────── - describe("showPreview", () => { - test("showPreview 호출 시 프리뷰 상태가 업데이트된다", () => { - const { result } = renderHook(() => useImageHoverPreview()); - - act(() => { - result.current.showPreview("/test.png", "테스트 이미지"); - }); - - expect(result.current.isVisible).toBe(true); - expect(result.current.imageSrc).toBe("/test.png"); - expect(result.current.imageAlt).toBe("테스트 이미지"); - }); - }); - - // ───────────────────────────────────────────────────── - // 3. hidePreview 호출 - // ───────────────────────────────────────────────────── - describe("hidePreview", () => { - test("hidePreview 호출 시 초기 상태로 돌아간다", () => { - const { result } = renderHook(() => useImageHoverPreview()); - - act(() => { - result.current.showPreview("/test.png", "테스트"); - }); - - expect(result.current.isVisible).toBe(true); - - act(() => { - result.current.hidePreview(); - }); - - expect(result.current.isVisible).toBe(false); - expect(result.current.imageSrc).toBeNull(); - expect(result.current.imageAlt).toBe(""); - }); - }); -});