first commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/.env*
|
||||||
|
!apps/web/.env.build
|
||||||
|
docker
|
||||||
|
volumes
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the feature you'd like to request
|
||||||
|
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the solution you'd like to see
|
||||||
|
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: 🐞 Bug Report
|
||||||
|
description: Create a bug report to help us improve
|
||||||
|
title: "bug: "
|
||||||
|
labels: ["🐞❔ unconfirmed bug"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Provide environment information
|
||||||
|
description: |
|
||||||
|
Run this command in your project root and paste the results in a code block:
|
||||||
|
```bash
|
||||||
|
npx envinfo --system --binaries
|
||||||
|
```
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Link to reproduction
|
||||||
|
description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: To reproduce
|
||||||
|
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional information
|
||||||
|
description: Add any other information related to the bug here, screenshots if applicable.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
contact_links:
|
||||||
|
- name: Ask a question
|
||||||
|
url: https://github.com/line/abc-user-feedback/discussions
|
||||||
|
about: Ask questions and discuss with other community members
|
||||||
|
- name: Feature request
|
||||||
|
url: https://github.com/line/abc-user-feedback/discussions/new?category=ideas
|
||||||
|
about: Feature requests should be opened as discussions
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: ['*']
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
merge_group:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_COLOR: 3
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint && pnpm lint:ws
|
||||||
|
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Format
|
||||||
|
run: pnpm format
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm typecheck
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: setup environment variables (with opensearch)
|
||||||
|
run: |
|
||||||
|
echo "JWT_SECRET=${{ vars.TEST_JWT_SECRET }}" >> ./apps/api/.env.test
|
||||||
|
echo "OPENSEARCH_USE=true" >> ./apps/api/.env.test
|
||||||
|
echo "OPENSEARCH_NODE=http://localhost:9200" >> ./apps/api/.env.test
|
||||||
|
echo "SMTP_HOST='localhost'" >> ./apps/api/.env.test
|
||||||
|
echo "SMTP_PORT=25" >> ./apps/api/.env.test
|
||||||
|
echo "SMTP_SENDER='user@feedback.com'" >> ./apps/api/.env.test
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: setup environment variables (without opensearch)
|
||||||
|
run: |
|
||||||
|
rm ./apps/api/.env.test
|
||||||
|
echo "JWT_SECRET=${{ vars.TEST_JWT_SECRET }}" >> ./apps/api/.env.test
|
||||||
|
echo "OPENSEARCH_USE=false" >> ./apps/api/.env.test
|
||||||
|
echo "SMTP_HOST='localhost'" >> ./apps/api/.env.test
|
||||||
|
echo "SMTP_PORT=25" >> ./apps/api/.env.test
|
||||||
|
echo "SMTP_SENDER='user@feedback.com'" >> ./apps/api/.env.test
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: pnpm test
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Docker Dev Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '**-dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Docker meta for API
|
||||||
|
id: api-meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: line/abc-user-feedback-api
|
||||||
|
bake-target: api
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Docker meta for Web
|
||||||
|
id: web-meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: line/abc-user-feedback-web
|
||||||
|
bake-target: web
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Bake and push multi-platform Docker images
|
||||||
|
uses: docker/bake-action@v7
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
./docker/docker-bake.hcl
|
||||||
|
push: true
|
||||||
|
set: |
|
||||||
|
api.tags=${{ steps.api-meta.outputs.tags }}
|
||||||
|
web.tags=${{ steps.web-meta.outputs.tags }}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: Docker Prod Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
- '!**-beta'
|
||||||
|
- '!**-dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
with:
|
||||||
|
driver: docker-container
|
||||||
|
|
||||||
|
- name: Docker meta for API
|
||||||
|
id: api-meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: line/abc-user-feedback-api
|
||||||
|
bake-target: api
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Docker meta for Web
|
||||||
|
id: web-meta
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
with:
|
||||||
|
images: line/abc-user-feedback-web
|
||||||
|
bake-target: web
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Bake and push multi-platform Docker images
|
||||||
|
uses: docker/bake-action@v7
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
./docker/docker-bake.hcl
|
||||||
|
cwd://${{ steps.api-meta.outputs.bake-file }}
|
||||||
|
cwd://${{ steps.web-meta.outputs.bake-file }}
|
||||||
|
push: true
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [dev, main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0.39
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: userfeedback
|
||||||
|
MYSQL_DATABASE: e2e
|
||||||
|
MYSQL_USER: userfeedback
|
||||||
|
MYSQL_PASSWORD: userfeedback
|
||||||
|
TZ: UTC
|
||||||
|
ports:
|
||||||
|
- 13307:3306
|
||||||
|
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||||
|
smtp:
|
||||||
|
image: rnwood/smtp4dev:v3
|
||||||
|
ports:
|
||||||
|
- 5080:80
|
||||||
|
- 25:25
|
||||||
|
- 143:143
|
||||||
|
opensearch:
|
||||||
|
image: opensearchproject/opensearch:2.4.1
|
||||||
|
env:
|
||||||
|
discovery.type: single-node
|
||||||
|
bootstrap.memory_lock: 'true'
|
||||||
|
plugins.security.disabled: 'true'
|
||||||
|
options: >-
|
||||||
|
--health-cmd="curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"'"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
ports:
|
||||||
|
- 9200:9200
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup
|
||||||
|
uses: ./tooling/github/setup
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run e2e tests
|
||||||
|
run: |
|
||||||
|
CI=true pnpm test:e2e
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always() && !cancelled()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: apps/e2e/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Integration Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [dev, main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: userfeedback
|
||||||
|
MYSQL_DATABASE: e2e
|
||||||
|
MYSQL_USER: userfeedback
|
||||||
|
MYSQL_PASSWORD: userfeedback
|
||||||
|
TZ: UTC
|
||||||
|
ports:
|
||||||
|
- 13307:3306
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
|
||||||
|
smtp:
|
||||||
|
image: rnwood/smtp4dev:v3
|
||||||
|
ports:
|
||||||
|
- 5080:80
|
||||||
|
- 25:25
|
||||||
|
- 143:143
|
||||||
|
|
||||||
|
opensearch:
|
||||||
|
image: opensearchproject/opensearch:2.17.1
|
||||||
|
env:
|
||||||
|
discovery.type: single-node
|
||||||
|
bootstrap.memory_lock: 'true'
|
||||||
|
plugins.security.disabled: 'true'
|
||||||
|
OPENSEARCH_INITIAL_ADMIN_PASSWORD: 'UserFeedback123!@#'
|
||||||
|
options: >-
|
||||||
|
--health-cmd="curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"'"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
ports:
|
||||||
|
- 9200:9200
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup integration test (with opensearch)
|
||||||
|
run: |
|
||||||
|
npm install -g corepack@latest
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
echo "JWT_SECRET=DEV" >> ./apps/api/.env
|
||||||
|
echo "OPENSEARCH_USE=true" >> ./apps/api/.env
|
||||||
|
echo "OPENSEARCH_NODE=http://localhost:9200" >> ./apps/api/.env
|
||||||
|
echo "SMTP_HOST='localhost'" >> ./apps/api/.env
|
||||||
|
echo "SMTP_PORT=25" >> ./apps/api/.env
|
||||||
|
echo "SMTP_SENDER='user@feedback.com'" >> ./apps/api/.env
|
||||||
|
|
||||||
|
- name: Run integration tests (with opensearch)
|
||||||
|
run: |
|
||||||
|
npm run test:integration
|
||||||
|
|
||||||
|
- name: Setup integration test (without opensearch)
|
||||||
|
run: |
|
||||||
|
echo "OPENSEARCH_USE=false" >> ./apps/api/.env
|
||||||
|
|
||||||
|
- name: Run integration tests (without opensearch)
|
||||||
|
run: |
|
||||||
|
npm run test:integration
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Publish Api Docs to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-api-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: userfeedback
|
||||||
|
MYSQL_DATABASE: userfeedback
|
||||||
|
MYSQL_USER: userfeedback
|
||||||
|
MYSQL_PASSWORD: userfeedback
|
||||||
|
TZ: UTC
|
||||||
|
ports:
|
||||||
|
- 13306:3306
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Build app and swagger docs
|
||||||
|
run: |
|
||||||
|
npm install -g corepack@latest
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
cd apps/api
|
||||||
|
cp .env.example .env
|
||||||
|
npx ts-node -r tsconfig-paths/register src/scripts/build-swagger-docs.ts
|
||||||
|
|
||||||
|
- name: Run Redocly CLI
|
||||||
|
uses: fluximus-prime/redocly-cli-github-action@v1
|
||||||
|
with:
|
||||||
|
args: 'build-docs apps/api/swagger.json --output docs/index.html'
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: docs
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
!.turbo/.gitkeep
|
||||||
|
**/.turbo
|
||||||
|
|
||||||
|
# .env
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
!.env.build
|
||||||
|
|
||||||
|
volumes
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# playwright
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
playwright/
|
||||||
|
|
||||||
|
# JetBrains
|
||||||
|
.idea
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"yoavbls.pretty-ts-errors"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"[typescript,typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
|
||||||
|
"eslint.runtime": "node",
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{ "pattern": "apps/*/" },
|
||||||
|
{ "pattern": "packages/*/" },
|
||||||
|
{ "pattern": "tooling/*/" }
|
||||||
|
],
|
||||||
|
"prettier.ignorePath": ".gitignore",
|
||||||
|
"tailwindCSS.experimental.classRegex": [
|
||||||
|
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||||
|
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||||
|
],
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
},
|
||||||
|
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||||
|
"@testing-library/react",
|
||||||
|
"react-i18next"
|
||||||
|
],
|
||||||
|
"[ignore]": {
|
||||||
|
"editor.defaultFormatter": "foxundermoon.shell-format"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, caste, color, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
- Demonstrating empathy and kindness toward other people
|
||||||
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
- Giving and gracefully accepting constructive feedback
|
||||||
|
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
- Public or private harassment
|
||||||
|
- Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
[dl_oss_dev@linecorp.com](mailto:dl_oss_dev@linecorp.com).
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||||
|
at [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# How to contribute to ABC User Feedback
|
||||||
|
|
||||||
|
First of all, thank you so much for taking your time to contribute! ABC User Feedback is not very different from any other open source projects. It will be fantastic if you help us by doing any of the following:
|
||||||
|
|
||||||
|
- File an issue in [the issue tracker](https://github.com/line/abc-user-feedback/issues)
|
||||||
|
to report bugs and propose new features and improvements.
|
||||||
|
- Ask a question using [the issue tracker](https://github.com/line/abc-user-feedback/issues).
|
||||||
|
- Contribute your work by sending [a pull request](https://github.com/line/abc-user-feedback/pulls).
|
||||||
|
- You should make your pull request base branch as `dev` not `main`. After merging to `dev` branch, we test the pull request in our separate environment and if there is no issue, it would be merged to `main` and released.
|
||||||
|
|
||||||
|
## Contributor license agreement
|
||||||
|
|
||||||
|
If you are sending a pull request and it's a non-trivial change beyond fixing
|
||||||
|
typos, please make sure to sign the [ICLA (Individual Contributor License Agreement)](https://cla-assistant.io/line/abc-user-feedback).
|
||||||
|
Please [contact us](mailto:dl_oss_dev@linecorp.com) if you need the CCLA (Corporate Contributor License Agreement).
|
||||||
|
|
||||||
|
## Code of conduct
|
||||||
|
|
||||||
|
We expect contributors to follow [our code of conduct](./CODE_OF_CONDUCT.md).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
software_apps ||--o{ workspaces : "defines"
|
||||||
|
service_types ||--o{ workspaces : "defines"
|
||||||
|
users ||--o{ user_workspace_access : "has"
|
||||||
|
workspaces ||--o{ user_workspace_access : "accessed_by"
|
||||||
|
workspaces ||--o{ support_tickets : "contains"
|
||||||
|
users ||--o{ support_tickets : "requester"
|
||||||
|
support_tickets ||--o{ ticket_comments : "has"
|
||||||
|
support_tickets ||--o{ attachments : "has"
|
||||||
|
support_tickets ||--o{ request_approvals : "needs"
|
||||||
|
support_tickets ||--o{ asset_allocations : "requests"
|
||||||
|
support_tickets ||--o{ vehicle_schedules : "schedules"
|
||||||
|
support_tickets ||--o{ remote_support : "needs"
|
||||||
|
assets ||--o{ asset_allocations : "allocated"
|
||||||
|
assets ||--o{ vehicle_schedules : "scheduled"
|
||||||
|
workspaces ||--o{ faqs : "provides"
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
software_apps ||--o{ workspaces : defines
|
||||||
|
service_types ||--o{ workspaces : defines
|
||||||
|
workspaces ||--o{ user_workspace_access : accessed_by
|
||||||
|
workspaces ||--o{ support_tickets : contains
|
||||||
|
workspaces ||--o{ attachments : stores
|
||||||
|
workspaces ||--o{ faqs : provides
|
||||||
|
support_tickets ||--o{ ticket_comments : has
|
||||||
|
support_tickets ||--o{ attachments : has
|
||||||
|
support_tickets ||--o{ request_approvals : needs
|
||||||
|
support_tickets ||--o{ asset_allocations : requests
|
||||||
|
support_tickets ||--o{ vehicle_schedules : schedules
|
||||||
|
support_tickets ||--o{ remote_support : needs
|
||||||
|
support_tickets ||--o{ notification_logs : notifies
|
||||||
|
assets ||--o{ asset_allocations : allocated
|
||||||
|
assets ||--o{ vehicle_schedules : scheduled
|
||||||
|
support_status_codes ||--o{ support_tickets : statuses
|
||||||
|
support_category_codes ||--o{ support_tickets : categorizes
|
||||||
|
remote_support_status_codes ||--o{ remote_support : statuses
|
||||||
|
|
||||||
|
software_apps {
|
||||||
|
int id PK
|
||||||
|
string app_code UK
|
||||||
|
string app_name UK
|
||||||
|
string description
|
||||||
|
boolean is_active
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
service_types {
|
||||||
|
int id PK
|
||||||
|
string service_code UK
|
||||||
|
string service_name UK
|
||||||
|
string description
|
||||||
|
boolean is_active
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
int id PK
|
||||||
|
string workspace_type
|
||||||
|
int software_app_id FK
|
||||||
|
int service_type_id FK
|
||||||
|
string workspace_code UK
|
||||||
|
string workspace_name
|
||||||
|
boolean is_active
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
user_workspace_access {
|
||||||
|
int id PK
|
||||||
|
string user_id
|
||||||
|
string tenant_id
|
||||||
|
int workspace_id FK
|
||||||
|
string workspace_role
|
||||||
|
boolean can_read
|
||||||
|
boolean can_write
|
||||||
|
boolean can_manage
|
||||||
|
boolean can_approve
|
||||||
|
string page_scope
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
user_notification_profiles {
|
||||||
|
int id PK
|
||||||
|
string user_id
|
||||||
|
string tenant_id
|
||||||
|
string user_type
|
||||||
|
string phone_number
|
||||||
|
string naverworks_user_key
|
||||||
|
boolean sms_opt_in
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
support_status_codes {
|
||||||
|
string code PK
|
||||||
|
string name
|
||||||
|
int sort_order
|
||||||
|
}
|
||||||
|
|
||||||
|
support_category_codes {
|
||||||
|
string code PK
|
||||||
|
string name
|
||||||
|
int sort_order
|
||||||
|
}
|
||||||
|
|
||||||
|
support_tickets {
|
||||||
|
int id PK
|
||||||
|
int workspace_id FK
|
||||||
|
string requester_id
|
||||||
|
string requester_tenant_id
|
||||||
|
string ticket_type
|
||||||
|
string title
|
||||||
|
string content
|
||||||
|
string category_code FK
|
||||||
|
string status_code FK
|
||||||
|
boolean is_secret
|
||||||
|
datetime requested_start_at
|
||||||
|
datetime requested_end_at
|
||||||
|
string priority
|
||||||
|
datetime created_at
|
||||||
|
datetime updated_at
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket_comments {
|
||||||
|
int id PK
|
||||||
|
int ticket_id FK
|
||||||
|
string author_id
|
||||||
|
string author_tenant_id
|
||||||
|
string content
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments {
|
||||||
|
int id PK
|
||||||
|
string parent_type
|
||||||
|
int parent_id
|
||||||
|
int workspace_id FK
|
||||||
|
string file_name
|
||||||
|
string file_path
|
||||||
|
int file_size
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
request_approvals {
|
||||||
|
int id PK
|
||||||
|
int ticket_id FK
|
||||||
|
string approver_id
|
||||||
|
string approver_tenant_id
|
||||||
|
string approval_status
|
||||||
|
string comment
|
||||||
|
datetime approved_at
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
assets {
|
||||||
|
int id PK
|
||||||
|
string asset_code UK
|
||||||
|
string asset_name
|
||||||
|
string asset_type
|
||||||
|
int quantity
|
||||||
|
boolean is_active
|
||||||
|
string location
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
asset_allocations {
|
||||||
|
int id PK
|
||||||
|
int ticket_id FK
|
||||||
|
int asset_id FK
|
||||||
|
string assignee_id
|
||||||
|
string assignee_tenant_id
|
||||||
|
string allocation_status
|
||||||
|
datetime loaned_at
|
||||||
|
datetime due_at
|
||||||
|
datetime returned_at
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
vehicle_schedules {
|
||||||
|
int id PK
|
||||||
|
int ticket_id FK
|
||||||
|
int asset_id FK
|
||||||
|
datetime departure_at
|
||||||
|
datetime arrival_at
|
||||||
|
string destination
|
||||||
|
string driver_name
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_support_status_codes {
|
||||||
|
string code PK
|
||||||
|
string name
|
||||||
|
int sort_order
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_support {
|
||||||
|
int id PK
|
||||||
|
int ticket_id FK
|
||||||
|
string status_code FK
|
||||||
|
string support_engineer_id
|
||||||
|
string support_engineer_tenant_id
|
||||||
|
datetime scheduled_time
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
faqs {
|
||||||
|
int id PK
|
||||||
|
int workspace_id FK
|
||||||
|
string title
|
||||||
|
string content
|
||||||
|
boolean is_active
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
notification_logs {
|
||||||
|
int id PK
|
||||||
|
int ticket_id FK
|
||||||
|
string recipient_id
|
||||||
|
string recipient_tenant_id
|
||||||
|
string channel
|
||||||
|
string target_address
|
||||||
|
string delivery_status
|
||||||
|
string fallback_channel
|
||||||
|
string error_message
|
||||||
|
datetime sent_at
|
||||||
|
}
|
||||||
|
|
||||||
|
user_notification_profiles ||..o{ notification_logs : receives
|
||||||
|
```
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# ABC User Feedback Integration Guide
|
||||||
|
|
||||||
|
## Image Storage Integration
|
||||||
|
|
||||||
|
ABC User Feedback supports the integration of image storage solutions to handle images submitted as part of user feedback. We currently support AWS S3 and S3-compatible storage services.
|
||||||
|
|
||||||
|
### Uploading Images
|
||||||
|
|
||||||
|
There are two methods for uploading images associated with feedback:
|
||||||
|
|
||||||
|
1. **Multipart Upload API**: This method requires setting up the [image configuration](#S3-configuration). Once configured, you can use the multipart upload API to securely upload images directly to your storage service.
|
||||||
|
|
||||||
|
2. **Feedback Creation API with Image URLs**: Alternatively, users can submit feedback with image URLs. This method does not require the image configuration setup; however, the image URLs must come from the whitelisted domains.
|
||||||
|
|
||||||
|
**Note**: For detailed instructions on using these methods, please refer to the API documentation. You can see the documentation by accessing to `{API server host}/docs` or `{API server host}/docs/redoc`.
|
||||||
|
|
||||||
|
### S3 Configuration
|
||||||
|
|
||||||
|
To enable image uploads directly to the server, you must configure the image storage settings. The service uses the following configuration parameters and you can set them in the setting menu.
|
||||||
|
|
||||||
|
- `accessKeyId`: Your storage service access key ID.
|
||||||
|
- `secretAccessKey`: Your storage service secret access key.
|
||||||
|
- `endpoint`: The endpoint URL for the storage service.
|
||||||
|
- `region`: The region your storage service is located in.
|
||||||
|
- `bucket`: The name of the bucket where images will be stored.
|
||||||
|
- `enablePresignedUrlDownload`: Enable the setting to enhance download security by using the pre-signed URL feature supported by AWS S3.
|
||||||
|
|
||||||
|
Depending on your use case and the desired level of access, you may need to adjust the permissions of your S3 bucket. If your application requires that the images be publicly accessible, configure your S3 bucket's policy to allow public reads.
|
||||||
|
|
||||||
|
### Domain Whitelist
|
||||||
|
|
||||||
|
Users can specify a whitelist of domains for image URLs. This ensures that only images from trusted sources are accepted and managed by User Feedback API server.
|
||||||
|
|
||||||
|
**Note**: The domain whitelist is enforced at the time of posting feedback with images. This means that validation against the whitelist occurs only during the submission of new feedback. Once an image URL has been uploaded to the database and accepted, it will be accessible through the web admin interface regardless of its current status on the whitelist. It is important to ensure that image URLs are from trusted sources before they are uploaded, as subsequent changes to the whitelist will not retroactively affect previously stored image URLs.
|
||||||
|
|
||||||
|
## Webhook Feature
|
||||||
|
|
||||||
|
### Introduction to Webhooks
|
||||||
|
|
||||||
|
Webhooks in ABC User Feedback provide a powerful way to integrate with external services. They allow you to receive real-time notifications when specific events occur within the application, such as new feedback submissions or issue updates.
|
||||||
|
|
||||||
|
Furthermore, you can combine webhooks with ABC User Feedback API to make more powerful features such as translation, sentiment analysis or whatever you want. Just make a webhook listener and build your own script and send the result to ABC User Feedback by API.
|
||||||
|
|
||||||
|
### Setting Up Webhooks
|
||||||
|
|
||||||
|
To set up webhooks in ABC User Feedback:
|
||||||
|
|
||||||
|
1. Navigate to the project settings where you want to enable webhooks.
|
||||||
|
2. Add a new webhook by providing the URL endpoint that ABC User Feedback will send the data to when events occur.
|
||||||
|
3. Turn on the events you wish to subscribe to.
|
||||||
|
4. Save the webhook configuration.
|
||||||
|
|
||||||
|
Ensure that the endpoint you provide is secure and can accept POST requests with a JSON payload.
|
||||||
|
|
||||||
|
### Event Types and Request Bodies
|
||||||
|
|
||||||
|
ABC User Feedback's webhook supports the following event types, each with its own specific payload structure:
|
||||||
|
|
||||||
|
| No. | Title | Description |
|
||||||
|
|-----|--------------------|------------------------------------|
|
||||||
|
| 1 | FEEDBACK_CREATION | When the new Feedback is created. |
|
||||||
|
| 2 | ISSUE_ADDITION | When an Issue is added to a feedback. |
|
||||||
|
| 3 | ISSUE_CREATION | When the new Issue is created. |
|
||||||
|
| 4 | ISSUE_STATUS_CHANGE | When the Issue status is changed. |
|
||||||
|
|
||||||
|
|
||||||
|
#### 1. FEEDBACK_CREATION
|
||||||
|
|
||||||
|
This event is triggered when a new piece of feedback is created.
|
||||||
|
|
||||||
|
**Payload Structure:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "FEEDBACK_CREATION",
|
||||||
|
"data": {
|
||||||
|
"feedback": {
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"name": "issue name",
|
||||||
|
"description": "issue description",
|
||||||
|
"status": "INIT",
|
||||||
|
"externalIssueId": "123",
|
||||||
|
"feedbackCount": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "channel name"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "project name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. ISSUE_ADDITION
|
||||||
|
|
||||||
|
This event is triggered when an issue is added to an existing piece of feedback.
|
||||||
|
|
||||||
|
**Payload Structure:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "ISSUE_ADDITION",
|
||||||
|
"data": {
|
||||||
|
"feedback": {
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"name": "issue name",
|
||||||
|
"description": "issue description",
|
||||||
|
"status": "INIT",
|
||||||
|
"externalIssueId": "123",
|
||||||
|
"feedbackCount": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "channel name"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "project name"
|
||||||
|
},
|
||||||
|
"addedIssue": {
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"name": "issue name",
|
||||||
|
"description": "issue description",
|
||||||
|
"status": "INIT",
|
||||||
|
"externalIssueId": "123",
|
||||||
|
"feedbackCount": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. ISSUE_CREATION
|
||||||
|
|
||||||
|
This event is triggered when a new issue is created within a project.
|
||||||
|
|
||||||
|
**Payload Structure:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "ISSUE_CREATION",
|
||||||
|
"data": {
|
||||||
|
"issue": {
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"name": "issue name",
|
||||||
|
"description": "issue description",
|
||||||
|
"status": "INIT",
|
||||||
|
"externalIssueId": "123",
|
||||||
|
"feedbackCount": 1
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "project name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. ISSUE_STATUS_CHANGE
|
||||||
|
|
||||||
|
This event is triggered when the status of an issue is updated.
|
||||||
|
|
||||||
|
**Payload Structure:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "ISSUE_STATUS_CHANGE",
|
||||||
|
"data": {
|
||||||
|
"issue": {
|
||||||
|
"id": 1,
|
||||||
|
"createdAt": "2023-04-02T15:30:00Z",
|
||||||
|
"updatedAt": "2023-04-02T15:30:00Z",
|
||||||
|
"name": "issue name",
|
||||||
|
"description": "issue description",
|
||||||
|
"status": "ON_REVIEW",
|
||||||
|
"externalIssueId": "123",
|
||||||
|
"feedbackCount": 1
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "project name"
|
||||||
|
},
|
||||||
|
"previousStatus": "INIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Webhooks
|
||||||
|
|
||||||
|
Upon receiving a webhook payload, your endpoint should:
|
||||||
|
|
||||||
|
Parse the JSON payload.
|
||||||
|
Take appropriate action based on the event type and data received.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2021 LINE Corporation
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# ABC User Feedback
|
||||||
|
|
||||||
|
ABC User Feedback is a standalone web application designed to manage Voice of Customer (VoC) data. It enables you to efficiently gather and categorize customer feedback. The application is currently utilized in services with a reach of 10 million MAU.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Feedback Tag**: Categorize feedback by topic with customizable tags
|
||||||
|
- **Kanban Mode**: Visualize and organize issue groups efficiently
|
||||||
|
- **Issue Tracker**: Simple issue tracking with status indicators and external ticket linking
|
||||||
|
- **Single Sign-on**: Enterprise-level OAuth authentication
|
||||||
|
- **Role Management**: Role Based Access Control (RBAC)
|
||||||
|
- **Dashboard**: Statistical visualization for feedback and issues
|
||||||
|
- **🤖 AI Field**: AI-powered feedback analysis (summarization, translation, sentiment analysis)
|
||||||
|
- **🤖 AI Issue Recommendation**: Intelligent issue recommendations based on feedback
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
Get started with ABC User Feedback in minutes using our CLI tool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx auf-cli init # initialize infrastructure
|
||||||
|
npx auf-cli start # start app
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it! The application will be running with all required infrastructure.
|
||||||
|
|
||||||
|
**For detailed installation options**, see our [Installation Guide](https://docs.abc-user-feedback.com/en/developer-guide/installation/docker-hub-images).
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Complete documentation is available at **[https://docs.abc-user-feedback.com/en/](https://docs.abc-user-feedback.com/en/)**
|
||||||
|
|
||||||
|
- [Getting Started](https://docs.abc-user-feedback.com/en/user-guide/getting-started) - User guide and tutorials
|
||||||
|
- [Installation](https://docs.abc-user-feedback.com/en/developer-guide/installation) - Detailed setup instructions
|
||||||
|
- [API Integration](https://docs.abc-user-feedback.com/en/developer-guide/api-integration) - REST API documentation
|
||||||
|
- [Configuration](https://docs.abc-user-feedback.com/en/developer-guide/installation/configuration) - Environment variables and settings
|
||||||
|
|
||||||
|
## 🐳 Docker Images
|
||||||
|
|
||||||
|
Pre-built Docker images are available on Docker Hub:
|
||||||
|
|
||||||
|
- **Web Frontend**: `docker pull line/abc-user-feedback-web`
|
||||||
|
- **API Backend**: `docker pull line/abc-user-feedback-api`
|
||||||
|
|
||||||
|
See [Docker Hub Images Guide](https://docs.abc-user-feedback.com/en/developer-guide/installation/docker-hub-images) for usage details.
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
For local development setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/line/abc-user-feedback
|
||||||
|
cd abc-user-feedback
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Manual Setup](https://docs.abc-user-feedback.com/en/developer-guide/installation/manual-setup) for complete development instructions.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please see our [Contributing Guidelines](./CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Copyright 2025 LY Corporation
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For questions and support, please visit our [documentation](https://docs.abc-user-feedback.com/en/).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Required environment variables
|
||||||
|
JWT_SECRET=DEV
|
||||||
|
|
||||||
|
MYSQL_PRIMARY_URL=mysql://userfeedback:userfeedback@localhost:13306/userfeedback # required
|
||||||
|
|
||||||
|
ACCESS_TOKEN_EXPIRED_TIME=10m # default: 10m
|
||||||
|
REFRESH_TOKEN_EXPIRED_TIME=1h # default: 1h
|
||||||
|
|
||||||
|
# ADMIN_WEB_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Optional environment variables
|
||||||
|
|
||||||
|
# BASE_URL=http://localhost:4000
|
||||||
|
|
||||||
|
# APP_PORT=4000 # default: 4000
|
||||||
|
# APP_ADDRESS=0.0.0.0 # default: 0.0.0.0
|
||||||
|
|
||||||
|
# MYSQL_SECONDARY_URLS= ["mysql://userfeedback:userfeedback@localhost:13306/userfeedback"] # optional
|
||||||
|
|
||||||
|
SMTP_HOST=localhost # required
|
||||||
|
SMTP_PORT=25 # required
|
||||||
|
SMTP_SENDER=user@feedback.com # required
|
||||||
|
# SMTP_USERNAME= # optional
|
||||||
|
# SMTP_PASSWORD= # optional
|
||||||
|
# SMTP_TLS= # default: false
|
||||||
|
# SMTP_CIPHER_SPEC= # default: TLSv1.2 if SMTP_TLS=true
|
||||||
|
# SMTP_OPPORTUNISTIC_TLS= # default: true if SMTP_TLS=true
|
||||||
|
|
||||||
|
# OPENSEARCH_USE=false # default: false
|
||||||
|
# OPENSEARCH_NODE= # required if OPENSEARCH_USE=true
|
||||||
|
# OPENSEARCH_USERNAME= # optional
|
||||||
|
# OPENSEARCH_PASSWORD= # optional
|
||||||
|
|
||||||
|
# AUTO_MIGRATION=true # default: true
|
||||||
|
|
||||||
|
# MASTER_API_KEY= # default: none
|
||||||
|
|
||||||
|
# AUTO_FEEDBACK_DELETION_ENABLED=false # default: false
|
||||||
|
# AUTO_FEEDBACK_DELETION_PERIOD_DAYS=365*5
|
||||||
|
|
||||||
|
# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4319/v1/logs
|
||||||
|
# OTEL_RESOURCE_ATTRIBUTES=service.name=abc-user-feedback-api,service.version=1.0.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*.hbs
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# ABC User Feedback Backend
|
||||||
|
|
||||||
|
ABC User Feedback Backend provides API and its related operations. It is built with Node.js, NestJS, Typeorm, and many more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
ABC User Feedback is using a mono-repo with multiple packages.
|
||||||
|
|
||||||
|
## Useful Targets
|
||||||
|
|
||||||
|
You can find a full list of targets in the [package.json](./package.json) file.
|
||||||
|
|
||||||
|
### `dev`
|
||||||
|
|
||||||
|
Runs the app in development mode.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test`
|
||||||
|
|
||||||
|
Executes tests. This command applies to the environment variables in `.env.test` file.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test:e2e`
|
||||||
|
|
||||||
|
Executes e2e tests. This command applies to the environment variables in `.env.test` file.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### `lint`
|
||||||
|
|
||||||
|
Performs a linting check using ESLint.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### `build`
|
||||||
|
|
||||||
|
Builds the app for production. The distributable is expored to the `dist` folder in the repository's root folder.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### `migration:generate`
|
||||||
|
|
||||||
|
Generate the migration file using typeorm. The file is generated in `src/configs/modules/typeorm-config/migrations`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run migration:generate --name={NAME}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `migration:run`
|
||||||
|
|
||||||
|
Run the migration files for database migrations
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run migration:run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The following is a list of environment variables used by the application, along with their descriptions and default values.
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
| Environment | Description | Default Value |
|
||||||
|
| ------------------- | -------------------------------------------- | ------------- |
|
||||||
|
| `JWT_SECRET` | Secret key for signing JSON Web Tokens (JWT) | _required_ |
|
||||||
|
| `MYSQL_PRIMARY_URL` | Primary MySQL connection URL | _required_ |
|
||||||
|
| `SMTP_HOST` | SMTP server host | _required_ |
|
||||||
|
| `SMTP_PORT` | SMTP server port | _required_ |
|
||||||
|
| `SMTP_SENDER` | Email address used as sender in emails | _required_ |
|
||||||
|
|
||||||
|
### Optional Environment Variables
|
||||||
|
|
||||||
|
<!-- markdownlint-disable MD060 -->
|
||||||
|
|
||||||
|
| Environment | Description | Default Value |
|
||||||
|
| ------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| `ADMIN_WEB_URL` | Admin Web URL | `http://localhost:3000` |
|
||||||
|
| `BASE_URL` | Public API server URL used in Swagger documentation | _optional_ |
|
||||||
|
| `APP_PORT` | The port that the server runs on | `4000` |
|
||||||
|
| `APP_ADDRESS` | The address that the server binds to | `0.0.0.0` |
|
||||||
|
| `MYSQL_SECONDARY_URLS` | Secondary MySQL connection URLs (must be in JSON array format) | _optional_ |
|
||||||
|
| `SMTP_USERNAME` | SMTP server authentication username | _optional_ |
|
||||||
|
| `SMTP_PASSWORD` | SMTP server authentication password | _optional_ |
|
||||||
|
| `SMTP_TLS` | Flag to enable SMTP server with secure option | `false` |
|
||||||
|
| `SMTP_CIPHER_SPEC` | SMTP Cipher Algorithm Specification | `TLSv1.2` |
|
||||||
|
| `SMTP_OPPORTUNISTIC_TLS` | Use Opportunistic TLS using STARTTLS | `true` |
|
||||||
|
| `OPENSEARCH_USE` | Flag to enable OpenSearch integration | `false` |
|
||||||
|
| `OPENSEARCH_NODE` | OpenSearch node URL | _required if `OPENSEARCH_USE=true`_ |
|
||||||
|
| `OPENSEARCH_USERNAME` | OpenSearch username (if authentication is enabled) | "" |
|
||||||
|
| `OPENSEARCH_PASSWORD` | OpenSearch password (if authentication is enabled) | "" |
|
||||||
|
| `AUTO_MIGRATION` | Automatically perform database migration on application start | `true` |
|
||||||
|
| `MASTER_API_KEY` | Master API key for privileged operations | _none_ |
|
||||||
|
| `AUTO_FEEDBACK_DELETION_ENABLED` | Enable auto old feedback deletion cron on application start | `false` |
|
||||||
|
| `AUTO_FEEDBACK_DELETION_PERIOD_DAYS` | Auto old feedback deletion period (in days) | _required if `AUTO_FEEDBACK_DELETION_ENABLED`_ |
|
||||||
|
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | OTLP HTTP logs endpoint that enables API log export when set | _optional_ |
|
||||||
|
| `OTEL_RESOURCE_ATTRIBUTES` | OpenTelemetry resource attributes for exported logs | _optional_ |
|
||||||
|
| `ACCESS_TOKEN_EXPIRED_TIME` | Duration until the access token expires | `10m` |
|
||||||
|
| `REFRESH_TOKEN_EXPIRED_TIME` | Duration until the refresh token expires | `1h` |
|
||||||
|
|
||||||
|
<!-- markdownlint-enable MD060 -->
|
||||||
|
|
||||||
|
Please ensure that you set the required environment variables before starting the application. Optional variables can be set as needed based on your specific configuration and requirements.
|
||||||
|
|
||||||
|
If you want to export API logs through OpenTelemetry locally, set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4319/v1/logs`. You can also set `OTEL_RESOURCE_ATTRIBUTES=service.name=abc-user-feedback-api,service.version=1.1.1` to attach standard OpenTelemetry resource metadata to the exported logs. When this endpoint is configured, the API keeps writing pretty console logs and also sends the same logs to the OTLP HTTP endpoint. The `pino-opentelemetry-transport` package reads these standard OTEL environment variables directly, so no additional application configuration is required. For the full setup and verification flow, refer to the [developer guide configuration document](../docs/i18n/en/docusaurus-plugin-content-docs/current/02-developer-guide/01-installation/05-configuration.md).
|
||||||
|
|
||||||
|
## Swagger
|
||||||
|
|
||||||
|
The swagger documentation can be found on the `/docs` endpoint.
|
||||||
|
|
||||||
|
If you are serving the API server on a different URL (e.g., behind a reverse proxy), you can set the `BASE_URL` environment variable to specify the public URL. This will be used in the Swagger documentation to generate correct API endpoint URLs.
|
||||||
|
|
||||||
|
## Dashboard statistics data migration
|
||||||
|
|
||||||
|
Dashboard data is generated by mysql data every AM 00:00 with the timezone set by its project with schedulers.
|
||||||
|
The schedulers generate data for 365 days.
|
||||||
|
If you want to generate dashboard data by yourself, you can use `/migration/statistics` APIs (ref: [migration API](./src/domains/migration/migration.controller.ts))
|
||||||
|
With the APIs you can generate data which are inserted more than 365 days.
|
||||||
|
|
||||||
|
If you are willing to change the project's timezone, you can manually change it in mysql database. (it is not available in admin web as it is not a usual case.)
|
||||||
|
Then you should delete all statistics data and re-genearte by migration APIs.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn NestJS, check out the [NestJS documentation](https://nestjs.com/).
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
import baseConfig from '@ufb/eslint-config/base';
|
||||||
|
import nestjsConfig from '@ufb/eslint-config/nestjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', '**/*.js'],
|
||||||
|
},
|
||||||
|
...baseConfig,
|
||||||
|
...nestjsConfig,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.jest },
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 5,
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.spec.ts', '**/*.test.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
export async function createConnection() {
|
||||||
|
return await mysql.createConnection({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 13307,
|
||||||
|
user: 'root',
|
||||||
|
password: 'userfeedback',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { join } from 'path';
|
||||||
|
import { createConnection } from 'typeorm';
|
||||||
|
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
|
||||||
|
|
||||||
|
import { createConnection as connect } from './database-utils';
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.MYSQL_PRIMARY_URL =
|
||||||
|
'mysql://root:userfeedback@localhost:13307/integration';
|
||||||
|
process.env.MYSQL_SECONDARY_URLS = JSON.stringify([
|
||||||
|
'mysql://root:userfeedback@localhost:13307/integration',
|
||||||
|
]);
|
||||||
|
process.env.MASTER_API_KEY = 'master-api-key';
|
||||||
|
process.env.AUTO_FEEDBACK_DELETION_ENABLED = 'true';
|
||||||
|
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS = '30';
|
||||||
|
|
||||||
|
async function createTestDatabase() {
|
||||||
|
const connection = await connect();
|
||||||
|
|
||||||
|
await connection.query(`DROP DATABASE IF EXISTS integration;`);
|
||||||
|
await connection.query(`CREATE DATABASE IF NOT EXISTS integration;`);
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
const connection = await createConnection({
|
||||||
|
type: 'mysql',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 13307,
|
||||||
|
username: 'root',
|
||||||
|
password: 'userfeedback',
|
||||||
|
database: 'integration',
|
||||||
|
migrations: [
|
||||||
|
join(
|
||||||
|
__dirname,
|
||||||
|
'../src/configs/modules/typeorm-config/migrations/*.{ts,js}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
migrationsTableName: 'migrations',
|
||||||
|
namingStrategy: new SnakeNamingStrategy(),
|
||||||
|
timezone: '+00:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.runMigrations();
|
||||||
|
await connection.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
await createTestDatabase();
|
||||||
|
await runMigrations();
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { createConnection as connect } from './database-utils';
|
||||||
|
|
||||||
|
async function dropTestDatabase() {
|
||||||
|
const connection = await connect();
|
||||||
|
|
||||||
|
await connection.query(`DROP DATABASE IF EXISTS integration;`);
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async () => {
|
||||||
|
await dropTestDatabase();
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"displayName": "api-integration",
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": ["<rootDir>/../src/$1"]
|
||||||
|
},
|
||||||
|
"testRegex": ".integration-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"transformIgnorePatterns": ["node_modules/(?!@faker-js|uuid)"],
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/../integration-test/jest-integration.setup.ts"
|
||||||
|
],
|
||||||
|
"globalSetup": "<rootDir>/../integration-test/global.setup.ts",
|
||||||
|
"globalTeardown": "<rootDir>/../integration-test/global.teardown.ts"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
jest.mock('@nestjs-modules/mailer/dist/adapters/handlebars.adapter', () => {
|
||||||
|
return {
|
||||||
|
HandlebarsAdapter: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import { ApiKeyService } from '@/domains/admin/project/api-key/api-key.service';
|
||||||
|
import { CreateApiKeyRequestDto } from '@/domains/admin/project/api-key/dtos/requests';
|
||||||
|
import type { FindApiKeysResponseDto } from '@/domains/admin/project/api-key/dtos/responses';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('ApiKeyController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let _apiKeyService: ApiKeyService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
_apiKeyService = module.get(ApiKeyService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/api-keys (POST)', () => {
|
||||||
|
it('should create an API key', async () => {
|
||||||
|
const dto = new CreateApiKeyRequestDto();
|
||||||
|
dto.value = 'TestApiKey1234567890';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201)
|
||||||
|
.then(
|
||||||
|
({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
id: number;
|
||||||
|
value: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
expect(body).toHaveProperty('id');
|
||||||
|
expect(body).toHaveProperty('value');
|
||||||
|
expect(body).toHaveProperty('createdAt');
|
||||||
|
expect(body.value).toBe('TestApiKey1234567890');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an API key with auto-generated value when not provided', async () => {
|
||||||
|
const dto = new CreateApiKeyRequestDto();
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201)
|
||||||
|
.then(
|
||||||
|
({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: {
|
||||||
|
id: number;
|
||||||
|
value: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
expect(body).toHaveProperty('id');
|
||||||
|
expect(body).toHaveProperty('value');
|
||||||
|
expect(body).toHaveProperty('createdAt');
|
||||||
|
expect(body.value).toMatch(/^[A-F0-9]{20}$/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid API key length', async () => {
|
||||||
|
const dto = new CreateApiKeyRequestDto();
|
||||||
|
dto.value = 'ShortKey';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new CreateApiKeyRequestDto();
|
||||||
|
dto.value = 'TestApiKey1234567890';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/api-keys (GET)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateApiKeyRequestDto();
|
||||||
|
dto.value = 'TestApiKeyForList123';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find API keys by project id', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindApiKeysResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('id');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('value');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('createdAt');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('deletedAt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/api-keys/:apiKeyId (DELETE)', () => {
|
||||||
|
let apiKeyId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateApiKeyRequestDto();
|
||||||
|
dto.value = 'TestApiKeyForDelete1';
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/api-keys`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
apiKeyId = (response.body as { id: number }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete API key', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/api-keys/${apiKeyId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/api-keys/${apiKeyId}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import {
|
||||||
|
EmailUserSignInRequestDto,
|
||||||
|
EmailUserSignUpRequestDto,
|
||||||
|
EmailVerificationCodeRequestDto,
|
||||||
|
InvitationUserSignUpRequestDto,
|
||||||
|
} from '@/domains/admin/auth/dtos/requests';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('AuthController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let _dataSource: DataSource;
|
||||||
|
let _authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
_dataSource = module.get(getDataSourceToken());
|
||||||
|
_authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/auth/email/code/verify (POST)', () => {
|
||||||
|
it('should verify email code successfully', async () => {
|
||||||
|
const dto = new EmailVerificationCodeRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.code = '123456';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/email/code/verify')
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/auth/signUp/email (POST)', () => {
|
||||||
|
it('should sign up user with email', async () => {
|
||||||
|
const email = faker.internet.email();
|
||||||
|
|
||||||
|
const dto = new EmailUserSignUpRequestDto();
|
||||||
|
dto.email = email;
|
||||||
|
dto.password = 'password123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for weak password', async () => {
|
||||||
|
const dto = new EmailUserSignUpRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = '123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid email format', async () => {
|
||||||
|
const dto = new EmailUserSignUpRequestDto();
|
||||||
|
dto.email = 'invalid-email';
|
||||||
|
dto.password = 'password123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 409 for duplicate email', async () => {
|
||||||
|
const email = faker.internet.email();
|
||||||
|
const dto = new EmailUserSignUpRequestDto();
|
||||||
|
dto.email = email;
|
||||||
|
dto.password = 'password123';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/auth/signIn/email (POST)', () => {
|
||||||
|
it('should sign in user with email and password', async () => {
|
||||||
|
const dto = new EmailUserSignInRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = 'password123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signIn/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for wrong password', async () => {
|
||||||
|
const dto = new EmailUserSignInRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = 'wrong-password';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signIn/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent email', async () => {
|
||||||
|
const dto = new EmailUserSignInRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = 'password123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signIn/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid email format', async () => {
|
||||||
|
const dto = new EmailUserSignInRequestDto();
|
||||||
|
dto.email = 'invalid-email';
|
||||||
|
dto.password = 'password123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signIn/email')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/auth/signUp/invitation (POST)', () => {
|
||||||
|
it('should sign up user with invitation code', async () => {
|
||||||
|
const dto = new InvitationUserSignUpRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = 'password123';
|
||||||
|
dto.code = 'invitation-code-123';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/invitation')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid invitation code', async () => {
|
||||||
|
const dto = new InvitationUserSignUpRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = 'password123';
|
||||||
|
dto.code = 'invalid-code';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/invitation')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for expired invitation code', async () => {
|
||||||
|
const dto = new InvitationUserSignUpRequestDto();
|
||||||
|
dto.email = faker.internet.email();
|
||||||
|
dto.password = 'password123';
|
||||||
|
dto.code = 'expired-code';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/auth/signUp/invitation')
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import { CategoryService } from '@/domains/admin/project/category/category.service';
|
||||||
|
import {
|
||||||
|
CreateCategoryRequestDto,
|
||||||
|
UpdateCategoryRequestDto,
|
||||||
|
} from '@/domains/admin/project/category/dtos/requests';
|
||||||
|
import type { GetAllCategoriesResponseDto } from '@/domains/admin/project/category/dtos/responses';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('CategoryController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let _categoryService: CategoryService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
_categoryService = module.get(CategoryService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/categories (POST)', () => {
|
||||||
|
it('should create a category', async () => {
|
||||||
|
const dto = new CreateCategoryRequestDto();
|
||||||
|
dto.name = 'TestCategory';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: { id: number } }) => {
|
||||||
|
expect(body).toHaveProperty('id');
|
||||||
|
expect(typeof body.id).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new CreateCategoryRequestDto();
|
||||||
|
dto.name = 'TestCategory';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/categories/search (POST)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateCategoryRequestDto();
|
||||||
|
dto.name = 'TestCategoryForList';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find categories by project id', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
categoryName: 'TestCategory',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('id');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty list when no categories match search', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
categoryName: 'NonExistentCategory',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories/search`)
|
||||||
|
.send({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/categories/:categoryId (PUT)', () => {
|
||||||
|
let categoryId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateCategoryRequestDto();
|
||||||
|
dto.name = 'TestCategoryForUpdate';
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
categoryId = (response.body as { id: number }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update category', async () => {
|
||||||
|
const dto = new UpdateCategoryRequestDto();
|
||||||
|
dto.name = 'UpdatedTestCategory';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
categoryName: 'UpdatedTestCategory',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||||
|
expect(body.items.length).toBeGreaterThan(0);
|
||||||
|
expect(body.items[0].name).toBe('UpdatedTestCategory');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent category', async () => {
|
||||||
|
const dto = new UpdateCategoryRequestDto();
|
||||||
|
dto.name = 'UpdatedCategory';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/categories/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new UpdateCategoryRequestDto();
|
||||||
|
dto.name = 'UpdatedCategory';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/categories/:categoryId (DELETE)', () => {
|
||||||
|
let categoryId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateCategoryRequestDto();
|
||||||
|
dto.name = 'TestCategoryForDelete';
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
categoryId = (response.body as { id: number }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete category', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/categories/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
categoryName: 'TestCategoryForDelete',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||||
|
expect(body.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when deleting non-existent category', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/categories/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import {
|
||||||
|
FieldFormatEnum,
|
||||||
|
FieldPropertyEnum,
|
||||||
|
FieldStatusEnum,
|
||||||
|
} from '@/common/enums';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import {
|
||||||
|
CreateChannelRequestDto,
|
||||||
|
CreateChannelRequestFieldDto,
|
||||||
|
FindChannelsByProjectIdRequestDto,
|
||||||
|
UpdateChannelFieldsRequestDto,
|
||||||
|
UpdateChannelRequestDto,
|
||||||
|
UpdateChannelRequestFieldDto,
|
||||||
|
} from '@/domains/admin/channel/channel/dtos/requests';
|
||||||
|
import type {
|
||||||
|
FindChannelByIdResponseDto,
|
||||||
|
FindChannelsByProjectIdResponseDto,
|
||||||
|
} from '@/domains/admin/channel/channel/dtos/responses';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('ChannelController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels (POST)', () => {
|
||||||
|
it('should create a channel', async () => {
|
||||||
|
const dto = new CreateChannelRequestDto();
|
||||||
|
dto.name = 'TestChannel';
|
||||||
|
|
||||||
|
const fieldDto = new CreateChannelRequestFieldDto();
|
||||||
|
fieldDto.name = 'TestField';
|
||||||
|
fieldDto.key = 'testField';
|
||||||
|
fieldDto.format = FieldFormatEnum.text;
|
||||||
|
fieldDto.property = FieldPropertyEnum.EDITABLE;
|
||||||
|
fieldDto.status = FieldStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
dto.fields = [fieldDto];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/channels`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels (GET)', () => {
|
||||||
|
it('should find channels by project id', async () => {
|
||||||
|
const dto = new FindChannelsByProjectIdRequestDto();
|
||||||
|
dto.searchText = 'TestChannel';
|
||||||
|
dto.page = 1;
|
||||||
|
dto.limit = 10;
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/channels`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query(dto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindChannelsByProjectIdResponseDto }) => {
|
||||||
|
expect(body.items.length).toBe(1);
|
||||||
|
expect(body.items[0].name).toBe('TestChannel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId (GET)', () => {
|
||||||
|
it('should find channel by id', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
|
||||||
|
expect(body.name).toBe('TestChannel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId (PUT)', () => {
|
||||||
|
it('should update channel', async () => {
|
||||||
|
const dto = new UpdateChannelRequestDto();
|
||||||
|
dto.name = 'TestChannelUpdated';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
|
||||||
|
expect(body.name).toBe('TestChannelUpdated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId/fields (PUT)', () => {
|
||||||
|
it('should update channel fields', async () => {
|
||||||
|
const dto = new UpdateChannelFieldsRequestDto();
|
||||||
|
const fieldDto = new UpdateChannelRequestFieldDto();
|
||||||
|
fieldDto.id = 5;
|
||||||
|
fieldDto.format = FieldFormatEnum.text;
|
||||||
|
fieldDto.key = 'testField';
|
||||||
|
fieldDto.name = 'TestFieldUpdated';
|
||||||
|
dto.fields = [fieldDto];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/channels/1/fields`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
|
||||||
|
expect(body.fields.length).toBe(5);
|
||||||
|
expect(body.fields[4].name).toBe('TestFieldUpdated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 error when update channel field key with special character', async () => {
|
||||||
|
const dto = new UpdateChannelFieldsRequestDto();
|
||||||
|
const fieldDto = new UpdateChannelRequestFieldDto();
|
||||||
|
fieldDto.id = 5;
|
||||||
|
fieldDto.format = FieldFormatEnum.text;
|
||||||
|
fieldDto.key = 'testField!';
|
||||||
|
fieldDto.name = 'testField!';
|
||||||
|
dto.fields = [fieldDto];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/channels/1/fields`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId (DELETE)', () => {
|
||||||
|
it('should delete channel', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const dto = new FindChannelsByProjectIdRequestDto();
|
||||||
|
dto.page = 1;
|
||||||
|
dto.limit = 10;
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/channels`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query(dto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindChannelsByProjectIdResponseDto }) => {
|
||||||
|
expect(body.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Channel validation tests', () => {
|
||||||
|
it('should return 400 when creating channel with invalid field key', async () => {
|
||||||
|
const dto = new CreateChannelRequestDto();
|
||||||
|
dto.name = 'TestChannel';
|
||||||
|
|
||||||
|
const fieldDto = new CreateChannelRequestFieldDto();
|
||||||
|
fieldDto.name = 'TestField';
|
||||||
|
fieldDto.key = 'invalid-key!@#';
|
||||||
|
fieldDto.format = FieldFormatEnum.text;
|
||||||
|
fieldDto.property = FieldPropertyEnum.EDITABLE;
|
||||||
|
fieldDto.status = FieldStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
dto.fields = [fieldDto];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/channels`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when updating channel with invalid data', async () => {
|
||||||
|
const dto = new UpdateChannelRequestDto();
|
||||||
|
dto.name = '';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/channels/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import type { Client } from '@opensearch-project/opensearch';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { FieldFormatEnum, QueryV2ConditionsEnum } from '@/common/enums';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import { ChannelEntity } from '@/domains/admin/channel/channel/channel.entity';
|
||||||
|
import { ChannelService } from '@/domains/admin/channel/channel/channel.service';
|
||||||
|
import { FieldEntity } from '@/domains/admin/channel/field/field.entity';
|
||||||
|
import type { CreateFeedbackDto } from '@/domains/admin/feedback/dtos';
|
||||||
|
import type { FindFeedbacksByChannelIdRequestDtoV2 } from '@/domains/admin/feedback/dtos/requests/find-feedbacks-by-channel-id-request-v2.dto';
|
||||||
|
import type { FindFeedbacksByChannelIdResponseDto } from '@/domains/admin/feedback/dtos/responses';
|
||||||
|
import { FeedbackService } from '@/domains/admin/feedback/feedback.service';
|
||||||
|
import { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { getRandomValue } from '@/test-utils/fixtures';
|
||||||
|
import {
|
||||||
|
clearAllEntities,
|
||||||
|
clearEntities,
|
||||||
|
createChannel,
|
||||||
|
createProject,
|
||||||
|
createTenant,
|
||||||
|
signInTestUser,
|
||||||
|
} from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('FeedbackController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let channelService: ChannelService;
|
||||||
|
let feedbackService: FeedbackService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
|
||||||
|
let tenantRepo: Repository<TenantEntity>;
|
||||||
|
let projectRepo: Repository<ProjectEntity>;
|
||||||
|
let channelRepo: Repository<ChannelEntity>;
|
||||||
|
let fieldRepo: Repository<FieldEntity>;
|
||||||
|
let osService: Client;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
let channel: ChannelEntity;
|
||||||
|
let fields: FieldEntity[];
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
channelService = module.get(ChannelService);
|
||||||
|
feedbackService = module.get(FeedbackService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
|
||||||
|
tenantRepo = module.get(getRepositoryToken(TenantEntity));
|
||||||
|
projectRepo = module.get(getRepositoryToken(ProjectEntity));
|
||||||
|
channelRepo = module.get(getRepositoryToken(ChannelEntity));
|
||||||
|
fieldRepo = module.get(getRepositoryToken(FieldEntity));
|
||||||
|
osService = module.get<Client>('OPENSEARCH_CLIENT');
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
await createTenant(tenantService);
|
||||||
|
project = await createProject(projectService);
|
||||||
|
const { id: channelId } = await createChannel(channelService, project);
|
||||||
|
|
||||||
|
channel = await channelService.findById({ channelId });
|
||||||
|
|
||||||
|
fields = await fieldRepo.find({
|
||||||
|
where: { channel: { id: channel.id } },
|
||||||
|
relations: { options: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId/feedbacks (POST)', () => {
|
||||||
|
it('should create random feedbacks', async () => {
|
||||||
|
const dto: Record<string, string | number | string[] | number[]> = {};
|
||||||
|
fields
|
||||||
|
.filter(
|
||||||
|
({ key }) =>
|
||||||
|
key !== 'id' &&
|
||||||
|
key !== 'issues' &&
|
||||||
|
key !== 'createdAt' &&
|
||||||
|
key !== 'updatedAt',
|
||||||
|
)
|
||||||
|
.forEach(({ key, format, options }) => {
|
||||||
|
dto[key] = getRandomValue(format, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
|
||||||
|
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201)
|
||||||
|
.then(
|
||||||
|
async ({
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
body: Record<string, any> & { issueNames?: string[] };
|
||||||
|
}) => {
|
||||||
|
expect(body.id).toBeDefined();
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
const esResult = await osService.get({
|
||||||
|
id: body.id as string,
|
||||||
|
index: channel.id.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
['id', 'createdAt', 'updatedAt'].forEach(
|
||||||
|
(field) => delete esResult.body._source?.[field],
|
||||||
|
);
|
||||||
|
expect(dto).toMatchObject(esResult.body._source ?? {});
|
||||||
|
} else {
|
||||||
|
const feedback = await feedbackService.findById({
|
||||||
|
channelId: channel.id,
|
||||||
|
feedbackId: body.id as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
|
||||||
|
(field) => delete feedback[field],
|
||||||
|
);
|
||||||
|
expect(dto).toMatchObject(feedback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId/feedbacks/search (POST)', () => {
|
||||||
|
it('should return all searched feedbacks', async () => {
|
||||||
|
const dto: CreateFeedbackDto = {
|
||||||
|
channelId: channel.id,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
let availableFieldKey = '';
|
||||||
|
fields
|
||||||
|
.filter(
|
||||||
|
({ key }) =>
|
||||||
|
key !== 'id' &&
|
||||||
|
key !== 'issues' &&
|
||||||
|
key !== 'createdAt' &&
|
||||||
|
key !== 'updatedAt',
|
||||||
|
)
|
||||||
|
.forEach(({ key, format, options }) => {
|
||||||
|
dto.data[key] = getRandomValue(format, options);
|
||||||
|
availableFieldKey = key;
|
||||||
|
});
|
||||||
|
|
||||||
|
dto.data[availableFieldKey] = 'test';
|
||||||
|
|
||||||
|
await feedbackService.create(dto);
|
||||||
|
|
||||||
|
const keywordField = fields.find(
|
||||||
|
({ format }) => format === FieldFormatEnum.keyword,
|
||||||
|
);
|
||||||
|
if (!keywordField) return;
|
||||||
|
|
||||||
|
const findFeedbackDto: FindFeedbacksByChannelIdRequestDtoV2 = {
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
key: availableFieldKey,
|
||||||
|
value: 'test',
|
||||||
|
condition: QueryV2ConditionsEnum.IS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
operator: 'AND',
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(
|
||||||
|
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/search`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(findFeedbackDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: FindFeedbacksByChannelIdResponseDto }) => {
|
||||||
|
expect(body.meta.itemCount).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/channels/:channelId/feedbacks/:feedbackId (PUT)', () => {
|
||||||
|
it('should update a feedback', async () => {
|
||||||
|
const dto: CreateFeedbackDto = {
|
||||||
|
channelId: channel.id,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
let availableFieldKey = '';
|
||||||
|
fields
|
||||||
|
.filter(
|
||||||
|
({ key }) =>
|
||||||
|
key !== 'id' &&
|
||||||
|
key !== 'issues' &&
|
||||||
|
key !== 'createdAt' &&
|
||||||
|
key !== 'updatedAt',
|
||||||
|
)
|
||||||
|
.forEach(({ key, format, options }) => {
|
||||||
|
dto.data[key] = getRandomValue(format, options);
|
||||||
|
availableFieldKey = key;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedback = await feedbackService.create(dto);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(
|
||||||
|
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/${feedback.id}`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
[availableFieldKey]: 'test',
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
.then(async () => {
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
const esResult = await osService.get({
|
||||||
|
id: feedback.id.toString(),
|
||||||
|
index: channel.id.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
['id', 'createdAt', 'updatedAt'].forEach(
|
||||||
|
(field) => delete esResult.body._source?.[field],
|
||||||
|
);
|
||||||
|
|
||||||
|
dto.data[availableFieldKey] = 'test';
|
||||||
|
expect(dto.data).toMatchObject(esResult.body._source ?? {});
|
||||||
|
} else {
|
||||||
|
const updatedFeedback = await feedbackService.findById({
|
||||||
|
channelId: channel.id,
|
||||||
|
feedbackId: feedback.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
|
||||||
|
(field) => delete updatedFeedback[field],
|
||||||
|
);
|
||||||
|
|
||||||
|
dto.data[availableFieldKey] = 'test';
|
||||||
|
expect(dto.data).toMatchObject(updatedFeedback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a feedback with special character', async () => {
|
||||||
|
const dto: CreateFeedbackDto = {
|
||||||
|
channelId: channel.id,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
let availableFieldKey = '';
|
||||||
|
fields
|
||||||
|
.filter(
|
||||||
|
({ key }) =>
|
||||||
|
key !== 'id' &&
|
||||||
|
key !== 'issues' &&
|
||||||
|
key !== 'createdAt' &&
|
||||||
|
key !== 'updatedAt',
|
||||||
|
)
|
||||||
|
.forEach(({ key, format, options }) => {
|
||||||
|
dto.data[key] = getRandomValue(format, options);
|
||||||
|
availableFieldKey = key;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedback = await feedbackService.create(dto);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(
|
||||||
|
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/${feedback.id}`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
[availableFieldKey]: '?',
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
.then(async () => {
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
const esResult = await osService.get({
|
||||||
|
id: feedback.id.toString(),
|
||||||
|
index: channel.id.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
['id', 'createdAt', 'updatedAt'].forEach(
|
||||||
|
(field) => delete esResult.body._source?.[field],
|
||||||
|
);
|
||||||
|
|
||||||
|
dto.data[availableFieldKey] = '?';
|
||||||
|
expect(dto.data).toMatchObject(esResult.body._source ?? {});
|
||||||
|
} else {
|
||||||
|
const updatedFeedback = await feedbackService.findById({
|
||||||
|
channelId: channel.id,
|
||||||
|
feedbackId: feedback.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
|
||||||
|
(field) => delete updatedFeedback[field],
|
||||||
|
);
|
||||||
|
|
||||||
|
dto.data[availableFieldKey] = '?';
|
||||||
|
expect(dto.data).toMatchObject(updatedFeedback);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('old feedback deletion test', () => {
|
||||||
|
it('should create feedbacks and delete feedbacks within specific date range', async () => {
|
||||||
|
const dto: Record<string, string | number | string[] | number[]> = {};
|
||||||
|
fields
|
||||||
|
.filter(
|
||||||
|
({ key }) =>
|
||||||
|
key !== 'id' &&
|
||||||
|
key !== 'issues' &&
|
||||||
|
key !== 'createdAt' &&
|
||||||
|
key !== 'updatedAt',
|
||||||
|
)
|
||||||
|
.forEach(({ key, format, options }) => {
|
||||||
|
dto[key] = getRandomValue(format, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
dto.createdAt = DateTime.now().minus({ month: 7 }).toFormat('yyyy-MM-dd');
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
|
||||||
|
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
dto.createdAt = DateTime.now().minus({ days: 1 }).toFormat('yyyy-MM-dd');
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
|
||||||
|
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await tenantService.deleteOldFeedbacks();
|
||||||
|
|
||||||
|
const findFeedbackDto: FindFeedbacksByChannelIdRequestDtoV2 = {
|
||||||
|
defaultQueries: [
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
value: {
|
||||||
|
gte: DateTime.now().minus({ years: 1 }).toFormat('yyyy-MM-dd'),
|
||||||
|
lt: DateTime.now().toFormat('yyyy-MM-dd'),
|
||||||
|
},
|
||||||
|
condition: QueryV2ConditionsEnum.BETWEEN,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
operator: 'AND',
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(
|
||||||
|
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/search`,
|
||||||
|
)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(findFeedbackDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: FindFeedbacksByChannelIdResponseDto }) => {
|
||||||
|
expect(body.meta.itemCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await clearEntities([tenantRepo, projectRepo, channelRepo, fieldRepo]);
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { IssueStatusEnum } from '@/common/enums';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import { FindIssuesByProjectIdRequestDto } from '@/domains/admin/project/issue/dtos/requests';
|
||||||
|
import type {
|
||||||
|
FindIssueByIdResponseDto,
|
||||||
|
FindIssuesByProjectIdResponseDto,
|
||||||
|
} from '@/domains/admin/project/issue/dtos/responses';
|
||||||
|
import type { CountIssuesByIdResponseDto } from '@/domains/admin/project/project/dtos/responses';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('IssueController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issues (POST)', () => {
|
||||||
|
it('should create an issue', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/issues`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({ name: 'TestIssue' })
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issues/:issueId (GET)', () => {
|
||||||
|
it('should get an issue', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/issues/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindIssueByIdResponseDto }) => {
|
||||||
|
expect(body.name).toBe('TestIssue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issue-count (GET)', () => {
|
||||||
|
it('should return correct issue count', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/issue-count`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: CountIssuesByIdResponseDto }) => {
|
||||||
|
expect(body.total).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issues/search (POST)', () => {
|
||||||
|
it('should return all searched issues', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/issues`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({ name: 'TestIssue2' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const searchDto = new FindIssuesByProjectIdRequestDto();
|
||||||
|
searchDto.query = {
|
||||||
|
searchText: 'TestIssue',
|
||||||
|
};
|
||||||
|
searchDto.page = 1;
|
||||||
|
searchDto.limit = 10;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/issues/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(searchDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body).toHaveProperty('items');
|
||||||
|
expect(body.items.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issues/:issueId (PUT)', () => {
|
||||||
|
it('should update an issue', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/issues/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
name: 'TestIssue',
|
||||||
|
description: 'TestIssueUpdated',
|
||||||
|
status: IssueStatusEnum.IN_PROGRESS,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/issues/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindIssueByIdResponseDto }) => {
|
||||||
|
expect(body.description).toBe('TestIssueUpdated');
|
||||||
|
expect(body.status).toBe(IssueStatusEnum.IN_PROGRESS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issues/:issueId (DELETE)', () => {
|
||||||
|
it('should delete an issue', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/issues/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const searchDto = new FindIssuesByProjectIdRequestDto();
|
||||||
|
searchDto.query = {
|
||||||
|
searchText: 'TestIssue',
|
||||||
|
};
|
||||||
|
searchDto.page = 1;
|
||||||
|
searchDto.limit = 10;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/issues/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(searchDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body).toHaveProperty('items');
|
||||||
|
expect(body.items.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/issues (DELETE)', () => {
|
||||||
|
it('should delete many issues', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/issues`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({ issueIds: [2] })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const searchDto = new FindIssuesByProjectIdRequestDto();
|
||||||
|
searchDto.query = {
|
||||||
|
searchText: 'TestIssue',
|
||||||
|
};
|
||||||
|
searchDto.page = 1;
|
||||||
|
searchDto.limit = 10;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/issues/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(searchDto)
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
|
||||||
|
expect(body).toBeDefined();
|
||||||
|
expect(body).toHaveProperty('items');
|
||||||
|
expect(body.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 when deleting with invalid issueIds', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/issues`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({ issueIds: [] })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/issues`)
|
||||||
|
.send({ issueIds: [1] })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Issue validation tests', () => {
|
||||||
|
it('should return 400 when updating non-existent issue', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/issues/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
name: 'NonExistentIssue',
|
||||||
|
description: 'This should fail',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 when getting non-existent issue', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/issues/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import {
|
||||||
|
CreateMemberRequestDto,
|
||||||
|
UpdateMemberRequestDto,
|
||||||
|
} from '@/domains/admin/project/member/dtos/requests';
|
||||||
|
import type { GetAllMemberResponseDto } from '@/domains/admin/project/member/dtos/responses';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { PermissionEnum } from '@/domains/admin/project/role/permission.enum';
|
||||||
|
import type { RoleEntity } from '@/domains/admin/project/role/role.entity';
|
||||||
|
import { RoleService } from '@/domains/admin/project/role/role.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
UserStateEnum,
|
||||||
|
UserTypeEnum,
|
||||||
|
} from '@/domains/admin/user/entities/enums';
|
||||||
|
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('MemberController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let roleService: RoleService;
|
||||||
|
|
||||||
|
let configService: ConfigService;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
let role: RoleEntity;
|
||||||
|
let user: UserEntity;
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
roleService = module.get(RoleService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
role = await roleService.create({
|
||||||
|
projectId: project.id,
|
||||||
|
name: 'TestRole',
|
||||||
|
permissions: [
|
||||||
|
PermissionEnum.feedback_download_read,
|
||||||
|
PermissionEnum.feedback_update,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
|
||||||
|
const userRepo = dataSource.getRepository(UserEntity);
|
||||||
|
user = await userRepo.save({
|
||||||
|
email: faker.internet.email(),
|
||||||
|
state: UserStateEnum.Active,
|
||||||
|
hashPassword: faker.internet.password(),
|
||||||
|
type: UserTypeEnum.GENERAL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/members (POST)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||||
|
role.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a member', async () => {
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = user.id;
|
||||||
|
dto.roleId = role.id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for duplicate member', async () => {
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = user.id;
|
||||||
|
dto.roleId = role.id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for non-existent user', async () => {
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = 999;
|
||||||
|
dto.roleId = role.id;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent role', async () => {
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = user.id;
|
||||||
|
dto.roleId = 999;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = user.id;
|
||||||
|
dto.roleId = role.id;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/members/search (POST)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||||
|
role.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find members by project id', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
userId: user.id,
|
||||||
|
roleId: role.id,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
value: user.email,
|
||||||
|
condition: 'LIKE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
operator: 'AND',
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: GetAllMemberResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('id');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('user');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('role');
|
||||||
|
expect(responseBody.items[0].user).toHaveProperty('email');
|
||||||
|
expect(responseBody.items[0].role).toHaveProperty('name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty list when no members match search', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members/search`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
value: 'NonExistentUser',
|
||||||
|
condition: 'LIKE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
operator: 'AND',
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
.then(({ body }: { body: GetAllMemberResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members/search`)
|
||||||
|
.send({
|
||||||
|
queries: [],
|
||||||
|
operator: 'AND',
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/members/:memberId (GET)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||||
|
role.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent member', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/members/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/members/:memberId (PUT)', () => {
|
||||||
|
let memberId: number;
|
||||||
|
let newRole: RoleEntity;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
newRole = await roleService.create({
|
||||||
|
projectId: project.id,
|
||||||
|
name: `NewTestRole_${Date.now()}`,
|
||||||
|
permissions: [PermissionEnum.feedback_download_read],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = user.id;
|
||||||
|
dto.roleId = role.id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const allMembers: { id: number }[] = await dataSource.query(
|
||||||
|
'SELECT id FROM members ORDER BY id DESC LIMIT 1',
|
||||||
|
);
|
||||||
|
memberId = allMembers.length > 0 ? allMembers[0].id : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await dataSource.query(
|
||||||
|
'DELETE FROM members WHERE role_id = ? OR role_id = ?',
|
||||||
|
[role.id, newRole.id],
|
||||||
|
);
|
||||||
|
await dataSource.query('DELETE FROM roles WHERE id = ?', [newRole.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update member role', async () => {
|
||||||
|
const dto = new UpdateMemberRequestDto();
|
||||||
|
dto.roleId = newRole.id;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/members/${memberId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent role', async () => {
|
||||||
|
const dto = new UpdateMemberRequestDto();
|
||||||
|
dto.roleId = 999;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/members/${memberId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for non-existent member', async () => {
|
||||||
|
const dto = new UpdateMemberRequestDto();
|
||||||
|
dto.roleId = newRole.id;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/members/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new UpdateMemberRequestDto();
|
||||||
|
dto.roleId = newRole.id;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/members/${memberId}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/members/:memberId (DELETE)', () => {
|
||||||
|
let memberId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateMemberRequestDto();
|
||||||
|
dto.userId = user.id;
|
||||||
|
dto.roleId = role.id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/members`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const allMembers: { id: number }[] = await dataSource.query(
|
||||||
|
'SELECT id FROM members ORDER BY id DESC LIMIT 1',
|
||||||
|
);
|
||||||
|
memberId = allMembers.length > 0 ? allMembers[0].id : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||||
|
role.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete member', async () => {
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/members/${memberId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 200 when deleting non-existent member', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/members/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/members/${memberId}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import { ChannelService } from '@/domains/admin/channel/channel/channel.service';
|
||||||
|
import { FieldEntity } from '@/domains/admin/channel/field/field.entity';
|
||||||
|
import { FeedbackService } from '@/domains/admin/feedback/feedback.service';
|
||||||
|
import {
|
||||||
|
CreateProjectRequestDto,
|
||||||
|
FindProjectsRequestDto,
|
||||||
|
UpdateProjectRequestDto,
|
||||||
|
} from '@/domains/admin/project/project/dtos/requests';
|
||||||
|
import type {
|
||||||
|
CountFeedbacksByIdResponseDto,
|
||||||
|
FindProjectByIdResponseDto,
|
||||||
|
FindProjectsResponseDto,
|
||||||
|
} from '@/domains/admin/project/project/dtos/responses';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import {
|
||||||
|
clearAllEntities,
|
||||||
|
createChannel,
|
||||||
|
createFeedback,
|
||||||
|
signInTestUser,
|
||||||
|
} from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('ProjectController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let channelService: ChannelService;
|
||||||
|
let feedbackService: FeedbackService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
|
||||||
|
let fieldRepo: Repository<FieldEntity>;
|
||||||
|
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
channelService = module.get(ChannelService);
|
||||||
|
feedbackService = module.get(FeedbackService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
fieldRepo = module.get(getRepositoryToken(FieldEntity));
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects (POST)', () => {
|
||||||
|
it('should create a project', async () => {
|
||||||
|
const dto = new CreateProjectRequestDto();
|
||||||
|
dto.name = 'TestProject';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects (GET)', () => {
|
||||||
|
it('should find projects', async () => {
|
||||||
|
const dto = new FindProjectsRequestDto();
|
||||||
|
dto.limit = 10;
|
||||||
|
dto.page = 1;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query(dto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindProjectsResponseDto }) => {
|
||||||
|
expect(body.items.length).toEqual(1);
|
||||||
|
expect(body.items[0].name).toEqual('TestProject');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId (GET)', () => {
|
||||||
|
it('should find a project by id', async () => {
|
||||||
|
const dto = new FindProjectsRequestDto();
|
||||||
|
dto.limit = 10;
|
||||||
|
dto.page = 1;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query(dto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindProjectByIdResponseDto }) => {
|
||||||
|
expect(body.name).toEqual('TestProject');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/feedback-count (GET)', () => {
|
||||||
|
it('should count feedbacks by project id', async () => {
|
||||||
|
const project = await projectService.findById({ projectId: 1 });
|
||||||
|
const channel = await createChannel(channelService, project);
|
||||||
|
|
||||||
|
const fields = await fieldRepo.find({
|
||||||
|
where: { channel: { id: channel.id } },
|
||||||
|
relations: { options: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createFeedback(fields, channel.id, feedbackService);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/1/feedback-count`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: CountFeedbacksByIdResponseDto }) => {
|
||||||
|
expect(body.total).toEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId (PUT)', () => {
|
||||||
|
it('should update a project', async () => {
|
||||||
|
const dto = new UpdateProjectRequestDto();
|
||||||
|
dto.name = 'UpdatedTestProject';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const findDto = new FindProjectsRequestDto();
|
||||||
|
findDto.limit = 10;
|
||||||
|
findDto.page = 1;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query(findDto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindProjectsResponseDto }) => {
|
||||||
|
expect(body.items.length).toEqual(1);
|
||||||
|
expect(body.items[0].name).toEqual('UpdatedTestProject');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId (DELETE)', () => {
|
||||||
|
it('should delete a project', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/1`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const findDto = new FindProjectsRequestDto();
|
||||||
|
findDto.limit = 10;
|
||||||
|
findDto.page = 1;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query(findDto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: FindProjectsResponseDto }) => {
|
||||||
|
expect(body.items.length).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import {
|
||||||
|
CreateRoleRequestDto,
|
||||||
|
UpdateRoleRequestDto,
|
||||||
|
} from '@/domains/admin/project/role/dtos/requests';
|
||||||
|
import type { GetAllRolesResponseDto } from '@/domains/admin/project/role/dtos/responses';
|
||||||
|
import type { GetAllRolesResponseRoleDto } from '@/domains/admin/project/role/dtos/responses/get-all-roles-response.dto';
|
||||||
|
import { PermissionEnum } from '@/domains/admin/project/role/permission.enum';
|
||||||
|
import { RoleService } from '@/domains/admin/project/role/role.service';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('RoleController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let _roleService: RoleService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
_roleService = module.get(RoleService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/roles (POST)', () => {
|
||||||
|
it('should create a role', async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.name = 'TestRole';
|
||||||
|
dto.permissions = [
|
||||||
|
PermissionEnum.feedback_download_read,
|
||||||
|
PermissionEnum.feedback_update,
|
||||||
|
];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const listResponse = await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query({
|
||||||
|
searchText: 'TestRole',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const roles = (listResponse.body as GetAllRolesResponseDto).roles;
|
||||||
|
expect(roles.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const createdRole = roles.find(
|
||||||
|
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRole',
|
||||||
|
);
|
||||||
|
expect(createdRole).toBeDefined();
|
||||||
|
expect(createdRole?.name).toBe('TestRole');
|
||||||
|
expect(createdRole?.permissions).toEqual([
|
||||||
|
'feedback_download_read',
|
||||||
|
'feedback_update',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for empty role name', async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid permissions', async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.name = 'TestRole';
|
||||||
|
dto.permissions = [];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.name = 'TestRole';
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/roles (GET)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.name = 'TestRoleForList';
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find roles by project id', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query({
|
||||||
|
searchText: 'TestRole',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: GetAllRolesResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.roles.length).toBeGreaterThan(0);
|
||||||
|
expect(responseBody.roles[0]).toHaveProperty('id');
|
||||||
|
expect(responseBody.roles[0]).toHaveProperty('name');
|
||||||
|
expect(responseBody.roles[0]).toHaveProperty('permissions');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.query({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/roles/:roleId (PUT)', () => {
|
||||||
|
let roleId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.name = 'TestRoleForUpdate';
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const roles = (response.body as GetAllRolesResponseDto).roles;
|
||||||
|
const createdRole = roles.find(
|
||||||
|
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForUpdate',
|
||||||
|
);
|
||||||
|
if (!createdRole) {
|
||||||
|
throw new Error('TestRoleForUpdate not found');
|
||||||
|
}
|
||||||
|
roleId = createdRole.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update role', async () => {
|
||||||
|
const dto = new UpdateRoleRequestDto();
|
||||||
|
dto.name = 'UpdatedTestRole';
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: GetAllRolesResponseDto }) => {
|
||||||
|
const roles = body.roles;
|
||||||
|
const updatedRole = roles.find(
|
||||||
|
(role: GetAllRolesResponseRoleDto) =>
|
||||||
|
role.name === 'UpdatedTestRole',
|
||||||
|
);
|
||||||
|
if (!updatedRole) {
|
||||||
|
throw new Error('UpdatedTestRole not found');
|
||||||
|
}
|
||||||
|
expect(updatedRole.name).toBe('UpdatedTestRole');
|
||||||
|
expect(updatedRole.permissions).toEqual(['feedback_download_read']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for empty role name', async () => {
|
||||||
|
const dto = new UpdateRoleRequestDto();
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new UpdateRoleRequestDto();
|
||||||
|
dto.name = 'UpdatedRole';
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/roles/:roleId (DELETE)', () => {
|
||||||
|
let roleId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const dto = new CreateRoleRequestDto();
|
||||||
|
dto.name = 'TestRoleForDelete';
|
||||||
|
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const roles = (response.body as GetAllRolesResponseDto).roles;
|
||||||
|
const createdRole = roles.find(
|
||||||
|
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForDelete',
|
||||||
|
);
|
||||||
|
if (!createdRole) {
|
||||||
|
throw new Error('TestRoleForDelete not found');
|
||||||
|
}
|
||||||
|
roleId = createdRole.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete role', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const roles = (response.body as GetAllRolesResponseDto).roles;
|
||||||
|
const deletedRole = roles.find(
|
||||||
|
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForDelete',
|
||||||
|
);
|
||||||
|
expect(deletedRole).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import {
|
||||||
|
SetupTenantRequestDto,
|
||||||
|
UpdateTenantRequestDto,
|
||||||
|
} from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import type { GetTenantResponseDto } from '@/domains/admin/tenant/dtos/responses';
|
||||||
|
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
|
||||||
|
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
|
||||||
|
import {
|
||||||
|
clearAllEntities,
|
||||||
|
clearEntities,
|
||||||
|
signInTestUser,
|
||||||
|
} from '@/test-utils/util-functions';
|
||||||
|
import { HttpStatusCode } from '@/types/http-status';
|
||||||
|
|
||||||
|
describe('TenantController (integration)', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let tenantRepo: Repository<TenantEntity>;
|
||||||
|
let userRepo: Repository<UserEntity>;
|
||||||
|
|
||||||
|
let authService: AuthService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
tenantRepo = dataSource.getRepository(TenantEntity);
|
||||||
|
userRepo = dataSource.getRepository(UserEntity);
|
||||||
|
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearAllEntities(module);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/tenants (POST)', () => {
|
||||||
|
it('should create a tenant', async () => {
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
|
||||||
|
return await request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/tenants')
|
||||||
|
.send(dto)
|
||||||
|
.expect(201)
|
||||||
|
.then(async () => {
|
||||||
|
const tenants = await tenantRepo.find();
|
||||||
|
expect(tenants).toHaveLength(1);
|
||||||
|
const [tenant] = tenants;
|
||||||
|
for (const key in dto) {
|
||||||
|
if (['email', 'password'].includes(key)) continue;
|
||||||
|
const value = dto[key] as string;
|
||||||
|
expect(tenant[key]).toEqual(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should return bad request since tenant is already exists', async () => {
|
||||||
|
await tenantRepo.save({
|
||||||
|
siteName: faker.string.sample(),
|
||||||
|
allowDomains: [],
|
||||||
|
});
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/tenants')
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await clearEntities([tenantRepo]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/tenants (PUT)', () => {
|
||||||
|
let tenant: TenantEntity;
|
||||||
|
let accessToken: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
tenant = await tenantRepo.save({
|
||||||
|
siteName: faker.string.sample(),
|
||||||
|
allowDomains: [],
|
||||||
|
});
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
it('should update a tenant', async () => {
|
||||||
|
const dto = new UpdateTenantRequestDto();
|
||||||
|
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.allowDomains = [];
|
||||||
|
|
||||||
|
return await request(app.getHttpServer() as Server)
|
||||||
|
.put('/admin/tenants')
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(204)
|
||||||
|
.then(async () => {
|
||||||
|
const updatedTenant = await tenantRepo.findOne({
|
||||||
|
where: { id: tenant.id },
|
||||||
|
});
|
||||||
|
expect(updatedTenant?.siteName).toEqual(dto.siteName);
|
||||||
|
expect(updatedTenant?.allowDomains).toEqual(dto.allowDomains);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should fail to find a tenant', async () => {
|
||||||
|
await clearEntities([tenantRepo]);
|
||||||
|
|
||||||
|
const dto = new UpdateTenantRequestDto();
|
||||||
|
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.allowDomains = [];
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put('/admin/tenants')
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
it('should reject the request when unauthorized', async () => {
|
||||||
|
const dto = new UpdateTenantRequestDto();
|
||||||
|
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.allowDomains = [];
|
||||||
|
|
||||||
|
return await request(app.getHttpServer() as Server)
|
||||||
|
.put('/admin/tenants')
|
||||||
|
.send(dto)
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/tenants (GET)', () => {
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearEntities([tenantRepo, userRepo]);
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post('/admin/tenants')
|
||||||
|
.send(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find a tenant', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get('/admin/tenants')
|
||||||
|
.expect(200)
|
||||||
|
.expect(({ body }) => {
|
||||||
|
expect(dto.siteName).toEqual((body as GetTenantResponseDto).siteName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import { RoleEntity } from '@/domains/admin/project/role/role.entity';
|
||||||
|
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import type { UserDto } from '@/domains/admin/user/dtos';
|
||||||
|
import type { GetAllUserResponseDto } from '@/domains/admin/user/dtos/responses/get-all-user-response.dto';
|
||||||
|
import { UserStateEnum } from '@/domains/admin/user/entities/enums';
|
||||||
|
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
|
||||||
|
import {
|
||||||
|
clearEntities,
|
||||||
|
createTenant,
|
||||||
|
getRandomEnumValue,
|
||||||
|
signInTestUser,
|
||||||
|
} from '@/test-utils/util-functions';
|
||||||
|
import { HttpStatusCode } from '@/types/http-status';
|
||||||
|
|
||||||
|
describe('UserController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let userRepo: Repository<UserEntity>;
|
||||||
|
let roleRepo: Repository<RoleEntity>;
|
||||||
|
let tenantRepo: Repository<TenantEntity>;
|
||||||
|
|
||||||
|
let tenantService: TenantService;
|
||||||
|
|
||||||
|
let authService: AuthService;
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({ transform: true, whitelist: true }),
|
||||||
|
);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
userRepo = dataSource.getRepository(UserEntity);
|
||||||
|
roleRepo = dataSource.getRepository(RoleEntity);
|
||||||
|
tenantRepo = dataSource.getRepository(TenantEntity);
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
|
||||||
|
await clearEntities([tenantRepo, userRepo, roleRepo]);
|
||||||
|
|
||||||
|
await createTenant(tenantService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dataSource.destroy();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
let total: number;
|
||||||
|
let userEntities: UserEntity[];
|
||||||
|
let accessToken: string;
|
||||||
|
let ownerUser: UserEntity;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearEntities([userRepo, roleRepo]);
|
||||||
|
|
||||||
|
const length = faker.number.int({ min: 3, max: 8 });
|
||||||
|
|
||||||
|
userEntities = (
|
||||||
|
await userRepo.save(
|
||||||
|
Array.from({ length: length }).map(() => ({
|
||||||
|
email: faker.internet.email(),
|
||||||
|
state: getRandomEnumValue(UserStateEnum),
|
||||||
|
hashPassword: faker.internet.password(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
).sort((a, b) =>
|
||||||
|
DateTime.fromJSDate(b.createdAt)
|
||||||
|
.diff(DateTime.fromJSDate(a.createdAt))
|
||||||
|
.as('milliseconds'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { jwt, user } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
ownerUser = user;
|
||||||
|
|
||||||
|
total = length + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/users (GET)', () => {
|
||||||
|
it('should return all users', async () => {
|
||||||
|
const expectUsers = userEntities
|
||||||
|
.concat(ownerUser)
|
||||||
|
.sort((a, b) =>
|
||||||
|
DateTime.fromJSDate(a.createdAt)
|
||||||
|
.diff(DateTime.fromJSDate(b.createdAt))
|
||||||
|
.as('milliseconds'),
|
||||||
|
)
|
||||||
|
.map(({ id, email }) => ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
}))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get('/admin/users')
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(HttpStatusCode.OK)
|
||||||
|
.expect(({ body }) => {
|
||||||
|
expect(body).toHaveProperty('items');
|
||||||
|
expect(body).toHaveProperty('meta');
|
||||||
|
|
||||||
|
const { items, meta } = body as GetAllUserResponseDto;
|
||||||
|
[
|
||||||
|
'name',
|
||||||
|
'department',
|
||||||
|
'type',
|
||||||
|
'members',
|
||||||
|
'createdAt',
|
||||||
|
'signUpMethod',
|
||||||
|
].forEach((field) => items.forEach((item) => delete item[field]));
|
||||||
|
expect(items).toEqual(expectUsers);
|
||||||
|
expect(meta.totalItems).toEqual(total);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unauthorized status code', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get('/admin/users')
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/users (DELETE)', () => {
|
||||||
|
it('should return empty result', async () => {
|
||||||
|
const ids = faker.helpers.arrayElements(userEntities).map((v) => v.id);
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/users`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send({ ids })
|
||||||
|
.expect(HttpStatusCode.OK)
|
||||||
|
.then(async () => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const result = await userRepo.findOneBy({ id });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unauthorized status code', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/users`)
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/users/:id (GET)', () => {
|
||||||
|
it('check signed-in user', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/users/${ownerUser.id}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200)
|
||||||
|
.expect(({ body }) => {
|
||||||
|
expect((body as UserDto).id).toEqual(ownerUser.id);
|
||||||
|
expect((body as UserDto).email).toEqual(ownerUser.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should return unauthorized status code', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/users/${ownerUser.id}`)
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/users/:id (DELETE)', () => {
|
||||||
|
it('should return empty result', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/users/${ownerUser.id}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(HttpStatusCode.OK)
|
||||||
|
.then(async () => {
|
||||||
|
const result = await userRepo.findOneBy({ id: ownerUser.id });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should return unauthorized status code', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/users/${faker.number.int()}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
it('should return unauthorized status code', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/users/${ownerUser.id}`)
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/users/:id/roles (GET)', () => {
|
||||||
|
it('should return OK', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/users/${ownerUser.id}/roles`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(HttpStatusCode.OK);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/users/:id/roles (PUT)', () => {
|
||||||
|
it('should return unauthorized status code', async () => {
|
||||||
|
const role = await roleRepo.save({
|
||||||
|
name: faker.string.sample(),
|
||||||
|
permissions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/users/${ownerUser.id}`)
|
||||||
|
.send({ roleId: role.id })
|
||||||
|
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Server } from 'net';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { TestingModule } from '@nestjs/testing';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import request from 'supertest';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||||
|
|
||||||
|
import { AppModule } from '@/app.module';
|
||||||
|
import {
|
||||||
|
EventStatusEnum,
|
||||||
|
EventTypeEnum,
|
||||||
|
WebhookStatusEnum,
|
||||||
|
} from '@/common/enums';
|
||||||
|
import { OpensearchRepository } from '@/common/repositories';
|
||||||
|
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||||
|
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||||
|
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||||
|
import {
|
||||||
|
CreateWebhookRequestDto,
|
||||||
|
UpdateWebhookRequestDto,
|
||||||
|
} from '@/domains/admin/project/webhook/dtos/requests';
|
||||||
|
import type {
|
||||||
|
GetWebhookByIdResponseDto,
|
||||||
|
GetWebhooksByProjectIdResponseDto,
|
||||||
|
} from '@/domains/admin/project/webhook/dtos/responses';
|
||||||
|
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||||
|
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||||
|
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||||
|
|
||||||
|
describe('WebhookController (integration)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
let dataSource: DataSource;
|
||||||
|
let authService: AuthService;
|
||||||
|
let tenantService: TenantService;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
let configService: ConfigService;
|
||||||
|
let opensearchRepository: OpensearchRepository;
|
||||||
|
|
||||||
|
let project: ProjectEntity;
|
||||||
|
let accessToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
initializeTransactionalContext();
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = module.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
dataSource = module.get(getDataSourceToken());
|
||||||
|
authService = module.get(AuthService);
|
||||||
|
tenantService = module.get(TenantService);
|
||||||
|
projectService = module.get(ProjectService);
|
||||||
|
configService = module.get(ConfigService);
|
||||||
|
opensearchRepository = module.get(OpensearchRepository);
|
||||||
|
|
||||||
|
await clearAllEntities(module);
|
||||||
|
if (configService.get('opensearch.use')) {
|
||||||
|
await opensearchRepository.deleteAllIndexes();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = new SetupTenantRequestDto();
|
||||||
|
dto.siteName = faker.string.sample();
|
||||||
|
dto.password = '12345678';
|
||||||
|
await tenantService.create(dto);
|
||||||
|
|
||||||
|
project = await projectService.create({
|
||||||
|
name: faker.lorem.words(),
|
||||||
|
description: faker.lorem.lines(1),
|
||||||
|
timezone: {
|
||||||
|
countryCode: 'KR',
|
||||||
|
name: 'Asia/Seoul',
|
||||||
|
offset: '+09:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { jwt } = await signInTestUser(dataSource, authService);
|
||||||
|
accessToken = jwt.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/webhooks (POST)', () => {
|
||||||
|
it('should create a webhook', async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhook';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: GetWebhooksByProjectIdResponseDto }) => {
|
||||||
|
expect(body.items[0].name).toBe('TestWebhook');
|
||||||
|
expect(body.items[0].url).toBe('https://example.com/webhook');
|
||||||
|
expect(body.items[0].events).toHaveLength(1);
|
||||||
|
expect(body.items[0].status).toBe(WebhookStatusEnum.ACTIVE);
|
||||||
|
expect(body.items[0].createdAt).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for empty webhook name', async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for invalid URL format', async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhook';
|
||||||
|
dto.url = 'invalid-url';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for empty events array', async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhook';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhook';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/webhooks (GET)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhookForList';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find webhooks by project id', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.query({
|
||||||
|
searchText: 'TestWebhook',
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }: { body: GetWebhooksByProjectIdResponseDto }) => {
|
||||||
|
const responseBody = body;
|
||||||
|
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('id');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('name');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('url');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('events');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('status');
|
||||||
|
expect(responseBody.items[0]).toHaveProperty('createdAt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.query({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/webhooks/:webhookId (GET)', () => {
|
||||||
|
let webhookId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhookForGet';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
webhookId = (response.body as { id: number }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find webhook by id', async () => {
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const body = response.body as GetWebhookByIdResponseDto[];
|
||||||
|
expect(response.body).toBeDefined();
|
||||||
|
expect(body[0].id).toBe(webhookId);
|
||||||
|
expect(body[0].name).toBe('TestWebhookForGet');
|
||||||
|
expect(body[0].url).toBe('https://example.com/webhook');
|
||||||
|
expect(body[0].events).toHaveLength(1);
|
||||||
|
expect(body[0].status).toBe(WebhookStatusEnum.ACTIVE);
|
||||||
|
expect(body[0].createdAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/webhooks/:webhookId (PUT)', () => {
|
||||||
|
let webhookId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhookForUpdate';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
webhookId = (response.body as { id: number }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update webhook', async () => {
|
||||||
|
const dto = new UpdateWebhookRequestDto();
|
||||||
|
dto.name = 'UpdatedTestWebhook';
|
||||||
|
dto.url = 'https://updated-example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
dto.token = null;
|
||||||
|
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toBeDefined();
|
||||||
|
const body = response.body as GetWebhookByIdResponseDto[];
|
||||||
|
expect(body[0].name).toBe('UpdatedTestWebhook');
|
||||||
|
expect(body[0].url).toBe('https://updated-example.com/webhook');
|
||||||
|
expect(body[0].events).toHaveLength(1);
|
||||||
|
expect(body[0].events[0].type).toBe(EventTypeEnum.FEEDBACK_CREATION);
|
||||||
|
expect(body[0].status).toBe(WebhookStatusEnum.ACTIVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update webhook with empty name', async () => {
|
||||||
|
const dto = new UpdateWebhookRequestDto();
|
||||||
|
dto.name = '';
|
||||||
|
dto.url = 'https://updated-example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
dto.token = null;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent webhook', async () => {
|
||||||
|
const dto = new UpdateWebhookRequestDto();
|
||||||
|
dto.name = 'UpdatedWebhook';
|
||||||
|
dto.url = 'https://updated-example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
dto.token = null;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/webhooks/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
const dto = new UpdateWebhookRequestDto();
|
||||||
|
dto.name = 'UpdatedWebhook';
|
||||||
|
dto.url = 'https://updated-example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.put(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.send(dto)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/admin/projects/:projectId/webhooks/:webhookId (DELETE)', () => {
|
||||||
|
let webhookId: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dto = new CreateWebhookRequestDto();
|
||||||
|
dto.name = 'TestWebhookForDelete';
|
||||||
|
dto.url = 'https://example.com/webhook';
|
||||||
|
dto.events = [
|
||||||
|
{
|
||||||
|
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||||
|
status: EventStatusEnum.ACTIVE,
|
||||||
|
channelIds: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dto.status = WebhookStatusEnum.ACTIVE;
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer() as Server)
|
||||||
|
.post(`/admin/projects/${project.id}/webhooks`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.send(dto);
|
||||||
|
|
||||||
|
webhookId = (response.body as { id: number }).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete webhook', async () => {
|
||||||
|
await request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when deleting non-existent webhook', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/webhooks/999`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when unauthorized', async () => {
|
||||||
|
return request(app.getHttpServer() as Server)
|
||||||
|
.delete(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
await delay(500);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
displayName: 'api',
|
||||||
|
rootDir: './src',
|
||||||
|
testRegex: '.*\\.spec\\.ts$',
|
||||||
|
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||||
|
testEnvironment: 'node',
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': ['<rootDir>/$1'],
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(t|j)s$': ['@swc-node/jest'],
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: ['node_modules/(?!@faker-js|uuid)'],
|
||||||
|
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||||
|
coverageDirectory: '../coverage',
|
||||||
|
clearMocks: true,
|
||||||
|
resetMocks: true,
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/../jest.setup.js'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
jest.mock('typeorm-transactional', () => ({
|
||||||
|
Transactional: () => () => ({}),
|
||||||
|
initializeTransactionalContext: () => {},
|
||||||
|
addTransactionalDataSource: (res) => res,
|
||||||
|
}));
|
||||||
|
jest.mock('nestjs-typeorm-paginate', () => ({
|
||||||
|
paginate: (_, option) => {
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
itemCount: 1,
|
||||||
|
totalItems: (option.page - 1) * option.limit + 1,
|
||||||
|
pageCount: option.page,
|
||||||
|
currentPage: option.page,
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"builder": "swc",
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": ["**/*.hbs"],
|
||||||
|
"watchAssets": true,
|
||||||
|
"tsConfigPath": "tsconfig.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"clean": "git clean -xdf dist .turbo node_modules .cache",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||||
|
"format:fix": "prettier --write --list-different \"./src/**/*.{js,cjs,mjs,ts,tsx,md,json}\"",
|
||||||
|
"lint": "eslint",
|
||||||
|
"migration:generate": "npm run typeorm -- migration:generate src/configs/modules/typeorm-config/migrations/$npm_config_name",
|
||||||
|
"migration:revert": "npm run typeorm -- migration:revert",
|
||||||
|
"migration:run": "npm run typeorm -- migration:run",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"test": "jest --detectOpenHandles --forceExit",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand --detectOpenHandles",
|
||||||
|
"test:integration": "jest --config ./integration-test/jest-integration.json --runInBand --detectOpenHandles",
|
||||||
|
"test:watch": "jest --watch --detectOpenHandles",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"typeorm": "ts-node --project ./tsconfig.json -r tsconfig-paths/register ../../node_modules/typeorm/cli -d src/configs/modules/typeorm-config/typeorm-config.datasource.ts"
|
||||||
|
},
|
||||||
|
"prettier": "@ufb/prettier-config",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1015.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@nestjs/common": "^11.1.17",
|
||||||
|
"@nestjs/config": "^4.0.3",
|
||||||
|
"@nestjs/core": "^11.1.17",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.1.17",
|
||||||
|
"@nestjs/platform-fastify": "^11.1.17",
|
||||||
|
"@nestjs/schedule": "^6.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
|
"@nestjs/terminus": "^11.1.1",
|
||||||
|
"@nestjs/typeorm": "^11.0.0",
|
||||||
|
"@opensearch-project/opensearch": "^3.5.1",
|
||||||
|
"@opentelemetry/exporter-logs-otlp-http": "^0.213.0",
|
||||||
|
"@opentelemetry/resources": "^2.6.0",
|
||||||
|
"@opentelemetry/sdk-logs": "^0.213.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@ufb/shared": "workspace:*",
|
||||||
|
"@willsoto/nestjs-prometheus": "^6.0.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"cron": "^4.3.3",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"fast-csv": "^5.0.5",
|
||||||
|
"fastify": "^5.8.4",
|
||||||
|
"joi": "^18.1.1",
|
||||||
|
"luxon": "^3.7.2",
|
||||||
|
"magic-bytes.js": "^1.13.0",
|
||||||
|
"mysql2": "^3.20.0",
|
||||||
|
"nestjs-cls": "^6.2.0",
|
||||||
|
"nestjs-pino": "^4.6.1",
|
||||||
|
"nestjs-typeorm-paginate": "^4.1.0",
|
||||||
|
"nodemailer": "^8.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-custom": "^1.1.1",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"pino-http": "^11.0.0",
|
||||||
|
"pino-opentelemetry-transport": "^3.0.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"typeorm": "^0.3.28",
|
||||||
|
"typeorm-naming-strategies": "^4.1.0",
|
||||||
|
"typeorm-transactional": "^0.5.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^10.4.0",
|
||||||
|
"@nestjs/cli": "^11.0.16",
|
||||||
|
"@nestjs/schematics": "^11.0.9",
|
||||||
|
"@nestjs/testing": "^11.1.17",
|
||||||
|
"@swc-node/jest": "^1.9.1",
|
||||||
|
"@swc/cli": "0.8.0",
|
||||||
|
"@swc/core": "1.13.5",
|
||||||
|
"@swc/helpers": "^0.5.19",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/luxon": "^3.7.1",
|
||||||
|
"@types/node": "24.12.0",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
|
"@types/passport-jwt": "*",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"@typescript-eslint/parser": "^8.46.0",
|
||||||
|
"@ufb/eslint-config": "workspace:*",
|
||||||
|
"@ufb/prettier-config": "workspace:*",
|
||||||
|
"@ufb/tsconfig": "workspace:*",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"jest": "^30.3.0",
|
||||||
|
"mockdate": "^3.0.5",
|
||||||
|
"prettier": "catalog:",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
|
"ts-loader": "^9.5.4",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ClsModule } from 'nestjs-cls';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
import { appConfig, appConfigSchema } from './configs/app.config';
|
||||||
|
import { jwtConfig, jwtConfigSchema } from './configs/jwt.config';
|
||||||
|
import {
|
||||||
|
MailerConfigModule,
|
||||||
|
OpensearchConfigModule,
|
||||||
|
TypeOrmConfigModule,
|
||||||
|
} from './configs/modules';
|
||||||
|
import { mysqlConfig, mysqlConfigSchema } from './configs/mysql.config';
|
||||||
|
import {
|
||||||
|
opensearchConfig,
|
||||||
|
opensearchConfigSchema,
|
||||||
|
} from './configs/opensearch.config';
|
||||||
|
import { createOtelLogTransport } from './configs/otel-log.config';
|
||||||
|
import { smtpConfig, smtpConfigSchema } from './configs/smtp.config';
|
||||||
|
import { AuthModule } from './domains/admin/auth/auth.module';
|
||||||
|
import { ChannelModule } from './domains/admin/channel/channel/channel.module';
|
||||||
|
import { FieldModule } from './domains/admin/channel/field/field.module';
|
||||||
|
import { OptionModule } from './domains/admin/channel/option/option.module';
|
||||||
|
import { FeedbackModule } from './domains/admin/feedback/feedback.module';
|
||||||
|
import { HistoryModule } from './domains/admin/history/history.module';
|
||||||
|
import { AIModule } from './domains/admin/project/ai/ai.module';
|
||||||
|
import { ApiKeyModule } from './domains/admin/project/api-key/api-key.module';
|
||||||
|
import { CategoryModule } from './domains/admin/project/category/category.module';
|
||||||
|
import { IssueTrackerModule } from './domains/admin/project/issue-tracker/issue-tracker.module';
|
||||||
|
import { IssueModule } from './domains/admin/project/issue/issue.module';
|
||||||
|
import { MemberModule } from './domains/admin/project/member/member.module';
|
||||||
|
import { ProjectModule } from './domains/admin/project/project/project.module';
|
||||||
|
import { RoleModule } from './domains/admin/project/role/role.module';
|
||||||
|
import { WebhookModule } from './domains/admin/project/webhook/webhook.module';
|
||||||
|
import { FeedbackIssueStatisticsModule } from './domains/admin/statistics/feedback-issue/feedback-issue-statistics.module';
|
||||||
|
import { FeedbackStatisticsModule } from './domains/admin/statistics/feedback/feedback-statistics.module';
|
||||||
|
import { IssueStatisticsModule } from './domains/admin/statistics/issue/issue-statistics.module';
|
||||||
|
import { TenantModule } from './domains/admin/tenant/tenant.module';
|
||||||
|
import { UserModule } from './domains/admin/user/user.module';
|
||||||
|
import { APIModule } from './domains/api/api.module';
|
||||||
|
import { HealthModule } from './domains/operation/health/health.module';
|
||||||
|
import { MigrationModule } from './domains/operation/migration/migration.module';
|
||||||
|
import { SchedulerLockModule } from './domains/operation/scheduler-lock/scheduler-lock.module';
|
||||||
|
|
||||||
|
export const domainModules = [
|
||||||
|
AuthModule,
|
||||||
|
ChannelModule,
|
||||||
|
FieldModule,
|
||||||
|
OptionModule,
|
||||||
|
FeedbackModule,
|
||||||
|
CategoryModule,
|
||||||
|
HealthModule,
|
||||||
|
MigrationModule,
|
||||||
|
ApiKeyModule,
|
||||||
|
IssueTrackerModule,
|
||||||
|
IssueModule,
|
||||||
|
ProjectModule,
|
||||||
|
RoleModule,
|
||||||
|
TenantModule,
|
||||||
|
UserModule,
|
||||||
|
MemberModule,
|
||||||
|
HistoryModule,
|
||||||
|
WebhookModule,
|
||||||
|
FeedbackStatisticsModule,
|
||||||
|
IssueStatisticsModule,
|
||||||
|
FeedbackIssueStatisticsModule,
|
||||||
|
APIModule,
|
||||||
|
SchedulerLockModule,
|
||||||
|
AIModule,
|
||||||
|
] as (typeof AuthModule)[];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmConfigModule,
|
||||||
|
OpensearchConfigModule,
|
||||||
|
MailerConfigModule,
|
||||||
|
PrometheusModule.register(),
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [appConfig, opensearchConfig, smtpConfig, jwtConfig, mysqlConfig],
|
||||||
|
validationSchema: appConfigSchema
|
||||||
|
.concat(jwtConfigSchema)
|
||||||
|
.concat(mysqlConfigSchema)
|
||||||
|
.concat(smtpConfigSchema)
|
||||||
|
.concat(opensearchConfigSchema),
|
||||||
|
validationOptions: { abortEarly: true },
|
||||||
|
}),
|
||||||
|
LoggerModule.forRootAsync({
|
||||||
|
useFactory: () => {
|
||||||
|
const transport: pino.TransportMultiOptions = {
|
||||||
|
targets: [
|
||||||
|
{ target: 'pino-pretty', options: { singleLine: true } },
|
||||||
|
createOtelLogTransport(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
pinoHttp: {
|
||||||
|
transport,
|
||||||
|
autoLogging: {
|
||||||
|
ignore: (req: Request) => req.originalUrl === '/api/health',
|
||||||
|
},
|
||||||
|
customLogLevel: (req, res, err) => {
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
return 'silent';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode === 401) {
|
||||||
|
return 'silent';
|
||||||
|
}
|
||||||
|
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||||
|
return 'warn';
|
||||||
|
} else if (res.statusCode >= 500) {
|
||||||
|
return 'error';
|
||||||
|
} else if (err != null) {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ClsModule.forRoot({
|
||||||
|
global: true,
|
||||||
|
middleware: { mount: true },
|
||||||
|
}),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
...domainModules,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { Type } from '@nestjs/common';
|
||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PaginationResponseDto } from '../dtos/pagination-response.dto';
|
||||||
|
|
||||||
|
export const ApiOkResponsePagination = <Dto extends Type<unknown>>(dto: Dto) =>
|
||||||
|
applyDecorators(
|
||||||
|
ApiExtraModels(PaginationResponseDto, dto),
|
||||||
|
ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
allOf: [
|
||||||
|
{ $ref: getSchemaPath(PaginationResponseDto) },
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: getSchemaPath(dto) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
import DtoValidator from './dto-validator';
|
||||||
|
|
||||||
|
class Dto {
|
||||||
|
@IsString()
|
||||||
|
str: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestClass {
|
||||||
|
@DtoValidator()
|
||||||
|
noParam() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@DtoValidator()
|
||||||
|
dtoParam(_dto: Dto) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@DtoValidator()
|
||||||
|
dtosParam(_dtos: Dto[]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@DtoValidator()
|
||||||
|
compositionParam(_a: any, _dtos: Dto) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
describe('dto validator', () => {
|
||||||
|
let instance: TestClass;
|
||||||
|
beforeEach(() => {
|
||||||
|
instance = new TestClass();
|
||||||
|
});
|
||||||
|
it('method call with no params', () => {
|
||||||
|
instance.noParam();
|
||||||
|
});
|
||||||
|
it('method call with no params', () => {
|
||||||
|
const dto = new Dto();
|
||||||
|
dto.str = 'test';
|
||||||
|
instance.dtoParam(dto);
|
||||||
|
const dto2 = new Dto();
|
||||||
|
void expect(instance.dtoParam(dto2)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('method call with no params', () => {
|
||||||
|
const dto = new Dto();
|
||||||
|
dto.str = 'test';
|
||||||
|
instance.dtosParam([dto]);
|
||||||
|
const dto2 = new Dto();
|
||||||
|
void expect(instance.dtosParam([dto2])).rejects.toThrow();
|
||||||
|
});
|
||||||
|
it('method call with no params', () => {
|
||||||
|
const dto = new Dto();
|
||||||
|
dto.str = '123';
|
||||||
|
instance.compositionParam([1], dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import type { ValidationError } from 'class-validator';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
|
||||||
|
type Method = (...args: object[]) => any;
|
||||||
|
|
||||||
|
const DtoValidator =
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
target: unknown,
|
||||||
|
propName: string,
|
||||||
|
descriptor: TypedPropertyDescriptor<any>,
|
||||||
|
) => {
|
||||||
|
const methodRef = descriptor.value as Method;
|
||||||
|
|
||||||
|
descriptor.value = async function (...args: object[]): Promise<any> {
|
||||||
|
for (const arg of args) {
|
||||||
|
let errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
if (!Array.isArray(arg) && typeof arg === 'object') {
|
||||||
|
errors = await validate(arg);
|
||||||
|
} else if (
|
||||||
|
Array.isArray(arg) &&
|
||||||
|
arg.length > 0 &&
|
||||||
|
typeof arg[0] === 'object'
|
||||||
|
) {
|
||||||
|
errors = (
|
||||||
|
await Promise.all(
|
||||||
|
arg.map(async (item: object) => await validate(item)),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new InternalServerErrorException(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (await methodRef.call(this, ...args)) as object;
|
||||||
|
};
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
export default DtoValidator;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { default as DtoValidator } from './dto-validator';
|
||||||
|
export { ApiOkResponsePagination } from './api-ok-response-pagination.decorator';
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { ValidationError, Validator } from 'class-validator';
|
||||||
|
|
||||||
|
import { IsPassword } from './is-password';
|
||||||
|
|
||||||
|
class IsPasswordTest {
|
||||||
|
@IsPassword()
|
||||||
|
password: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('IsPassword decorator', () => {
|
||||||
|
it('', () => {
|
||||||
|
const instance = new IsPasswordTest();
|
||||||
|
instance.password = faker.string.sample(
|
||||||
|
faker.number.int({ min: 8, max: 15 }),
|
||||||
|
);
|
||||||
|
const validator = new Validator();
|
||||||
|
void validator.validate(instance).then((errors) => {
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('minLength', () => {
|
||||||
|
const instance = new IsPasswordTest();
|
||||||
|
instance.password = faker.string.sample(
|
||||||
|
faker.number.int({ min: 0, max: 7 }),
|
||||||
|
);
|
||||||
|
const validator = new Validator();
|
||||||
|
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||||
|
expect(errors.length).toEqual(1);
|
||||||
|
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('minLength');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isString', () => {
|
||||||
|
const instance = new IsPasswordTest();
|
||||||
|
instance.password = faker.number.int({ min: 10000000, max: 99999999 });
|
||||||
|
|
||||||
|
const validator = new Validator();
|
||||||
|
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||||
|
expect(errors.length).toEqual(1);
|
||||||
|
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isString, minLength', () => {
|
||||||
|
const instance = new IsPasswordTest();
|
||||||
|
instance.password = faker.number.int({ min: 0, max: 9999999 });
|
||||||
|
const validator = new Validator();
|
||||||
|
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||||
|
expect(errors.length).toEqual(1);
|
||||||
|
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||||
|
expect(Object.keys(errors[0].constraints ?? {})[1]).toEqual('minLength');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export function IsPassword() {
|
||||||
|
return applyDecorators(IsString(), MinLength(8));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { PaginationDto } from './pagination.dto';
|
||||||
|
export { PaginationRequestDto } from './pagination-request.dto';
|
||||||
|
export { PaginationResponseDto } from './pagination-response.dto';
|
||||||
|
export { TimeRange } from './time-range.dto';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
|
import { toNumber } from '@/common/helper/cast.helper';
|
||||||
|
|
||||||
|
export class PaginationRequestDto {
|
||||||
|
@Transform(({ value }: { value: string }) =>
|
||||||
|
toNumber(value, { default: 10, min: 1 }),
|
||||||
|
)
|
||||||
|
@ApiProperty({ required: false, minimum: 1, default: 10, example: 10 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
@Transform(({ value }: { value: string }) =>
|
||||||
|
toNumber(value, { default: 1, min: 1 }),
|
||||||
|
)
|
||||||
|
@ApiProperty({ required: false, minimum: 1, default: 1, example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
constructor(limit = 10, page = 1) {
|
||||||
|
this.limit = limit;
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Expose, Type } from 'class-transformer';
|
||||||
|
import type { IPaginationMeta, Pagination } from 'nestjs-typeorm-paginate';
|
||||||
|
|
||||||
|
class PaginationMetaDto implements IPaginationMeta {
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
@Expose()
|
||||||
|
itemCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 100 })
|
||||||
|
@Expose()
|
||||||
|
totalItems?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
@Expose()
|
||||||
|
itemsPerPage: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 10 })
|
||||||
|
@Expose()
|
||||||
|
totalPages?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 1 })
|
||||||
|
@Expose()
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class PaginationResponseDto<T> implements Pagination<T> {
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
@Type(() => PaginationMetaDto)
|
||||||
|
meta: PaginationMetaDto;
|
||||||
|
|
||||||
|
abstract items: T[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export class PaginationDto {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class TimeRange {
|
||||||
|
@ApiProperty({ name: 'gte (UTC)' })
|
||||||
|
gte: string;
|
||||||
|
@ApiProperty({ name: 'lt (UTC)' })
|
||||||
|
lt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import {
|
||||||
|
BeforeInsert,
|
||||||
|
BeforeUpdate,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export abstract class CommonEntity {
|
||||||
|
@PrimaryGeneratedColumn('increment')
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn()
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
beforeInsertHook() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (!this.createdAt) {
|
||||||
|
this.createdAt = DateTime.utc().toJSDate();
|
||||||
|
}
|
||||||
|
this.updatedAt = DateTime.utc().toJSDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
beforeUpdateHook() {
|
||||||
|
this.updatedAt = DateTime.utc().toJSDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { CommonEntity } from './common.entity';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum AIPromptStatusEnum {
|
||||||
|
success = 'success',
|
||||||
|
error = 'error',
|
||||||
|
loading = 'loading',
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum AIProvidersEnum {
|
||||||
|
OPEN_AI = 'OPEN_AI',
|
||||||
|
GEMINI = 'GEMINI',
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum EventStatusEnum {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
INACTIVE = 'INACTIVE',
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum EventTypeEnum {
|
||||||
|
FEEDBACK_CREATION = 'FEEDBACK_CREATION',
|
||||||
|
ISSUE_CREATION = 'ISSUE_CREATION',
|
||||||
|
ISSUE_STATUS_CHANGE = 'ISSUE_STATUS_CHANGE',
|
||||||
|
ISSUE_ADDITION = 'ISSUE_ADDITION',
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum FieldFormatEnum {
|
||||||
|
text = 'text',
|
||||||
|
keyword = 'keyword',
|
||||||
|
number = 'number',
|
||||||
|
select = 'select',
|
||||||
|
multiSelect = 'multiSelect',
|
||||||
|
date = 'date',
|
||||||
|
images = 'images',
|
||||||
|
aiField = 'aiField',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSelectFieldFormat(type: FieldFormatEnum) {
|
||||||
|
return [FieldFormatEnum.select, FieldFormatEnum.multiSelect].includes(type);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum FieldPropertyEnum {
|
||||||
|
READ_ONLY = 'READ_ONLY',
|
||||||
|
EDITABLE = 'EDITABLE',
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum FieldStatusEnum {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
INACTIVE = 'INACTIVE',
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { FieldFormatEnum, isSelectFieldFormat } from './field-format.enum';
|
||||||
|
export { FieldPropertyEnum } from './field-property.enum';
|
||||||
|
export { FieldStatusEnum } from './field-status.enum';
|
||||||
|
export { IssueStatusEnum } from './issue-status.enum';
|
||||||
|
export { SortMethodEnum } from './sort-method.enum';
|
||||||
|
export { EventTypeEnum } from './event-type.enum';
|
||||||
|
export { EventStatusEnum } from './event-status.enum';
|
||||||
|
export { WebhookStatusEnum } from './webhook-status.enum';
|
||||||
|
export { QueryV2ConditionsEnum } from './query-v2-conditions.enum';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum IssueStatusEnum {
|
||||||
|
INIT = 'INIT',
|
||||||
|
ON_REVIEW = 'ON_REVIEW',
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS',
|
||||||
|
RESOLVED = 'RESOLVED',
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum QueryV2ConditionsEnum {
|
||||||
|
CONTAINS = 'CONTAINS',
|
||||||
|
IS = 'IS',
|
||||||
|
BETWEEN = 'BETWEEN',
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum SortMethodEnum {
|
||||||
|
ASC = 'ASC',
|
||||||
|
DESC = 'DESC',
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export enum WebhookStatusEnum {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
INACTIVE = 'INACTIVE',
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { ArgumentsHost } from '@nestjs/common';
|
||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import { HttpExceptionFilter } from './http-exception.filter';
|
||||||
|
|
||||||
|
describe('HttpExceptionFilter', () => {
|
||||||
|
let filter: HttpExceptionFilter;
|
||||||
|
let mockRequest: FastifyRequest;
|
||||||
|
let mockResponse: FastifyReply;
|
||||||
|
let mockArgumentsHost: ArgumentsHost;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [HttpExceptionFilter],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
filter = module.get<HttpExceptionFilter>(HttpExceptionFilter);
|
||||||
|
|
||||||
|
// Mock FastifyRequest
|
||||||
|
mockRequest = {
|
||||||
|
url: '/test-endpoint',
|
||||||
|
method: 'GET',
|
||||||
|
headers: {},
|
||||||
|
query: {},
|
||||||
|
params: {},
|
||||||
|
body: {},
|
||||||
|
} as FastifyRequest;
|
||||||
|
|
||||||
|
// Mock FastifyReply
|
||||||
|
mockResponse = {
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
send: jest.fn().mockReturnThis(),
|
||||||
|
} as unknown as FastifyReply;
|
||||||
|
|
||||||
|
// Mock ArgumentsHost
|
||||||
|
mockArgumentsHost = {
|
||||||
|
switchToHttp: jest.fn().mockReturnValue({
|
||||||
|
getRequest: jest.fn().mockReturnValue(mockRequest),
|
||||||
|
getResponse: jest.fn().mockReturnValue(mockResponse),
|
||||||
|
}),
|
||||||
|
} as unknown as ArgumentsHost;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('catch', () => {
|
||||||
|
it('should handle string exception response', () => {
|
||||||
|
const exception = new HttpException(
|
||||||
|
'Test error message',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
response: 'Test error message',
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle object exception response', () => {
|
||||||
|
const exceptionResponse = {
|
||||||
|
message: 'Validation failed',
|
||||||
|
error: 'Bad Request',
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
};
|
||||||
|
const exception = new HttpException(
|
||||||
|
exceptionResponse,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
message: 'Validation failed',
|
||||||
|
error: 'Bad Request',
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different HTTP status codes', () => {
|
||||||
|
const statusCodes = [
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
];
|
||||||
|
|
||||||
|
statusCodes.forEach((statusCode) => {
|
||||||
|
const exception = new HttpException('Test error', statusCode);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(statusCode);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
response: 'Test error',
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex object exception response', () => {
|
||||||
|
const exceptionResponse = {
|
||||||
|
message: ['Email is required', 'Password is too short'],
|
||||||
|
error: 'Validation Error',
|
||||||
|
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
details: {
|
||||||
|
field: 'email',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const exception = new HttpException(
|
||||||
|
exceptionResponse,
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
message: ['Email is required', 'Password is too short'],
|
||||||
|
error: 'Validation Error',
|
||||||
|
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
details: {
|
||||||
|
field: 'email',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string exception response', () => {
|
||||||
|
const exception = new HttpException('', HttpStatus.NO_CONTENT);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
response: '',
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null exception response', () => {
|
||||||
|
const exception = new HttpException(null as any, HttpStatus.NO_CONTENT);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
statusCode: HttpStatus.NO_CONTENT,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined exception response', () => {
|
||||||
|
const exception = new HttpException(
|
||||||
|
undefined as any,
|
||||||
|
HttpStatus.NO_CONTENT,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
statusCode: HttpStatus.NO_CONTENT,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different request URLs', () => {
|
||||||
|
const urls = [
|
||||||
|
'/api/users',
|
||||||
|
'/api/projects/123',
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/feedback?page=1&limit=10',
|
||||||
|
];
|
||||||
|
|
||||||
|
urls.forEach((url) => {
|
||||||
|
Object.assign(mockRequest, { url });
|
||||||
|
const exception = new HttpException(
|
||||||
|
'Test error',
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
response: 'Test error',
|
||||||
|
path: url,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested object exception response', () => {
|
||||||
|
const exceptionResponse = {
|
||||||
|
message: 'Complex error',
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
nested: {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
value: 'deep nested value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const exception = new HttpException(
|
||||||
|
exceptionResponse,
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
message: 'Complex error',
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
nested: {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
value: 'deep nested value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle array exception response', () => {
|
||||||
|
const exceptionResponse = ['Error 1', 'Error 2', 'Error 3'];
|
||||||
|
const exception = new HttpException(
|
||||||
|
exceptionResponse,
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
0: 'Error 1',
|
||||||
|
1: 'Error 2',
|
||||||
|
2: 'Error 3',
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle boolean exception response', () => {
|
||||||
|
const exception = new HttpException(true as any, HttpStatus.OK);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
statusCode: HttpStatus.OK,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle number exception response', () => {
|
||||||
|
const exception = new HttpException(42 as any, HttpStatus.OK);
|
||||||
|
|
||||||
|
filter.catch(exception, mockArgumentsHost);
|
||||||
|
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||||
|
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||||
|
statusCode: HttpStatus.OK,
|
||||||
|
path: '/test-endpoint',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
|
||||||
|
import { Catch, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<FastifyReply>();
|
||||||
|
const request = ctx.getRequest<FastifyRequest>();
|
||||||
|
|
||||||
|
const statusCode = exception.getStatus();
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
|
this.logger.error({ statusCode, exceptionResponse });
|
||||||
|
if (typeof exceptionResponse === 'string') {
|
||||||
|
void response.status(statusCode).send({
|
||||||
|
response: exceptionResponse,
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
void response.status(statusCode).send({
|
||||||
|
...exceptionResponse,
|
||||||
|
statusCode,
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { HttpExceptionFilter } from './http-exception.filter';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
interface ToNumberOptions {
|
||||||
|
default?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLowerCase(value: string): string {
|
||||||
|
return value.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trim(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDate(value: string): Date {
|
||||||
|
return new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBoolean(value: string): boolean {
|
||||||
|
value = value.toLowerCase();
|
||||||
|
|
||||||
|
return value === 'true' || value === '1' ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
|
||||||
|
let newValue: number = Number.parseInt(value || String(opts.default), 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(newValue)) {
|
||||||
|
newValue = opts.default ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.min) {
|
||||||
|
if (newValue < opts.min) {
|
||||||
|
newValue = opts.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.max && newValue > opts.max) {
|
||||||
|
newValue = opts.max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { paginate } from 'nestjs-typeorm-paginate';
|
||||||
|
import type {
|
||||||
|
IPaginationMeta,
|
||||||
|
IPaginationOptions,
|
||||||
|
} from 'nestjs-typeorm-paginate';
|
||||||
|
import type { FindManyOptions, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
export async function paginateHelper(
|
||||||
|
queryBuilder: SelectQueryBuilder<any>,
|
||||||
|
findOptions: FindManyOptions<any>,
|
||||||
|
options: IPaginationOptions,
|
||||||
|
) {
|
||||||
|
const totalItems = await queryBuilder
|
||||||
|
.clone()
|
||||||
|
.setFindOptions(findOptions)
|
||||||
|
.getCount();
|
||||||
|
return await paginate(queryBuilder.setFindOptions(findOptions), {
|
||||||
|
...options,
|
||||||
|
countQueries: false,
|
||||||
|
metaTransformer: (meta: IPaginationMeta): IPaginationMeta => {
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
totalItems,
|
||||||
|
totalPages: Math.ceil(totalItems / meta.itemsPerPage),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export class CreateDataDto {
|
||||||
|
id?: string;
|
||||||
|
index: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export class CreateIndexDto {
|
||||||
|
index: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export class DeleteBulkDataDto {
|
||||||
|
ids: number[];
|
||||||
|
index: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { PaginationDto } from '@/common/dtos';
|
||||||
|
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||||
|
|
||||||
|
export class GetDataDto extends PaginationDto {
|
||||||
|
index: string;
|
||||||
|
query: OsQueryDto;
|
||||||
|
sort: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { CreateIndexDto } from './create-index.dto';
|
||||||
|
export { PutMappingsDto } from './put-mappings.dto';
|
||||||
|
export { CreateDataDto } from './create-data.dto';
|
||||||
|
export { GetDataDto } from './get-data.dto';
|
||||||
|
export { UpdateDataDto } from './update-data.dto';
|
||||||
|
export { DeleteBulkDataDto } from './delete-bulk-data.dto';
|
||||||
|
export { ScrollDto } from './scroll.dto';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Property } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||||
|
|
||||||
|
export class PutMappingsDto {
|
||||||
|
index: string;
|
||||||
|
mappings: Record<string, Property>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||||
|
|
||||||
|
export class ScrollDto {
|
||||||
|
index: string;
|
||||||
|
query: OsQueryDto;
|
||||||
|
sort: string[];
|
||||||
|
size: number;
|
||||||
|
scrollId: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export class UpdateDataDto {
|
||||||
|
id: string;
|
||||||
|
index: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { OpensearchRepository } from './opensearch.repository';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ErrorCode } from '@ufb/shared';
|
||||||
|
|
||||||
|
export class LargeWindowException extends BadRequestException {
|
||||||
|
constructor(message: string) {
|
||||||
|
super({
|
||||||
|
code: ErrorCode.Opensearch.LargeWindow,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import {
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import type { Client } from '@opensearch-project/opensearch';
|
||||||
|
import type { TextProperty } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||||
|
|
||||||
|
import { getMockProvider } from '@/test-utils/util-functions';
|
||||||
|
import { CreateDataDto, PutMappingsDto } from './dtos';
|
||||||
|
import { OpensearchRepository } from './opensearch.repository';
|
||||||
|
|
||||||
|
const MockClient = {
|
||||||
|
indices: {
|
||||||
|
create: jest.fn(),
|
||||||
|
putAlias: jest.fn(),
|
||||||
|
exists: jest.fn(),
|
||||||
|
putMapping: jest.fn(),
|
||||||
|
getMapping: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
index: jest.fn(),
|
||||||
|
search: jest.fn(),
|
||||||
|
scroll: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
deleteByQuery: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpensearchRepositoryProviders = [
|
||||||
|
OpensearchRepository,
|
||||||
|
getMockProvider('OPENSEARCH_CLIENT', MockClient),
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPLICATE_JSON = {
|
||||||
|
KEY1: 'VALUE1',
|
||||||
|
KEY2: 'VALUE2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAPPING_JSON = {
|
||||||
|
KEY1: {
|
||||||
|
type: 'text',
|
||||||
|
} as TextProperty,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Opensearch Repository Test suite', () => {
|
||||||
|
let osRepo: OpensearchRepository;
|
||||||
|
let osClient: Client;
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: OpensearchRepositoryProviders,
|
||||||
|
}).compile();
|
||||||
|
osRepo = module.get(OpensearchRepository);
|
||||||
|
osClient = module.get('OPENSEARCH_CLIENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create index', () => {
|
||||||
|
it('positive case', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const indexName = 'channel_' + index;
|
||||||
|
jest.spyOn(osClient.indices, 'create');
|
||||||
|
jest.spyOn(osClient.indices, 'putAlias');
|
||||||
|
|
||||||
|
await osRepo.createIndex({ index });
|
||||||
|
|
||||||
|
expect(osClient.indices.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.create).toHaveBeenCalledWith({
|
||||||
|
index: indexName,
|
||||||
|
body: {
|
||||||
|
settings: {
|
||||||
|
index: { max_ngram_diff: 1 },
|
||||||
|
analysis: {
|
||||||
|
analyzer: {
|
||||||
|
ngram_analyzer: {
|
||||||
|
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||||
|
tokenizer: 'ngram_tokenizer',
|
||||||
|
type: 'custom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokenizer: {
|
||||||
|
ngram_tokenizer: {
|
||||||
|
type: 'ngram',
|
||||||
|
min_gram: 1,
|
||||||
|
max_gram: 2,
|
||||||
|
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(osClient.indices.putAlias).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.putAlias).toHaveBeenCalledWith({
|
||||||
|
index: indexName,
|
||||||
|
name: index,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creating index handles errors', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const error = new Error('Index creation failed');
|
||||||
|
|
||||||
|
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(osRepo.createIndex({ index })).rejects.toThrow(
|
||||||
|
'Index creation failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creating index handles OpenSearch specific errors', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const error = {
|
||||||
|
meta: {
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
type: 'resource_already_exists_exception',
|
||||||
|
reason: 'index already exists',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(osRepo.createIndex({ index })).rejects.toEqual(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('putMappings', () => {
|
||||||
|
it('putting mappings succeeds with an existent index', async () => {
|
||||||
|
const dto = new PutMappingsDto();
|
||||||
|
dto.index = faker.number.int().toString();
|
||||||
|
dto.mappings = MAPPING_JSON;
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'exists')
|
||||||
|
.mockResolvedValue({ statusCode: 200 } as never);
|
||||||
|
jest.spyOn(osClient.indices, 'putMapping');
|
||||||
|
|
||||||
|
await osRepo.putMappings(dto);
|
||||||
|
|
||||||
|
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.putMapping).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.putMapping).toHaveBeenCalledWith({
|
||||||
|
index: dto.index,
|
||||||
|
body: { properties: dto.mappings },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('putting mappings fails with a nonexistent index', async () => {
|
||||||
|
const dto = new PutMappingsDto();
|
||||||
|
dto.index = faker.number.int().toString();
|
||||||
|
dto.mappings = MAPPING_JSON;
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'exists')
|
||||||
|
.mockResolvedValue({ statusCode: 404 } as never);
|
||||||
|
jest.spyOn(osClient.indices, 'putMapping');
|
||||||
|
|
||||||
|
await expect(osRepo.putMappings(dto)).rejects.toThrow(
|
||||||
|
new NotFoundException('index is not found'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.putMapping).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('putting mappings handles OpenSearch errors', async () => {
|
||||||
|
const dto = new PutMappingsDto();
|
||||||
|
dto.index = faker.number.int().toString();
|
||||||
|
dto.mappings = MAPPING_JSON;
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'exists')
|
||||||
|
.mockResolvedValue({ statusCode: 200 } as never);
|
||||||
|
|
||||||
|
const error = {
|
||||||
|
meta: {
|
||||||
|
body: {
|
||||||
|
error: {
|
||||||
|
type: 'illegal_argument_exception',
|
||||||
|
reason: 'mapping update failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'putMapping')
|
||||||
|
.mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(osRepo.putMappings(dto)).rejects.toEqual(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createData', () => {
|
||||||
|
it('creating data succeeds with valid inputs', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const id = faker.number.int().toString();
|
||||||
|
const dto = new CreateDataDto();
|
||||||
|
dto.id = id;
|
||||||
|
dto.index = index;
|
||||||
|
dto.data = COMPLICATE_JSON;
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'exists')
|
||||||
|
.mockResolvedValue({ statusCode: 200 } as never);
|
||||||
|
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
['channel_' + index]: {
|
||||||
|
mappings: {
|
||||||
|
properties: dto.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
_id: dto.id,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const response = await osRepo.createData(dto);
|
||||||
|
|
||||||
|
expect(response.id).toEqual(dto.id);
|
||||||
|
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.index).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.index).toHaveBeenCalledWith({
|
||||||
|
id: dto.id,
|
||||||
|
index: 'channel_' + index,
|
||||||
|
body: dto.data,
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('creating data fails with an invalid index', async () => {
|
||||||
|
const invalidIndex = faker.number.int().toString();
|
||||||
|
const id = faker.number.int().toString();
|
||||||
|
const dto = new CreateDataDto();
|
||||||
|
dto.id = id;
|
||||||
|
dto.index = invalidIndex;
|
||||||
|
dto.data = COMPLICATE_JSON;
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'exists')
|
||||||
|
.mockResolvedValue({ body: false } as never);
|
||||||
|
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
['channel_' + faker.number.int().toString()]: {
|
||||||
|
mappings: {
|
||||||
|
properties: dto.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
_id: dto.id,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||||
|
new NotFoundException('index is not found'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.getMapping).not.toHaveBeenCalled();
|
||||||
|
expect(osClient.index).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('creating data fails with invalid data', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const id = faker.number.int().toString();
|
||||||
|
const data = COMPLICATE_JSON;
|
||||||
|
const dto = new CreateDataDto();
|
||||||
|
dto.id = id;
|
||||||
|
dto.index = index;
|
||||||
|
dto.data = {
|
||||||
|
...data,
|
||||||
|
invalidKey: 'invalidValue',
|
||||||
|
};
|
||||||
|
jest
|
||||||
|
.spyOn(osClient.indices, 'exists')
|
||||||
|
.mockResolvedValue({ statusCode: 200 } as never);
|
||||||
|
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
['channel_' + index]: {
|
||||||
|
mappings: {
|
||||||
|
properties: data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
_id: dto.id,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||||
|
new InternalServerErrorException('error!!!'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||||
|
expect(osClient.index).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getData', () => {
|
||||||
|
it('getting data succeeds with valid inputs', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
const sort = ['_id:desc'];
|
||||||
|
const limit = 10;
|
||||||
|
const page = 1;
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
hits: {
|
||||||
|
hits: [
|
||||||
|
{ _source: { KEY1: 'VALUE1' } },
|
||||||
|
{ _source: { KEY2: 'VALUE2' } },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await osRepo.getData({ index, query, sort, limit, page });
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
expect(osClient.search).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
from: 0,
|
||||||
|
size: limit,
|
||||||
|
sort,
|
||||||
|
body: { query },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getting data with empty sort adds default sort', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
const sort: string[] = [];
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
hits: {
|
||||||
|
hits: [],
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.getData({ index, query, sort, page: 1, limit: 100 });
|
||||||
|
|
||||||
|
expect(osClient.search).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
from: 0,
|
||||||
|
size: 100,
|
||||||
|
sort: ['_id:desc'],
|
||||||
|
body: { query },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getting data handles large window exception', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
|
||||||
|
const error = new Error('Result window is too large');
|
||||||
|
error.name = 'OpenSearchClientError';
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'search').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
osRepo.getData({ index, query, sort: [], page: 1, limit: 100 }),
|
||||||
|
).rejects.toThrow('Result window is too large');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getting data handles total as object', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
hits: {
|
||||||
|
hits: [],
|
||||||
|
total: { value: 100, relation: 'eq' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await osRepo.getData({
|
||||||
|
index,
|
||||||
|
query,
|
||||||
|
sort: [],
|
||||||
|
page: 1,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.total).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scroll', () => {
|
||||||
|
it('scrolling with scrollId succeeds', async () => {
|
||||||
|
const scrollId = faker.string.alphanumeric(32);
|
||||||
|
const mockData = [{ KEY1: 'VALUE1' }, { KEY2: 'VALUE2' }];
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'scroll').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
hits: {
|
||||||
|
hits: mockData.map((data) => ({ _source: data })),
|
||||||
|
},
|
||||||
|
_scroll_id: scrollId,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await osRepo.scroll({
|
||||||
|
scrollId,
|
||||||
|
index: '',
|
||||||
|
size: 10,
|
||||||
|
query: { bool: { must: [{ term: { status: 'active' } }] } },
|
||||||
|
sort: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockData);
|
||||||
|
expect(result.scrollId).toEqual(scrollId);
|
||||||
|
expect(osClient.scroll).toHaveBeenCalledWith({
|
||||||
|
scroll_id: scrollId,
|
||||||
|
scroll: '1m',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolling without scrollId performs initial search', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
const sort = ['_id:desc'];
|
||||||
|
const size = 10;
|
||||||
|
const mockData = [{ KEY1: 'VALUE1' }];
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
hits: {
|
||||||
|
hits: mockData.map((data) => ({ _source: data })),
|
||||||
|
},
|
||||||
|
_scroll_id: 'new_scroll_id',
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await osRepo.scroll({
|
||||||
|
index,
|
||||||
|
query,
|
||||||
|
sort,
|
||||||
|
size,
|
||||||
|
scrollId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockData);
|
||||||
|
expect(result.scrollId).toEqual('new_scroll_id');
|
||||||
|
expect(osClient.search).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
size,
|
||||||
|
sort,
|
||||||
|
body: { query },
|
||||||
|
scroll: '1m',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolling with empty sort adds default sort', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
const sort: string[] = [];
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
hits: { hits: [] },
|
||||||
|
_scroll_id: 'scroll_id',
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.scroll({ index, query, sort, size: 10, scrollId: null });
|
||||||
|
|
||||||
|
expect(osClient.search).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
size: 10,
|
||||||
|
sort: ['_id:desc'],
|
||||||
|
body: { query },
|
||||||
|
scroll: '1m',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateData', () => {
|
||||||
|
it('updating data succeeds with valid inputs', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const id = faker.number.int().toString();
|
||||||
|
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'update').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
_id: id,
|
||||||
|
result: 'updated',
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.updateData({ index, id, data: updateData });
|
||||||
|
|
||||||
|
expect(osClient.update).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
body: {
|
||||||
|
doc: updateData,
|
||||||
|
},
|
||||||
|
refresh: true,
|
||||||
|
retry_on_conflict: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updating data handles errors', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const id = faker.number.int().toString();
|
||||||
|
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||||
|
const error = new Error('Update failed');
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'update').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
osRepo.updateData({ index, id, data: updateData }),
|
||||||
|
).rejects.toThrow('Update failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteBulkData', () => {
|
||||||
|
it('deleting bulk data succeeds with valid ids', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const ids = [faker.number.int(), faker.number.int()];
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
deleted: ids.length,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.deleteBulkData({ index, ids });
|
||||||
|
|
||||||
|
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
body: { query: { terms: { _id: ids } } },
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting bulk data with empty ids array', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const ids: number[] = [];
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
deleted: 0,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.deleteBulkData({ index, ids });
|
||||||
|
|
||||||
|
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
body: { query: { terms: { _id: ids } } },
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteIndex', () => {
|
||||||
|
it('deleting index succeeds with valid index', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const indexName = 'channel_' + index;
|
||||||
|
|
||||||
|
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
acknowledged: true,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.deleteIndex(index);
|
||||||
|
|
||||||
|
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||||
|
index: indexName,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting index handles errors', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const error = new Error('Delete failed');
|
||||||
|
|
||||||
|
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(osRepo.deleteIndex(index)).rejects.toThrow('Delete failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTotal', () => {
|
||||||
|
it('getting total count succeeds with valid query', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
count: 100,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await osRepo.getTotal(index, query);
|
||||||
|
|
||||||
|
expect(result).toBe(100);
|
||||||
|
expect(osClient.count).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
body: { query },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getting total count with complex query', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = {
|
||||||
|
bool: {
|
||||||
|
must: [
|
||||||
|
{ term: { status: 'active' } },
|
||||||
|
{ range: { created_at: { gte: '2023-01-01' } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
count: 50,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const result = await osRepo.getTotal(index, query);
|
||||||
|
|
||||||
|
expect(result).toBe(50);
|
||||||
|
expect(osClient.count).toHaveBeenCalledWith({
|
||||||
|
index,
|
||||||
|
body: { query },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getting total count handles errors', async () => {
|
||||||
|
const index = faker.number.int().toString();
|
||||||
|
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||||
|
const error = new Error('Count failed');
|
||||||
|
|
||||||
|
jest.spyOn(osClient, 'count').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(osRepo.getTotal(index, query)).rejects.toThrow(
|
||||||
|
'Count failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteAllIndexes', () => {
|
||||||
|
it('deleting all indexes succeeds', async () => {
|
||||||
|
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||||
|
body: {
|
||||||
|
acknowledged: true,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await osRepo.deleteAllIndexes();
|
||||||
|
|
||||||
|
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||||
|
index: '_all',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting all indexes handles errors', async () => {
|
||||||
|
const error = new Error('Delete all failed');
|
||||||
|
|
||||||
|
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||||
|
|
||||||
|
await expect(osRepo.deleteAllIndexes()).rejects.toThrow(
|
||||||
|
'Delete all failed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Client, errors } from '@opensearch-project/opensearch';
|
||||||
|
import { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CreateDataDto,
|
||||||
|
CreateIndexDto,
|
||||||
|
DeleteBulkDataDto,
|
||||||
|
GetDataDto,
|
||||||
|
PutMappingsDto,
|
||||||
|
ScrollDto,
|
||||||
|
UpdateDataDto,
|
||||||
|
} from './dtos';
|
||||||
|
import { LargeWindowException } from './large-window.exception';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpensearchRepository {
|
||||||
|
private logger = new Logger(OpensearchRepository.name);
|
||||||
|
private opensearchClient: Client;
|
||||||
|
constructor(@Inject('OPENSEARCH_CLIENT') opensearchClient: Client) {
|
||||||
|
this.opensearchClient = opensearchClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIndex({ index }: CreateIndexDto) {
|
||||||
|
const indexName = 'channel_' + index;
|
||||||
|
try {
|
||||||
|
const response = await this.opensearchClient.indices.create({
|
||||||
|
index: indexName,
|
||||||
|
body: {
|
||||||
|
settings: {
|
||||||
|
index: { max_ngram_diff: 1 },
|
||||||
|
analysis: {
|
||||||
|
analyzer: {
|
||||||
|
ngram_analyzer: {
|
||||||
|
type: 'custom',
|
||||||
|
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||||
|
tokenizer: 'ngram_tokenizer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tokenizer: {
|
||||||
|
ngram_tokenizer: {
|
||||||
|
type: 'ngram',
|
||||||
|
min_gram: 1,
|
||||||
|
max_gram: 2,
|
||||||
|
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (response) {
|
||||||
|
this.logger.log(
|
||||||
|
`Index created successfully: ${JSON.stringify(response.body, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log(`Error creating index: ${error}`);
|
||||||
|
if (error?.meta?.body) {
|
||||||
|
this.logger.log(
|
||||||
|
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await this.opensearchClient.indices.putAlias({
|
||||||
|
index: indexName,
|
||||||
|
name: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async putMappings({ index, mappings }: PutMappingsDto) {
|
||||||
|
const { statusCode } = await this.opensearchClient.indices.exists({
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
if (statusCode !== 200) throw new NotFoundException('index is not found');
|
||||||
|
|
||||||
|
let response: Indices_PutMapping_Response;
|
||||||
|
try {
|
||||||
|
response = await this.opensearchClient.indices.putMapping({
|
||||||
|
index,
|
||||||
|
body: { properties: mappings },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log(`Error put mapping: ${error}`);
|
||||||
|
if (error?.meta?.body) {
|
||||||
|
this.logger.log(
|
||||||
|
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createData({ id, index, data }: CreateDataDto) {
|
||||||
|
const indexName = 'channel_' + index;
|
||||||
|
const existence = await this.opensearchClient.indices.exists({
|
||||||
|
index: indexName,
|
||||||
|
});
|
||||||
|
if (existence.statusCode !== 200)
|
||||||
|
throw new NotFoundException('index is not found');
|
||||||
|
|
||||||
|
const response = await this.opensearchClient.indices.getMapping({
|
||||||
|
index: indexName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappingKeys = Object.keys(
|
||||||
|
response.body[indexName].mappings.properties as object,
|
||||||
|
);
|
||||||
|
const dataKeys = Object.keys(data);
|
||||||
|
if (!dataKeys.every((v) => mappingKeys.includes(v))) {
|
||||||
|
throw new InternalServerErrorException('error!!!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body } = await this.opensearchClient.index({
|
||||||
|
id,
|
||||||
|
index: indexName,
|
||||||
|
body: data,
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: body._id as unknown as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(dto: GetDataDto) {
|
||||||
|
const { index, limit = 100, page = 1, query, sort } = dto;
|
||||||
|
|
||||||
|
if (sort.length === 0) {
|
||||||
|
sort.push('_id:desc');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { body } = await this.opensearchClient.search({
|
||||||
|
index,
|
||||||
|
from: (page - 1) * limit,
|
||||||
|
size: limit,
|
||||||
|
sort,
|
||||||
|
body: { query },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: body.hits.hits.map((v) => ({
|
||||||
|
...v._source,
|
||||||
|
})) as Record<string, any>[],
|
||||||
|
total:
|
||||||
|
typeof body.hits.total === 'number' ?
|
||||||
|
body.hits.total
|
||||||
|
: (body.hits.total?.value ?? 0),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof errors.OpenSearchClientError) {
|
||||||
|
if (error.message.includes('Result window is too large')) {
|
||||||
|
throw new LargeWindowException(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async scroll(dto: ScrollDto) {
|
||||||
|
const { index, size, scrollId, query, sort } = dto;
|
||||||
|
|
||||||
|
if (sort.length === 0) sort.push('_id:desc');
|
||||||
|
|
||||||
|
if (scrollId) {
|
||||||
|
const { body } = await this.opensearchClient.scroll({
|
||||||
|
scroll_id: scrollId,
|
||||||
|
scroll: '1m',
|
||||||
|
});
|
||||||
|
return this.convertToScrollData(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body } = await this.opensearchClient.search({
|
||||||
|
index,
|
||||||
|
size,
|
||||||
|
sort,
|
||||||
|
body: { query },
|
||||||
|
scroll: '1m',
|
||||||
|
});
|
||||||
|
return this.convertToScrollData(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertToScrollData(body) {
|
||||||
|
return {
|
||||||
|
data: body.hits.hits.map((v) => ({
|
||||||
|
...v._source,
|
||||||
|
})) as Record<string, any>[],
|
||||||
|
scrollId: body._scroll_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateData({ id, index, data }: UpdateDataDto) {
|
||||||
|
try {
|
||||||
|
await this.opensearchClient.update({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
body: { doc: data },
|
||||||
|
refresh: true,
|
||||||
|
retry_on_conflict: 5,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error updating data: ${error}`);
|
||||||
|
if (error?.meta?.body) {
|
||||||
|
this.logger.error(
|
||||||
|
`OpenSearch error details: ${JSON.stringify(error.meta.body, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBulkData({ ids, index }: DeleteBulkDataDto) {
|
||||||
|
await this.opensearchClient.deleteByQuery({
|
||||||
|
index,
|
||||||
|
body: { query: { terms: { _id: ids } } },
|
||||||
|
refresh: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIndex(index: string) {
|
||||||
|
await this.opensearchClient.indices.delete({ index: 'channel_' + index });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllIndexes() {
|
||||||
|
await this.opensearchClient.indices.delete({ index: '_all' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTotal(index: string, query: object): Promise<number> {
|
||||||
|
const { body } = await this.opensearchClient.count({
|
||||||
|
index,
|
||||||
|
body: { query },
|
||||||
|
});
|
||||||
|
|
||||||
|
return body.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { ValidationArguments, ValidationOptions } from 'class-validator';
|
||||||
|
import { registerDecorator } from 'class-validator';
|
||||||
|
|
||||||
|
export const ArrayDistinct = (
|
||||||
|
property?: string,
|
||||||
|
validationOptions?: ValidationOptions,
|
||||||
|
) => {
|
||||||
|
return (object: object, propertyName: string) => {
|
||||||
|
registerDecorator({
|
||||||
|
name: 'ArrayDistinct',
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
constraints: [property],
|
||||||
|
options: validationOptions,
|
||||||
|
validator: {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return Array.isArray(value) ?
|
||||||
|
[...new Set(value)].length === value.length
|
||||||
|
: false;
|
||||||
|
},
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `must not contains duplicate entry for ${args.constraints[0]}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { ArrayDistinct } from './array-distinct';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { IsNotEmpty, validate } from 'class-validator';
|
||||||
|
|
||||||
|
import { TokenValidator } from './token-validator';
|
||||||
|
|
||||||
|
class TokenDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@TokenValidator({ message: 'Invalid token format' })
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TokenValidator', () => {
|
||||||
|
it('should validate a correct token', async () => {
|
||||||
|
const dto = new TokenDto();
|
||||||
|
dto.token = 'validToken123456';
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invalidate a token with invalid characters', async () => {
|
||||||
|
const dto = new TokenDto();
|
||||||
|
dto.token = 'invalidToken$123';
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invalidate a token that is too short', async () => {
|
||||||
|
const dto = new TokenDto();
|
||||||
|
dto.token = 'short';
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invalidate an empty token', async () => {
|
||||||
|
const dto = new TokenDto();
|
||||||
|
dto.token = '';
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ async: false })
|
||||||
|
export class TokenValidatorConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(token: string | null) {
|
||||||
|
const regex = /^[a-zA-Z0-9._-]+$/;
|
||||||
|
return (
|
||||||
|
!token ||
|
||||||
|
(typeof token === 'string' && regex.test(token) && token.length >= 16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage() {
|
||||||
|
return 'Token must be at least 16 characters long and contain only alphanumeric characters, dots, hyphens, and underscores.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenValidator(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: TokenValidatorConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
import Joi from 'joi';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export const appConfigSchema = Joi.object({
|
||||||
|
APP_PORT: Joi.number().default(4000),
|
||||||
|
APP_ADDRESS: Joi.string().default('0.0.0.0'),
|
||||||
|
ADMIN_WEB_URL: Joi.string().default('http://localhost:3000'),
|
||||||
|
BASE_URL: Joi.string().optional(),
|
||||||
|
AUTO_FEEDBACK_DELETION_ENABLED: Joi.boolean().default(false),
|
||||||
|
AUTO_FEEDBACK_DELETION_PERIOD_DAYS: Joi.number().when(
|
||||||
|
'AUTO_FEEDBACK_DELETION_ENABLED',
|
||||||
|
{
|
||||||
|
is: true,
|
||||||
|
then: Joi.required(),
|
||||||
|
otherwise: Joi.optional(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appConfig = registerAs('app', () => ({
|
||||||
|
port: process.env.APP_PORT,
|
||||||
|
address: process.env.APP_ADDRESS,
|
||||||
|
adminWebUrl: process.env.ADMIN_WEB_URL,
|
||||||
|
baseUrl: process.env.BASE_URL,
|
||||||
|
enableAutoFeedbackDeletion:
|
||||||
|
process.env.AUTO_FEEDBACK_DELETION_ENABLED === 'true',
|
||||||
|
autoFeedbackDeletionPeriodDays:
|
||||||
|
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS,
|
||||||
|
serverId: uuidv4(),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
import Joi from 'joi';
|
||||||
|
|
||||||
|
export const jwtConfigSchema = Joi.object({
|
||||||
|
JWT_SECRET: Joi.string().required(),
|
||||||
|
ACCESS_TOKEN_EXPIRED_TIME: Joi.string().default('10m'),
|
||||||
|
REFRESH_TOKEN_EXPIRED_TIME: Joi.string().default('1h'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const jwtConfig = registerAs('jwt', () => ({
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
accessTokenExpiredTime: process.env.ACCESS_TOKEN_EXPIRED_TIME,
|
||||||
|
refreshTokenExpiredTime: process.env.REFRESH_TOKEN_EXPIRED_TIME,
|
||||||
|
}));
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
export { OpensearchConfigModule } from './opensearch-config/opensearch-config.module';
|
||||||
|
export { MailerConfigModule } from './mailer-config/mailer-config.module';
|
||||||
|
export { TypeOrmConfigModule } from './typeorm-config/typeorm-config.module';
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2025 LY Corporation
|
||||||
|
*
|
||||||
|
* LY Corporation licenses this file to you under the Apache License,
|
||||||
|
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at:
|
||||||
|
*
|
||||||
|
* https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
* License for the specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { MailerModule } from '@nestjs-modules/mailer';
|
||||||
|
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MailerModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService<ConfigServiceType>) => {
|
||||||
|
const {
|
||||||
|
host,
|
||||||
|
password,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
sender,
|
||||||
|
cipherSpec,
|
||||||
|
opportunisticTLS,
|
||||||
|
tls,
|
||||||
|
} = configService.get('smtp', { infer: true }) ?? {};
|
||||||
|
return {
|
||||||
|
transport: {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
tls: { ciphers: cipherSpec },
|
||||||
|
auth:
|
||||||
|
username && password ?
|
||||||
|
{ user: username, pass: password }
|
||||||
|
: undefined,
|
||||||
|
secure: tls,
|
||||||
|
pool: true,
|
||||||
|
},
|
||||||
|
defaults: { from: `"User feedback" <${sender}>` },
|
||||||
|
template: {
|
||||||
|
dir: __dirname + '/templates/',
|
||||||
|
adapter: new HandlebarsAdapter(),
|
||||||
|
options: { strict: true },
|
||||||
|
},
|
||||||
|
opportunisticTLS,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MailerConfigModule {}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||||
|
http-equiv='x-ua-compatible'
|
||||||
|
content='ie=edge'
|
||||||
|
/>
|
||||||
|
<meta name='x-apple-disable-message-reformatting' />
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||||
|
<meta
|
||||||
|
name='format-detection'
|
||||||
|
content='telephone=no, date=no, address=no, email=no'
|
||||||
|
/>
|
||||||
|
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||||
|
<style type='text/css'>
|
||||||
|
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||||
|
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||||
|
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||||
|
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||||
|
a,#MessageViewBody
|
||||||
|
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||||
|
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||||
|
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||||
|
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||||
|
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||||
|
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||||
|
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||||
|
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||||
|
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||||
|
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||||
|
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||||
|
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||||
|
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||||
|
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||||
|
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||||
|
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||||
|
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||||
|
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||||
|
!important;line-height:0 !important;height:0
|
||||||
|
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||||
|
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||||
|
!important;line-height:24px !important;height:24px
|
||||||
|
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||||
|
!important;height:40px !important}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||||
|
bgcolor='#ffffff'
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
valign='top'
|
||||||
|
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td valign='top' align='center'>
|
||||||
|
<table
|
||||||
|
align='center'
|
||||||
|
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr style='height: 32px;'>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<table
|
||||||
|
class='ax-center'
|
||||||
|
align='center'
|
||||||
|
style='margin: 0 auto;'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class=''
|
||||||
|
width='88'
|
||||||
|
height='100'
|
||||||
|
src='{{baseUrl}}/assets/mailing/email-signup.png'
|
||||||
|
alt='Email Signup'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table
|
||||||
|
class='s-10 w-full'
|
||||||
|
style='width: 100%;'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
height='40'
|
||||||
|
>
|
||||||
|
 
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div
|
||||||
|
class='fw-700 text-center text-lg'
|
||||||
|
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||||
|
align='center'
|
||||||
|
>Sign up to UserFeedback</div>
|
||||||
|
<table
|
||||||
|
class='s-3 w-full'
|
||||||
|
style='width: 100%;'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
height='12'
|
||||||
|
>
|
||||||
|
 
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div
|
||||||
|
class='fw-400 text-center text-sm'
|
||||||
|
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||||
|
align='center'
|
||||||
|
>Please sign up using the button below.
|
||||||
|
<br />
|
||||||
|
This link will expire after 24 hours or if it is used
|
||||||
|
once.</div>
|
||||||
|
<table
|
||||||
|
class='s-6 w-full'
|
||||||
|
style='width: 100%;'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
height='24'
|
||||||
|
>
|
||||||
|
 
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table
|
||||||
|
class='ax-center btn btn-black w-56'
|
||||||
|
align='center'
|
||||||
|
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
|
||||||
|
width='224'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
|
||||||
|
align='center'
|
||||||
|
bgcolor='#000000'
|
||||||
|
width='224'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href='{{link}}'
|
||||||
|
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
|
||||||
|
>Sign Up</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class='fw-400 text-secondary text-center text-xs'
|
||||||
|
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||||
|
align='center'
|
||||||
|
>This is an automated message. Please do not reply to this
|
||||||
|
email.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<table class='s-6' style='width: 100%;' width='100%'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||||
|
alt='Logo'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
width='116'
|
||||||
|
height='18'
|
||||||
|
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||||
|
alt='UserFeedback'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align='left' valign='center'>
|
||||||
|
<table align='center' style='margin: 0 auto;'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='padding-right: 12px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||||
|
alt='Website'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style='padding-right: 12px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href='https://github.com/line/abc-user-feedback'
|
||||||
|
style='color: #0d6efd;'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||||
|
alt='GitHub'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style='padding-right: 0; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||||
|
style='color: #0d6efd;'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||||
|
http-equiv='x-ua-compatible'
|
||||||
|
content='ie=edge'
|
||||||
|
/>
|
||||||
|
<meta name='x-apple-disable-message-reformatting' />
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||||
|
<meta
|
||||||
|
name='format-detection'
|
||||||
|
content='telephone=no, date=no, address=no, email=no'
|
||||||
|
/>
|
||||||
|
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||||
|
<style type='text/css'>
|
||||||
|
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||||
|
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||||
|
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||||
|
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||||
|
a,#MessageViewBody
|
||||||
|
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||||
|
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||||
|
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||||
|
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||||
|
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||||
|
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||||
|
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||||
|
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||||
|
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||||
|
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||||
|
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||||
|
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||||
|
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||||
|
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||||
|
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||||
|
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||||
|
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||||
|
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||||
|
!important;line-height:0 !important;height:0
|
||||||
|
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||||
|
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||||
|
!important;line-height:24px !important;height:24px
|
||||||
|
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||||
|
!important;height:40px !important}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||||
|
bgcolor='#ffffff'
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
valign='top'
|
||||||
|
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td valign='top' align='center'>
|
||||||
|
<table
|
||||||
|
align='center'
|
||||||
|
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr style='height: 32px;'>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<table
|
||||||
|
class='ax-center'
|
||||||
|
align='center'
|
||||||
|
style='margin: 0 auto;'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='160'
|
||||||
|
height='160'
|
||||||
|
src='{{baseUrl}}/assets/mailing/email-reset.png'
|
||||||
|
alt='Reset Password'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table
|
||||||
|
class='s-10 w-full'
|
||||||
|
style='width: 100%;'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
height='40'
|
||||||
|
>
|
||||||
|
 
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div
|
||||||
|
class='fw-700 text-center text-lg'
|
||||||
|
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||||
|
align='center'
|
||||||
|
>Reset Password</div>
|
||||||
|
<table
|
||||||
|
class='s-3 w-full'
|
||||||
|
style='width: 100%;'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
height='12'
|
||||||
|
>
|
||||||
|
 
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div
|
||||||
|
class='fw-400 text-center text-sm'
|
||||||
|
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||||
|
align='center'
|
||||||
|
>Please change your password through the button below.
|
||||||
|
<br />
|
||||||
|
This link will expire after 24 hours or if it is used
|
||||||
|
once.</div>
|
||||||
|
<table
|
||||||
|
class='s-6 w-full'
|
||||||
|
style='width: 100%;'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
height='24'
|
||||||
|
>
|
||||||
|
 
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table
|
||||||
|
class='ax-center btn btn-black w-56'
|
||||||
|
align='center'
|
||||||
|
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
|
||||||
|
width='224'
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
|
||||||
|
align='center'
|
||||||
|
bgcolor='#000000'
|
||||||
|
width='224'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href='{{link}}'
|
||||||
|
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
|
||||||
|
>Change Password</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class='fw-400 text-secondary text-center text-xs'
|
||||||
|
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||||
|
align='center'
|
||||||
|
>This is an automated message. Please do not reply to this
|
||||||
|
email.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
width='100%'
|
||||||
|
>
|
||||||
|
<table class='s-6' style='width: 100%;' width='100%'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||||
|
alt='Logo'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<img
|
||||||
|
width='116'
|
||||||
|
height='18'
|
||||||
|
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||||
|
alt='UserFeedback'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align='left' valign='center'>
|
||||||
|
<table align='center' style='margin: 0 auto;'>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
style='padding-right: 12px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||||
|
alt='Website'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style='padding-right: 12px; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href='https://github.com/line/abc-user-feedback'
|
||||||
|
style='color: #0d6efd;'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||||
|
alt='GitHub'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style='padding-right: 0; margin: 0;'
|
||||||
|
align='left'
|
||||||
|
valign='top'
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||||
|
style='color: #0d6efd;'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width='16'
|
||||||
|
height='16'
|
||||||
|
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||||
|
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user