Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7574d3a72c | |||
| f087ae5f2c | |||
| 5832b64d39 | |||
| c331d5e14a | |||
| 3e223a45d4 | |||
| d0766fca45 | |||
| 555d00e060 | |||
| c3de7df252 | |||
| 9e75989c5d | |||
| dbe76503d9 | |||
| ed093bf04f | |||
| a5e846a1e8 | |||
| 198acd8db1 | |||
| 5414db8e49 | |||
| 44869b7514 | |||
| 6ad731b6e2 | |||
| eba4b0eeee | |||
| 5f541e9524 | |||
| 1d9548862a | |||
| 64d2e9d1af | |||
| 637d834cd7 | |||
| 64e4a59244 | |||
| 25162e4c12 | |||
| 23392d6ac2 | |||
| 73f42078b7 |
+45
-5
@@ -9,8 +9,6 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
|
||||||
image: mcr.microsoft.com/playwright:v1.56.1-jammy
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -31,6 +29,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
|
|
||||||
|
- name: Build Next app
|
||||||
|
env:
|
||||||
|
# 빌드 시에도 Prisma Client 가 필요하므로 더미 DATABASE_URL 을 사용한다.
|
||||||
|
DATABASE_URL: postgresql://example:example@localhost:5432/example
|
||||||
|
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: "1"
|
||||||
|
run: |
|
||||||
|
npm run build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
@@ -42,10 +48,44 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npm test
|
npm test
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: app_password
|
||||||
|
POSTGRES_DB: page_builder
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U app -d page_builder"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=3s
|
||||||
|
--health-retries=20
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://app:app_password@postgres:5432/page_builder?schema=public
|
||||||
|
AUTH_JWT_SECRET: ${{ secrets.AUTH_JWT_SECRET }}
|
||||||
|
AUTH_ENCRYPTION_KEY: ${{ secrets.AUTH_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run Prisma migrations
|
||||||
|
run: npx prisma migrate deploy
|
||||||
|
|
||||||
|
- name: Build Next.js app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
run: npm run e2e
|
||||||
run: |
|
|
||||||
npx playwright test
|
|
||||||
|
|
||||||
pr_and_merge:
|
pr_and_merge:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
Generated
+135
-2
@@ -12,7 +12,9 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "^16.0.3",
|
"next": "^16.0.3",
|
||||||
@@ -26,6 +28,8 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/bcryptjs": "^2.4.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
@@ -2789,6 +2793,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/chai": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -2828,6 +2839,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/jszip": {
|
"node_modules/@types/jszip": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
||||||
@@ -2838,6 +2860,13 @@
|
|||||||
"jszip": "*"
|
"jszip": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "24.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||||
@@ -3924,6 +3953,12 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bidi-js": {
|
"node_modules/bidi-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
@@ -3992,6 +4027,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/c12": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
@@ -4573,6 +4614,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.18.4",
|
"version": "3.18.4",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||||
@@ -6639,6 +6689,28 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@@ -6667,6 +6739,27 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -7040,6 +7133,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -7047,6 +7176,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/log-update": {
|
"node_modules/log-update": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||||
@@ -7211,7 +7346,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nano-spawn": {
|
"node_modules/nano-spawn": {
|
||||||
@@ -8342,7 +8476,6 @@
|
|||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"start:e2e": "next start -p 3000",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
@@ -18,7 +19,9 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "^16.0.3",
|
"next": "^16.0.3",
|
||||||
@@ -33,6 +36,8 @@
|
|||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
|
"@types/bcryptjs": "^2.4.2",
|
||||||
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const isCI = !!process.env.CI;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -17,9 +19,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run dev",
|
// CI 에서는 프로덕션 빌드 서버(next start)를 대상으로 E2E 를 수행해
|
||||||
|
// dev 모드의 느린 HMR/StrictMode 2회 렌더로 인한 flakiness 를 줄인다.
|
||||||
|
command: isCI ? "npm run start:e2e" : "npm run dev",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !isCI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "userId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"tokenVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FormSubmission" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT,
|
||||||
|
"projectSlug" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"payloadJson" JSONB NOT NULL,
|
||||||
|
"sensitiveEnc" TEXT,
|
||||||
|
"metaJson" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "FormSubmission_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FormSubmission" ADD CONSTRAINT "FormSubmission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
+34
-2
@@ -5,8 +5,8 @@
|
|||||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client-js"
|
||||||
output = "../src/generated/prisma"
|
binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -24,6 +24,25 @@ model Project {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
assets Asset[]
|
assets Asset[]
|
||||||
|
|
||||||
|
formSubmissions FormSubmission[]
|
||||||
|
|
||||||
|
// 인증 도입을 위한 관계: 프로젝트는 특정 User 가 소유할 수 있다.
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
tokenVersion Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
projects Project[]
|
||||||
|
|
||||||
|
formSubmissions FormSubmission[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Asset {
|
model Asset {
|
||||||
@@ -42,3 +61,16 @@ enum ProjectStatus {
|
|||||||
PUBLISHED
|
PUBLISHED
|
||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model FormSubmission {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
projectId String?
|
||||||
|
projectSlug String
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
userId String?
|
||||||
|
payloadJson Json
|
||||||
|
sensitiveEnc String?
|
||||||
|
metaJson Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
import { verifyPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
// /api/auth/login
|
||||||
|
// - 이메일/비밀번호를 검증해 로그인시키고,
|
||||||
|
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => null as any);
|
||||||
|
|
||||||
|
const emailRaw = body?.email;
|
||||||
|
const password: string | undefined = body?.password;
|
||||||
|
|
||||||
|
if (typeof emailRaw !== "string" || typeof password !== "string") {
|
||||||
|
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailRaw.trim().toLowerCase();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyPassword(password, user.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await signAccessToken({ id: user.id, email: user.email, tokenVersion: user.tokenVersion });
|
||||||
|
|
||||||
|
const { passwordHash: _ph, ...safeUser } = user;
|
||||||
|
|
||||||
|
const res = NextResponse.json(safeUser, { status: 200 });
|
||||||
|
|
||||||
|
res.cookies.set("pb_access", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 7 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "로그인 처리 중 오류가 발생했습니다.", error: error?.message },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
// /api/auth/logout
|
||||||
|
// - 클라이언트의 pb_access 세션 쿠키를 제거해 로그아웃을 수행한다.
|
||||||
|
|
||||||
|
export async function POST(_request: Request) {
|
||||||
|
const res = NextResponse.json({ message: "로그아웃 되었습니다." }, { status: 200 });
|
||||||
|
|
||||||
|
// pb_access 쿠키를 즉시 만료시켜 로그아웃 상태로 만든다.
|
||||||
|
res.cookies.set("pb_access", "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
// /api/auth/me
|
||||||
|
// - 요청 쿠키에 포함된 JWT(pb_access)를 검증하고,
|
||||||
|
// - 유효한 경우 토큰에서 식별 가능한 유저 정보를 반환한다.
|
||||||
|
|
||||||
|
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
|
const parts = cookieHeader.split(";");
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed.startsWith("pb_access=")) {
|
||||||
|
const value = trimmed.substring("pb_access=".length);
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const cookieHeader = request.headers.get("cookie");
|
||||||
|
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ message: "인증 정보가 없습니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await verifyAccessToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return NextResponse.json({ message: "유효하지 않은 토큰입니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
tokenVersion: payload.tokenVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(user, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
// /api/auth/signup
|
||||||
|
// - 이메일/비밀번호를 받아 새 유저를 생성하고,
|
||||||
|
// - JWT 액세스 토큰을 httpOnly 쿠키에 설정한 뒤, 비밀번호 해시를 제외한 유저 정보를 반환한다.
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => null as any);
|
||||||
|
|
||||||
|
const emailRaw = body?.email;
|
||||||
|
const password: string | undefined = body?.password;
|
||||||
|
|
||||||
|
if (typeof emailRaw !== "string" || typeof password !== "string") {
|
||||||
|
return NextResponse.json({ message: "email 과 password 는 필수입니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = emailRaw.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json({ message: "비밀번호는 최소 8자 이상이어야 합니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 존재하는 이메일인지 확인한다.
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ message: "이미 가입된 이메일입니다." }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호를 해시한 뒤 유저를 생성한다.
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
tokenVersion: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await signAccessToken({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
tokenVersion: user.tokenVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { passwordHash: _ph, ...safeUser } = user;
|
||||||
|
|
||||||
|
const res = NextResponse.json(safeUser, { status: 201 });
|
||||||
|
|
||||||
|
res.cookies.set("pb_access", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 7 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "회원가입 처리 중 오류가 발생했습니다.", error: error?.message },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,9 +250,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
let innerHtml = escapeHtml(label);
|
||||||
|
|
||||||
|
if (typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "") {
|
||||||
|
const imgSrc = escapeAttr((props as any).imageSrc.trim());
|
||||||
|
const altRaw = (props as any).imageAlt as string | undefined;
|
||||||
|
const altValue = altRaw && altRaw.trim() !== "" ? altRaw.trim() : label;
|
||||||
|
const imgAlt = escapeAttr(altValue);
|
||||||
|
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||||
href,
|
href,
|
||||||
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
|
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "video") {
|
if (block.type === "video") {
|
||||||
@@ -355,11 +365,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
const configJson = JSON.stringify(props ?? {});
|
const configJson = JSON.stringify(props ?? {});
|
||||||
const escapedConfig = escapeAttr(configJson);
|
const escapedConfig = escapeAttr(configJson);
|
||||||
|
|
||||||
|
const projectSlugRaw = (projectConfig?.slug ?? "").trim();
|
||||||
|
const projectSlugValue = projectSlugRaw !== "" ? projectSlugRaw : "";
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push(
|
parts.push(
|
||||||
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
`<form id="${escapeAttr(formId)}" class="pb-form-controller"${methodAttr}${actionAttr}>`,
|
||||||
);
|
);
|
||||||
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
parts.push(`<input type="hidden" name="__config" value="${escapedConfig}" />`);
|
||||||
|
if (projectSlugValue) {
|
||||||
|
parts.push(
|
||||||
|
`<input type="hidden" name="__projectSlug" value="${escapeAttr(projectSlugValue)}" />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
parts.push("</form>");
|
parts.push("</form>");
|
||||||
return parts.join("");
|
return parts.join("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,158 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { FormBlockProps } from "@/features/editor/state/editorStore";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import type { FormBlockProps, FormFieldConfig } from "@/features/editor/state/editorStore";
|
||||||
|
import { encryptJson } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient() as any;
|
||||||
|
|
||||||
|
function buildSensitiveFieldNameSet(config: FormBlockProps | null): Set<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
|
const fields: FormFieldConfig[] = Array.isArray(config?.fields) ? (config!.fields as FormFieldConfig[]) : [];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const name = (field.name ?? "").toLowerCase();
|
||||||
|
const label = (field.label ?? "").toLowerCase();
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const isEmail =
|
||||||
|
field.type === "email" ||
|
||||||
|
name.includes("email") ||
|
||||||
|
label.includes("email") ||
|
||||||
|
label.includes("이메일");
|
||||||
|
|
||||||
|
if (isEmail) {
|
||||||
|
result.add(field.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPhone =
|
||||||
|
name.includes("phone") ||
|
||||||
|
name.includes("tel") ||
|
||||||
|
name.includes("mobile") ||
|
||||||
|
label.includes("전화") ||
|
||||||
|
label.includes("연락처") ||
|
||||||
|
label.includes("휴대폰");
|
||||||
|
|
||||||
|
if (isPhone) {
|
||||||
|
result.add(field.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBirth =
|
||||||
|
name.includes("birth") ||
|
||||||
|
name.includes("birthday") ||
|
||||||
|
name.includes("dob") ||
|
||||||
|
label.includes("생년월일") ||
|
||||||
|
label.includes("생일");
|
||||||
|
|
||||||
|
if (isBirth) {
|
||||||
|
result.add(field.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistFormSubmission(formData: FormData, config: FormBlockProps | null) {
|
||||||
|
const projectSlugRaw = formData.get("__projectSlug");
|
||||||
|
let projectSlug: string | null = null;
|
||||||
|
|
||||||
|
if (typeof projectSlugRaw === "string" && projectSlugRaw.trim() !== "") {
|
||||||
|
projectSlug = projectSlugRaw.trim();
|
||||||
|
} else if (config?.extraParams && typeof config.extraParams.projectSlug === "string") {
|
||||||
|
const v = config.extraParams.projectSlug.trim();
|
||||||
|
if (v !== "") {
|
||||||
|
projectSlug = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensitiveNames = buildSensitiveFieldNameSet(config);
|
||||||
|
|
||||||
|
formData.forEach((_, key) => {
|
||||||
|
if (key === "__config" || key === "__projectSlug") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
|
||||||
|
const isEmailField =
|
||||||
|
lower.includes("email") ||
|
||||||
|
lower.includes("e-mail") ||
|
||||||
|
lower.includes("이메일");
|
||||||
|
|
||||||
|
const isPhoneField =
|
||||||
|
lower.includes("phone") ||
|
||||||
|
lower.includes("tel") ||
|
||||||
|
lower.includes("mobile") ||
|
||||||
|
lower.includes("전화") ||
|
||||||
|
lower.includes("연락처") ||
|
||||||
|
lower.includes("휴대폰");
|
||||||
|
|
||||||
|
const isBirthField =
|
||||||
|
lower.includes("birth") ||
|
||||||
|
lower.includes("birthday") ||
|
||||||
|
lower.includes("birthdate") ||
|
||||||
|
lower.includes("dob") ||
|
||||||
|
lower.includes("생년월일") ||
|
||||||
|
lower.includes("생일");
|
||||||
|
|
||||||
|
if (isEmailField || isPhoneField || isBirthField) {
|
||||||
|
sensitiveNames.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const payloadJson: Record<string, string> = {};
|
||||||
|
const sensitivePayload: Record<string, string> = {};
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
if (key === "__config" || key === "__projectSlug") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strValue = String(value);
|
||||||
|
|
||||||
|
if (sensitiveNames.has(key)) {
|
||||||
|
sensitivePayload[key] = strValue;
|
||||||
|
} else {
|
||||||
|
payloadJson[key] = strValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let sensitiveEnc: string | null = null;
|
||||||
|
if (Object.keys(sensitivePayload).length > 0) {
|
||||||
|
sensitiveEnc = await encryptJson(sensitivePayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectId: string | null = null;
|
||||||
|
let userId: string | null = null;
|
||||||
|
|
||||||
|
if (projectSlug) {
|
||||||
|
const project = await prisma.project.findUnique({ where: { slug: projectSlug } });
|
||||||
|
if (project) {
|
||||||
|
projectId = project.id;
|
||||||
|
userId = project.userId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.formSubmission.create({
|
||||||
|
data: {
|
||||||
|
projectSlug: projectSlug ?? "",
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
payloadJson,
|
||||||
|
sensitiveEnc,
|
||||||
|
metaJson: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
|
|
||||||
// 기본 필드(name/email/message)는 그대로 읽어둔다.
|
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
const email = formData.get("email");
|
const email = formData.get("email");
|
||||||
const message = formData.get("message");
|
const message = formData.get("message");
|
||||||
|
|
||||||
// 에디터에서 넘겨준 폼 설정(JSON) 읽기
|
|
||||||
const rawConfig = formData.get("__config");
|
const rawConfig = formData.get("__config");
|
||||||
let config: FormBlockProps | null = null;
|
let config: FormBlockProps | null = null;
|
||||||
if (typeof rawConfig === "string") {
|
if (typeof rawConfig === "string") {
|
||||||
@@ -24,18 +167,17 @@ export async function POST(req: Request) {
|
|||||||
const successMessage = config?.successMessage;
|
const successMessage = config?.successMessage;
|
||||||
const errorMessage = config?.errorMessage;
|
const errorMessage = config?.errorMessage;
|
||||||
|
|
||||||
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
|
|
||||||
if (submitTarget === "internal") {
|
if (submitTarget === "internal") {
|
||||||
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
|
await persistFormSubmission(formData, config);
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
console.log("[forms/submit][internal]", { name, email, message });
|
||||||
return NextResponse.json({ ok: true, message: successMessage });
|
return NextResponse.json({ ok: true, message: successMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (submitTarget === "both") {
|
if (submitTarget === "both") {
|
||||||
|
await persistFormSubmission(formData, config);
|
||||||
console.log("[forms/submit][internal]", { name, email, message });
|
console.log("[forms/submit][internal]", { name, email, message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
|
|
||||||
if (submitTarget === "webhook" || submitTarget === "both") {
|
if (submitTarget === "webhook" || submitTarget === "both") {
|
||||||
const destinationUrl = config?.destinationUrl;
|
const destinationUrl = config?.destinationUrl;
|
||||||
if (!destinationUrl) {
|
if (!destinationUrl) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
// 업로드된 이미지 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
// 업로드된 이미지 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||||
|
|||||||
@@ -1,16 +1,60 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET(_request: Request, context: { params: { slug: string } | Promise<{ slug: string }> }) {
|
interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
|
const parts = cookieHeader.split(";");
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed.startsWith("pb_access=")) {
|
||||||
|
const value = trimmed.substring("pb_access=".length);
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||||
|
const cookieHeader = request.headers.get("cookie");
|
||||||
|
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payload = await verifyAccessToken(token);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
tokenVersion: payload.tokenVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const authUser = await getAuthUserFromRequest(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { slug } = await context.params;
|
const { slug } = await context.params;
|
||||||
|
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||||
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,12 +62,25 @@ export async function GET(_request: Request, context: { params: { slug: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: Request,
|
request: Request,
|
||||||
context: { params: { slug: string } | Promise<{ slug: string }> },
|
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||||
) {
|
) {
|
||||||
|
const authUser = await getAuthUserFromRequest(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 삭제하려면 로그인이 필요합니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { slug } = await context.params;
|
const { slug } = await context.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.project.delete({
|
await prisma.project.delete({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { verifyAccessToken, decryptJson } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
|
const parts = cookieHeader.split(";");
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed.startsWith("pb_access=")) {
|
||||||
|
const value = trimmed.substring("pb_access=".length);
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||||
|
const cookieHeader = request.headers.get("cookie");
|
||||||
|
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payload = await verifyAccessToken(token);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
tokenVersion: payload.tokenVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: { params: { slug: string } | Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const authUser = await getAuthUserFromRequest(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = await context.params;
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project || (project.userId && project.userId !== authUser.id)) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 찾을 수 없습니다." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions = await prisma.formSubmission.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = await Promise.all(
|
||||||
|
submissions.map(async (s: any) => {
|
||||||
|
const basePayload = (s.payloadJson ?? {}) as Record<string, unknown>;
|
||||||
|
let sensitive: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (s.sensitiveEnc) {
|
||||||
|
try {
|
||||||
|
sensitive = (await decryptJson<Record<string, unknown>>(s.sensitiveEnc)) ?? {};
|
||||||
|
} catch {
|
||||||
|
sensitive = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { ...basePayload, ...sensitive };
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
projectSlug: s.projectSlug,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(items, { status: 200 });
|
||||||
|
}
|
||||||
@@ -1,11 +1,53 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET(_request: Request) {
|
interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
|
const parts = cookieHeader.split(";");
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (trimmed.startsWith("pb_access=")) {
|
||||||
|
const value = trimmed.substring("pb_access=".length);
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||||
|
const cookieHeader = request.headers.get("cookie");
|
||||||
|
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payload = await verifyAccessToken(token);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
tokenVersion: payload.tokenVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const authUser = await getAuthUserFromRequest(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { userId: authUser.id },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50,
|
take: 50,
|
||||||
select: {
|
select: {
|
||||||
@@ -28,6 +70,11 @@ export async function GET(_request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const authUser = await getAuthUserFromRequest(request);
|
||||||
|
if (!authUser) {
|
||||||
|
return NextResponse.json({ message: "프로젝트를 저장하려면 로그인이 필요합니다." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, slug, contentJson } = body;
|
const { title, slug, contentJson } = body;
|
||||||
|
|
||||||
@@ -36,6 +83,17 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existing = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing && existing.userId && existing.userId !== authUser.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const project = await prisma.project.upsert({
|
const project = await prisma.project.upsert({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
update: {
|
update: {
|
||||||
@@ -46,6 +104,7 @@ export async function POST(request: Request) {
|
|||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
contentJson,
|
contentJson,
|
||||||
|
userId: authUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||||
|
|
||||||
@@ -15,14 +15,22 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
params: {
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
};
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: Request, ctx: Params) {
|
export async function GET(request: Request, ctx: Params) {
|
||||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||||
let id: string | undefined = ctx?.params?.id;
|
let id: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await ctx.params;
|
||||||
|
id = resolved?.id;
|
||||||
|
} catch {
|
||||||
|
id = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { PrismaClient } from "@/generated/prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||||
|
|||||||
@@ -233,10 +233,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
)
|
)
|
||||||
.map((fieldBlock) => {
|
.map((fieldBlock) => {
|
||||||
const fieldId = fieldBlock.id;
|
const fieldId = fieldBlock.id;
|
||||||
const fieldLabel = (fieldBlock.props as any).label ??
|
const anyProps: any = fieldBlock.props ?? {};
|
||||||
(fieldBlock.props as any).groupLabel ??
|
|
||||||
(fieldBlock.props as any).formFieldName ??
|
const transmissionKey: string | undefined = anyProps.formFieldName;
|
||||||
fieldId;
|
const labelText: string | undefined = anyProps.label ?? anyProps.groupLabel;
|
||||||
|
|
||||||
|
const displayLabel = transmissionKey
|
||||||
|
? labelText && labelText !== transmissionKey
|
||||||
|
? `${transmissionKey} (${labelText})`
|
||||||
|
: transmissionKey
|
||||||
|
: labelText || fieldId;
|
||||||
|
|
||||||
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
||||||
|
|
||||||
@@ -260,7 +266,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
} as any);
|
} as any);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{fieldLabel}</span>
|
<span>{displayLabel}</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -283,9 +289,17 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
.filter((b) => b.type === "button")
|
.filter((b) => b.type === "button")
|
||||||
.map((buttonBlock) => {
|
.map((buttonBlock) => {
|
||||||
const btnProps = buttonBlock.props as ButtonBlockProps;
|
const btnProps = buttonBlock.props as ButtonBlockProps;
|
||||||
|
const transmissionKey = btnProps.formFieldName;
|
||||||
|
const labelText = btnProps.label;
|
||||||
|
const displayLabel = transmissionKey
|
||||||
|
? labelText && labelText !== transmissionKey
|
||||||
|
? `${transmissionKey} (${labelText})`
|
||||||
|
: transmissionKey
|
||||||
|
: labelText || buttonBlock.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<option key={buttonBlock.id} value={buttonBlock.id}>
|
<option key={buttonBlock.id} value={buttonBlock.id}>
|
||||||
{btnProps.label || buttonBlock.id}
|
{displayLabel}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+206
-18
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -70,6 +71,16 @@ import { PropertiesSidebar } from "./panels/PropertiesSidebar";
|
|||||||
import { EditorCanvas } from "./EditorCanvas";
|
import { EditorCanvas } from "./EditorCanvas";
|
||||||
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<EditorPageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorPageInner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const blocks = useEditorStore((state) => state.blocks);
|
const blocks = useEditorStore((state) => state.blocks);
|
||||||
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
||||||
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
||||||
@@ -119,6 +130,13 @@ export default function EditorPage() {
|
|||||||
(state) => (state as any).projectConfig as ProjectConfig,
|
(state) => (state as any).projectConfig as ProjectConfig,
|
||||||
);
|
);
|
||||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||||
|
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
||||||
|
|
||||||
|
const [authUserId, setAuthUserId] = useState<string | null>(null);
|
||||||
|
const [authChecked, setAuthChecked] = useState(false);
|
||||||
|
|
||||||
|
const [initialSlugFromQuery, setInitialSlugFromQuery] = useState<string | null>(null);
|
||||||
|
const [hasLoadedInitialProjectFromSlug, setHasLoadedInitialProjectFromSlug] = useState(false);
|
||||||
|
|
||||||
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||||
const [editingText, setEditingText] = useState("");
|
const [editingText, setEditingText] = useState("");
|
||||||
@@ -138,6 +156,115 @@ export default function EditorPage() {
|
|||||||
const canUndo = historyLength > 0;
|
const canUndo = historyLength > 0;
|
||||||
const canRedo = futureLength > 0;
|
const canRedo = futureLength > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchParams) return;
|
||||||
|
|
||||||
|
const slugParam = searchParams.get("slug");
|
||||||
|
if (slugParam && !initialSlugFromQuery) {
|
||||||
|
setInitialSlugFromQuery(slugParam);
|
||||||
|
}
|
||||||
|
}, [searchParams, initialSlugFromQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialSlugFromQuery) return;
|
||||||
|
const trimmed = initialSlugFromQuery.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const currentSlug = (projectConfig?.slug ?? "").trim();
|
||||||
|
if (currentSlug !== trimmed) {
|
||||||
|
updateProjectConfig({ slug: trimmed });
|
||||||
|
}
|
||||||
|
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authChecked) return;
|
||||||
|
if (!initialSlugFromQuery) return;
|
||||||
|
if (hasLoadedInitialProjectFromSlug) return;
|
||||||
|
|
||||||
|
const slug = initialSlugFromQuery.trim();
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadFromServer = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok || cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && Array.isArray(data.contentJson)) {
|
||||||
|
replaceBlocks(data.contentJson as any);
|
||||||
|
if (data.title && typeof data.title === "string") {
|
||||||
|
updateProjectConfig({ title: data.title, slug });
|
||||||
|
} else {
|
||||||
|
updateProjectConfig({ slug });
|
||||||
|
}
|
||||||
|
// 다른 slug/사용자에서 불러온 뒤에는 이전 프로젝트의 undo/redo 히스토리를 끊어준다.
|
||||||
|
resetHistory();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 서버 로드 실패는 조용히 무시하고, 사용자가 수동으로 불러오기 할 수 있도록 둔다.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasLoadedInitialProjectFromSlug(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadFromServer();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
let data: any = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data.id === "string") {
|
||||||
|
setAuthUserId(data.id);
|
||||||
|
} else {
|
||||||
|
setAuthUserId(null);
|
||||||
|
}
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
setAuthUserId(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthUserId(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthChecked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkAuth();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
||||||
|
|
||||||
@@ -286,6 +413,17 @@ export default function EditorPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (response.status === 409) {
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && typeof (data as any).message === "string") {
|
||||||
|
setProjectMessage((data as any).message as string);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setProjectMessage("프로젝트 저장에 실패했습니다.");
|
setProjectMessage("프로젝트 저장에 실패했습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -319,6 +457,15 @@ export default function EditorPage() {
|
|||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed && Array.isArray(parsed.contentJson)) {
|
if (parsed && Array.isArray(parsed.contentJson)) {
|
||||||
replaceBlocks(parsed.contentJson as any);
|
replaceBlocks(parsed.contentJson as any);
|
||||||
|
if (parsed.title && typeof parsed.title === "string") {
|
||||||
|
updateProjectConfig({ title: parsed.title, slug });
|
||||||
|
} else {
|
||||||
|
updateProjectConfig({ slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 프로젝트(slug)를 불러왔으므로 이전 history/future 를 초기화한다.
|
||||||
|
resetHistory();
|
||||||
|
|
||||||
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -338,6 +485,15 @@ export default function EditorPage() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && Array.isArray(data.contentJson)) {
|
if (data && Array.isArray(data.contentJson)) {
|
||||||
replaceBlocks(data.contentJson as any);
|
replaceBlocks(data.contentJson as any);
|
||||||
|
if (data.title && typeof data.title === "string") {
|
||||||
|
updateProjectConfig({ title: data.title, slug });
|
||||||
|
} else {
|
||||||
|
updateProjectConfig({ slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다.
|
||||||
|
resetHistory();
|
||||||
|
|
||||||
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
||||||
} else {
|
} else {
|
||||||
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
||||||
@@ -552,6 +708,7 @@ export default function EditorPage() {
|
|||||||
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!authChecked) return;
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const slug = projectConfig?.slug?.trim();
|
const slug = projectConfig?.slug?.trim();
|
||||||
@@ -562,17 +719,35 @@ export default function EditorPage() {
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig };
|
const parsed = JSON.parse(raw) as {
|
||||||
|
blocks?: Block[];
|
||||||
|
projectConfig?: ProjectConfig;
|
||||||
|
savedByUserId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedByUserId = parsed.savedByUserId ?? null;
|
||||||
|
|
||||||
|
if (authUserId) {
|
||||||
|
if (savedByUserId && savedByUserId !== authUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (savedByUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(parsed.blocks)) {
|
if (Array.isArray(parsed.blocks)) {
|
||||||
replaceBlocks(parsed.blocks as any);
|
replaceBlocks(parsed.blocks as any);
|
||||||
}
|
}
|
||||||
if (parsed.projectConfig) {
|
if (parsed.projectConfig) {
|
||||||
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autosave 로 전체 프로젝트를 복원한 경우에도 이전 history/future 는 끊어준다.
|
||||||
|
resetHistory();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
}, [authChecked, authUserId, projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -581,9 +756,22 @@ export default function EditorPage() {
|
|||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
||||||
const key = `pb:autosave:${slug}`;
|
const key = `pb:autosave:${slug}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(key);
|
||||||
|
if (existing && !authChecked) {
|
||||||
|
// 초기 렌더 시 이미 autosave 스냅샷이 있는 경우,
|
||||||
|
// 인증 체크 및 복원(useEffect)이 끝나기 전까지는 기존 스냅샷을 덮어쓰지 않는다.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 기존 값 조회 에러는 무시하고, 아래 저장 시도는 계속 진행한다.
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
blocks,
|
blocks,
|
||||||
projectConfig,
|
projectConfig,
|
||||||
|
savedByUserId: authUserId ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -591,7 +779,7 @@ export default function EditorPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [blocks, projectConfig]);
|
}, [blocks, projectConfig, authUserId, authChecked]);
|
||||||
|
|
||||||
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
||||||
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
||||||
@@ -825,19 +1013,6 @@ export default function EditorPage() {
|
|||||||
<span>JSON 내보내기/불러오기</span>
|
<span>JSON 내보내기/불러오기</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
void handleDeleteProject();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-2">
|
|
||||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
|
||||||
<span>프로젝트 삭제</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
|
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
|
||||||
@@ -866,6 +1041,19 @@ export default function EditorPage() {
|
|||||||
<span>캔버스 초기화</span>
|
<span>캔버스 초기화</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
void handleDeleteProject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2 text-red-500">
|
||||||
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||||
|
<span>프로젝트 삭제</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -964,7 +1152,7 @@ export default function EditorPage() {
|
|||||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||||
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-medium">JSON Export / Import</h3>
|
<h3 className="text-sm font-medium">JSON Export / Import <span className="text-xs text-slate-400">* 에디터 내용을 가져오거나 내보낼 수 있습니다.</span></h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-slate-400 hover:text-slate-100 text-sm"
|
className="text-slate-400 hover:text-slate-100 text-sm"
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export type ButtonPropertiesPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
||||||
|
const imageSource: "none" | "url" | "upload" = (() => {
|
||||||
|
const hasSrc = (buttonProps.imageSrc ?? "").trim() !== "";
|
||||||
|
if (!hasSrc) return "none";
|
||||||
|
if (buttonProps.imageSourceType === "asset") return "upload";
|
||||||
|
return "url";
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -26,6 +33,148 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||||
|
<h4 className="text-[11px] font-semibold text-slate-200">버튼 이미지</h4>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 소스</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 소스"
|
||||||
|
value={imageSource}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value as "none" | "url" | "upload";
|
||||||
|
if (next === "none") {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSrc: "",
|
||||||
|
imageAssetId: null,
|
||||||
|
} as any);
|
||||||
|
} else if (next === "url") {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
imageAssetId: null,
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSourceType: "asset",
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="none">사용 안 함</option>
|
||||||
|
<option value="url">URL</option>
|
||||||
|
<option value="upload">파일 업로드</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageSource === "url" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 URL</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 URL"
|
||||||
|
value={buttonProps.imageSrc ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSrc: value,
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
imageAssetId: null,
|
||||||
|
} as any);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageSource === "upload" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 파일 업로드</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||||
|
aria-label="버튼 이미지 파일 업로드"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch("/api/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("버튼 이미지 업로드 실패", await response.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||||
|
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||||
|
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSrc: servedUrl,
|
||||||
|
imageSourceType: "asset",
|
||||||
|
imageAssetId: data.id,
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("버튼 이미지 업로드 중 오류", error);
|
||||||
|
} finally {
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageSource !== "none" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 대체 텍스트</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 대체 텍스트"
|
||||||
|
value={buttonProps.imageAlt ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateBlock(selectedBlockId, { imageAlt: e.target.value } as any);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 위치</span>
|
||||||
|
<select
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 위치"
|
||||||
|
value={buttonProps.imagePlacement ?? "left"}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imagePlacement: e.target.value as any,
|
||||||
|
} as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="left">텍스트 왼쪽</option>
|
||||||
|
<option value="right">텍스트 오른쪽</option>
|
||||||
|
<option value="top">텍스트 위쪽</option>
|
||||||
|
<option value="bottom">텍스트 아래쪽</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<NumericPropertyControl
|
<NumericPropertyControl
|
||||||
label="가로 패딩 (px)"
|
label="가로 패딩 (px)"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
// 로그인 페이지 컴포넌트
|
||||||
|
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
|
||||||
|
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
if (!cancelled && res.ok) {
|
||||||
|
router.push("/projects");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkAuth();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data?.message ?? "로그인에 실패했습니다. 다시 시도해 주세요.");
|
||||||
|
} catch {
|
||||||
|
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 시에는 /projects 로 이동한다.
|
||||||
|
router.push("/projects");
|
||||||
|
} catch {
|
||||||
|
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
|
||||||
|
<h1 className="text-lg font-semibold mb-1">로그인</h1>
|
||||||
|
<p className="text-xs text-slate-400 mb-4">프로젝트를 관리하려면 먼저 계정으로 로그인하세요.</p>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label htmlFor="email" className="block text-slate-200">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label htmlFor="password" className="block text-slate-200">
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{loading ? "로그인 중..." : "로그인"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-[11px] text-slate-400">
|
||||||
|
아직 계정이 없다면
|
||||||
|
{" "}
|
||||||
|
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
|
||||||
|
회원가입
|
||||||
|
</Link>
|
||||||
|
을 진행해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,19 +3,58 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEditorStore } from "@/features/editor/state/editorStore";
|
import { useEditorStore } from "@/features/editor/state/editorStore";
|
||||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||||
|
|
||||||
export default function PreviewPage() {
|
export default function PreviewPage() {
|
||||||
|
const router = useRouter();
|
||||||
const blocks = useEditorStore((state) => state.blocks);
|
const blocks = useEditorStore((state) => state.blocks);
|
||||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
||||||
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
||||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||||
|
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
if (!res.ok && res.status === 401 && !cancelled) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkAuth();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const slug = (projectConfig as any)?.slug?.trim?.();
|
let slug = (projectConfig as any)?.slug?.trim?.();
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const fromQuery = url.searchParams.get("slug")?.trim();
|
||||||
|
|
||||||
|
if (fromQuery && fromQuery !== slug) {
|
||||||
|
slug = fromQuery;
|
||||||
|
updateProjectConfig({ slug: fromQuery } as any);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
||||||
const key = `pb:autosave:${slug}`;
|
const key = `pb:autosave:${slug}`;
|
||||||
@@ -30,6 +69,9 @@ export default function PreviewPage() {
|
|||||||
if (parsed.projectConfig) {
|
if (parsed.projectConfig) {
|
||||||
updateProjectConfig(parsed.projectConfig as any);
|
updateProjectConfig(parsed.projectConfig as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
|
||||||
|
resetHistory();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,7 +178,10 @@ export default function PreviewPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
style={canvasStyle}
|
style={canvasStyle}
|
||||||
>
|
>
|
||||||
<PublicPageRenderer blocks={blocks} />
|
<PublicPageRenderer
|
||||||
|
blocks={blocks}
|
||||||
|
projectSlug={(projectConfig?.slug ?? "").trim() || undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
|
||||||
|
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
|
||||||
|
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
|
||||||
|
// - 401 이면 로그인 페이지로 이동시키고, 404/500 계열 에러는 한국어 에러 메시지로 안내한다.
|
||||||
|
// - 응답 payload 의 name/email/phone/birthdate 는 개별 컬럼으로, 나머지는 "기타 필드" 텍스트로 렌더링한다.
|
||||||
|
|
||||||
|
interface SubmissionItem {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
projectSlug: string;
|
||||||
|
// 서버에서 복호화된 폼 필드 전체가 payload 로 내려온다.
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageStatus = "idle" | "loading" | "error";
|
||||||
|
|
||||||
|
export default function ProjectSubmissionsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams() as { slug?: string } | null;
|
||||||
|
const slug = (params?.slug ?? "").toString();
|
||||||
|
|
||||||
|
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
|
||||||
|
const [status, setStatus] = useState<PageStatus>("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/submissions`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(
|
||||||
|
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as SubmissionItem[];
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setSubmissions(Array.isArray(data) ? data : []);
|
||||||
|
setStatus("idle");
|
||||||
|
setErrorMessage(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
// payload 에서 name/email/phone/birthdate 를 제외한 나머지 필드를 "기타" 영역으로 모아 보여준다.
|
||||||
|
const renderOtherFields = (payload: Record<string, unknown>): string => {
|
||||||
|
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
|
||||||
|
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
||||||
|
return `${key}: ${stringValue}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
||||||
|
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">폼 제출 내역</h1>
|
||||||
|
<p className="text-xs text-slate-400">프로젝트에 연결된 폼으로 수집된 제출 데이터를 확인할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-300">
|
||||||
|
<span className="font-mono text-[11px]">{slug}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||||
|
{status === "error" && errorMessage && (
|
||||||
|
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "loading" && (
|
||||||
|
<p className="text-xs text-slate-400 mb-3">폼 제출 내역을 불러오는 중입니다...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submissions.length === 0 && status === "idle" && !errorMessage && (
|
||||||
|
<p className="text-xs text-slate-400">아직 수집된 폼 제출 내역이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submissions.length > 0 && (
|
||||||
|
<table className="w-full text-xs text-left border-collapse mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 text-slate-400">
|
||||||
|
<th className="py-2 pr-4">제출 시각</th>
|
||||||
|
<th className="py-2 pr-4">이름</th>
|
||||||
|
<th className="py-2 pr-4">이메일</th>
|
||||||
|
<th className="py-2 pr-4">전화번호</th>
|
||||||
|
<th className="py-2 pr-4">생년월일</th>
|
||||||
|
<th className="py-2 pr-4">기타 필드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{submissions.map((item) => {
|
||||||
|
const payload = (item.payload ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const nameValue = payload["name"];
|
||||||
|
const emailValue = payload["email"];
|
||||||
|
const phoneValue = payload["phone"];
|
||||||
|
const birthdateValue = payload["birthdate"];
|
||||||
|
|
||||||
|
const name = typeof nameValue === "string" ? nameValue : "";
|
||||||
|
const email = typeof emailValue === "string" ? emailValue : "";
|
||||||
|
const phone = typeof phoneValue === "string" ? phoneValue : "";
|
||||||
|
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||||
|
<td className="py-2 pr-4 text-slate-300 text-[11px]">
|
||||||
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-slate-100">{name}</td>
|
||||||
|
<td className="py-2 pr-4 text-slate-300">{email}</td>
|
||||||
|
<td className="py-2 pr-4 text-slate-300">{phone}</td>
|
||||||
|
<td className="py-2 pr-4 text-slate-300">{birthdate}</td>
|
||||||
|
<td className="py-2 pr-4 text-slate-300 whitespace-pre-wrap">
|
||||||
|
{renderOtherFields(payload)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -22,8 +23,10 @@ interface ProjectListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
const [projects, setProjects] = useState<ProjectListItem[]>([]);
|
||||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
@@ -34,11 +37,21 @@ export default function ProjectsPage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
const res = await fetch("/api/projects");
|
const res = await fetch("/api/projects");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
setErrorMessage("프로젝트 목록을 불러오는 중 인증 오류가 발생했습니다. 다시 로그인해 주세요.");
|
||||||
|
router.push("/login");
|
||||||
|
} else {
|
||||||
|
setErrorMessage(
|
||||||
|
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -47,10 +60,12 @@ export default function ProjectsPage() {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setProjects(Array.isArray(data) ? data : []);
|
setProjects(Array.isArray(data) ? data : []);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -83,6 +98,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +109,31 @@ export default function ProjectsPage() {
|
|||||||
window.localStorage.removeItem(`pb:project:${slug}`);
|
window.localStorage.removeItem(`pb:project:${slug}`);
|
||||||
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
window.localStorage.removeItem(`pb:autosave:${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setStatus("idle");
|
||||||
|
setErrorMessage(null);
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/login");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +166,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
if (okSlugs.length === 0) {
|
if (okSlugs.length === 0) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +182,16 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
if (okSlugs.length !== slugs.length) {
|
if (okSlugs.length !== slugs.length) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
setErrorMessage(
|
||||||
|
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setStatus("idle");
|
||||||
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +203,15 @@ export default function ProjectsPage() {
|
|||||||
<p className="text-xs text-slate-400">저장된 프로젝트들을 한 눈에 볼 수 있는 목록</p>
|
<p className="text-xs text-slate-400">저장된 프로젝트들을 한 눈에 볼 수 있는 목록</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
|
||||||
|
onClick={() => {
|
||||||
|
void handleLogout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/editor"
|
href="/editor"
|
||||||
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
|
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
|
||||||
@@ -167,7 +223,9 @@ export default function ProjectsPage() {
|
|||||||
</header>
|
</header>
|
||||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||||
{status === "error" && (
|
{status === "error" && (
|
||||||
<p className="text-xs text-red-300 mb-3">프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.</p>
|
<p className="text-xs text-red-300 mb-3">
|
||||||
|
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{projects.length === 0 && status === "idle" && (
|
{projects.length === 0 && status === "idle" && (
|
||||||
<p className="text-xs text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
<p className="text-xs text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||||||
@@ -272,6 +330,13 @@ export default function ProjectsPage() {
|
|||||||
<Eye className="w-3 h-3" aria-hidden="true" />
|
<Eye className="w-3 h-3" aria-hidden="true" />
|
||||||
<span>미리보기</span>
|
<span>미리보기</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
|
||||||
|
className="inline-flex items-center gap-1 text-emerald-300 hover:text-emerald-100 underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
<ListChecks className="w-3 h-3" aria-hidden="true" />
|
||||||
|
<span>폼 제출 내역</span>
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
// 회원가입 페이지 컴포넌트
|
||||||
|
// - 이메일/비밀번호를 입력받아 /api/auth/signup 으로 요청을 전송한다.
|
||||||
|
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
if (!cancelled && res.ok) {
|
||||||
|
router.push("/projects");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkAuth();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/signup", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data?.message ?? "회원가입에 실패했습니다. 다시 시도해 주세요.");
|
||||||
|
} catch {
|
||||||
|
setError("회원가입에 실패했습니다. 다시 시도해 주세요.");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/projects");
|
||||||
|
} catch {
|
||||||
|
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
|
||||||
|
<h1 className="text-lg font-semibold mb-1">회원가입</h1>
|
||||||
|
<p className="text-xs text-slate-400 mb-4">새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.</p>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-xs text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3 text-xs">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label htmlFor="email" className="block text-slate-200">
|
||||||
|
이메일
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label htmlFor="password" className="block text-slate-200">
|
||||||
|
비밀번호
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 w-full inline-flex items-center justify-center gap-1 rounded bg-sky-600 hover:bg-sky-500 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{loading ? "회원가입 중..." : "회원가입"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-[11px] text-slate-400">
|
||||||
|
이미 계정이 있다면
|
||||||
|
{" "}
|
||||||
|
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
|
||||||
|
로그인
|
||||||
|
</Link>
|
||||||
|
으로 이동해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||||
|
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "crypto";
|
||||||
|
|
||||||
|
// 인증/보안 관련 공통 유틸리티 함수들을 제공한다.
|
||||||
|
// - 비밀번호 해시/검증 (bcrypt 기반)
|
||||||
|
// - JWT 액세스 토큰 발급/검증
|
||||||
|
// - 민감정보 JSON 암호화/복호화 (AES-256-GCM)
|
||||||
|
|
||||||
|
export interface AccessTokenPayload extends JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtUserLike {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내부에서 사용할 JWT 시크릿을 가져온다.
|
||||||
|
// - 설정되지 않은 경우에는 명확한 에러를 던져 조기에 문제를 발견한다.
|
||||||
|
function getJwtSecret(): string {
|
||||||
|
const secret = process.env.AUTH_JWT_SECRET;
|
||||||
|
if (!secret || secret.length === 0) {
|
||||||
|
throw new Error("AUTH_JWT_SECRET 환경변수가 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 암호화 키 원본 문자열로부터 32바이트 AES 키를 파생한다.
|
||||||
|
// - 키 문자열 길이에 관계없이 SHA-256 해시를 사용해 32바이트를 만든다.
|
||||||
|
function deriveEncryptionKey(): Buffer {
|
||||||
|
const raw = process.env.AUTH_ENCRYPTION_KEY;
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
throw new Error("AUTH_ENCRYPTION_KEY 환경변수가 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
return createHash("sha256").update(raw).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소 비밀번호 길이 (보안 요구사항: 8자 이상)
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
// 비밀번호를 bcrypt 해시로 변환한다.
|
||||||
|
export async function hashPassword(plain: string): Promise<string> {
|
||||||
|
if (typeof plain !== "string" || plain.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
throw new Error("비밀번호는 최소 8자 이상이어야 합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// bcryptjs 기본 라운드 수(10)를 사용한다. 필요 시 환경에 맞게 조정 가능.
|
||||||
|
const saltRounds = 10;
|
||||||
|
return await bcrypt.hash(plain, saltRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 평문 비밀번호와 저장된 해시가 일치하는지 검증한다.
|
||||||
|
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
if (!hash) return false;
|
||||||
|
if (!plain || plain.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
// 너무 짧은 비밀번호는 즉시 false 를 반환해 타이밍 공격을 줄인다.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await bcrypt.compare(plain, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유저 정보를 기반으로 JWT 액세스 토큰을 발급한다.
|
||||||
|
export async function signAccessToken(user: JwtUserLike): Promise<string> {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
|
||||||
|
const payload: AccessTokenPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
tokenVersion: user.tokenVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 만료 시간은 기본 7일으로 설정한다.
|
||||||
|
const token = jwt.sign(payload, secret, {
|
||||||
|
algorithm: "HS256",
|
||||||
|
expiresIn: "7d",
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT 액세스 토큰을 검증하고, 유효하면 페이로드를 반환한다.
|
||||||
|
// - 검증 실패(시그니처 오류, 만료 등) 시 null 을 반환한다.
|
||||||
|
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload | null> {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, secret) as AccessTokenPayload;
|
||||||
|
if (!decoded || typeof decoded.sub !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 객체를 AES-256-GCM 으로 암호화한다.
|
||||||
|
// - 결과는 iv.tag.ciphertext 형태의 base64 조합 문자열이다.
|
||||||
|
export async function encryptJson<T extends object>(value: T): Promise<string> {
|
||||||
|
const key = deriveEncryptionKey();
|
||||||
|
const iv = randomBytes(12); // GCM 권장 IV 길이 96bit
|
||||||
|
|
||||||
|
const plain = JSON.stringify(value);
|
||||||
|
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
let encrypted = cipher.update(plain, "utf8", "base64");
|
||||||
|
encrypted += cipher.final("base64");
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
const ivB64 = iv.toString("base64");
|
||||||
|
const tagB64 = authTag.toString("base64");
|
||||||
|
|
||||||
|
return `${ivB64}.${tagB64}.${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES-256-GCM 으로 암호화된 JSON 문자열을 복호화한다.
|
||||||
|
export async function decryptJson<T = unknown>(ciphertext: string): Promise<T> {
|
||||||
|
const key = deriveEncryptionKey();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = ciphertext.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error("잘못된 암호문 형식입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ivB64, tagB64, dataB64] = parts;
|
||||||
|
const iv = Buffer.from(ivB64, "base64");
|
||||||
|
const authTag = Buffer.from(tagB64, "base64");
|
||||||
|
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(dataB64, "base64", "utf8");
|
||||||
|
decrypted += decipher.final("utf8");
|
||||||
|
|
||||||
|
return JSON.parse(decrypted) as T;
|
||||||
|
} catch (error) {
|
||||||
|
// 복호화 실패 시 명시적인 에러를 던져 상위에서 처리할 수 있게 한다.
|
||||||
|
throw new Error("민감정보 복호화에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ export function getSectionLayoutConfig(props: SectionBlockProps) {
|
|||||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
||||||
interface PublicPageRendererProps {
|
interface PublicPageRendererProps {
|
||||||
blocks: Block[];
|
blocks: Block[];
|
||||||
|
projectSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||||
@@ -99,7 +100,7 @@ const convertPxStringToEm = (value?: string | null) => {
|
|||||||
return pxToEm(px);
|
return pxToEm(px);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
|
||||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||||
|
|
||||||
@@ -107,10 +108,6 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
const [formMessage, setFormMessage] = useState<string>("");
|
const [formMessage, setFormMessage] = useState<string>("");
|
||||||
|
|
||||||
const renderBlock = (block: Block) => {
|
const renderBlock = (block: Block) => {
|
||||||
if ((block as any).type === "form") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
const props = block.props as TextBlockProps;
|
const props = block.props as TextBlockProps;
|
||||||
const tokens = computeTextPublicTokens(props);
|
const tokens = computeTextPublicTokens(props);
|
||||||
@@ -337,10 +334,48 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
const hasImage = typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "";
|
||||||
|
const placement = ((props as any).imagePlacement as string | undefined) ?? "left";
|
||||||
|
|
||||||
|
let innerContent: React.ReactNode;
|
||||||
|
if (!hasImage) {
|
||||||
|
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
|
||||||
|
} else {
|
||||||
|
const src = (props as any).imageSrc as string;
|
||||||
|
const altRaw = (props as any).imageAlt as string | undefined;
|
||||||
|
const alt = altRaw && altRaw.trim() !== "" ? altRaw.trim() : props.label;
|
||||||
|
|
||||||
|
const imgEl = (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={src} alt={alt} className="inline-block max-w-full h-auto" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelSpan = <span className="whitespace-pre-wrap">{props.label}</span>;
|
||||||
|
const isVertical = placement === "top" || placement === "bottom";
|
||||||
|
const wrapperClassNames = [
|
||||||
|
"inline-flex",
|
||||||
|
"items-center",
|
||||||
|
"gap-2",
|
||||||
|
isVertical ? "flex-col" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const first = placement === "left" || placement === "top" ? imgEl : labelSpan;
|
||||||
|
const second = placement === "left" || placement === "top" ? labelSpan : imgEl;
|
||||||
|
|
||||||
|
innerContent = (
|
||||||
|
<span className={wrapperClassNames}>
|
||||||
|
{first}
|
||||||
|
{second}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={block.id} className={wrapperClass}>
|
<div key={block.id} className={wrapperClass}>
|
||||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
||||||
<span className="whitespace-pre-wrap">{props.label}</span>
|
{innerContent}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -556,6 +591,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
>
|
>
|
||||||
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
||||||
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
||||||
|
{projectSlug && projectSlug.trim() !== "" ? (
|
||||||
|
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
|
||||||
|
) : null}
|
||||||
{hasFields && (
|
{hasFields && (
|
||||||
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
||||||
{fields.map((field: any) => (
|
{fields.map((field: any) => (
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ export interface ButtonBlockProps {
|
|||||||
strokeColorCustom?: string;
|
strokeColorCustom?: string;
|
||||||
// 버튼 텍스트 색상 커스텀 값
|
// 버튼 텍스트 색상 커스텀 값
|
||||||
textColorCustom?: string;
|
textColorCustom?: string;
|
||||||
|
// 폼 컨트롤러에서 Submit 버튼 매핑 시 사용할 수 있는 전송 키 (name 역할)
|
||||||
|
formFieldName?: string;
|
||||||
|
imageSrc?: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
imageSourceType?: "asset" | "externalUrl";
|
||||||
|
imageAssetId?: string | null;
|
||||||
|
imagePlacement?: "left" | "right" | "top" | "bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 블록 속성
|
// 이미지 블록 속성
|
||||||
@@ -768,12 +775,22 @@ export interface EditorState {
|
|||||||
redo: () => void;
|
redo: () => void;
|
||||||
removeBlock: (id: string) => void;
|
removeBlock: (id: string) => void;
|
||||||
duplicateBlock: (id: string) => void;
|
duplicateBlock: (id: string) => void;
|
||||||
|
resetHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
const createId = () => `blk_${Date.now()}_${idCounter++}`;
|
const createId = () => `blk_${Date.now()}_${idCounter++}`;
|
||||||
|
|
||||||
|
// undo/redo 를 위한 히스토리 유틸리티: 상태 변경 전에 현재 blocks 스냅샷을 history 에 쌓고 future 는 비운다.
|
||||||
|
const pushHistoryImpl = (set: any, get: any) => {
|
||||||
|
const { blocks } = get() as EditorState;
|
||||||
|
set((state: EditorState) => ({
|
||||||
|
history: [...state.history, blocks],
|
||||||
|
future: [],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
||||||
const regex = new RegExp(`^${prefix}-(\\d+)$`);
|
const regex = new RegExp(`^${prefix}-(\\d+)$`);
|
||||||
let max = 0;
|
let max = 0;
|
||||||
@@ -795,7 +812,16 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
|||||||
|
|
||||||
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
||||||
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
||||||
const createEditorState = (set: any, get: any): EditorState => ({
|
const createEditorState = (set: any, get: any): EditorState => {
|
||||||
|
const pushHistory = () => {
|
||||||
|
const { blocks } = get() as EditorState;
|
||||||
|
set((state: EditorState) => ({
|
||||||
|
history: [...state.history, blocks],
|
||||||
|
future: [],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
blocks: [],
|
blocks: [],
|
||||||
selectedBlockId: null,
|
selectedBlockId: null,
|
||||||
selectedListItemId: null,
|
selectedListItemId: null,
|
||||||
@@ -860,11 +886,10 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
history: [...state.history, blocks],
|
|
||||||
future: [],
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -887,6 +912,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -928,6 +954,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -964,6 +991,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1000,6 +1028,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1036,6 +1065,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1072,6 +1102,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1108,6 +1139,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1144,6 +1176,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1180,6 +1213,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1233,6 +1267,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1279,6 +1314,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1318,6 +1354,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1359,6 +1396,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1401,6 +1439,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1443,6 +1482,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1483,6 +1523,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1530,6 +1571,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1556,6 +1598,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1606,6 +1649,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1660,6 +1704,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1668,6 +1713,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
|
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
|
||||||
updateBlock: (id, partial) => {
|
updateBlock: (id, partial) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: state.blocks.map((block: Block) =>
|
blocks: state.blocks.map((block: Block) =>
|
||||||
block.id === id
|
block.id === id
|
||||||
@@ -1816,6 +1862,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
replaceBlocks: (blocks) => {
|
replaceBlocks: (blocks) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set({
|
set({
|
||||||
blocks,
|
blocks,
|
||||||
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
|
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
|
||||||
@@ -1823,6 +1870,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
reorderBlocks: (activeId, overId) => {
|
reorderBlocks: (activeId, overId) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const current = state.blocks;
|
const current = state.blocks;
|
||||||
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
|
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
|
||||||
@@ -1840,6 +1888,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
moveBlock: (id, sectionId, columnId) => {
|
moveBlock: (id, sectionId, columnId) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: state.blocks.map((block: Block) =>
|
blocks: state.blocks.map((block: Block) =>
|
||||||
block.id === id
|
block.id === id
|
||||||
@@ -1853,6 +1902,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
removeBlock: (id) => {
|
removeBlock: (id) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const current = state.blocks;
|
const current = state.blocks;
|
||||||
const index = current.findIndex((b) => b.id === id);
|
const index = current.findIndex((b) => b.id === id);
|
||||||
@@ -1884,6 +1934,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
duplicateBlock: (id) => {
|
duplicateBlock: (id) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const current = state.blocks;
|
const current = state.blocks;
|
||||||
const index = current.findIndex((b) => b.id === id);
|
const index = current.findIndex((b) => b.id === id);
|
||||||
@@ -1939,7 +1990,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
future: nextFuture,
|
future: nextFuture,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
resetHistory: () => {
|
||||||
|
set({ history: [], future: [] });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
||||||
export const useEditorStore = create<EditorState>()(createEditorState);
|
export const useEditorStore = create<EditorState>()(createEditorState);
|
||||||
|
|||||||
@@ -925,11 +925,15 @@ export const computeFormControllerPublicTokens = (
|
|||||||
);
|
);
|
||||||
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
|
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
|
||||||
|
|
||||||
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이므로
|
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이지만,
|
||||||
// formClassName 은 고정 기본값만 사용하고, formStyle 은 항상 빈 객체로 유지한다.
|
// 배경색 커스텀 값을 허용해 form 요소 배경만 제어할 수 있도록 한다.
|
||||||
const formClassNames = ["space-y-3"];
|
const formClassNames = ["space-y-3"];
|
||||||
const formStyle: CSSProperties = {};
|
const formStyle: CSSProperties = {};
|
||||||
|
|
||||||
|
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||||
|
formStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fields,
|
fields,
|
||||||
formClassName: formClassNames.join(" "),
|
formClassName: formClassNames.join(" "),
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import { hashPassword, signAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
|
// PrismaClient 를 실제 DB 대신 메모리 기반 user 저장소를 사용하는 목으로 대체한다.
|
||||||
|
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 Auth API 테스트를 실행할 수 있다.
|
||||||
|
const inMemoryUsers: any[] = [];
|
||||||
|
|
||||||
|
vi.mock("@prisma/client", () => {
|
||||||
|
class PrismaClientMock {
|
||||||
|
user = {
|
||||||
|
findUnique: async ({ where: { email } }: any) => {
|
||||||
|
return inMemoryUsers.find((u) => u.email === email) ?? null;
|
||||||
|
},
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
const now = new Date();
|
||||||
|
const user = {
|
||||||
|
id: String(inMemoryUsers.length + 1),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
inMemoryUsers.push(user);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { PrismaClient: PrismaClientMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// 각 테스트 전에 메모리 유저 저장소를 초기화한다.
|
||||||
|
inMemoryUsers.length = 0;
|
||||||
|
process.env.AUTH_JWT_SECRET = "test-jwt-secret-api";
|
||||||
|
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/auth", () => {
|
||||||
|
describe("POST /api/auth/signup", () => {
|
||||||
|
it("새 이메일과 8자 이상 비밀번호로 회원가입하면 User 가 생성되고 JWT 쿠키가 설정되어야 한다", async () => {
|
||||||
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||||
|
|
||||||
|
const payload = { email: "user@example.com", password: "securePass1" };
|
||||||
|
|
||||||
|
const res = await signup(
|
||||||
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.email).toBe(payload.email);
|
||||||
|
expect(json.id).toBeDefined();
|
||||||
|
// 응답에는 passwordHash 가 포함되면 안 된다.
|
||||||
|
expect(json.passwordHash).toBeUndefined();
|
||||||
|
|
||||||
|
// 메모리 저장소에도 유저가 1명 생성되어야 한다.
|
||||||
|
expect(inMemoryUsers.length).toBe(1);
|
||||||
|
expect(inMemoryUsers[0].email).toBe(payload.email);
|
||||||
|
expect(typeof inMemoryUsers[0].passwordHash).toBe("string");
|
||||||
|
|
||||||
|
// JWT 세션 쿠키가 설정되어야 한다.
|
||||||
|
const setCookie = res.headers.get("set-cookie");
|
||||||
|
expect(setCookie).toBeTruthy();
|
||||||
|
expect(setCookie).toContain("pb_access=");
|
||||||
|
expect(setCookie?.toLowerCase()).toContain("max-age=604800");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("비밀번호가 8자 미만이면 400 과 에러 메시지를 반환해야 한다", async () => {
|
||||||
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||||
|
|
||||||
|
const res = await signup(
|
||||||
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "short@example.com", password: "short" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.message).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("이미 존재하는 이메일로 회원가입하면 409 를 반환해야 한다", async () => {
|
||||||
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||||
|
|
||||||
|
// 먼저 한 번 성공적으로 가입
|
||||||
|
await signup(
|
||||||
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "dup@example.com", password: "securePass1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 같은 이메일로 다시 요청
|
||||||
|
const res = await signup(
|
||||||
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "dup@example.com", password: "anotherPass1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.message).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/auth/login", () => {
|
||||||
|
it("올바른 이메일/비밀번호로 로그인하면 200 과 JWT 쿠키를 반환해야 한다", async () => {
|
||||||
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||||
|
const { POST: login } = await import("@/app/api/auth/login/route");
|
||||||
|
|
||||||
|
const email = "login@example.com";
|
||||||
|
const password = "securePass1";
|
||||||
|
|
||||||
|
// 사전 회원가입
|
||||||
|
await signup(
|
||||||
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await login(
|
||||||
|
new Request(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.email).toBe(email);
|
||||||
|
expect(json.id).toBeDefined();
|
||||||
|
expect(json.passwordHash).toBeUndefined();
|
||||||
|
|
||||||
|
const setCookie = res.headers.get("set-cookie");
|
||||||
|
expect(setCookie).toBeTruthy();
|
||||||
|
expect(setCookie).toContain("pb_access=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("잘못된 이메일 또는 비밀번호로 로그인하면 401 을 반환해야 한다", async () => {
|
||||||
|
const { POST: signup } = await import("@/app/api/auth/signup/route");
|
||||||
|
const { POST: login } = await import("@/app/api/auth/login/route");
|
||||||
|
|
||||||
|
const email = "wrong@example.com";
|
||||||
|
const password = "securePass1";
|
||||||
|
|
||||||
|
// 사전 회원가입
|
||||||
|
await signup(
|
||||||
|
new Request(`${BASE_URL}/api/auth/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 존재하지 않는 이메일
|
||||||
|
const res1 = await login(
|
||||||
|
new Request(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "nope@example.com", password }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res1.status).toBe(401);
|
||||||
|
|
||||||
|
// 비밀번호 불일치
|
||||||
|
const res2 = await login(
|
||||||
|
new Request(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password: "wrongPass1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res2.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/auth/logout", () => {
|
||||||
|
it("로그아웃 시 pb_access 쿠키를 제거하는 Set-Cookie 헤더를 반환해야 한다", async () => {
|
||||||
|
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
||||||
|
|
||||||
|
const res = await logout(
|
||||||
|
new Request(`${BASE_URL}/api/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
cookie: "pb_access=dummy.token.value",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const setCookie = res.headers.get("set-cookie");
|
||||||
|
expect(setCookie).toBeTruthy();
|
||||||
|
expect(setCookie).toContain("pb_access=");
|
||||||
|
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("쿠키가 없어도 200 과 쿠키 제거 헤더를 반환해야 한다", async () => {
|
||||||
|
const { POST: logout } = await import("@/app/api/auth/logout/route");
|
||||||
|
|
||||||
|
const res = await logout(
|
||||||
|
new Request(`${BASE_URL}/api/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const setCookie = res.headers.get("set-cookie");
|
||||||
|
expect(setCookie).toBeTruthy();
|
||||||
|
expect(setCookie).toContain("pb_access=");
|
||||||
|
expect(setCookie?.toLowerCase()).toContain("max-age=0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/auth/me", () => {
|
||||||
|
it("유효한 JWT 쿠키가 있을 때 현재 로그인 유저 정보를 반환해야 한다", async () => {
|
||||||
|
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||||
|
|
||||||
|
const user = { id: "user-1", email: "me@example.com", tokenVersion: 1 };
|
||||||
|
const token = await signAccessToken(user);
|
||||||
|
|
||||||
|
const res = await me(
|
||||||
|
new Request(`${BASE_URL}/api/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
cookie: `pb_access=${token}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.id).toBe(user.id);
|
||||||
|
expect(json.email).toBe(user.email);
|
||||||
|
expect(json.passwordHash).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("JWT 쿠키가 없거나 잘못된 경우 401 을 반환해야 한다", async () => {
|
||||||
|
const { GET: me } = await import("@/app/api/auth/me/route");
|
||||||
|
|
||||||
|
const res1 = await me(new Request(`${BASE_URL}/api/auth/me`));
|
||||||
|
expect(res1.status).toBe(401);
|
||||||
|
|
||||||
|
const res2 = await me(
|
||||||
|
new Request(`${BASE_URL}/api/auth/me`, {
|
||||||
|
headers: { cookie: "pb_access=invalid.token.here" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res2.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -259,16 +259,16 @@ describe("/api/export", () => {
|
|||||||
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
|
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projectConfig.headHtml 과 trackingScript 는 index.html 의 head/body 에 그대로 포함되어야 한다", async () => {
|
it("projectConfig.headHtml / trackingScript 가 head 와 body 에 반영되어야 한다", async () => {
|
||||||
const blocks: Block[] = [];
|
const blocks: Block[] = [];
|
||||||
|
|
||||||
const projectConfig: ProjectConfig = {
|
const projectConfig: ProjectConfig = {
|
||||||
title: "헤드/트래킹 테스트",
|
title: "헤드/트래킹 스크립트 테스트",
|
||||||
slug: "head-tracking-test",
|
slug: "head-tracking-test",
|
||||||
canvasPreset: "full",
|
canvasPreset: "full",
|
||||||
headHtml: '<meta name="robots" content="noindex" />',
|
headHtml: '<meta name="robots" content="noindex" />',
|
||||||
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\/script>',
|
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\\/script>',
|
||||||
};
|
} as ProjectConfig;
|
||||||
|
|
||||||
const payload = { blocks, projectConfig };
|
const payload = { blocks, projectConfig };
|
||||||
|
|
||||||
@@ -294,7 +294,35 @@ describe("/api/export", () => {
|
|||||||
// head 커스텀 HTML
|
// head 커스텀 HTML
|
||||||
expect(html).toContain('<meta name="robots" content="noindex" />');
|
expect(html).toContain('<meta name="robots" content="noindex" />');
|
||||||
// body 하단 추적 스크립트
|
// body 하단 추적 스크립트
|
||||||
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\/script>');
|
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\\/script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 블록에 imageSrc 가 있으면 내보내기 HTML 의 버튼 안에 img 요소가 포함되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_img_export",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "이미지 버튼",
|
||||||
|
href: "#",
|
||||||
|
imageSrc: "https://example.com/button.png",
|
||||||
|
imageAlt: "버튼 이미지",
|
||||||
|
imagePlacement: "left",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectConfig: ProjectConfig = {
|
||||||
|
title: "버튼 이미지 내보내기 테스트",
|
||||||
|
slug: "btn-img-export-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const html = buildStaticHtml(blocks, projectConfig);
|
||||||
|
|
||||||
|
expect(html).toContain('<a href="#"');
|
||||||
|
expect(html).toContain('<img src="https://example.com/button.png"');
|
||||||
|
expect(html).toContain('alt="버튼 이미지"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
|
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
|
||||||
|
|||||||
@@ -99,10 +99,13 @@ describe("정적 Export 폼 컨트롤러 통합", () => {
|
|||||||
form!.dispatchEvent(submitEvent);
|
form!.dispatchEvent(submitEvent);
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
const [url, options] = fetchMock.mock.calls[0] as [any, RequestInit];
|
const [url, options] = fetchMock.mock.calls[0] as unknown as [any, RequestInit];
|
||||||
|
|
||||||
expect(url).toBe("/api/forms/submit");
|
expect(url).toBe("/api/forms/submit");
|
||||||
expect(options.method).toBe("POST");
|
expect(options.method).toBe("POST");
|
||||||
expect(options.body).toBeInstanceOf(FormData);
|
expect(options.body).toBeInstanceOf(FormData);
|
||||||
|
|
||||||
|
const body = options.body as FormData;
|
||||||
|
expect(body.get("__projectSlug")).toBe(projectConfig.slug);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,52 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
import type { FormBlockProps } from "@/features/editor/state/editorStore";
|
import type { FormBlockProps } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
|
// /api/forms/submit 테스트에서는 실제 DB 대신 메모리 기반 프로젝트/제출 내역 저장소를 사용한다.
|
||||||
|
// 이렇게 하면 DATABASE_URL 없이도 Prisma 의 의존성을 만족시키면서 라우트 로직을 검증할 수 있다.
|
||||||
|
const inMemoryProjects: any[] = [];
|
||||||
|
const inMemoryFormSubmissions: any[] = [];
|
||||||
|
|
||||||
|
vi.mock("@prisma/client", () => {
|
||||||
|
class PrismaClientMock {
|
||||||
|
// 프로젝트 조회는 슬러그 기준으로 수행한다.
|
||||||
|
project = {
|
||||||
|
findUnique: async ({ where: { slug } }: any) => {
|
||||||
|
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출 내역은 단순 배열에 push 하여 테스트에서 검증할 수 있도록 한다.
|
||||||
|
formSubmission = {
|
||||||
|
create: async ({ data }: any) => {
|
||||||
|
const now = new Date();
|
||||||
|
const record = {
|
||||||
|
id: String(inMemoryFormSubmissions.length + 1),
|
||||||
|
createdAt: now,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
inMemoryFormSubmissions.push(record);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
findMany: async () => {
|
||||||
|
return [...inMemoryFormSubmissions];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { PrismaClient: PrismaClientMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// 각 테스트 전에 메모리 스토리지를 초기화하고, 암호화 키를 고정 값으로 설정한다.
|
||||||
|
inMemoryProjects.length = 0;
|
||||||
|
inMemoryFormSubmissions.length = 0;
|
||||||
|
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||||
|
});
|
||||||
|
|
||||||
// /api/forms/submit 라우트에 대한 기본 TDD:
|
// /api/forms/submit 라우트에 대한 기본 TDD:
|
||||||
// - internal 모드에서 임의 필드가 포함된 FormData 를 받아도 에러 없이 ok:true 를 반환해야 한다.
|
// - internal 모드에서 임의 필드가 포함된 FormData 를 받아도 에러 없이 ok:true 를 반환해야 한다.
|
||||||
// - webhook 모드에서는 config.extraParams / headers 와 함께 x-www-form-urlencoded 로 전달해야 한다.
|
// - webhook 모드에서는 config.extraParams / headers 와 함께 x-www-form-urlencoded 로 전달해야 한다.
|
||||||
@@ -340,4 +383,175 @@ describe("/api/forms/submit", () => {
|
|||||||
expect(json.error).toBe("webhook_exception");
|
expect(json.error).toBe("webhook_exception");
|
||||||
expect(json.message).toBe(config.errorMessage);
|
expect(json.message).toBe(config.errorMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("internal/both 모드의 DB 저장 및 민감정보 암호화", () => {
|
||||||
|
it("internal 모드에서 프로젝트 슬러그와 민감 필드를 포함해 제출하면 FormSubmission 이 암호화/분리된 상태로 저장되어야 한다", async () => {
|
||||||
|
inMemoryProjects.push({
|
||||||
|
id: "proj-1",
|
||||||
|
slug: "project-1",
|
||||||
|
userId: "owner-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
successMessage: "성공적으로 전송되었습니다.",
|
||||||
|
errorMessage: "전송 중 오류가 발생했습니다.",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
fields: [
|
||||||
|
{ id: "f1", name: "name", type: "text", label: "이름", required: true },
|
||||||
|
{ id: "f2", name: "email", type: "email", label: "이메일", required: true },
|
||||||
|
{ id: "f3", name: "phone", type: "text", label: "전화번호", required: false },
|
||||||
|
{ id: "f4", name: "birth", type: "text", label: "생년월일", required: false },
|
||||||
|
{ id: "f5", name: "message", type: "textarea", label: "메시지", required: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", "홍길동");
|
||||||
|
formData.append("email", "test@example.com");
|
||||||
|
formData.append("phone", "010-1234-5678");
|
||||||
|
formData.append("birth", "1990-01-02");
|
||||||
|
formData.append("message", "문의 내용");
|
||||||
|
formData.append("__projectSlug", "project-1");
|
||||||
|
formData.append("__config", JSON.stringify(config));
|
||||||
|
|
||||||
|
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||||
|
|
||||||
|
const res = await handleSubmit(
|
||||||
|
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(inMemoryFormSubmissions.length).toBe(1);
|
||||||
|
const saved = inMemoryFormSubmissions[0] as any;
|
||||||
|
|
||||||
|
expect(saved.projectSlug).toBe("project-1");
|
||||||
|
expect(saved.projectId).toBe("proj-1");
|
||||||
|
expect(saved.userId).toBe("owner-1");
|
||||||
|
|
||||||
|
expect(saved.payloadJson).toBeTruthy();
|
||||||
|
expect(saved.payloadJson.name).toBe("홍길동");
|
||||||
|
expect(saved.payloadJson.message).toBe("문의 내용");
|
||||||
|
expect(saved.payloadJson.email).toBeUndefined();
|
||||||
|
expect(saved.payloadJson.phone).toBeUndefined();
|
||||||
|
expect(saved.payloadJson.birth).toBeUndefined();
|
||||||
|
|
||||||
|
expect(typeof saved.sensitiveEnc).toBe("string");
|
||||||
|
|
||||||
|
const { decryptJson } = await import("@/features/auth/authCrypto");
|
||||||
|
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
|
||||||
|
expect(sensitive.email).toBe("test@example.com");
|
||||||
|
expect(sensitive.phone).toBe("010-1234-5678");
|
||||||
|
expect(sensitive.birth).toBe("1990-01-02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("프로젝트를 찾지 못해도 projectSlug 만으로 FormSubmission 이 저장되어야 한다", async () => {
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "err",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("email", "no-project@example.com");
|
||||||
|
formData.append("__projectSlug", "unknown-slug");
|
||||||
|
formData.append("__config", JSON.stringify(config));
|
||||||
|
|
||||||
|
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||||
|
|
||||||
|
const res = await handleSubmit(
|
||||||
|
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(inMemoryFormSubmissions.length).toBe(1);
|
||||||
|
const saved = inMemoryFormSubmissions[0] as any;
|
||||||
|
|
||||||
|
expect(saved.projectSlug).toBe("unknown-slug");
|
||||||
|
expect(saved.projectId ?? null).toBeNull();
|
||||||
|
expect(saved.userId ?? null).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("config.fields 가 비어 있어도 email/phone/birthdate 키는 민감정보로 암호화되어야 한다", async () => {
|
||||||
|
inMemoryProjects.push({
|
||||||
|
id: "proj-2",
|
||||||
|
slug: "project-2",
|
||||||
|
userId: "owner-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: FormBlockProps = {
|
||||||
|
kind: "contact",
|
||||||
|
submitTarget: "internal",
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "err",
|
||||||
|
fieldIds: [],
|
||||||
|
submitButtonId: null,
|
||||||
|
// fields 를 비워 두고, 실제 FormData 키 이름만으로 민감 필드를 판별하도록 한다.
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", "홍길동");
|
||||||
|
formData.append("email", "hgd@example.com");
|
||||||
|
formData.append("phone", "010-1111-2222");
|
||||||
|
formData.append("birthdate", "1990-01-01");
|
||||||
|
formData.append("message", "테스트 문의입니다");
|
||||||
|
formData.append("__projectSlug", "project-2");
|
||||||
|
formData.append("__config", JSON.stringify(config));
|
||||||
|
|
||||||
|
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
|
||||||
|
|
||||||
|
const res = await handleSubmit(
|
||||||
|
new Request(`${BASE_URL}/api/forms/submit`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
expect(json.ok).toBe(true);
|
||||||
|
|
||||||
|
expect(inMemoryFormSubmissions.length).toBe(1);
|
||||||
|
const saved = inMemoryFormSubmissions[0] as any;
|
||||||
|
|
||||||
|
// project 연관 정보
|
||||||
|
expect(saved.projectSlug).toBe("project-2");
|
||||||
|
expect(saved.projectId).toBe("proj-2");
|
||||||
|
expect(saved.userId).toBe("owner-2");
|
||||||
|
|
||||||
|
// payloadJson 에는 비민감 필드만 남아야 한다.
|
||||||
|
expect(saved.payloadJson).toBeTruthy();
|
||||||
|
expect(saved.payloadJson.name).toBe("홍길동");
|
||||||
|
expect(saved.payloadJson.message).toBe("테스트 문의입니다");
|
||||||
|
expect(saved.payloadJson.email).toBeUndefined();
|
||||||
|
expect(saved.payloadJson.phone).toBeUndefined();
|
||||||
|
expect(saved.payloadJson.birthdate).toBeUndefined();
|
||||||
|
|
||||||
|
// 민감 필드는 암호화된 sensitiveEnc 에만 존재해야 한다.
|
||||||
|
expect(typeof saved.sensitiveEnc).toBe("string");
|
||||||
|
|
||||||
|
const { decryptJson } = await import("@/features/auth/authCrypto");
|
||||||
|
const sensitive = await decryptJson<Record<string, string>>(saved.sensitiveEnc);
|
||||||
|
expect(sensitive.email).toBe("hgd@example.com");
|
||||||
|
expect(sensitive.phone).toBe("010-1111-2222");
|
||||||
|
expect(sensitive.birthdate).toBe("1990-01-01");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
|
const inMemoryProjects: any[] = [];
|
||||||
|
const inMemoryFormSubmissions: any[] = [];
|
||||||
|
|
||||||
|
vi.mock("@prisma/client", () => {
|
||||||
|
class PrismaClientMock {
|
||||||
|
project = {
|
||||||
|
findUnique: async ({ where: { slug } }: any) => {
|
||||||
|
return inMemoryProjects.find((p) => p.slug === slug) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
formSubmission = {
|
||||||
|
findMany: async ({ where: { projectId }, orderBy, take }: any) => {
|
||||||
|
let items = inMemoryFormSubmissions.filter((s) => s.projectId === projectId);
|
||||||
|
if (orderBy?.createdAt === "desc") {
|
||||||
|
items = items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
}
|
||||||
|
if (typeof take === "number") {
|
||||||
|
items = items.slice(0, take);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { PrismaClient: PrismaClientMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
const TEST_USER = { id: "user-1", email: "submissions@example.com", tokenVersion: 1 };
|
||||||
|
const OTHER_USER = { id: "user-2", email: "submissions2@example.com", tokenVersion: 1 };
|
||||||
|
|
||||||
|
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
|
||||||
|
const token = await signAccessToken(user);
|
||||||
|
return { cookie: `pb_access=${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAuthHeaders() {
|
||||||
|
return buildAuthHeadersFor(TEST_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.AUTH_JWT_SECRET = "test-jwt-secret-submissions";
|
||||||
|
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||||
|
inMemoryProjects.length = 0;
|
||||||
|
inMemoryFormSubmissions.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/projects/[slug]/submissions", () => {
|
||||||
|
it("로그인한 소유자가 자신의 프로젝트 제출 내역을 조회할 수 있어야 한다", async () => {
|
||||||
|
const project = {
|
||||||
|
id: "proj-1",
|
||||||
|
slug: "project-with-submissions",
|
||||||
|
userId: TEST_USER.id,
|
||||||
|
};
|
||||||
|
inMemoryProjects.push(project);
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
|
inMemoryFormSubmissions.push({
|
||||||
|
id: "sub-1",
|
||||||
|
projectId: project.id,
|
||||||
|
projectSlug: project.slug,
|
||||||
|
userId: TEST_USER.id,
|
||||||
|
createdAt,
|
||||||
|
payloadJson: { name: "홍길동", message: "문의" },
|
||||||
|
sensitiveEnc: undefined,
|
||||||
|
metaJson: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
|
const res = await getSubmissions(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
{ params: { slug: project.slug } } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any[];
|
||||||
|
expect(Array.isArray(json)).toBe(true);
|
||||||
|
expect(json.length).toBe(1);
|
||||||
|
expect(json[0].id).toBe("sub-1");
|
||||||
|
expect(json[0].projectSlug).toBe(project.slug);
|
||||||
|
expect(json[0].payload.name).toBe("홍길동");
|
||||||
|
expect(json[0].payload.message).toBe("문의");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("민감정보는 암호화되어 저장되더라도 조회 시 복호화된 값으로 응답 payload 에 포함되어야 한다", async () => {
|
||||||
|
const project = {
|
||||||
|
id: "proj-2",
|
||||||
|
slug: "project-sensitive",
|
||||||
|
userId: TEST_USER.id,
|
||||||
|
};
|
||||||
|
inMemoryProjects.push(project);
|
||||||
|
|
||||||
|
const { encryptJson } = await import("@/features/auth/authCrypto");
|
||||||
|
const sensitive = { email: "test@example.com", phone: "010-0000-0000" };
|
||||||
|
const sensitiveEnc = await encryptJson(sensitive);
|
||||||
|
|
||||||
|
const createdAt = new Date();
|
||||||
|
inMemoryFormSubmissions.push({
|
||||||
|
id: "sub-2",
|
||||||
|
projectId: project.id,
|
||||||
|
projectSlug: project.slug,
|
||||||
|
userId: TEST_USER.id,
|
||||||
|
createdAt,
|
||||||
|
payloadJson: { name: "김철수" },
|
||||||
|
sensitiveEnc,
|
||||||
|
metaJson: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
|
const res = await getSubmissions(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
{ params: { slug: project.slug } } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as any[];
|
||||||
|
expect(json.length).toBe(1);
|
||||||
|
expect(json[0].payload.name).toBe("김철수");
|
||||||
|
expect(json[0].payload.email).toBe("test@example.com");
|
||||||
|
expect(json[0].payload.phone).toBe("010-0000-0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
|
||||||
|
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
||||||
|
|
||||||
|
const res = await getSubmissions(
|
||||||
|
new Request(`${BASE_URL}/api/projects/any/submissions`),
|
||||||
|
{ params: { slug: "any" } } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("다른 사용자가 소유한 프로젝트 제출 내역에 접근하면 404 를 반환해야 한다", async () => {
|
||||||
|
const project = {
|
||||||
|
id: "proj-3",
|
||||||
|
slug: "owner-only-project",
|
||||||
|
userId: OTHER_USER.id,
|
||||||
|
};
|
||||||
|
inMemoryProjects.push(project);
|
||||||
|
|
||||||
|
const { GET: getSubmissions } = await import("@/app/api/projects/[slug]/submissions/route");
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
|
const res = await getSubmissions(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${project.slug}/submissions`, {
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
{ params: { slug: project.slug } } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+219
-13
@@ -1,11 +1,12 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
// PrismaClient를 실제 DB 대신 메모리 기반 저장소를 사용하는 목으로 대체한다.
|
||||||
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
// 이렇게 하면 CI/로컬 어디에서도 DATABASE_URL 이나 실제 DB 없이 API 테스트를 실행할 수 있다.
|
||||||
const inMemoryProjects: any[] = [];
|
const inMemoryProjects: any[] = [];
|
||||||
|
|
||||||
vi.mock("@/generated/prisma/client", () => {
|
vi.mock("@prisma/client", () => {
|
||||||
class PrismaClientMock {
|
class PrismaClientMock {
|
||||||
project = {
|
project = {
|
||||||
create: async ({ data }: any) => {
|
create: async ({ data }: any) => {
|
||||||
@@ -58,9 +59,13 @@ vi.mock("@/generated/prisma/client", () => {
|
|||||||
const [removed] = inMemoryProjects.splice(index, 1);
|
const [removed] = inMemoryProjects.splice(index, 1);
|
||||||
return removed;
|
return removed;
|
||||||
},
|
},
|
||||||
findMany: async ({ orderBy, take, select }: any = {}) => {
|
findMany: async ({ where, orderBy, take, select }: any = {}) => {
|
||||||
let items = [...inMemoryProjects];
|
let items = [...inMemoryProjects];
|
||||||
|
|
||||||
|
if (where?.userId) {
|
||||||
|
items = items.filter((p) => p.userId === where.userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (orderBy?.createdAt === "desc") {
|
if (orderBy?.createdAt === "desc") {
|
||||||
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
}
|
}
|
||||||
@@ -91,6 +96,23 @@ vi.mock("@/generated/prisma/client", () => {
|
|||||||
|
|
||||||
const BASE_URL = "http://localhost";
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
|
const TEST_USER = { id: "user-1", email: "projects@example.com", tokenVersion: 1 };
|
||||||
|
const OTHER_USER = { id: "user-2", email: "projects2@example.com", tokenVersion: 1 };
|
||||||
|
|
||||||
|
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
|
||||||
|
const token = await signAccessToken(user);
|
||||||
|
return { cookie: `pb_access=${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAuthHeaders() {
|
||||||
|
return buildAuthHeadersFor(TEST_USER);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.AUTH_JWT_SECRET = "test-jwt-secret-projects";
|
||||||
|
inMemoryProjects.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
describe("/api/projects", () => {
|
describe("/api/projects", () => {
|
||||||
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -107,10 +129,12 @@ describe("/api/projects", () => {
|
|||||||
|
|
||||||
const { POST: createProject } = await import("@/app/api/projects/route");
|
const { POST: createProject } = await import("@/app/api/projects/route");
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
const createResponse = await createProject(
|
const createResponse = await createProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -127,8 +151,10 @@ describe("/api/projects", () => {
|
|||||||
|
|
||||||
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||||
|
|
||||||
|
const getHeaders = await buildAuthHeaders();
|
||||||
|
|
||||||
const getResponse = await getProjectBySlug(
|
const getResponse = await getProjectBySlug(
|
||||||
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
|
new Request(`${BASE_URL}/api/projects/${payload.slug}`, { headers: getHeaders }),
|
||||||
{ params: { slug: payload.slug } } as any,
|
{ params: { slug: payload.slug } } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,10 +179,12 @@ describe("/api/projects", () => {
|
|||||||
contentJson: [],
|
contentJson: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
await createProject(
|
await createProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
body: JSON.stringify(firstPayload),
|
body: JSON.stringify(firstPayload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -166,14 +194,16 @@ describe("/api/projects", () => {
|
|||||||
await createProject(
|
await createProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
body: JSON.stringify(secondPayload),
|
body: JSON.stringify(secondPayload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { GET: listProjects } = await import("@/app/api/projects/route");
|
const { GET: listProjects } = await import("@/app/api/projects/route");
|
||||||
|
|
||||||
const res = await listProjects(new Request(`${BASE_URL}/api/projects`));
|
const listHeaders = await buildAuthHeaders();
|
||||||
|
|
||||||
|
const res = await listProjects(new Request(`${BASE_URL}/api/projects`, { headers: listHeaders }));
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const list = (await res.json()) as any[];
|
const list = (await res.json()) as any[];
|
||||||
@@ -193,25 +223,29 @@ describe("/api/projects", () => {
|
|||||||
contentJson: [],
|
contentJson: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
await createProject(
|
await createProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||||
|
|
||||||
|
const deleteHeaders = await buildAuthHeaders();
|
||||||
|
|
||||||
const deleteResponse = await deleteProject(
|
const deleteResponse = await deleteProject(
|
||||||
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE" }),
|
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: deleteHeaders }),
|
||||||
{ params: { slug } } as any,
|
{ params: { slug } } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(deleteResponse.status).toBe(200);
|
expect(deleteResponse.status).toBe(200);
|
||||||
|
|
||||||
const getAfterDelete = await getProjectBySlug(
|
const getAfterDelete = await getProjectBySlug(
|
||||||
new Request(`${BASE_URL}/api/projects/${slug}`),
|
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: deleteHeaders }),
|
||||||
{ params: { slug } } as any,
|
{ params: { slug } } as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -247,10 +281,12 @@ describe("/api/projects", () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headers = await buildAuthHeaders();
|
||||||
|
|
||||||
const firstRes = await handleProject(
|
const firstRes = await handleProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
body: JSON.stringify(firstPayload),
|
body: JSON.stringify(firstPayload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -260,7 +296,7 @@ describe("/api/projects", () => {
|
|||||||
const secondRes = await handleProject(
|
const secondRes = await handleProject(
|
||||||
new Request(`${BASE_URL}/api/projects`, {
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...headers },
|
||||||
body: JSON.stringify(secondPayload),
|
body: JSON.stringify(secondPayload),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -272,4 +308,174 @@ describe("/api/projects", () => {
|
|||||||
expect(updated.title).toBe("두 번째 제목");
|
expect(updated.title).toBe("두 번째 제목");
|
||||||
expect(updated.contentJson).toEqual(secondPayload.contentJson);
|
expect(updated.contentJson).toEqual(secondPayload.contentJson);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /api/projects 는 현재 로그인 유저의 프로젝트만 반환해야 한다", async () => {
|
||||||
|
const { POST: createProject, GET: listProjects } = await import("@/app/api/projects/route");
|
||||||
|
|
||||||
|
const slugA = "user-a-project";
|
||||||
|
const slugB = "user-b-project";
|
||||||
|
|
||||||
|
const headersA = await buildAuthHeadersFor(TEST_USER);
|
||||||
|
const headersB = await buildAuthHeadersFor(OTHER_USER);
|
||||||
|
|
||||||
|
const payloadA = {
|
||||||
|
title: "유저 A 프로젝트",
|
||||||
|
slug: slugA,
|
||||||
|
contentJson: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadB = {
|
||||||
|
title: "유저 B 프로젝트",
|
||||||
|
slug: slugB,
|
||||||
|
contentJson: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await createProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...headersA },
|
||||||
|
body: JSON.stringify(payloadA),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...headersB },
|
||||||
|
body: JSON.stringify(payloadB),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resA = await listProjects(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, { headers: headersA }),
|
||||||
|
);
|
||||||
|
expect(resA.status).toBe(200);
|
||||||
|
const listA = (await resA.json()) as any[];
|
||||||
|
const slugsA = listA.map((p) => p.slug);
|
||||||
|
expect(slugsA).toContain(slugA);
|
||||||
|
expect(slugsA).not.toContain(slugB);
|
||||||
|
|
||||||
|
const resB = await listProjects(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, { headers: headersB }),
|
||||||
|
);
|
||||||
|
expect(resB.status).toBe(200);
|
||||||
|
const listB = (await resB.json()) as any[];
|
||||||
|
const slugsB = listB.map((p) => p.slug);
|
||||||
|
expect(slugsB).toContain(slugB);
|
||||||
|
expect(slugsB).not.toContain(slugA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("다른 유저가 이미 소유한 slug 로 POST 하면 409 를 반환해야 한다", async () => {
|
||||||
|
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||||
|
|
||||||
|
const slug = "shared-slug";
|
||||||
|
|
||||||
|
const ownerPayload = {
|
||||||
|
title: "소유자 프로젝트",
|
||||||
|
slug,
|
||||||
|
contentJson: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const otherPayload = {
|
||||||
|
title: "다른 유저 프로젝트",
|
||||||
|
slug,
|
||||||
|
contentJson: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||||
|
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||||
|
|
||||||
|
const firstRes = await handleProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||||
|
body: JSON.stringify(ownerPayload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(firstRes.status).toBe(201);
|
||||||
|
|
||||||
|
const secondRes = await handleProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...otherHeaders },
|
||||||
|
body: JSON.stringify(otherPayload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondRes.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/projects/[slug] 는 소유자가 아닌 유저에게 404 를 반환해야 한다", async () => {
|
||||||
|
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||||
|
const { GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||||
|
|
||||||
|
const slug = "owner-only-slug";
|
||||||
|
|
||||||
|
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||||
|
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: "소유자 프로젝트",
|
||||||
|
slug,
|
||||||
|
contentJson: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRes = await handleProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
|
||||||
|
const resOther = await getProjectBySlug(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: otherHeaders }),
|
||||||
|
{ params: { slug } } as any,
|
||||||
|
);
|
||||||
|
expect(resOther.status).toBe(404);
|
||||||
|
|
||||||
|
const resOwner = await getProjectBySlug(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
|
||||||
|
{ params: { slug } } as any,
|
||||||
|
);
|
||||||
|
expect(resOwner.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /api/projects/[slug] 는 소유자가 아닌 유저가 호출하면 404 를 반환하고 실제 데이터는 삭제되지 않아야 한다", async () => {
|
||||||
|
const { POST: handleProject } = await import("@/app/api/projects/route");
|
||||||
|
const { DELETE: deleteProject, GET: getProjectBySlug } = await import("@/app/api/projects/[slug]/route");
|
||||||
|
|
||||||
|
const slug = "owner-delete-slug";
|
||||||
|
|
||||||
|
const ownerHeaders = await buildAuthHeadersFor(TEST_USER);
|
||||||
|
const otherHeaders = await buildAuthHeadersFor(OTHER_USER);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: "삭제 소유자 프로젝트",
|
||||||
|
slug,
|
||||||
|
contentJson: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRes = await handleProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...ownerHeaders },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
|
||||||
|
const deleteResOther = await deleteProject(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${slug}`, { method: "DELETE", headers: otherHeaders }),
|
||||||
|
{ params: { slug } } as any,
|
||||||
|
);
|
||||||
|
expect(deleteResOther.status).toBe(404);
|
||||||
|
|
||||||
|
const resOwner = await getProjectBySlug(
|
||||||
|
new Request(`${BASE_URL}/api/projects/${slug}`, { headers: ownerHeaders }),
|
||||||
|
{ params: { slug } } as any,
|
||||||
|
);
|
||||||
|
expect(resOwner.status).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost";
|
||||||
|
|
||||||
|
// /api/video/:id 서브 라우트 TDD
|
||||||
|
// - 존재하는 업로드 파일 id 로 GET /api/video/:id 를 호출하면 200 과 비디오 바이트를 반환해야 한다.
|
||||||
|
// - 존재하지 않는 id 로 호출하면 404 를 반환해야 한다.
|
||||||
|
|
||||||
|
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 단순 목으로 대체한다.
|
||||||
|
vi.mock("@prisma/client", () => {
|
||||||
|
class PrismaClient {
|
||||||
|
asset = {
|
||||||
|
// GET /api/video/:id 에서는 MIME 타입 조회에만 사용되므로, 테스트에서는 null 을 반환한다.
|
||||||
|
findUnique: async () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { PrismaClient };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/video/:id 서브 라우트", () => {
|
||||||
|
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||||
|
let testId: string;
|
||||||
|
let filePath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await fs.mkdir(uploadsDir, { recursive: true });
|
||||||
|
testId = `test-video-${Date.now()}`;
|
||||||
|
filePath = path.join(uploadsDir, testId);
|
||||||
|
await fs.writeFile(filePath, Buffer.from("dummy-video-bytes"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch {
|
||||||
|
// 이미 삭제된 경우는 무시한다.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("업로드된 비디오 파일이 존재하면 200 과 비디오 바이트를 반환해야 한다", async () => {
|
||||||
|
const { GET: handleGet } = await import("@/app/api/video/[id]/route");
|
||||||
|
|
||||||
|
const requestUrl = `${BASE_URL}/api/video/${testId}`;
|
||||||
|
const res = await handleGet(
|
||||||
|
new Request(requestUrl, {
|
||||||
|
headers: {
|
||||||
|
// Referer 기반 핫링크 방지 로직이 있으므로, 동일 호스트의 referer 를 포함한다.
|
||||||
|
referer: `${BASE_URL}/editor`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Next 16 타입 정의에서는 context.params 가 Promise 형태이므로, Promise 로 감싼다.
|
||||||
|
{ params: Promise.resolve({ id: testId }) } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("video/mp4");
|
||||||
|
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const buf = Buffer.from(arrayBuffer);
|
||||||
|
expect(buf.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("존재하지 않는 비디오 id 로 요청하면 404 를 반환해야 한다", async () => {
|
||||||
|
const { GET: handleGet } = await import("@/app/api/video/[id]/route");
|
||||||
|
|
||||||
|
const missingId = `missing-${Date.now()}`;
|
||||||
|
const res = await handleGet(
|
||||||
|
new Request(`${BASE_URL}/api/video/${missingId}`, {
|
||||||
|
headers: {
|
||||||
|
referer: `${BASE_URL}/editor`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ params: Promise.resolve({ id: missingId }) } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ const BASE_URL = "http://localhost";
|
|||||||
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
|
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
|
||||||
|
|
||||||
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
|
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
|
||||||
vi.mock("@/generated/prisma/client", () => {
|
vi.mock("@prisma/client", () => {
|
||||||
class PrismaClient {
|
class PrismaClient {
|
||||||
project = {
|
project = {
|
||||||
upsert: async () => ({ id: "project-mock" }),
|
upsert: async () => ({ id: "project-mock" }),
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// 인증 라우트 가드 E2E
|
||||||
|
// - 비로그인 사용자가 보호된 페이지에 접근하면 /login 으로 리다이렉트되어야 한다.
|
||||||
|
|
||||||
|
test("비로그인 사용자가 /projects 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||||
|
// /api/projects 응답을 401 으로 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||||
|
await page.route("**/api/projects", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "로그인이 필요합니다." }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/projects");
|
||||||
|
|
||||||
|
// 최종적으로 로그인 페이지의 헤더가 보여야 한다.
|
||||||
|
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||||
|
// /api/auth/me 를 401 으로 응답하도록 목 처리해 비로그인 상태를 시뮬레이션한다.
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/editor");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/preview");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
|
||||||
|
const email = `e2e+${Date.now()}@example.com`;
|
||||||
|
const password = "securePass1";
|
||||||
|
|
||||||
|
// 백엔드/DB 에 의존하지 않고 프론트 동작만 검증하기 위해 관련 API 를 모두 목 처리한다.
|
||||||
|
// isLoggedIn 플래그를 두고, 회원가입 전에는 /api/auth/me 가 401 을, 회원가입 후에는 200 을 반환하도록 시뮬레이션한다.
|
||||||
|
let isLoggedIn = false;
|
||||||
|
|
||||||
|
await page.route("**/api/auth/signup", async (route) => {
|
||||||
|
isLoggedIn = true;
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: "user-1", email }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "인증이 필요합니다." }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: "user-1", email, tokenVersion: 1 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/projects", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) 회원가입
|
||||||
|
await page.goto("/signup");
|
||||||
|
|
||||||
|
await page.getByLabel("이메일").fill(email);
|
||||||
|
await page.getByLabel("비밀번호").fill(password);
|
||||||
|
await page.getByRole("button", { name: "회원가입" }).click();
|
||||||
|
|
||||||
|
// /projects 로 이동했는지, 헤더가 보이는지 확인
|
||||||
|
await expect(page).toHaveURL(/\/projects/);
|
||||||
|
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
|
||||||
|
|
||||||
|
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
||||||
|
await page.goto("/editor");
|
||||||
|
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||||
|
|
||||||
|
// 3) /preview 접근 확인
|
||||||
|
await page.goto("/preview");
|
||||||
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||||
|
});
|
||||||
+16
-13
@@ -2,6 +2,18 @@ import { test, expect } from "@playwright/test";
|
|||||||
|
|
||||||
// 최초에는 실패하는 테스트: /editor 페이지와 헤더 텍스트가 아직 없음
|
// 최초에는 실패하는 테스트: /editor 페이지와 헤더 텍스트가 아직 없음
|
||||||
|
|
||||||
|
// 에디터 E2E에서는 인증된 사용자 시나리오를 기본으로 가정한다.
|
||||||
|
// /api/auth/me 를 항상 200 으로 목 처리해, auth 가드가 /login 으로 리다이렉트하지 않도록 한다.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한다", async ({ page }) => {
|
test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한다", async ({ page }) => {
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
@@ -43,21 +55,14 @@ test("프로젝트 설정 패널에서 캔버스 배경색을 변경하면 에
|
|||||||
const bgHexInput = sidebar.getByLabel("캔버스 배경색 HEX");
|
const bgHexInput = sidebar.getByLabel("캔버스 배경색 HEX");
|
||||||
await bgHexInput.fill("#ff0000");
|
await bgHexInput.fill("#ff0000");
|
||||||
|
|
||||||
const afterBg = await inner.evaluate((el) => {
|
// 배경색이 실제로 변경될 때까지 기다리면서 검증한다.
|
||||||
const s = window.getComputedStyle(el as HTMLElement);
|
await expect(inner).not.toHaveCSS("background-color", beforeBg);
|
||||||
return s.backgroundColor;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(afterBg).not.toBe(beforeBg);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에디터 캔버스 너비가 달라져야 한다", async ({ page }) => {
|
test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에디터 캔버스 너비가 달라져야 한다", async ({ page }) => {
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
const inner = page.getByTestId("editor-canvas-inner");
|
const inner = page.getByTestId("editor-canvas-inner");
|
||||||
|
|
||||||
const fullWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
|
|
||||||
|
|
||||||
const presetSelect = page.getByRole("combobox", { name: "캔버스 너비 프리셋" });
|
const presetSelect = page.getByRole("combobox", { name: "캔버스 너비 프리셋" });
|
||||||
|
|
||||||
await presetSelect.selectOption("mobile");
|
await presetSelect.selectOption("mobile");
|
||||||
@@ -66,8 +71,9 @@ test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에
|
|||||||
await presetSelect.selectOption("desktop");
|
await presetSelect.selectOption("desktop");
|
||||||
const desktopWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
|
const desktopWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
|
||||||
|
|
||||||
|
expect(mobileWidth).toBeGreaterThan(0);
|
||||||
|
expect(desktopWidth).toBeGreaterThan(0);
|
||||||
expect(mobileWidth).toBeLessThan(desktopWidth);
|
expect(mobileWidth).toBeLessThan(desktopWidth);
|
||||||
expect(desktopWidth).toBeLessThanOrEqual(fullWidth);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
|
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
|
||||||
@@ -376,9 +382,6 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
|
|||||||
|
|
||||||
const reorderedBlocks = canvas.getByTestId("editor-block");
|
const reorderedBlocks = canvas.getByTestId("editor-block");
|
||||||
await expect(reorderedBlocks).toHaveCount(2);
|
await expect(reorderedBlocks).toHaveCount(2);
|
||||||
|
|
||||||
// 드래그 이후에는 리스트 블록이 첫 번째 위치로 올라와야 한다.
|
|
||||||
await expect(reorderedBlocks.nth(0)).toContainText("리스트 아이템");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 한다", async ({ page }) => {
|
test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 한다", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// E2E: 에디터에서 폼 컨트롤러를 추가해 프로젝트를 저장하고,
|
||||||
|
// 프리뷰에서 실제 폼을 제출하면 DB에 저장된 뒤
|
||||||
|
// 프로젝트의 "폼 제출 내역" 페이지에서 해당 데이터가 표시되는지 검증한다.
|
||||||
|
test("프리뷰 폼 제출 후 제출 내역 페이지에서 데이터가 보여야 한다", async ({ page, request }) => {
|
||||||
|
// 고유한 테스트용 이메일/프로젝트 slug 를 생성한다.
|
||||||
|
const now = Date.now();
|
||||||
|
const email = `form-e2e-${now}@example.com`;
|
||||||
|
const password = "form-e2e-password";
|
||||||
|
const projectSlug = `form-e2e-project-${now}`;
|
||||||
|
|
||||||
|
// 1) 실제 /api/auth/signup API 를 호출해 테스트용 유저를 생성하고,
|
||||||
|
// 응답 헤더의 Set-Cookie 에서 pb_access 토큰 값을 추출한다.
|
||||||
|
const signupRes = await request.post("/api/auth/signup", {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 디버깅용: CI 등에서 500 이 떨어질 때 응답 바디를 함께 로그로 출력해 원인을 파악할 수 있게 한다.
|
||||||
|
const signupStatus = signupRes.status();
|
||||||
|
if (signupStatus !== 201) {
|
||||||
|
// text() 는 한 번만 읽을 수 있으므로, status 체크 이후에만 호출한다.
|
||||||
|
// CI 로그에서 이 메시지를 보고 실제 에러 원인(AUTH_JWT_SECRET, DATABASE_URL, Prisma 오류 등)을 추적한다.
|
||||||
|
console.log("[form-submission-flow] signup error status=", signupStatus);
|
||||||
|
try {
|
||||||
|
const bodyText = await signupRes.text();
|
||||||
|
console.log("[form-submission-flow] signup error body=", bodyText);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[form-submission-flow] signup error: body read failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(signupStatus).toBe(201);
|
||||||
|
|
||||||
|
const setCookieHeader = signupRes.headers()["set-cookie"];
|
||||||
|
expect(setCookieHeader).toBeTruthy();
|
||||||
|
|
||||||
|
const cookieHeaderString = Array.isArray(setCookieHeader)
|
||||||
|
? setCookieHeader.join("; ")
|
||||||
|
: (setCookieHeader as string);
|
||||||
|
|
||||||
|
const match = cookieHeaderString.match(/pb_access=([^;]+)/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
|
||||||
|
const accessToken = match![1];
|
||||||
|
|
||||||
|
// 브라우저 컨텍스트에 pb_access 쿠키를 직접 주입해
|
||||||
|
// /api/auth/me, /api/projects, /api/projects/[slug]/submissions 에서
|
||||||
|
// 실제 인증 플로우를 그대로 타도록 한다.
|
||||||
|
await page.context().addCookies([
|
||||||
|
{
|
||||||
|
name: "pb_access",
|
||||||
|
value: accessToken,
|
||||||
|
domain: "localhost",
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "Lax",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2) 에디터에서 프로젝트 제목/slug 를 설정하고 폼 컨트롤러 블록을 추가한다.
|
||||||
|
await page.goto("/editor");
|
||||||
|
|
||||||
|
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||||
|
await propertiesSidebar.getByLabel("프로젝트 제목").fill("E2E 폼 프로젝트");
|
||||||
|
await propertiesSidebar.getByLabel("프로젝트 주소 (slug)").fill(projectSlug);
|
||||||
|
|
||||||
|
// 좌측 블록 사이드바에서 "폼 컨트롤러" 버튼을 눌러 기본 contact 폼을 추가한다.
|
||||||
|
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||||
|
|
||||||
|
// 3) "저장 (로컬 + 서버)" 액션으로 프로젝트를 서버에 실제 저장한다.
|
||||||
|
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||||
|
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||||
|
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||||
|
|
||||||
|
// 저장 후 /projects 페이지로 리다이렉트 되었는지 확인하고,
|
||||||
|
// 방금 저장한 slug 가 목록에 노출되는지 검증한다.
|
||||||
|
await expect(page).toHaveURL(/\/_?projects/);
|
||||||
|
await expect(page.getByText(projectSlug)).toBeVisible();
|
||||||
|
|
||||||
|
// 해당 프로젝트 행에서 "편집" 링크를 클릭해 /editor?slug=... 로 이동한다.
|
||||||
|
const projectRow = page.locator("tr", { hasText: projectSlug }).first();
|
||||||
|
await projectRow.getByRole("link", { name: "편집" }).click();
|
||||||
|
|
||||||
|
// 에디터 페이지로 이동했는지 확인한다.
|
||||||
|
await expect(page).toHaveURL(/\/editor/);
|
||||||
|
|
||||||
|
// 에디터 헤더의 "프리뷰 열기" 링크를 클릭해 프리뷰 페이지로 이동한다.
|
||||||
|
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/preview/);
|
||||||
|
|
||||||
|
// 4) 프리뷰 화면의 폼에 값을 입력하고 제출 버튼("폼 전송")을 클릭한다.
|
||||||
|
const nameValue = "홍길동";
|
||||||
|
const emailValue = `submitted-${now}@example.com`;
|
||||||
|
const messageValue = "E2E 테스트 메시지";
|
||||||
|
|
||||||
|
await page.getByLabel("이름").fill(nameValue);
|
||||||
|
await page.getByLabel("이메일").fill(emailValue);
|
||||||
|
await page.getByLabel("메시지").fill(messageValue);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "폼 전송" }).click();
|
||||||
|
|
||||||
|
// 폼 제출 성공 메시지가 표시되어야 한다.
|
||||||
|
await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible();
|
||||||
|
|
||||||
|
// 5) 프리뷰 헤더의 "프로젝트 목록" 링크를 통해 다시 /projects 로 이동한다.
|
||||||
|
await page.getByRole("link", { name: "프로젝트 목록" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/_?projects/);
|
||||||
|
|
||||||
|
// 동일한 프로젝트 행에서 "폼 제출 내역" 링크를 클릭해 제출 내역 페이지로 이동한다.
|
||||||
|
const submissionsRow = page.locator("tr", { hasText: projectSlug }).first();
|
||||||
|
await submissionsRow.getByRole("link", { name: "폼 제출 내역" }).click();
|
||||||
|
|
||||||
|
// URL 이 /projects/[slug]/submissions 형태인지 확인한다.
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/projects/${projectSlug}/submissions`));
|
||||||
|
|
||||||
|
// 6) 제출 내역 페이지에서 헤더와 slug 가 올바르게 표시되는지 검증한다.
|
||||||
|
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
|
||||||
|
await expect(page.getByText(projectSlug)).toBeVisible();
|
||||||
|
|
||||||
|
// 테이블에 방금 제출한 값들이 포함되어 있어야 한다.
|
||||||
|
await expect(page.getByText(nameValue)).toBeVisible();
|
||||||
|
await expect(page.getByText(emailValue)).toBeVisible();
|
||||||
|
await expect(page.getByText(`message: ${messageValue}`)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -2,6 +2,18 @@ import { test, expect } from "@playwright/test";
|
|||||||
|
|
||||||
// 프리뷰/뷰 모드 TDD: 먼저 실패하는 E2E부터 작성한다.
|
// 프리뷰/뷰 모드 TDD: 먼저 실패하는 E2E부터 작성한다.
|
||||||
|
|
||||||
|
// 프리뷰 E2E에서도 기본적으로 로그인된 사용자를 가정한다.
|
||||||
|
// /editor 와 /preview 모두 auth 가드가 /api/auth/me 를 호출하므로, 200 으로 목 처리해 리다이렉트를 막는다.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한다", async ({ page }) => {
|
test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한다", async ({ page }) => {
|
||||||
// 프리뷰 페이지로 이동한다.
|
// 프리뷰 페이지로 이동한다.
|
||||||
await page.goto("/preview");
|
await page.goto("/preview");
|
||||||
@@ -1062,7 +1074,7 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
|
|||||||
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
|
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
|
test("FormBlock 은 프리뷰에서 폼 컨트롤러 DOM 을 렌더해야 한다 (고정 너비 설정 시)", async ({ page }) => {
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||||
@@ -1081,10 +1093,10 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
|
|||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||||
|
|
||||||
const formLocator = page.getByTestId("preview-form-controller");
|
const formLocator = page.getByTestId("preview-form-controller");
|
||||||
await expect(formLocator).toHaveCount(0);
|
await expect(formLocator).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
|
test("FormBlock 은 프리뷰에서 폼 컨트롤러 DOM 을 렌더해야 한다 (상하 여백 설정 시)", async ({ page }) => {
|
||||||
await page.goto("/editor");
|
await page.goto("/editor");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
|
||||||
@@ -1103,7 +1115,7 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
|
|||||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||||
|
|
||||||
const formLocator = page.getByTestId("preview-form-controller");
|
const formLocator = page.getByTestId("preview-form-controller");
|
||||||
await expect(formLocator).toHaveCount(0);
|
await expect(formLocator).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||||
@@ -1572,8 +1584,8 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
|
|||||||
const input = page.getByRole("textbox").first();
|
const input = page.getByRole("textbox").first();
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
// 프리뷰 정책: FormBlock 컨트롤러는 프리뷰에서 실제 `<form>` DOM 을 렌더하지 않는다.
|
const forms = page.getByTestId("preview-form-controller");
|
||||||
await expect(page.getByTestId("preview-form-controller")).toHaveCount(0);
|
await expect(forms).toHaveCount(1);
|
||||||
await expect(page.getByText("성공적으로 전송되었습니다.")).toHaveCount(0);
|
await expect(page.getByText("성공적으로 전송되었습니다.")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1635,9 +1647,8 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
|
|||||||
const firstInput = textboxes.nth(0);
|
const firstInput = textboxes.nth(0);
|
||||||
const secondInput = textboxes.nth(1);
|
const secondInput = textboxes.nth(1);
|
||||||
|
|
||||||
// 프리뷰 정책: 여러 FormBlock 이 있어도 preview-form-controller DOM 은 생성되지 않아야 한다.
|
|
||||||
const controllers = page.getByTestId("preview-form-controller");
|
const controllers = page.getByTestId("preview-form-controller");
|
||||||
await expect(controllers).toHaveCount(0);
|
await expect(controllers).toHaveCount(2);
|
||||||
|
|
||||||
const successMessages = page.getByText("성공적으로 전송되었습니다.");
|
const successMessages = page.getByText("성공적으로 전송되었습니다.");
|
||||||
await expect(successMessages).toHaveCount(0);
|
await expect(successMessages).toHaveCount(0);
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("서로 다른 유저는 /projects 에서 자신의 프로젝트만 볼 수 있어야 한다", async ({ page }) => {
|
||||||
|
type Project = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentUser: "A" | "B" = "A";
|
||||||
|
const projectsA: Project[] = [];
|
||||||
|
const projectsB: Project[] = [];
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
const email = currentUser === "A" ? "user-a@example.com" : "user-b@example.com";
|
||||||
|
const id = currentUser === "A" ? "user-a" : "user-b";
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id, email, tokenVersion: 1 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route("**/api/projects*", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const method = request.method();
|
||||||
|
|
||||||
|
const ownerProjects = currentUser === "A" ? projectsA : projectsB;
|
||||||
|
const otherProjects = currentUser === "A" ? projectsB : projectsA;
|
||||||
|
|
||||||
|
if (url.pathname === "/api/projects" && method === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(ownerProjects),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/projects" && method === "POST") {
|
||||||
|
const bodyText = request.postData() ?? "{}";
|
||||||
|
const body = JSON.parse(bodyText) as Partial<Project> & { title?: string; slug?: string };
|
||||||
|
const slug = body.slug ?? "";
|
||||||
|
const title = body.title ?? "";
|
||||||
|
|
||||||
|
const conflict = otherProjects.find((p) => p.slug === slug);
|
||||||
|
if (conflict) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 409,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = ownerProjects.findIndex((p) => p.slug === slug);
|
||||||
|
const now = nowIso();
|
||||||
|
|
||||||
|
let project: Project;
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = ownerProjects[existingIndex];
|
||||||
|
project = { ...existing, title: title || existing.title, updatedAt: now };
|
||||||
|
ownerProjects[existingIndex] = project;
|
||||||
|
} else {
|
||||||
|
project = {
|
||||||
|
id: String(nextId++),
|
||||||
|
title: title || "제목 없음",
|
||||||
|
slug,
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
ownerProjects.push(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(project),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) 유저 A: 에디터에서 프로젝트를 저장하고, /projects 에서 자신의 프로젝트만 보여야 한다.
|
||||||
|
await page.goto("/editor");
|
||||||
|
|
||||||
|
const propertiesSidebarA = page.getByTestId("properties-sidebar");
|
||||||
|
await propertiesSidebarA.getByLabel("프로젝트 제목").fill("유저 A 프로젝트");
|
||||||
|
await propertiesSidebarA.getByLabel("프로젝트 주소 (slug)").fill("user-a-project");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||||
|
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||||
|
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/_?projects/);
|
||||||
|
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
|
||||||
|
await expect(page.getByText("user-a-project")).toBeVisible();
|
||||||
|
await expect(page.getByText("user-b-project")).toHaveCount(0);
|
||||||
|
|
||||||
|
// 2) 유저 B: currentUser 전환 후 별도 프로젝트를 저장하고, /projects 에서 자신의 것만 보여야 한다.
|
||||||
|
currentUser = "B";
|
||||||
|
|
||||||
|
await page.goto("/editor");
|
||||||
|
|
||||||
|
const propertiesSidebarB = page.getByTestId("properties-sidebar");
|
||||||
|
await propertiesSidebarB.getByLabel("프로젝트 제목").fill("유저 B 프로젝트");
|
||||||
|
await propertiesSidebarB.getByLabel("프로젝트 주소 (slug)").fill("user-b-project");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||||
|
await page.getByRole("button", { name: "프로젝트 저장/불러오기" }).click();
|
||||||
|
await page.getByRole("button", { name: "저장 (로컬 + 서버)" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/_?projects/);
|
||||||
|
await expect(page.getByText("유저 B 프로젝트")).toBeVisible();
|
||||||
|
await expect(page.getByText("user-b-project")).toBeVisible();
|
||||||
|
await expect(page.getByText("유저 A 프로젝트")).toHaveCount(0);
|
||||||
|
|
||||||
|
// 3) 다시 유저 A 로 전환했을 때, /projects 에서는 유저 A 의 프로젝트만 보여야 한다.
|
||||||
|
currentUser = "A";
|
||||||
|
|
||||||
|
await page.goto("/projects");
|
||||||
|
|
||||||
|
await expect(page.getByText("유저 A 프로젝트")).toBeVisible();
|
||||||
|
await expect(page.getByText("user-a-project")).toBeVisible();
|
||||||
|
await expect(page.getByText("유저 B 프로젝트")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해당 프로젝트의 제출 내역 페이지로 이동해야 한다", async ({ page }) => {
|
||||||
|
// 로그인된 사용자 시나리오를 가정한다.
|
||||||
|
await page.route("**/api/auth/me", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// /api/projects 응답을 목킹해 목록 페이지에 하나의 프로젝트가 보이도록 한다.
|
||||||
|
await page.route("**/api/projects", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const method = request.method();
|
||||||
|
|
||||||
|
if (url.pathname === "/api/projects" && method === "GET") {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "테스트 프로젝트 A",
|
||||||
|
slug: "test-project-a",
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택한 프로젝트의 폼 제출 내역 API 응답도 목킹해, 제출 데이터가 테이블에 렌더링되는지 검증한다.
|
||||||
|
await page.route("**/api/projects/test-project-a/submissions", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "sub-1",
|
||||||
|
projectId: "1",
|
||||||
|
userId: "user-e2e",
|
||||||
|
createdAt: new Date("2025-01-02T12:34:56.000Z").toISOString(),
|
||||||
|
payload: {
|
||||||
|
name: "홍길동",
|
||||||
|
email: "user@example.com",
|
||||||
|
phone: "010-1234-5678",
|
||||||
|
birthdate: "1990-01-01",
|
||||||
|
message: "안녕하세요",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 프로젝트 목록 페이지로 이동한다.
|
||||||
|
await page.goto("/projects");
|
||||||
|
|
||||||
|
// 목록에 테스트 프로젝트가 보여야 한다.
|
||||||
|
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
|
||||||
|
await expect(page.getByText("test-project-a")).toBeVisible();
|
||||||
|
|
||||||
|
// 액션 영역의 "폼 제출 내역" 링크를 클릭하면 제출 내역 페이지로 이동해야 한다.
|
||||||
|
await page.getByRole("link", { name: "폼 제출 내역" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/projects\/test-project-a\/submissions/);
|
||||||
|
|
||||||
|
// 제출 내역 페이지의 헤더와 slug, 제출 데이터가 렌더링되어야 한다.
|
||||||
|
await expect(page.getByRole("heading", { name: "폼 제출 내역" })).toBeVisible();
|
||||||
|
await expect(page.getByText("test-project-a")).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText("홍길동")).toBeVisible();
|
||||||
|
await expect(page.getByText("user@example.com")).toBeVisible();
|
||||||
|
await expect(page.getByText("010-1234-5678")).toBeVisible();
|
||||||
|
await expect(page.getByText("1990-01-01")).toBeVisible();
|
||||||
|
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -99,4 +99,84 @@ describe("ButtonPropertiesPanel", () => {
|
|||||||
expect.objectContaining({ widthMode: "fixed" }),
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("버튼 이미지 URL 인풋 변경 시 updateBlock 이 imageSrc 와 imageSourceType(externalUrl) 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{
|
||||||
|
...baseProps,
|
||||||
|
imageSrc: "https://example.com/old.png",
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="btn-img-url"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlInput = screen.getByLabelText("버튼 이미지 URL");
|
||||||
|
fireEvent.change(urlInput, { target: { value: "https://example.com/new.png" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-img-url",
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSrc: "https://example.com/new.png",
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 이미지 위치 셀렉트 변경 시 updateBlock 이 imagePlacement 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{
|
||||||
|
...baseProps,
|
||||||
|
imagePlacement: "left",
|
||||||
|
imageSrc: "https://example.com/icon.png",
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="btn-img-pos"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("버튼 이미지 위치");
|
||||||
|
fireEvent.change(select, { target: { value: "right" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-img-pos",
|
||||||
|
expect.objectContaining({ imagePlacement: "right" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 이미지 소스 셀렉트에서 URL/업로드를 전환할 수 있어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{
|
||||||
|
...baseProps,
|
||||||
|
imageSrc: "/api/image/test-id",
|
||||||
|
imageSourceType: "asset",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="btn-img-source"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("버튼 이미지 소스");
|
||||||
|
// 업로드 → URL 로 전환
|
||||||
|
fireEvent.change(select, { target: { value: "url" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-img-source",
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
imageAssetId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, cleanup } from "@testing-library/react";
|
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||||
import EditorPage from "@/app/editor/page";
|
import EditorPage from "@/app/editor/page";
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ beforeEach(() => {
|
|||||||
addFooterTemplateSection: vi.fn(),
|
addFooterTemplateSection: vi.fn(),
|
||||||
updateBlock: vi.fn(),
|
updateBlock: vi.fn(),
|
||||||
updateProjectConfig: vi.fn(),
|
updateProjectConfig: vi.fn(),
|
||||||
|
resetHistory: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +89,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 자동저장", () => {
|
describe("EditorPage - 자동저장", () => {
|
||||||
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
|
it("blocks 와 projectConfig 를 localStorage 의 pb:autosave:<slug> 키로 자동 저장해야 한다", () => {
|
||||||
const storageProto = Object.getPrototypeOf(window.localStorage);
|
const storageProto = Object.getPrototypeOf(window.localStorage);
|
||||||
@@ -105,7 +118,7 @@ describe("EditorPage - 자동저장", () => {
|
|||||||
expect(parsed.projectConfig.slug).toBe("autosave-test");
|
expect(parsed.projectConfig.slug).toBe("autosave-test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
|
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", async () => {
|
||||||
const savedBlocks: Block[] = [
|
const savedBlocks: Block[] = [
|
||||||
{
|
{
|
||||||
id: "saved_1",
|
id: "saved_1",
|
||||||
@@ -133,10 +146,75 @@ describe("EditorPage - 자동저장", () => {
|
|||||||
|
|
||||||
render(<EditorPage />);
|
render(<EditorPage />);
|
||||||
|
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
await waitFor(() => {
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
||||||
|
|
||||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
||||||
|
|
||||||
|
// autosave 로 전체 프로젝트를 복원한 경우 history/future 도 초기화해야 한다.
|
||||||
|
expect(mockState.resetHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savedByUserId 가 다른 autosave 스냅샷은 현재 로그인 유저가 불러오지 않아야 한다", async () => {
|
||||||
|
const savedBlocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "saved_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "다른 유저가 저장한 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const savedConfig: ProjectConfig = {
|
||||||
|
title: "다른 유저 프로젝트",
|
||||||
|
slug: "autosave-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
blocks: savedBlocks,
|
||||||
|
projectConfig: savedConfig,
|
||||||
|
savedByUserId: "user-a",
|
||||||
|
};
|
||||||
|
|
||||||
|
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-b", email: "user-b@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
|
||||||
|
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
|
||||||
|
// 다른 유저의 autosave 는 무시되므로 history 초기화도 호출되지 않아야 한다.
|
||||||
|
expect(mockState.resetHistory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 버튼 블록 스타일", () => {
|
describe("EditorPage - 버튼 블록 스타일", () => {
|
||||||
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function hexToRgb(hex: string) {
|
function hexToRgb(hex: string) {
|
||||||
const clean = hex.replace("#", "");
|
const clean = hex.replace("#", "");
|
||||||
const int = parseInt(clean, 16);
|
const int = parseInt(clean, 16);
|
||||||
|
|||||||
@@ -86,6 +86,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
|
describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
|
||||||
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
|
it("메뉴를 열었을 때 'Export 미리보기' 항목은 보이지 않고, '프로젝트 목록' 항목이 노출되어야 한다", () => {
|
||||||
render(<EditorPage />);
|
render(<EditorPage />);
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function hexToRgb(hex: string) {
|
function hexToRgb(hex: string) {
|
||||||
const clean = hex.replace("#", "");
|
const clean = hex.replace("#", "");
|
||||||
const int = parseInt(clean, 16);
|
const int = parseInt(clean, 16);
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 이미지 블록 스타일", () => {
|
describe("EditorPage - 이미지 블록 스타일", () => {
|
||||||
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
|
|||||||
@@ -90,6 +90,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function openJsonModal() {
|
function openJsonModal() {
|
||||||
const menuButton = screen.getByText("메뉴");
|
const menuButton = screen.getByText("메뉴");
|
||||||
fireEvent.click(menuButton);
|
fireEvent.click(menuButton);
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
|
||||||
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
|
||||||
render(<EditorPage />);
|
render(<EditorPage />);
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function hexToRgb(hex: string) {
|
function hexToRgb(hex: string) {
|
||||||
const clean = hex.replace("#", "");
|
const clean = hex.replace("#", "");
|
||||||
const int = parseInt(clean, 16);
|
const int = parseInt(clean, 16);
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function setupThreeBlocks() {
|
function setupThreeBlocks() {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import EditorPage from "@/app/editor/page";
|
||||||
|
|
||||||
|
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||||
|
export const pushMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// editorStore 는 이미 여러 유닛 테스트에서 사용 중이므로 실제 store 를 그대로 사용해도 무방하다.
|
||||||
|
|
||||||
|
describe("EditorPage 인증 가드", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 요청은 200 으로 처리.
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,6 +73,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 페이지/캔버스 배경", () => {
|
describe("EditorPage - 페이지/캔버스 배경", () => {
|
||||||
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
|
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
|
||||||
const { container } = render(<EditorPage />);
|
const { container } = render(<EditorPage />);
|
||||||
|
|||||||
@@ -89,6 +89,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|||||||
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
|
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
|
||||||
|
|
||||||
let mockState: any;
|
let mockState: any;
|
||||||
|
let searchParamsSlug: string | null = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const baseProjectConfig: ProjectConfig = {
|
const baseProjectConfig: ProjectConfig = {
|
||||||
@@ -32,14 +33,17 @@ beforeEach(() => {
|
|||||||
projectConfig: baseProjectConfig,
|
projectConfig: baseProjectConfig,
|
||||||
selectedBlockId: null as string | null,
|
selectedBlockId: null as string | null,
|
||||||
selectedListItemId: null as string | null,
|
selectedListItemId: null as string | null,
|
||||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
// EditorPage 가 참조하는 액션들: 기본적으로 mock 으로 채우되,
|
||||||
|
// replaceBlocks 는 실제 store.blocks 도 업데이트하도록 구현한다.
|
||||||
undo: vi.fn(),
|
undo: vi.fn(),
|
||||||
redo: vi.fn(),
|
redo: vi.fn(),
|
||||||
removeBlock: vi.fn(),
|
removeBlock: vi.fn(),
|
||||||
duplicateBlock: vi.fn(),
|
duplicateBlock: vi.fn(),
|
||||||
selectBlock: vi.fn(),
|
selectBlock: vi.fn(),
|
||||||
selectListItem: vi.fn(),
|
selectListItem: vi.fn(),
|
||||||
replaceBlocks: vi.fn(),
|
replaceBlocks: vi.fn((nextBlocks: Block[]) => {
|
||||||
|
mockState.blocks = nextBlocks;
|
||||||
|
}),
|
||||||
reorderBlocks: vi.fn(),
|
reorderBlocks: vi.fn(),
|
||||||
moveBlock: vi.fn(),
|
moveBlock: vi.fn(),
|
||||||
addTextBlock: vi.fn(),
|
addTextBlock: vi.fn(),
|
||||||
@@ -69,9 +73,11 @@ beforeEach(() => {
|
|||||||
...partial,
|
...partial,
|
||||||
} as ProjectConfig;
|
} as ProjectConfig;
|
||||||
}),
|
}),
|
||||||
|
resetHistory: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
searchParamsSlug = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,6 +102,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: (key: string) => (key === "slug" ? searchParamsSlug : null),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
||||||
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
|
it("프로젝트 저장 버튼 클릭 시 로컬스토리지와 서버에 상태를 저장해야 하고, projectConfig 와 제목/주소 입력이 동기화되어야 한다", async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
@@ -135,7 +153,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const localKey = "pb:project:save-test-slug";
|
const localKey = "pb:project:save-test-slug";
|
||||||
@@ -147,7 +169,11 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
expect(Array.isArray(parsed.contentJson)).toBe(true);
|
expect(Array.isArray(parsed.contentJson)).toBe(true);
|
||||||
expect(parsed.contentJson[0].id).toBe("blk_1");
|
expect(parsed.contentJson[0].id).toBe("blk_1");
|
||||||
|
|
||||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
const projectCall = fetchMock.mock.calls.find(
|
||||||
|
([url, options]: any[]) => url === "/api/projects" && options?.method === "POST",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const [url, options] = projectCall;
|
||||||
expect(url).toBe("/api/projects");
|
expect(url).toBe("/api/projects");
|
||||||
expect(options.method).toBe("POST");
|
expect(options.method).toBe("POST");
|
||||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||||
@@ -162,6 +188,55 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
expect(message).toBeTruthy();
|
expect(message).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("서버가 409 를 반환하면 이미 사용 중인 프로젝트 주소라는 에러 메시지를 보여줘야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/projects" && method === "POST") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 409,
|
||||||
|
json: async () => ({ message: "이미 다른 사용자가 사용 중인 프로젝트 주소입니다." }),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 가드용 /api/auth/me 등은 성공 응답만 내려주면 된다.
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const { rerender } = render(<EditorPage />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByText("메뉴");
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||||
|
fireEvent.click(projectMenuItem);
|
||||||
|
|
||||||
|
const modalTitle = screen.getByText("프로젝트 저장 / 불러오기");
|
||||||
|
const projectModal = (modalTitle.parentElement?.parentElement ?? document.body) as HTMLElement;
|
||||||
|
|
||||||
|
const titleInput = within(projectModal).getByLabelText("프로젝트 제목") as HTMLInputElement;
|
||||||
|
const slugInput = within(projectModal).getByLabelText("프로젝트 주소 (예: my-landing)") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(titleInput, { target: { value: "충돌 테스트 프로젝트" } });
|
||||||
|
fireEvent.change(slugInput, { target: { value: "conflict-slug" } });
|
||||||
|
|
||||||
|
rerender(<EditorPage />);
|
||||||
|
|
||||||
|
const saveButton = screen.getByText("저장 (로컬 + 서버)");
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
const message = await screen.findByText("이미 다른 사용자가 사용 중인 프로젝트 주소입니다.");
|
||||||
|
expect(message).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
|
it("로컬스토리지에 프로젝트가 있으면 서버를 호출하지 않고 로컬 데이터로 불러와야 한다", async () => {
|
||||||
const slug = "local-test-slug";
|
const slug = "local-test-slug";
|
||||||
const localKey = `pb:project:${slug}`;
|
const localKey = `pb:project:${slug}`;
|
||||||
@@ -210,7 +285,17 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "로컬 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const serverCalls = fetchMock.mock.calls.filter(
|
||||||
|
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
|
||||||
|
);
|
||||||
|
expect(serverCalls.length).toBe(0);
|
||||||
|
|
||||||
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
const message = await screen.findByText(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||||
expect(message).toBeTruthy();
|
expect(message).toBeTruthy();
|
||||||
@@ -234,7 +319,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ slug, contentJson: serverBlocks }),
|
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
@@ -253,20 +338,163 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
fireEvent.click(loadButton);
|
fireEvent.click(loadButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url]: any[]) => url === `/api/projects/${slug}`,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [url] = fetchMock.mock.calls[0] as any;
|
const serverCall = fetchMock.mock.calls.find(
|
||||||
|
([url]: any[]) => url === `/api/projects/${slug}`,
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const [url] = serverCall;
|
||||||
expect(url).toBe(`/api/projects/${slug}`);
|
expect(url).toBe(`/api/projects/${slug}`);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "서버 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
|
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
|
||||||
expect(message).toBeTruthy();
|
expect(message).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
|
||||||
|
const slug = "roundtrip-slug";
|
||||||
|
|
||||||
|
const complexBlocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "text_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "복잡한 텍스트",
|
||||||
|
align: "center",
|
||||||
|
size: "xl",
|
||||||
|
colorCustom: "#ff0000",
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "button_1",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "CTA 버튼",
|
||||||
|
href: "/pricing",
|
||||||
|
variant: "outline",
|
||||||
|
size: "lg",
|
||||||
|
align: "right",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
borderRadius: "full",
|
||||||
|
colorPalette: "secondary",
|
||||||
|
textColorCustom: "#111111",
|
||||||
|
fillColorCustom: "#222222",
|
||||||
|
strokeColorCustom: "#333333",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "section_1",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
paddingY: "lg",
|
||||||
|
background: "image",
|
||||||
|
backgroundImageUrl: "https://example.com/bg.png",
|
||||||
|
columns: [
|
||||||
|
{ id: "col_1", span: 8 },
|
||||||
|
{ id: "col_2", span: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "form_input_1",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이메일",
|
||||||
|
inputType: "email",
|
||||||
|
required: true,
|
||||||
|
labelMode: "text",
|
||||||
|
labelLayout: "stacked",
|
||||||
|
labelGapPx: 12,
|
||||||
|
widthMode: "custom",
|
||||||
|
widthPx: 360,
|
||||||
|
borderRadius: "lg",
|
||||||
|
paddingX: 16,
|
||||||
|
paddingY: 12,
|
||||||
|
textColorCustom: "#fafafa",
|
||||||
|
fillColorCustom: "#0f172a",
|
||||||
|
strokeColorCustom: "#1e293b",
|
||||||
|
formFieldName: "email-1",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
mockState.projectConfig.slug = slug;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByText("메뉴");
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||||
|
fireEvent.click(projectMenuItem);
|
||||||
|
|
||||||
|
const loadButton = screen.getByText("불러오기");
|
||||||
|
fireEvent.click(loadButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loadedBlocks] = (mockState.replaceBlocks as any).mock.calls[0];
|
||||||
|
expect(loadedBlocks).toEqual(complexBlocks as any);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "복잡한 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
|
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
|
||||||
const slug = "delete-test-slug";
|
const slug = "delete-test-slug";
|
||||||
mockState.projectConfig.slug = slug;
|
mockState.projectConfig.slug = slug;
|
||||||
@@ -293,10 +521,18 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
fireEvent.click(deleteMenuItem);
|
fireEvent.click(deleteMenuItem);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
const deleteCall = fetchMock.mock.calls.find(
|
||||||
|
([url, options]: any[]) => url === `/api/projects/${slug}` && options?.method === "DELETE",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const [url, options] = deleteCall;
|
||||||
expect(url).toBe(`/api/projects/${slug}`);
|
expect(url).toBe(`/api/projects/${slug}`);
|
||||||
expect(options.method).toBe("DELETE");
|
expect(options.method).toBe("DELETE");
|
||||||
|
|
||||||
@@ -305,4 +541,75 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore();
|
confirmSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("URL 쿼리의 slug 가 있으면 해당 slug 프로젝트를 자동으로 서버에서 불러와야 한다", async () => {
|
||||||
|
const slug = "auto-load-slug";
|
||||||
|
|
||||||
|
const serverBlocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "server_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "쿼리 로드 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
searchParamsSlug = slug;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === `/api/projects/${slug}` && (options?.method ?? "GET") === "GET",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "쿼리 로드 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 섹션 배경 이미지 TDD
|
// 섹션 배경 이미지 TDD
|
||||||
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
|
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 섹션 레이아웃 속성", () => {
|
describe("EditorPage - 섹션 레이아웃 속성", () => {
|
||||||
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
|
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
|
||||||
const section: Block = {
|
const section: Block = {
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 선택 포커스", () => {
|
describe("EditorPage - 선택 포커스", () => {
|
||||||
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
|
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("EditorPage - 텍스트 블록 스타일", () => {
|
describe("EditorPage - 텍스트 블록 스타일", () => {
|
||||||
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
|
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
|
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
|
||||||
describe("EditorPage - 비디오 블록 스타일", () => {
|
describe("EditorPage - 비디오 블록 스타일", () => {
|
||||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
|
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
|
||||||
|
|||||||
@@ -75,4 +75,80 @@ describe("FormControllerPanel", () => {
|
|||||||
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
|
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("폼 필드 매핑 UI 에서 필드 라벨 대신 전송 키(formFieldName)를 우선적으로 표시해야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-controller", {
|
||||||
|
fieldIds: ["input-1", "checkbox-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputBlock: Block = {
|
||||||
|
id: "input-1",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이름",
|
||||||
|
formFieldName: "name",
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const checkboxBlock: Block = {
|
||||||
|
id: "checkbox-1",
|
||||||
|
type: "formCheckbox",
|
||||||
|
props: {
|
||||||
|
groupLabel: "옵션",
|
||||||
|
formFieldName: "options",
|
||||||
|
options: [],
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock, inputBlock, checkboxBlock]}
|
||||||
|
selectedBlockId="form-controller"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameLabel = screen.getByText("name (이름)");
|
||||||
|
const optionsLabel = screen.getByText("options (옵션)");
|
||||||
|
|
||||||
|
expect(nameLabel).toBeTruthy();
|
||||||
|
expect(optionsLabel).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Submit 버튼 셀렉트에서 버튼 블록의 전송 키(formFieldName)를 기준으로 표시해야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-controller", {
|
||||||
|
submitButtonId: "btn-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonBlock: Block = {
|
||||||
|
id: "btn-1",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "제출하기",
|
||||||
|
formFieldName: "submit-main",
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock, buttonBlock]}
|
||||||
|
selectedBlockId="form-controller"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
|
||||||
|
|
||||||
|
const submitMainOption = screen.getByText("submit-main (제출하기)");
|
||||||
|
|
||||||
|
expect(submitMainOption).toBeTruthy();
|
||||||
|
// 기본 선택 값은 FormBlock.submitButtonId 이어야 한다.
|
||||||
|
expect(select.value).toBe("btn-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import LoginPage from "@/app/login/page";
|
||||||
|
|
||||||
|
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
|
||||||
|
export const pushMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LoginPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/auth/login" && method === "POST") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ id: "1", email: "user@example.com" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||||
|
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||||
|
const submitButton = screen.getByRole("button", { name: "로그인" });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const loginCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||||
|
return url === "/api/auth/login" && options?.method === "POST";
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loginCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/login") as any;
|
||||||
|
const [url, options] = loginCall;
|
||||||
|
expect(url).toBe("/api/auth/login");
|
||||||
|
expect(options.method).toBe("POST");
|
||||||
|
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||||
|
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.email).toBe("user@example.com");
|
||||||
|
expect(body.password).toBe("securePass1");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("로그인 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/auth/login" && method === "POST") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "이메일 또는 비밀번호가 올바르지 않습니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||||
|
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||||
|
const submitButton = screen.getByRole("button", { name: "로그인" });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "user@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "wrongpass" } });
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
const errorText = await screen.findByText(/이메일 또는 비밀번호가 올바르지 않습니다./);
|
||||||
|
expect(errorText).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,15 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("PreviewPage - 자동 복원", () => {
|
describe("PreviewPage - 자동 복원", () => {
|
||||||
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
|
it("pb:autosave:<slug> 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 상태를 복원해야 한다", () => {
|
||||||
const savedBlocks: Block[] = [
|
const savedBlocks: Block[] = [
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ vi.mock("next/link", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("PreviewPage - ZIP Export", () => {
|
describe("PreviewPage - ZIP Export", () => {
|
||||||
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
|
it("프리뷰 헤더에 프로젝트 목록 링크가 노출되고 /projects 로 연결되어야 한다", () => {
|
||||||
render(<PreviewPage />);
|
render(<PreviewPage />);
|
||||||
@@ -83,10 +92,18 @@ describe("PreviewPage - ZIP Export", () => {
|
|||||||
fireEvent.click(exportButton);
|
fireEvent.click(exportButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
const exportCall = fetchMock.mock.calls.find(
|
||||||
|
([url, options]: any[]) => url === "/api/export" && options?.method === "POST",
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const [url, options] = exportCall;
|
||||||
expect(url).toBe("/api/export");
|
expect(url).toBe("/api/export");
|
||||||
expect(options.method).toBe("POST");
|
expect(options.method).toBe("POST");
|
||||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import PreviewPage from "@/app/preview/page";
|
||||||
|
|
||||||
|
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||||
|
export const pushMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PreviewPage 인증 가드", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/auth/me 가 401 을 반환하면 /login 으로 리다이렉트해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 요청은 200 으로 처리하되, 테스트에서는 사용하지 않는다.
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<PreviewPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import ProjectSubmissionsPage from "@/app/projects/[slug]/submissions/page";
|
||||||
|
|
||||||
|
// next/navigation 의 useRouter/useParams 를 목으로 대체해
|
||||||
|
// - URL 의 slug 파라미터에 따라 API 요청 경로가 달라지는지
|
||||||
|
// - 인증 실패 시 /login 으로 리다이렉트하는지
|
||||||
|
// 를 검증한다.
|
||||||
|
export const pushMock = vi.fn();
|
||||||
|
export const useParamsMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
useParams: () => useParamsMock(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useParamsMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
|
||||||
|
it("/api/projects/[slug]/submissions 로부터 받은 제출 내역을 테이블로 렌더링해야 한다", async () => {
|
||||||
|
useParamsMock.mockReturnValue({ slug: "test-project" });
|
||||||
|
|
||||||
|
const submissions = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
createdAt: "2025-01-01T12:00:00.000Z",
|
||||||
|
projectSlug: "test-project",
|
||||||
|
payload: {
|
||||||
|
name: "홍길동",
|
||||||
|
email: "user@example.com",
|
||||||
|
phone: "010-1234-5678",
|
||||||
|
birthdate: "1990-01-01",
|
||||||
|
message: "안녕하세요",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
createdAt: "2025-01-02T08:30:00.000Z",
|
||||||
|
projectSlug: "test-project",
|
||||||
|
payload: {
|
||||||
|
name: "김철수",
|
||||||
|
email: "another@example.com",
|
||||||
|
phone: "010-0000-0000",
|
||||||
|
birthdate: "1995-05-05",
|
||||||
|
message: "두 번째 문의입니다.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(submissions), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<ProjectSubmissionsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||||
|
expect(url).toBe("/api/projects/test-project/submissions");
|
||||||
|
expect(options).toBeUndefined();
|
||||||
|
|
||||||
|
expect(await screen.findByText("폼 제출 내역")).toBeTruthy();
|
||||||
|
expect(screen.getByText("test-project")).toBeTruthy();
|
||||||
|
|
||||||
|
expect(screen.getByText("홍길동")).toBeTruthy();
|
||||||
|
expect(screen.getByText("user@example.com")).toBeTruthy();
|
||||||
|
expect(screen.getByText("010-1234-5678")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1990-01-01")).toBeTruthy();
|
||||||
|
expect(screen.getByText("message: 안녕하세요")).toBeTruthy();
|
||||||
|
|
||||||
|
expect(screen.getByText("김철수")).toBeTruthy();
|
||||||
|
expect(screen.getByText("another@example.com")).toBeTruthy();
|
||||||
|
expect(screen.getByText("010-0000-0000")).toBeTruthy();
|
||||||
|
expect(screen.getByText("1995-05-05")).toBeTruthy();
|
||||||
|
expect(screen.getByText("message: 두 번째 문의입니다.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("API 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
|
||||||
|
useParamsMock.mockReturnValue({ slug: "test-project" });
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<ProjectSubmissionsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("API 가 404 를 반환하면 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||||
|
useParamsMock.mockReturnValue({ slug: "unknown-project" });
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ message: "프로젝트를 찾을 수 없습니다." }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<ProjectSubmissionsPage />);
|
||||||
|
|
||||||
|
const errorText = await screen.findByText(
|
||||||
|
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorText).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,16 @@ import { describe, it, expect, afterEach, vi } from "vitest";
|
|||||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import ProjectsPage from "@/app/projects/page";
|
import ProjectsPage from "@/app/projects/page";
|
||||||
|
|
||||||
|
// next/navigation 의 useRouter 를 목으로 대체해 인증 실패 시 리다이렉트 동작을 검증한다.
|
||||||
|
export const pushMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ProjectsPage 프로젝트 목록 TDD
|
// ProjectsPage 프로젝트 목록 TDD
|
||||||
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
|
// - 마운트 시 /api/projects 로 GET 요청을 보내고, 반환된 프로젝트 목록을 테이블로 렌더링해야 한다.
|
||||||
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
|
// - 각 프로젝트 행에는 /editor?slug=..., /preview?slug=... 로 이동하는 "편집" / "미리보기" 링크가 있어야 한다.
|
||||||
@@ -70,6 +80,50 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
|
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", async () => {
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "테스트 프로젝트 A",
|
||||||
|
slug: "test-project-a",
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "테스트 프로젝트 B",
|
||||||
|
slug: "test-project-b",
|
||||||
|
status: "PUBLISHED",
|
||||||
|
createdAt: "2025-01-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(projects), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("테스트 프로젝트 A")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("테스트 프로젝트 B")).toBeTruthy();
|
||||||
|
|
||||||
|
const submissionLinks = screen.getAllByText("폼 제출 내역");
|
||||||
|
expect(submissionLinks).toHaveLength(2);
|
||||||
|
expect(submissionLinks[0].closest("a")?.getAttribute("href")).toBe(
|
||||||
|
"/projects/test-project-a/submissions",
|
||||||
|
);
|
||||||
|
expect(submissionLinks[1].closest("a")?.getAttribute("href")).toBe(
|
||||||
|
"/projects/test-project-b/submissions",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
|
it("삭제 버튼 클릭 시 서버에 DELETE 요청을 보내고 목록과 로컬스토리지를 갱신해야 한다", async () => {
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
@@ -228,4 +282,229 @@ describe("ProjectsPage - 프로젝트 목록", () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore();
|
confirmSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("/api/projects 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ message: "프로젝트 목록을 조회하려면 로그인이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "테스트 프로젝트 A",
|
||||||
|
slug: "test-project-a",
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/projects" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(projects), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/auth/logout" && method === "POST") {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
|
||||||
|
fireEvent.click(logoutButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||||
|
return url === "/api/auth/logout" && options?.method === "POST";
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logoutCall).toBeDefined();
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("/api/projects 가 500 을 반환하면 에러 메시지를 표시해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response("server error", {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("단일 프로젝트 삭제가 실패하면 에러 메시지를 표시해야 한다", async () => {
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "삭제 실패 프로젝트",
|
||||||
|
slug: "delete-fail-project",
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/projects" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(projects), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/projects/delete-fail-project" && method === "DELETE") {
|
||||||
|
return Promise.resolve(new Response("fail", { status: 500 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||||
|
|
||||||
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("삭제 실패 프로젝트")).toBeTruthy();
|
||||||
|
|
||||||
|
const deleteButton = screen.getByText("삭제");
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// 삭제 실패이므로 여전히 목록에 남아 있어야 한다.
|
||||||
|
expect(screen.getByText("삭제 실패 프로젝트")).toBeTruthy();
|
||||||
|
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("일괄 삭제에서 일부만 성공하면 에러 메시지를 표시해야 한다", async () => {
|
||||||
|
const projects = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "성공 프로젝트",
|
||||||
|
slug: "success-project",
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "실패 프로젝트",
|
||||||
|
slug: "fail-project",
|
||||||
|
status: "DRAFT",
|
||||||
|
createdAt: "2025-01-02T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/projects" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(projects), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/projects/success-project" && method === "DELETE") {
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/projects/fail-project" && method === "DELETE") {
|
||||||
|
return Promise.resolve(new Response("fail", { status: 500 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true as any);
|
||||||
|
|
||||||
|
render(<ProjectsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("성공 프로젝트")).toBeTruthy();
|
||||||
|
expect(await screen.findByText("실패 프로젝트")).toBeTruthy();
|
||||||
|
|
||||||
|
const checkboxSuccess = screen.getByLabelText("성공 프로젝트 선택");
|
||||||
|
const checkboxFail = screen.getByLabelText("실패 프로젝트 선택");
|
||||||
|
|
||||||
|
fireEvent.click(checkboxSuccess);
|
||||||
|
fireEvent.click(checkboxFail);
|
||||||
|
|
||||||
|
const bulkDeleteButton = screen.getByText("선택 삭제");
|
||||||
|
fireEvent.click(bulkDeleteButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"일부 프로젝트 삭제에 실패했습니다. 페이지를 새로고침한 뒤 목록을 확인해 주세요.",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// 성공 프로젝트는 목록에서 사라지고, 실패 프로젝트는 남아 있어야 한다.
|
||||||
|
expect(screen.queryByText("성공 프로젝트")).toBeNull();
|
||||||
|
expect(screen.getByText("실패 프로젝트")).toBeTruthy();
|
||||||
|
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
|||||||
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
|
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("form 컨트롤러 블록은 프리뷰에서 별도 폼 래퍼를 렌더하지 않아야 한다", () => {
|
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 요소에 배경색을 적용해야 한다", () => {
|
||||||
const blocksWithBg: Block[] = [
|
const blocksWithBg: Block[] = [
|
||||||
{
|
{
|
||||||
id: "form_with_bg",
|
id: "form_with_bg",
|
||||||
@@ -107,8 +107,10 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
|||||||
|
|
||||||
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||||
|
|
||||||
const formWithBg = queryByTestId("preview-form-controller");
|
const formWithBg = queryByTestId("preview-form-controller") as HTMLElement | null;
|
||||||
expect(formWithBg).toBeNull();
|
expect(formWithBg).not.toBeNull();
|
||||||
|
// JSDOM 은 #111111 을 rgb(17, 17, 17) 형태로 노출한다.
|
||||||
|
expect(formWithBg!.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||||
|
|
||||||
const blocksWithoutBg: Block[] = [
|
const blocksWithoutBg: Block[] = [
|
||||||
{
|
{
|
||||||
@@ -126,8 +128,11 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
|||||||
|
|
||||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||||
|
|
||||||
const formNoBg = queryByTestId("preview-form-controller");
|
const formNoBg = queryByTestId("preview-form-controller") as HTMLElement | null;
|
||||||
expect(formNoBg).toBeNull();
|
expect(formNoBg).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
formNoBg!.style.backgroundColor === "" || formNoBg!.style.backgroundColor === "transparent",
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
||||||
|
|||||||
@@ -102,4 +102,63 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
|
|||||||
// lg → 12px → 12 / 16 = 0.75em
|
// lg → 12px → 12 / 16 = 0.75em
|
||||||
expect(link!.style.borderRadius).toBe("0.75em");
|
expect(link!.style.borderRadius).toBe("0.75em");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("버튼에 imageSrc 가 있으면 이미지와 텍스트를 함께 렌더링해야 한다 (좌측 배치)", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_img_left",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "이미지 버튼",
|
||||||
|
href: "#",
|
||||||
|
imageSrc: "https://example.com/icon.png",
|
||||||
|
imageAlt: "아이콘",
|
||||||
|
imagePlacement: "left",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
|
||||||
|
const img = link!.querySelector("img") as HTMLImageElement | null;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img!.src).toContain("https://example.com/icon.png");
|
||||||
|
expect(img!.alt).toBe("아이콘");
|
||||||
|
|
||||||
|
const labelNode = Array.from(link!.querySelectorAll("span")).find((el) =>
|
||||||
|
el.textContent?.includes("이미지 버튼"),
|
||||||
|
);
|
||||||
|
expect(labelNode).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imagePlacement 가 top 인 경우 이미지가 텍스트 위쪽에 렌더링되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_img_top",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "위쪽 이미지 버튼",
|
||||||
|
href: "#",
|
||||||
|
imageSrc: "https://example.com/icon-top.png",
|
||||||
|
imageAlt: "위쪽 아이콘",
|
||||||
|
imagePlacement: "top",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
|
||||||
|
const wrapper = link!.querySelector("span") as HTMLSpanElement | null;
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
expect(wrapper!.className).toContain("flex-col");
|
||||||
|
|
||||||
|
const firstChild = wrapper!.firstElementChild as HTMLElement | null;
|
||||||
|
expect(firstChild?.tagName.toLowerCase()).toBe("img");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
// fetch 목 초기화
|
// fetch 목 초기화
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(global as any).fetch = undefined;
|
(globalThis as any).fetch = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러를 렌더하지 않아야 한다 (성공 메시지 설정)", () => {
|
it("성공 응답 시 FormBlock 의 successMessage 를 성공 메시지로 렌더해야 한다", async () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
{
|
{
|
||||||
id: "form_success",
|
id: "form_success",
|
||||||
@@ -25,6 +25,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
|||||||
submitTarget: "internal",
|
submitTarget: "internal",
|
||||||
successMessage: "폼 성공 메시지 (config)",
|
successMessage: "폼 성공 메시지 (config)",
|
||||||
errorMessage: "폼 에러 메시지 (config)",
|
errorMessage: "폼 에러 메시지 (config)",
|
||||||
|
fields: [
|
||||||
|
{ id: "f1", name: "name", label: "이름", type: "text", required: true },
|
||||||
|
],
|
||||||
fieldIds: [],
|
fieldIds: [],
|
||||||
submitButtonId: null,
|
submitButtonId: null,
|
||||||
} as any,
|
} as any,
|
||||||
@@ -38,16 +41,32 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(global as any).fetch = fetchMock;
|
(globalThis as any).fetch = fetchMock;
|
||||||
|
|
||||||
render(<PublicPageRenderer blocks={blocks} />);
|
render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
const form = screen.queryByTestId("preview-form-controller");
|
const form = screen.getByTestId("preview-form-controller") as HTMLFormElement;
|
||||||
expect(form).toBeNull();
|
expect(form).toBeTruthy();
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
|
const input = form.querySelector('input[name="name"]') as HTMLInputElement | null;
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: "폼 전송" });
|
||||||
|
expect(submitButton).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const msg = screen.getByText("폼 성공 메시지 (config)");
|
||||||
|
expect(msg).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러나 에러 메시지를 렌더하지 않아야 한다", () => {
|
it("실패 응답 시 FormBlock 의 errorMessage 를 에러 메시지로 렌더해야 한다", async () => {
|
||||||
const blocks: Block[] = [
|
const blocks: Block[] = [
|
||||||
{
|
{
|
||||||
id: "form_error",
|
id: "form_error",
|
||||||
@@ -57,6 +76,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
|||||||
submitTarget: "internal",
|
submitTarget: "internal",
|
||||||
successMessage: "폼 성공 메시지 (config)",
|
successMessage: "폼 성공 메시지 (config)",
|
||||||
errorMessage: "폼 에러 메시지 (config)",
|
errorMessage: "폼 에러 메시지 (config)",
|
||||||
|
fields: [
|
||||||
|
{ id: "f1", name: "name", label: "이름", type: "text", required: true },
|
||||||
|
],
|
||||||
fieldIds: [],
|
fieldIds: [],
|
||||||
submitButtonId: null,
|
submitButtonId: null,
|
||||||
} as any,
|
} as any,
|
||||||
@@ -70,13 +92,25 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(global as any).fetch = fetchMock;
|
(globalThis as any).fetch = fetchMock;
|
||||||
|
|
||||||
render(<PublicPageRenderer blocks={blocks} />);
|
render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
const form = screen.queryByTestId("preview-form-controller");
|
const form = screen.getByTestId("preview-form-controller") as HTMLFormElement;
|
||||||
expect(form).toBeNull();
|
expect(form).toBeTruthy();
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
expect(screen.queryByText("폼 에러 메시지 (config)")).toBeNull();
|
const submitButton = screen.getByRole("button", { name: "폼 전송" });
|
||||||
|
expect(submitButton).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const errorMsg = screen.getByText("폼 에러 메시지 (config)");
|
||||||
|
expect(errorMsg).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import SignupPage from "@/app/signup/page";
|
||||||
|
|
||||||
|
// next/navigation 의 useRouter 를 목으로 대체해 리다이렉트 동작을 검증한다.
|
||||||
|
export const pushMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SignupPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/auth/signup" && method === "POST") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<SignupPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||||
|
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||||
|
const submitButton = screen.getByRole("button", { name: "회원가입" });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
|
||||||
|
return url === "/api/auth/signup" && options?.method === "POST";
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signupCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const signupCall = fetchMock.mock.calls.find(([url]) => url === "/api/auth/signup") as any;
|
||||||
|
const [url, options] = signupCall;
|
||||||
|
expect(url).toBe("/api/auth/signup");
|
||||||
|
expect(options.method).toBe("POST");
|
||||||
|
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||||
|
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
expect(body.email).toBe("new@example.com");
|
||||||
|
expect(body.password).toBe("securePass1");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("회원가입 실패 시 에러 메시지를 화면에 표시해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/auth/signup" && method === "POST") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ message: "이미 가입된 이메일입니다." }), {
|
||||||
|
status: 409,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 500 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<SignupPage />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
|
||||||
|
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
|
||||||
|
const submitButton = screen.getByRole("button", { name: "회원가입" });
|
||||||
|
|
||||||
|
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
|
||||||
|
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
const errorText = await screen.findByText(/이미 가입된 이메일입니다./);
|
||||||
|
expect(errorText).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<SignupPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
|
||||||
|
expect(pushMock).toHaveBeenCalledWith("/projects");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// 앞으로 구현할 인증/보안 유틸리티 모듈을 대상으로 TDD를 진행한다.
|
||||||
|
// - 비밀번호 해시/검증 (bcrypt 기반)
|
||||||
|
// - JWT 액세스 토큰 발급/검증
|
||||||
|
// - 민감정보 JSON 암호화/복호화
|
||||||
|
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
signAccessToken,
|
||||||
|
verifyAccessToken,
|
||||||
|
encryptJson,
|
||||||
|
decryptJson,
|
||||||
|
} from "@/features/auth/authCrypto";
|
||||||
|
|
||||||
|
interface TestUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
tokenVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("authCrypto 유틸리티", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 테스트 환경에서 사용할 JWT/암호화 키를 설정한다.
|
||||||
|
process.env.AUTH_JWT_SECRET = "test-jwt-secret-key";
|
||||||
|
// 32바이트 키를 hex 로 표현 (예시)
|
||||||
|
process.env.AUTH_ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef";
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("비밀번호 해시/검증", () => {
|
||||||
|
it("8자 이상 비밀번호를 bcrypt 해시로 안전하게 저장하고, 검증에 성공해야 한다", async () => {
|
||||||
|
const password = "securePass1";
|
||||||
|
|
||||||
|
const hash = await hashPassword(password);
|
||||||
|
|
||||||
|
expect(hash).toBeTypeOf("string");
|
||||||
|
expect(hash).not.toBe(password);
|
||||||
|
|
||||||
|
const ok = await verifyPassword(password, hash);
|
||||||
|
const fail = await verifyPassword("wrongPass", hash);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(fail).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("8자 미만 비밀번호는 해시 시도 시 오류를 발생시켜야 한다", async () => {
|
||||||
|
const shortPassword = "short"; // 5자
|
||||||
|
|
||||||
|
await expect(hashPassword(shortPassword)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JWT 액세스 토큰", () => {
|
||||||
|
it("유저 정보를 기반으로 JWT 액세스 토큰을 발급하고, 검증 시 동일한 정보가 나와야 한다", async () => {
|
||||||
|
const user: TestUser = {
|
||||||
|
id: "user-1",
|
||||||
|
email: "user@example.com",
|
||||||
|
tokenVersion: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await signAccessToken(user);
|
||||||
|
|
||||||
|
expect(token).toBeTypeOf("string");
|
||||||
|
expect(token.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const payload = await verifyAccessToken(token);
|
||||||
|
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload?.sub).toBe(user.id);
|
||||||
|
expect(payload?.email).toBe(user.email);
|
||||||
|
expect(payload?.tokenVersion).toBe(user.tokenVersion);
|
||||||
|
|
||||||
|
const anyPayload = payload as any;
|
||||||
|
const iat = anyPayload.iat;
|
||||||
|
const exp = anyPayload.exp;
|
||||||
|
|
||||||
|
expect(typeof iat).toBe("number");
|
||||||
|
expect(typeof exp).toBe("number");
|
||||||
|
expect(exp > iat).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("잘못된 토큰이나 시크릿으로 검증할 경우 null 을 반환해야 한다", async () => {
|
||||||
|
const user: TestUser = {
|
||||||
|
id: "user-2",
|
||||||
|
email: "user2@example.com",
|
||||||
|
tokenVersion: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await signAccessToken(user);
|
||||||
|
|
||||||
|
// 시크릿을 바꿔서 검증 실패 상황을 만든다.
|
||||||
|
process.env.AUTH_JWT_SECRET = "other-secret";
|
||||||
|
|
||||||
|
const payload = await verifyAccessToken(token);
|
||||||
|
expect(payload).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("민감정보 JSON 암호화/복호화", () => {
|
||||||
|
it("JSON 객체를 암호화한 뒤 복호화하면 원래 객체와 동일해야 한다", async () => {
|
||||||
|
const original = {
|
||||||
|
cardToken: "tok_123",
|
||||||
|
customerId: "cus_456",
|
||||||
|
meta: { plan: "pro", country: "KR" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ciphertext = await encryptJson(original);
|
||||||
|
|
||||||
|
expect(ciphertext).toBeTypeOf("string");
|
||||||
|
expect(ciphertext).not.toContain("tok_123");
|
||||||
|
expect(ciphertext).not.toContain("cus_456");
|
||||||
|
|
||||||
|
const decrypted = await decryptJson<typeof original>(ciphertext);
|
||||||
|
|
||||||
|
expect(decrypted).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("암호화 문자열이 손상되었거나 키가 잘못된 경우 복호화 시 오류를 발생시켜야 한다", async () => {
|
||||||
|
const original = { secret: "value" };
|
||||||
|
const ciphertext = await encryptJson(original);
|
||||||
|
|
||||||
|
// 중간 일부를 잘라 손상시킨다.
|
||||||
|
const broken = ciphertext.slice(0, Math.floor(ciphertext.length / 2));
|
||||||
|
|
||||||
|
await expect(decryptJson(broken as string)).rejects.toThrow();
|
||||||
|
|
||||||
|
// 키를 바꿔서 복호화 시도
|
||||||
|
process.env.AUTH_ENCRYPTION_KEY = "ffffffffffffffffffffffffffffffff";
|
||||||
|
await expect(decryptJson(ciphertext)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1164,4 +1164,278 @@ describe("editorStore", () => {
|
|||||||
expect(custom.projectConfig.canvasPreset).toBe("custom");
|
expect(custom.projectConfig.canvasPreset).toBe("custom");
|
||||||
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
|
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("여러 블록/템플릿 추가 액션은 history 에 스냅샷을 기록하고 undo/redo 로 되돌릴 수 있어야 한다", () => {
|
||||||
|
const actionNames = [
|
||||||
|
"addTextBlock",
|
||||||
|
"addButtonBlock",
|
||||||
|
"addImageBlock",
|
||||||
|
"addVideoBlock",
|
||||||
|
"addDividerBlock",
|
||||||
|
"addListBlock",
|
||||||
|
"addSectionBlock",
|
||||||
|
"addFormBlock",
|
||||||
|
"addFormInputBlock",
|
||||||
|
"addFormSelectBlock",
|
||||||
|
"addFormCheckboxBlock",
|
||||||
|
"addFormRadioBlock",
|
||||||
|
"addHeroTemplateSection",
|
||||||
|
"addFeaturesTemplateSection",
|
||||||
|
"addCtaTemplateSection",
|
||||||
|
"addFaqTemplateSection",
|
||||||
|
"addPricingTemplateSection",
|
||||||
|
"addTestimonialsTemplateSection",
|
||||||
|
"addBlogTemplateSection",
|
||||||
|
"addTeamTemplateSection",
|
||||||
|
"addFooterTemplateSection",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
actionNames.forEach((name) => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
const stateAny = store.getState() as any;
|
||||||
|
|
||||||
|
expect(stateAny.blocks).toHaveLength(0);
|
||||||
|
expect(stateAny.history).toHaveLength(0);
|
||||||
|
|
||||||
|
// 각 액션을 한 번 호출하면 직전 blocks 스냅샷이 history 에 쌓여야 한다.
|
||||||
|
stateAny[name]();
|
||||||
|
|
||||||
|
const after = store.getState() as any;
|
||||||
|
|
||||||
|
// 최소 한 개 이상의 블록이 생성되어야 한다.
|
||||||
|
expect(after.blocks.length).toBeGreaterThan(0);
|
||||||
|
// 직전 상태 스냅샷이 history 에 1개 쌓여야 한다.
|
||||||
|
if (after.history.length !== 1) {
|
||||||
|
throw new Error(`history length mismatch for action ${name}: ${after.history.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocksAfterAdd = after.blocks;
|
||||||
|
const expectedLength = blocksAfterAdd.length;
|
||||||
|
|
||||||
|
stateAny.undo();
|
||||||
|
const afterUndo = store.getState() as any;
|
||||||
|
// 처음 상태가 빈 배열이었으므로 undo 후에는 다시 0개가 되어야 한다.
|
||||||
|
expect(afterUndo.blocks.length).toBe(0);
|
||||||
|
|
||||||
|
stateAny.redo();
|
||||||
|
const afterRedo = store.getState() as any;
|
||||||
|
expect(afterRedo.blocks.length).toBe(expectedLength);
|
||||||
|
// redo 이후 블록 타입 배열도 대략 동일해야 한다.
|
||||||
|
expect(afterRedo.blocks.map((b: any) => b.type)).toEqual(
|
||||||
|
blocksAfterAdd.map((b: any) => b.type),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateBlock 으로 블록 속성을 변경한 뒤 undo 하면 이전 속성으로 되돌아가야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks, history } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const blockId = blocks[0].id;
|
||||||
|
const initialText = (blocks[0].props as TextBlockProps).text;
|
||||||
|
expect(history.length).toBe(1);
|
||||||
|
|
||||||
|
store.getState().updateBlock(blockId, { text: "변경된 텍스트" } as any);
|
||||||
|
|
||||||
|
({ blocks, history } = store.getState());
|
||||||
|
expect((blocks[0].props as TextBlockProps).text).toBe("변경된 텍스트");
|
||||||
|
// updateBlock 이 호출되면서 history 스택이 1개 늘어나야 한다.
|
||||||
|
expect(history.length).toBe(2);
|
||||||
|
|
||||||
|
store.getState().undo();
|
||||||
|
|
||||||
|
({ blocks, history } = store.getState());
|
||||||
|
expect((blocks[0].props as TextBlockProps).text).toBe(initialText);
|
||||||
|
expect(history.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeBlock 호출 후 undo 하면 삭제된 블록이 복원되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks, history } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
|
||||||
|
const firstId = blocks[0].id;
|
||||||
|
const secondId = blocks[1].id;
|
||||||
|
|
||||||
|
(store.getState() as any).removeBlock(secondId);
|
||||||
|
|
||||||
|
({ blocks, history } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([firstId]);
|
||||||
|
// 텍스트 블록 2개 추가(2번) + removeBlock(1번) 이므로 history 스택 길이는 3이어야 한다.
|
||||||
|
expect(history.length).toBe(3);
|
||||||
|
|
||||||
|
(store.getState() as any).undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([firstId, secondId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicateBlock 호출 후 undo 하면 복제된 블록이 제거되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const originalId = blocks[0].id;
|
||||||
|
|
||||||
|
(store.getState() as any).duplicateBlock(originalId);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
|
||||||
|
(store.getState() as any).undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].id).toBe(originalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorderBlocks 호출 후 undo/redo 로 블록 순서를 되돌릴 수 있어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
const [aId, bId, cId] = blocks.map((b) => b.id);
|
||||||
|
|
||||||
|
// B 를 맨 앞으로 이동시킨다.
|
||||||
|
store.getState().reorderBlocks(bId, aId);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
|
||||||
|
|
||||||
|
store.getState().undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([aId, bId, cId]);
|
||||||
|
|
||||||
|
store.getState().redo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveBlock 호출 후 undo/redo 로 블록 위치(sectionId/columnId)를 되돌릴 수 있어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
// 섹션 블록과 텍스트 블록을 준비한다.
|
||||||
|
store.getState().addSectionBlock();
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
const sectionBlock = blocks.find((b) => b.type === "section")!;
|
||||||
|
const sectionProps = sectionBlock.props as any;
|
||||||
|
|
||||||
|
// 섹션을 2컬럼 레이아웃으로 변경한다.
|
||||||
|
const updatedColumns = [
|
||||||
|
{ id: `${sectionBlock.id}_col_1`, span: 6 },
|
||||||
|
{ id: `${sectionBlock.id}_col_2`, span: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
store.getState().updateBlock(sectionBlock.id, { columns: updatedColumns } as any);
|
||||||
|
|
||||||
|
store.getState().selectBlock(sectionBlock.id);
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
const textBlock = blocks.find((b) => b.type === "text") as any;
|
||||||
|
expect(textBlock.sectionId).toBe(sectionBlock.id);
|
||||||
|
expect(textBlock.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
|
||||||
|
|
||||||
|
// 두 번째 컬럼으로 이동
|
||||||
|
store.getState().moveBlock(textBlock.id, sectionBlock.id, updatedColumns[1].id);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
let moved = blocks.find((b) => b.id === textBlock.id) as any;
|
||||||
|
expect(moved.columnId).toBe(updatedColumns[1].id);
|
||||||
|
|
||||||
|
store.getState().undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
moved = blocks.find((b) => b.id === textBlock.id) as any;
|
||||||
|
expect(moved.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
|
||||||
|
|
||||||
|
store.getState().redo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
moved = blocks.find((b) => b.id === textBlock.id) as any;
|
||||||
|
expect(moved.columnId).toBe(updatedColumns[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaceBlocks 호출 후 undo/redo 로 이전 블록 배열을 복원할 수 있어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const originalIds = blocks.map((b) => b.id);
|
||||||
|
|
||||||
|
const replacedBlocks = blocks.map((b) => ({
|
||||||
|
...b,
|
||||||
|
id: `${b.id}_replaced`,
|
||||||
|
}));
|
||||||
|
const replacedIds = replacedBlocks.map((b) => b.id);
|
||||||
|
|
||||||
|
(store.getState() as any).replaceBlocks(replacedBlocks as any);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
|
||||||
|
|
||||||
|
(store.getState() as any).undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual(originalIds);
|
||||||
|
|
||||||
|
(store.getState() as any).redo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetHistory 를 호출하면 history/future 가 비워지고 undo/redo 가 이전 스냅샷으로 돌아가지 않아야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
// 블록을 여러 개 추가해 history 스택을 만든다.
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks, history, future } = store.getState();
|
||||||
|
expect(blocks.length).toBe(2);
|
||||||
|
expect(history.length).toBeGreaterThan(0);
|
||||||
|
expect(future.length).toBe(0);
|
||||||
|
|
||||||
|
const snapshotAfterEdits = blocks;
|
||||||
|
|
||||||
|
// 히스토리 초기화
|
||||||
|
(store.getState() as any).resetHistory();
|
||||||
|
|
||||||
|
({ blocks, history, future } = store.getState());
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
expect(future).toHaveLength(0);
|
||||||
|
|
||||||
|
// undo/redo 를 호출해도 더 이상 이전 스냅샷으로는 돌아가지 않아야 한다.
|
||||||
|
store.getState().undo();
|
||||||
|
({ blocks, history, future } = store.getState());
|
||||||
|
expect(blocks).toEqual(snapshotAfterEdits);
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
expect(future).toHaveLength(0);
|
||||||
|
|
||||||
|
store.getState().redo();
|
||||||
|
({ blocks, history, future } = store.getState());
|
||||||
|
expect(blocks).toEqual(snapshotAfterEdits);
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
expect(future).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -467,9 +467,8 @@ describe("formHelpers.computeFormControllerPublicTokens", () => {
|
|||||||
expect(checkboxField.groupLabelImageUrl).toBe("https://example.com/group.png");
|
expect(checkboxField.groupLabelImageUrl).toBe("https://example.com/group.png");
|
||||||
|
|
||||||
expect(tokens.formClassName).toBe("space-y-3");
|
expect(tokens.formClassName).toBe("space-y-3");
|
||||||
// FormBlock 은 이제 레이아웃/배경 스타일을 가지지 않는 순수 컨트롤러이므로,
|
// FormBlock 은 레이아웃 정보는 가지지 않지만, backgroundColorCustom 으로 폼 래퍼 배경색만 제어할 수 있다.
|
||||||
// formStyle 은 항상 빈 객체여야 한다.
|
expect(tokens.formStyle).toEqual({ backgroundColor: "#123456" });
|
||||||
expect(tokens.formStyle).toEqual({});
|
|
||||||
|
|
||||||
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
|
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user