initial commit
Change-Id: Ifa2442bb2e786af351ef5f575ca69828870c8b6e
This commit is contained in:
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
24
.eslintrc.json
Normal file
24
.eslintrc.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"ignorePatterns": ["!**/*"],
|
||||||
|
"plugins": ["@nx"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"extends": ["plugin:@nx/typescript"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.js", "*.jsx"],
|
||||||
|
"extends": ["plugin:@nx/javascript"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"],
|
||||||
|
"env": {
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
84
.gitea/workflows/build-push.yml
Normal file
84
.gitea/workflows/build-push.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Build and Push to ACR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ dev ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
AZURE_FEDERATED_TOKEN_FILE: /var/run/secrets/azure/tokens/azure-identity-token
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and Push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >-
|
||||||
|
github.ref != 'refs/heads/main' && (
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000')
|
||||||
|
)
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Azure CLI
|
||||||
|
run: |
|
||||||
|
command -v az &>/dev/null || curl -sL https://aka.ms/InstallAzureCLIDeb | bash
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: |
|
||||||
|
command -v docker &>/dev/null || (apt-get update -qq && apt-get install -y docker.io)
|
||||||
|
docker --version
|
||||||
|
|
||||||
|
- name: Azure login (OIDC)
|
||||||
|
run: |
|
||||||
|
az login \
|
||||||
|
--service-principal \
|
||||||
|
--username "$AZURE_CLIENT_ID" \
|
||||||
|
--tenant "$AZURE_TENANT_ID" \
|
||||||
|
--federated-token "$(cat $AZURE_FEDERATED_TOKEN_FILE)"
|
||||||
|
echo "✓ Azure login successful"
|
||||||
|
|
||||||
|
- name: Get ACR details
|
||||||
|
run: |
|
||||||
|
ACR_NAME=$(az acr list --query "[0].name" -o tsv)
|
||||||
|
ACR_NAME="${ACR_NAME:-bstagecjotdevacr}"
|
||||||
|
echo "ACR_NAME=$ACR_NAME" >> $GITHUB_ENV
|
||||||
|
echo "ACR_LOGIN_SERVER=${ACR_NAME}.azurecr.io" >> $GITHUB_ENV
|
||||||
|
echo "✓ Using ACR: ${ACR_NAME}.azurecr.io"
|
||||||
|
|
||||||
|
- name: ACR Login
|
||||||
|
run: |
|
||||||
|
ACR_TOKEN=$(az acr login --name "$ACR_NAME" --expose-token --output tsv --query accessToken)
|
||||||
|
docker login "$ACR_LOGIN_SERVER" \
|
||||||
|
--username 00000000-0000-0000-0000-000000000000 \
|
||||||
|
--password "$ACR_TOKEN"
|
||||||
|
echo "✓ ACR login successful"
|
||||||
|
|
||||||
|
- name: Build and Push Docker image
|
||||||
|
run: |
|
||||||
|
IMAGE_TAG="${{ gitea.sha }}"
|
||||||
|
IMAGE_FULL="${ACR_LOGIN_SERVER}/reno-realworld-5:${IMAGE_TAG}"
|
||||||
|
IMAGE_LATEST="${ACR_LOGIN_SERVER}/reno-realworld-5:latest"
|
||||||
|
docker build -t "$IMAGE_FULL" -t "$IMAGE_LATEST" .
|
||||||
|
docker push "$IMAGE_FULL"
|
||||||
|
docker push "$IMAGE_LATEST"
|
||||||
|
echo "IMAGE_FULL=$IMAGE_FULL" >> $GITHUB_ENV
|
||||||
|
echo "✓ Pushed: $IMAGE_FULL"
|
||||||
|
|
||||||
|
- name: Build Summary
|
||||||
|
run: |
|
||||||
|
echo "### ✅ Build Successful" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| | |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| **Service** | reno-realworld-5 |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| **Commit** | ${{ gitea.sha }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| **Image** | $IMAGE_FULL |" >> $GITHUB_STEP_SUMMARY
|
||||||
186
.gitea/workflows/deploy.yml
Normal file
186
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
name: Deploy to Orchestrator
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build and Push to ACR"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [ "dev", "staging", "prod" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Target environment'
|
||||||
|
required: true
|
||||||
|
default: 'dev'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- dev
|
||||||
|
- staging
|
||||||
|
- prod
|
||||||
|
|
||||||
|
env:
|
||||||
|
PO_API_URL: https://api.dev.orchestrator.crucible.kyndemo.live
|
||||||
|
PO_ORG_ID: crucible
|
||||||
|
PO_AUTH_TOKEN: ${{ secrets.PO_AUTH_TOKEN }}
|
||||||
|
# ONE ORCHESTRATOR PROJECT PER APPLICATION.
|
||||||
|
#
|
||||||
|
# This used to be the shared `apps-cluster` project with one environment per app, which
|
||||||
|
# made every app a peer of every other: the Orchestrator tab on any component listed the
|
||||||
|
# entire estate, and an app had exactly one environment named after itself, so there was
|
||||||
|
# nowhere for dev/staging/prod to live.
|
||||||
|
#
|
||||||
|
# That shape existed to avoid a Terraform pull request against config/projects.tf for every
|
||||||
|
# scaffolded app. That constraint turned out not to be real -- `octl create project`,
|
||||||
|
# `octl create runner-rule` and `octl create environment` are all runtime operations, so
|
||||||
|
# the workflow below builds the whole thing on first deploy and needs no repository change.
|
||||||
|
PROJECT_ID: reno-realworld-5
|
||||||
|
# The runner a project's workloads execute on. This is NOT cosmetic: the Kubernetes and
|
||||||
|
# Helm providers are ambient, so a workload lands in whichever cluster its runner lives in,
|
||||||
|
# and Terraform state is keyed per runner. A project bound to the wrong runner deploys to
|
||||||
|
# the wrong cluster, and repointing it afterwards orphans the state it already owns.
|
||||||
|
PO_RUNNER_ID: crucible-orchestrator-dev-apps-dev-runner
|
||||||
|
OCTL_VERSION: 1.0.0
|
||||||
|
IMAGE: bstagecjotdevacr.azurecr.io/reno-realworld-5
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
guard:
|
||||||
|
name: Platform guard
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
ready: ${{ steps.check.outputs.ready }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check platform initialized
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
if [ -f ".platform/initialized.md" ]; then
|
||||||
|
echo "ready=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "ready=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "Skipping: .platform/initialized.md not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Orchestrator
|
||||||
|
needs: guard
|
||||||
|
if: >-
|
||||||
|
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && needs.guard.outputs.ready == 'true') ||
|
||||||
|
(github.event_name == 'workflow_dispatch')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install octl
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsSLo /tmp/octl.tar.gz \
|
||||||
|
"https://github.com/stellwerk-labs/platform-orchestrator-cli/releases/download/v${OCTL_VERSION}/platform-orchestrator-cli_${OCTL_VERSION}_linux_amd64.tar.gz"
|
||||||
|
tar xzf /tmp/octl.tar.gz -C /tmp
|
||||||
|
install -m 755 /tmp/octl /usr/local/bin/octl
|
||||||
|
octl --version
|
||||||
|
|
||||||
|
- name: Derive environment
|
||||||
|
run: |
|
||||||
|
# The environment is now a STAGE of this application -- dev, staging, prod -- because
|
||||||
|
# the project is the application. It used to be the component id, which was the only
|
||||||
|
# option while every app shared one project and had to be distinguishable inside it.
|
||||||
|
DISPATCH_ENV="${{ github.event.inputs.environment }}"
|
||||||
|
if [ -n "$DISPATCH_ENV" ]; then
|
||||||
|
ENV_ID="$DISPATCH_ENV"
|
||||||
|
else
|
||||||
|
# On a workflow_run the branch is the triggering run's, not this job's checkout.
|
||||||
|
BRANCH="${{ github.event.workflow_run.head_branch }}"
|
||||||
|
BRANCH="${BRANCH:-${GITHUB_REF_NAME}}"
|
||||||
|
case "$BRANCH" in
|
||||||
|
staging) ENV_ID=staging ;;
|
||||||
|
prod|main|master) ENV_ID=prod ;;
|
||||||
|
*) ENV_ID=dev ;;
|
||||||
|
esac
|
||||||
|
echo "Branch '$BRANCH' maps to environment '$ENV_ID'"
|
||||||
|
fi
|
||||||
|
echo "ENV_ID=$ENV_ID" >> $GITHUB_ENV
|
||||||
|
echo "Deploying $PROJECT_ID to environment: $ENV_ID"
|
||||||
|
|
||||||
|
- name: Ensure the project and its runner binding exist
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Created on first deploy rather than by a pull request against config/projects.tf.
|
||||||
|
# None of this needs a repository change: project, runner rule and environment are
|
||||||
|
# all runtime objects.
|
||||||
|
#
|
||||||
|
# Neither create is idempotent, so both fall through to a read on the second run.
|
||||||
|
octl create project "$PROJECT_ID" \
|
||||||
|
--set display_name='reno-realworld-5' || \
|
||||||
|
octl get project "$PROJECT_ID"
|
||||||
|
|
||||||
|
# The runner rule is what routes this project's deployments to the apps cluster.
|
||||||
|
# Creating it twice would leave two rules matching the same project, so it is
|
||||||
|
# created only when absent -- `create` would happily add a duplicate.
|
||||||
|
if octl get runner-rules -o json 2>/dev/null | grep -q "\"project_id\": *\"$PROJECT_ID\""; then
|
||||||
|
echo "Runner rule for '$PROJECT_ID' already exists."
|
||||||
|
else
|
||||||
|
octl create runner-rule \
|
||||||
|
--set project_id="$PROJECT_ID" \
|
||||||
|
--set runner_id="$PO_RUNNER_ID" \
|
||||||
|
--no-prompt
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Ensure the environment exists
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Project and environment ids are POSITIONAL; only env_type_id and display_name go
|
||||||
|
# through --set. `dev` and `stable` are the only environment TYPES that exist, so
|
||||||
|
# staging rides on the dev type -- the type governs policy, the id governs identity.
|
||||||
|
case "$ENV_ID" in
|
||||||
|
prod) ENV_TYPE=stable ;;
|
||||||
|
*) ENV_TYPE=dev ;;
|
||||||
|
esac
|
||||||
|
octl create environment "$PROJECT_ID" "$ENV_ID" \
|
||||||
|
--set env_type_id="$ENV_TYPE" \
|
||||||
|
--set display_name="$ENV_ID" || \
|
||||||
|
octl get environment "$PROJECT_ID" "$ENV_ID"
|
||||||
|
|
||||||
|
- name: Deploy the Score workload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# `octl score deploy` is ADDITIVE — it adds or updates a workload in the manifest and
|
||||||
|
# never removes one. That is the opposite of `octl deploy`, where omission is
|
||||||
|
# deletion. Do not substitute one for the other.
|
||||||
|
# No --show-logs: octl 1.0.0 has no such flag and exits 1 with `unknown flag`
|
||||||
|
# BEFORE contacting the orchestrator, so the whole deploy dies on an argument
|
||||||
|
# typo. Its nearest relatives are --runner-logs-level (default `info`, already
|
||||||
|
# what we want) and --skip-logs (which suppresses storage). Neither streams the
|
||||||
|
# runner's logs into this job, so there is nothing to substitute -- the runner
|
||||||
|
# logs are read from the orchestrator, not from here.
|
||||||
|
# The tag must be the commit the BUILD built, and it must be the WHOLE sha.
|
||||||
|
#
|
||||||
|
# build-push.yml tags with `` -- all 40 characters -- so the
|
||||||
|
# 7-character `${GITHUB_SHA:0:7}` this used to pass named a tag that has never
|
||||||
|
# existed in the registry.
|
||||||
|
#
|
||||||
|
# And on a workflow_run, GITHUB_SHA is the DEFAULT branch's head, while the build
|
||||||
|
# that produced the image ran on `dev`. They coincide only while the branches are
|
||||||
|
# level. `workflow_run.head_sha` is the triggering run's own commit, which is by
|
||||||
|
# definition the one that was built; `github.sha` covers the workflow_dispatch case,
|
||||||
|
# where there is no triggering run.
|
||||||
|
IMAGE_TAG="${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||||
|
echo "Deploying ${IMAGE}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
octl score deploy "$PROJECT_ID" "$ENV_ID" score.yaml \
|
||||||
|
--default-image "${IMAGE}:${IMAGE_TAG}" \
|
||||||
|
--no-prompt
|
||||||
|
|
||||||
|
- name: Deployment summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
# Same commit the deploy step resolved, abbreviated for reading only -- the
|
||||||
|
# deployed tag is the full sha.
|
||||||
|
DEPLOYED_SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||||
|
SHORT_SHA="${DEPLOYED_SHA:0:7}"
|
||||||
|
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Project | \`$PROJECT_ID\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Environment | \`$ENV_ID\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "| Commit | \`$SHORT_SHA\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "[View in Orchestrator Console](https://console.dev.orchestrator.crucible.kyndemo.live/orgs/$PO_ORG_ID/projects/$PROJECT_ID/environments/$ENV_ID)" >> $GITHUB_STEP_SUMMARY
|
||||||
161
.gitea/workflows/integration-test.yml
Normal file
161
.gitea/workflows/integration-test.yml
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
|
||||||
|
name: Integration Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Job 1: Platform Conformance ───────────────────────────────────────────
|
||||||
|
platform-check:
|
||||||
|
name: Platform Conformance
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate catalog-info.yaml
|
||||||
|
run: |
|
||||||
|
if [ ! -f catalog-info.yaml ]; then
|
||||||
|
echo "✗ catalog-info.yaml not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 -c "import yaml; list(yaml.safe_load_all(open('catalog-info.yaml')))" 2>/dev/null \
|
||||||
|
|| (pip install pyyaml -q && python3 -c "import yaml; list(yaml.safe_load_all(open('catalog-info.yaml')))")
|
||||||
|
echo "✓ catalog-info.yaml is valid YAML"
|
||||||
|
|
||||||
|
- name: Check platform initialized
|
||||||
|
run: |
|
||||||
|
if [ -f ".platform/initialized.md" ]; then
|
||||||
|
echo "✓ Platform initialized"
|
||||||
|
else
|
||||||
|
echo "⚠ .platform/initialized.md not found — skipping guard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Job 2: Unit Tests + Container Smoke ───────────────────────────────────
|
||||||
|
smoke-test:
|
||||||
|
name: Unit Tests + Container Smoke
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: platform-check
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Free disk space
|
||||||
|
run: |
|
||||||
|
docker system prune -f 2>/dev/null || true
|
||||||
|
df -h / 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
run: command -v docker &>/dev/null || (apt-get update -qq && apt-get install -y docker.io)
|
||||||
|
|
||||||
|
- name: Build container image
|
||||||
|
run: |
|
||||||
|
if [ -f Dockerfile ]; then
|
||||||
|
docker build -t ci-image:test .
|
||||||
|
else
|
||||||
|
echo "No Dockerfile found — skipping container smoke test"
|
||||||
|
echo "SKIP_SMOKE=true" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Start service and wait for health
|
||||||
|
if: env.SKIP_SMOKE != 'true'
|
||||||
|
run: |
|
||||||
|
CONTAINER_NAME="ci-${GITHUB_RUN_ID}"
|
||||||
|
|
||||||
|
# Nothing sets CONTAINER_PORT, so this used to be 8080 on every run, for every
|
||||||
|
# application. That is fine for Spring Boot and wrong for everything else -- an
|
||||||
|
# Express app on 3000 was probed on 8080, found nothing, and failed the smoke test
|
||||||
|
# after burning the full 180 second health timeout.
|
||||||
|
#
|
||||||
|
# score.yaml is the one file in this repository that knows the answer: the agent
|
||||||
|
# writes targetPort from the port it detected in the application's own source. So
|
||||||
|
# read it, exactly as the health-path probe below discovers its path instead of
|
||||||
|
# assuming one. Both defaults stay as a last resort for a repo without a score.yaml.
|
||||||
|
PORT="${CONTAINER_PORT:-}"
|
||||||
|
if [ -z "${PORT}" ] && [ -f score.yaml ]; then
|
||||||
|
PORT=$(grep -oE 'targetPort:[[:space:]]*[0-9]+' score.yaml | head -1 | tr -dc '0-9')
|
||||||
|
[ -n "${PORT}" ] && echo "Detected container port ${PORT} from score.yaml"
|
||||||
|
fi
|
||||||
|
PORT="${PORT:-8080}"
|
||||||
|
echo "CONTAINER_NAME=${CONTAINER_NAME}" >> $GITHUB_ENV
|
||||||
|
echo "CONTAINER_PORT=${PORT}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
docker run -d --name "${CONTAINER_NAME}" -e OTEL_SDK_DISABLED=true ci-image:test
|
||||||
|
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
CONTAINER_IP=$(docker inspect "${CONTAINER_NAME}" --format '{{.NetworkSettings.IPAddress}}' 2>/dev/null)
|
||||||
|
[ -n "${CONTAINER_IP}" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "CONTAINER_IP=${CONTAINER_IP}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# This factory renovates arbitrary applications, so it cannot know the health path
|
||||||
|
# in advance. The previous hardcoded /health is wrong for the single most likely
|
||||||
|
# input: a Spring Boot app serves /actuator/health and 404s on /health, so the
|
||||||
|
# smoke test sat through its full 180s timeout against a container that was up and
|
||||||
|
# answering the whole time.
|
||||||
|
#
|
||||||
|
# Probe candidates instead, and settle on whichever answers. `/` is last and is a
|
||||||
|
# legitimate result -- it proves the container serves HTTP, which is all this test
|
||||||
|
# claims. HEALTH_ENDPOINT still wins if the caller sets it.
|
||||||
|
CANDIDATES="${HEALTH_ENDPOINT:-} /actuator/health /health /healthz /health/live /api/health /"
|
||||||
|
echo "Probing for a health endpoint on ${CONTAINER_IP}:${PORT} (up to 180s)..."
|
||||||
|
DEADLINE=$(($(date +%s) + 180))
|
||||||
|
HEALTH_PATH=""
|
||||||
|
while [ -z "${HEALTH_PATH}" ]; do
|
||||||
|
for c in ${CANDIDATES}; do
|
||||||
|
if curl -sf -o /dev/null "http://${CONTAINER_IP}:${PORT}${c}" 2>/dev/null; then
|
||||||
|
HEALTH_PATH="$c"; break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "${HEALTH_PATH}" ] && break
|
||||||
|
if [ $(date +%s) -ge $DEADLINE ]; then
|
||||||
|
echo "Timeout: no candidate path answered (${CANDIDATES})"
|
||||||
|
docker logs "${CONTAINER_NAME}" 2>&1 | tail -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! docker ps --filter "name=${CONTAINER_NAME}" --format '{{.Status}}' | grep -q Up; then
|
||||||
|
echo "Container exited:"
|
||||||
|
docker logs "${CONTAINER_NAME}" 2>&1 | tail -30
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " still waiting..."; sleep 3
|
||||||
|
done
|
||||||
|
echo "HEALTH_PATH=${HEALTH_PATH}" >> $GITHUB_ENV
|
||||||
|
echo "✓ Service healthy at ${CONTAINER_IP}:${PORT}${HEALTH_PATH}"
|
||||||
|
|
||||||
|
- name: Validate health response
|
||||||
|
if: env.SKIP_SMOKE != 'true'
|
||||||
|
run: |
|
||||||
|
curl -sf "http://${CONTAINER_IP}:${CONTAINER_PORT}${HEALTH_PATH}" > /tmp/health.json
|
||||||
|
echo "Health response:"
|
||||||
|
cat /tmp/health.json
|
||||||
|
echo ""
|
||||||
|
echo "✓ Container smoke test: PASSED"
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: docker rm -f "ci-${GITHUB_RUN_ID}" 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Post commit status
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
JOB_STATUS: ${{ job.status }}
|
||||||
|
run: |
|
||||||
|
STATE=$([[ "$JOB_STATUS" == "success" ]] && echo "success" || echo "failure")
|
||||||
|
DESC=$([[ "$STATE" == "success" ]] && echo "All checks passed" || echo "Some checks failed")
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/statuses/${GITHUB_SHA}" \
|
||||||
|
-d "{\"state\":\"${STATE}\",\"context\":\"Integration Test / Unit Tests + Container Smoke (workflow_dispatch)\",\"description\":\"${DESC}\"}" \
|
||||||
|
|| true
|
||||||
445
.gitea/workflows/sonar.yaml
Normal file
445
.gitea/workflows/sonar.yaml
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
|
||||||
|
name: SonarQube Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
name: Build, Test & Analyse
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
env:
|
||||||
|
SONAR_PROJECT_KEY: reno-realworld-5
|
||||||
|
SONAR_ADMIN_TOKEN: ${{ secrets.SONAR_ADMIN_TOKEN }}
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
BACKSTAGE_CLEANUP_TOKEN: ${{ secrets.BACKSTAGE_CLEANUP_TOKEN }}
|
||||||
|
BACKSTAGE_URL: https://backstage.dev.crucible.kyndemo.live
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# This factory renovates arbitrary applications, so the analysis cannot assume one of
|
||||||
|
# them. Everything below used to be Maven unconditionally, and the very first Java step
|
||||||
|
# was `chmod +x mvnw` -- which on a Node repository fails with
|
||||||
|
# "cannot access 'mvnw': No such file or directory" and takes the whole run with it.
|
||||||
|
#
|
||||||
|
# Two paths, chosen from what is actually in the repository:
|
||||||
|
#
|
||||||
|
# maven pom.xml present -> mvnw verify + sonar-maven-plugin, with JaCoCo
|
||||||
|
# cli anything else -> sonar-scanner CLI, which analyses JS/TS, Python, Go
|
||||||
|
# and more without needing that language's build
|
||||||
|
#
|
||||||
|
# `cli` rather than `node` because the fallback is genuinely language-agnostic: a Python
|
||||||
|
# or Go repository gets analysed too, just without coverage. Only Node adds a test step,
|
||||||
|
# because it is the one runtime whose coverage format the scanner reads for free.
|
||||||
|
- name: Detect project type
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -f pom.xml ]; then
|
||||||
|
KIND=maven
|
||||||
|
else
|
||||||
|
KIND=cli
|
||||||
|
fi
|
||||||
|
echo "PROJECT_KIND=${KIND}" >> $GITHUB_ENV
|
||||||
|
echo "Detected project kind: ${KIND}"
|
||||||
|
if [ "${KIND}" = "cli" ] && [ -f package.json ]; then
|
||||||
|
echo "HAS_NPM=true" >> $GITHUB_ENV
|
||||||
|
echo "package.json present — will run tests for coverage."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deliberately NOT gated on PROJECT_KIND. Maven needs it to build, and sonar-scanner is
|
||||||
|
# itself a Java program that ships no JRE -- so on the CLI path a Node image without
|
||||||
|
# java would fail inside the scanner's wrapper script with a bare "java: not found".
|
||||||
|
# Installing it once here is cheaper than diagnosing that.
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Make Maven wrapper executable
|
||||||
|
if: env.PROJECT_KIND == 'maven'
|
||||||
|
run: chmod +x mvnw
|
||||||
|
|
||||||
|
- name: Cache Maven packages
|
||||||
|
if: env.PROJECT_KIND == 'maven'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.m2/repository
|
||||||
|
key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
|
||||||
|
restore-keys: maven-${{ runner.os }}-
|
||||||
|
|
||||||
|
# The sonar-maven-plugin caches here. The CLI scanner keeps its own cache under
|
||||||
|
# ~/.sonar too, so this is useful on both paths -- but the key must not be keyed on
|
||||||
|
# pom.xml, which does not exist on the CLI path and would collapse every non-Maven
|
||||||
|
# repository onto one shared key.
|
||||||
|
- name: Cache SonarQube analysis data
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.sonar/cache
|
||||||
|
key: sonar-${{ runner.os }}-${{ hashFiles('**/pom.xml', '**/package-lock.json', '**/go.sum', '**/requirements.txt') }}
|
||||||
|
restore-keys: sonar-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Validate required secrets
|
||||||
|
run: |
|
||||||
|
[[ -n "$SONAR_ADMIN_TOKEN" ]] || { echo "::error::SONAR_ADMIN_TOKEN is not set"; exit 1; }
|
||||||
|
[[ -n "$SONAR_HOST_URL" ]] || { echo "::error::SONAR_HOST_URL is not set"; exit 1; }
|
||||||
|
[[ -n "$SONAR_PROJECT_KEY" ]] || { echo "::error::SONAR_PROJECT_KEY is not set"; exit 1; }
|
||||||
|
|
||||||
|
SONAR_HOST_URL="${SONAR_HOST_URL%/}"
|
||||||
|
|
||||||
|
AUTH_RESPONSE=$(curl -s -o /tmp/sonar-auth-response.json -w "%{http_code}" \
|
||||||
|
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/authentication/validate")
|
||||||
|
|
||||||
|
if [[ "$AUTH_RESPONSE" != "200" ]]; then
|
||||||
|
echo "::error::SonarQube is unreachable or returned HTTP ${AUTH_RESPONSE} — check SONAR_HOST_URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOKEN_VALID=$(jq -r '.valid' /tmp/sonar-auth-response.json 2>/dev/null || echo "false")
|
||||||
|
if [[ "$TOKEN_VALID" != "true" ]]; then
|
||||||
|
echo "::error::SONAR_ADMIN_TOKEN is invalid or has been revoked (SonarQube returned valid=false)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ SONAR_ADMIN_TOKEN verified against ${SONAR_HOST_URL}"
|
||||||
|
|
||||||
|
- name: Bootstrap SonarQube project and generate scan token
|
||||||
|
id: sonar-bootstrap
|
||||||
|
run: |
|
||||||
|
PROJECT_COUNT=$(curl -sf \
|
||||||
|
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/projects/search?projects=${SONAR_PROJECT_KEY}" \
|
||||||
|
| jq '.paging.total')
|
||||||
|
|
||||||
|
if [[ "$PROJECT_COUNT" == "0" ]]; then
|
||||||
|
echo "Project '${SONAR_PROJECT_KEY}' not found — creating it..."
|
||||||
|
curl -sf -X POST \
|
||||||
|
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/projects/create" \
|
||||||
|
--data-urlencode "name=${SONAR_PROJECT_KEY}" \
|
||||||
|
--data-urlencode "project=${SONAR_PROJECT_KEY}" \
|
||||||
|
--data-urlencode "mainBranch=main" \
|
||||||
|
--data-urlencode "visibility=private"
|
||||||
|
echo "✅ Project created."
|
||||||
|
else
|
||||||
|
echo "✅ Project '${SONAR_PROJECT_KEY}' already exists — skipping creation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The token name MUST be unique per run. It used to be `gitea-scan-<projectKey>`,
|
||||||
|
# shared by every job analysing this project, and each job both revokes that name
|
||||||
|
# before generating and revokes it again on cleanup. Concurrent jobs therefore
|
||||||
|
# destroyed each other's credentials and every analysis failed with
|
||||||
|
# `Not authorized. Please check the user token in 'sonar.token'` -- an error that
|
||||||
|
# reads like a permissions problem and is not one. Observed on reno-rehearsal-6,
|
||||||
|
# where three overlapping jobs failed in a chain:
|
||||||
|
#
|
||||||
|
# 18:37:39 job A generates the shared name
|
||||||
|
# 18:37:40 job B revokes it, generates its own
|
||||||
|
# 18:38:16 job C revokes it, generates its own
|
||||||
|
# 18:38:53 job A analysis -> Not authorized (killed at 18:37:40)
|
||||||
|
# 18:38:53 job A cleanup revokes the name -> kills job C's live token
|
||||||
|
# 18:39:26 job C analysis -> Not authorized (killed at 18:38:53)
|
||||||
|
#
|
||||||
|
# The `concurrency:` block above does not prevent this: Gitea Actions does not
|
||||||
|
# honour it, so the overlap is real no matter what the workflow declares. Scoping
|
||||||
|
# the name to the run id removes the shared resource instead of relying on
|
||||||
|
# serialisation, and the cleanup step revokes that same per-run name so tokens
|
||||||
|
# still do not accumulate.
|
||||||
|
TOKEN_NAME="gitea-scan-${SONAR_PROJECT_KEY}-${GITHUB_RUN_ID}"
|
||||||
|
echo "TOKEN_NAME=${TOKEN_NAME}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
curl -sf -X POST \
|
||||||
|
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/user_tokens/revoke" \
|
||||||
|
--data-urlencode "name=${TOKEN_NAME}" \
|
||||||
|
> /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
SCAN_TOKEN=$(curl -sf -X POST \
|
||||||
|
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/user_tokens/generate" \
|
||||||
|
--data-urlencode "name=${TOKEN_NAME}" \
|
||||||
|
--data-urlencode "type=PROJECT_ANALYSIS_TOKEN" \
|
||||||
|
--data-urlencode "projectKey=${SONAR_PROJECT_KEY}" \
|
||||||
|
| jq -r '.token')
|
||||||
|
|
||||||
|
# `jq -r` prints the string "null" for a missing key, which is non-empty and would
|
||||||
|
# sail past a bare -n test, then fail much later as an authorisation error.
|
||||||
|
if [[ -z "$SCAN_TOKEN" || "$SCAN_TOKEN" == "null" ]]; then
|
||||||
|
echo "::error::Failed to generate scan token '${TOKEN_NAME}'"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Scan token generated for project '${SONAR_PROJECT_KEY}'"
|
||||||
|
echo "::add-mask::${SCAN_TOKEN}"
|
||||||
|
echo "SCAN_TOKEN=${SCAN_TOKEN}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# Spring projects carry io.spring.nohttp, a Checkstyle rule that fails the build on any
|
||||||
|
# `http://` URL anywhere under ${basedir}. The platform artifacts this factory adds
|
||||||
|
# contain one legitimately -- the in-cluster OTLP collector, which is plaintext by
|
||||||
|
# design and has no https:// form -- so the app's own quality gate rejects the
|
||||||
|
# modernisation:
|
||||||
|
#
|
||||||
|
# [ERROR] score.yaml:[7,37] (extension) NoHttp: http:// URLs are not allowed
|
||||||
|
# [ERROR] overlays/otel/kustomization.yaml:[13,81] ...
|
||||||
|
# [ERROR] overlays/otel/patches/otel-patch.yaml:[17,19] ...
|
||||||
|
#
|
||||||
|
# The two -D properties on the build below cannot fix it: the SuppressionFilter in
|
||||||
|
# nohttp-checkstyle.xml reads a FIXED path, ${config_loc}/nohttp-checkstyle-suppressions.xml,
|
||||||
|
# so the only lever is the content of that file. Hence editing it rather than passing
|
||||||
|
# a flag.
|
||||||
|
#
|
||||||
|
# Scoped deliberately: `checks="NoHttp"` only, and only the paths this platform owns.
|
||||||
|
# Every other Checkstyle rule still applies to them, and NoHttp still applies to all
|
||||||
|
# application source -- which is the rule's actual purpose.
|
||||||
|
- name: Allowlist platform artifacts for nohttp
|
||||||
|
if: env.PROJECT_KIND == 'maven'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SUPPRESSIONS=src/checkstyle/nohttp-checkstyle-suppressions.xml
|
||||||
|
if [ ! -f "$SUPPRESSIONS" ]; then
|
||||||
|
echo "No nohttp suppressions file — project does not use nohttp, nothing to do."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if grep -q 'crucible-platform-artifacts' "$SUPPRESSIONS"; then
|
||||||
|
echo "Platform artifacts already allowlisted."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
grep -q '</suppressions>' "$SUPPRESSIONS" || {
|
||||||
|
echo "::error::$SUPPRESSIONS has no closing </suppressions> tag"; exit 1; }
|
||||||
|
|
||||||
|
# awk, not python3 or sed -i: this job runs in whatever image the app's build needs,
|
||||||
|
# so only POSIX tooling is safe to assume.
|
||||||
|
awk '
|
||||||
|
/<\/suppressions>/ && !inserted {
|
||||||
|
print "\t<!-- crucible-platform-artifacts: in-cluster OTLP endpoints are http:// by design -->";
|
||||||
|
print "\t<suppress files=\"score\\.yaml\" checks=\"NoHttp\"/>";
|
||||||
|
print "\t<suppress files=\"catalog-info\\.yaml\" checks=\"NoHttp\"/>";
|
||||||
|
print "\t<suppress files=\"overlays[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||||
|
print "\t<suppress files=\"\\.platform[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||||
|
print "\t<suppress files=\"\\.gitea[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||||
|
print "\t<suppress files=\"k6[\\\\/].*\" checks=\"NoHttp\"/>";
|
||||||
|
inserted = 1
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$SUPPRESSIONS" > "$SUPPRESSIONS.new" && mv "$SUPPRESSIONS.new" "$SUPPRESSIONS"
|
||||||
|
|
||||||
|
echo "Allowlisted platform artifacts for NoHttp:"
|
||||||
|
cat "$SUPPRESSIONS"
|
||||||
|
|
||||||
|
- name: Build and test
|
||||||
|
if: env.PROJECT_KIND == 'maven'
|
||||||
|
run: |
|
||||||
|
./mvnw -B verify \
|
||||||
|
-Dtest='!PostgresIntegrationTests,!MySqlIntegrationTests' \
|
||||||
|
-Dnohttp.checkstyle.suppressions.file=src/checkstyle/nohttp-checkstyle-suppressions.xml \
|
||||||
|
-Dcheckstyle.suppressionsFile=src/checkstyle/nohttp-checkstyle-suppressions.xml
|
||||||
|
|
||||||
|
- name: SonarQube analysis
|
||||||
|
if: env.PROJECT_KIND == 'maven'
|
||||||
|
run: |
|
||||||
|
./mvnw -B org.sonarsource.scanner.maven:sonar-maven-plugin:4.0.0.4121:sonar \
|
||||||
|
-Dsonar.projectKey="${SONAR_PROJECT_KEY}" \
|
||||||
|
-Dsonar.host.url="${SONAR_HOST_URL}" \
|
||||||
|
-Dsonar.token="${SCAN_TOKEN}" \
|
||||||
|
-Dsonar.java.source=17 \
|
||||||
|
-Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml
|
||||||
|
|
||||||
|
# ── Non-Maven path ────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Everything below runs only when there is no pom.xml, and feeds the SAME quality gate:
|
||||||
|
# the scan token, the gate poll, the Backstage notification and the token revoke are all
|
||||||
|
# language-agnostic and are not duplicated.
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
# Tests must not fail the job. A quality gate reports on what it measured; a repository
|
||||||
|
# with no test script, or one whose suite needs a database this job does not have, still
|
||||||
|
# deserves to be analysed. Coverage simply goes unreported, and the gate says so.
|
||||||
|
- name: Install and test
|
||||||
|
if: env.PROJECT_KIND == 'cli' && env.HAS_NPM == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
# Prisma validates DATABASE_URL when the client is constructed, and construction
|
||||||
|
# happens at import time -- including in suites that mock every query and never
|
||||||
|
# open a connection. A placeholder is enough to let those run; nothing here
|
||||||
|
# connects to it. Without it the RealWorld service tests fail on
|
||||||
|
# `Environment variable not found: DATABASE_URL` (7 of 27), which is not a finding
|
||||||
|
# about the code, and coverage goes unreported for a suite that would have passed.
|
||||||
|
DATABASE_URL: postgresql://test:test@localhost:5432/test
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
npm test --if-present -- --coverage 2>/dev/null || npm test --if-present || true
|
||||||
|
if [ -f coverage/lcov.info ]; then
|
||||||
|
echo "SONAR_LCOV=coverage/lcov.info" >> $GITHUB_ENV
|
||||||
|
echo "Found coverage/lcov.info — it will be reported."
|
||||||
|
else
|
||||||
|
echo "::notice::No coverage/lcov.info produced; analysing without coverage."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: SonarQube analysis (scanner CLI)
|
||||||
|
if: env.PROJECT_KIND == 'cli'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Pinned. `latest` would move the analyser under a quality gate that is supposed to
|
||||||
|
# mean the same thing from one run to the next.
|
||||||
|
SCANNER_VERSION=6.2.1.4610
|
||||||
|
SCANNER_DIR="sonar-scanner-${SCANNER_VERSION}-linux-x64"
|
||||||
|
|
||||||
|
# This job runs in whatever image the application's build needs, so unzip is not a
|
||||||
|
# given -- the same reason the nohttp step above uses awk rather than python3.
|
||||||
|
command -v unzip >/dev/null 2>&1 || {
|
||||||
|
(apt-get update -qq && apt-get install -y -qq unzip) >/dev/null 2>&1 \
|
||||||
|
|| { echo "::error::unzip is unavailable and could not be installed"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
curl -sSLo /tmp/sonar-scanner.zip \
|
||||||
|
"https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SCANNER_VERSION}-linux-x64.zip"
|
||||||
|
unzip -q /tmp/sonar-scanner.zip -d /opt
|
||||||
|
export PATH="/opt/${SCANNER_DIR}/bin:${PATH}"
|
||||||
|
|
||||||
|
# The scanner is a Java program and ships no JRE. Most build images have one; if
|
||||||
|
# this repository's does not, say so plainly rather than surface a bare
|
||||||
|
# "java: not found" from inside the wrapper script.
|
||||||
|
command -v java >/dev/null 2>&1 || {
|
||||||
|
echo "::error::sonar-scanner needs a JRE and none is on PATH in this image."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Excluded because they are this platform's artifacts, not the application's code,
|
||||||
|
# and counting them would move the gate for reasons the developer cannot act on.
|
||||||
|
# Mirrors the nohttp allowlist on the Maven path.
|
||||||
|
ARGS=(
|
||||||
|
-Dsonar.projectKey="${SONAR_PROJECT_KEY}"
|
||||||
|
-Dsonar.host.url="${SONAR_HOST_URL}"
|
||||||
|
-Dsonar.token="${SCAN_TOKEN}"
|
||||||
|
-Dsonar.sources=.
|
||||||
|
-Dsonar.exclusions="**/node_modules/**,**/coverage/**,**/dist/**,**/build/**,k6/**,overlays/**,.platform/**,.gitea/**"
|
||||||
|
)
|
||||||
|
if [ -n "${SONAR_LCOV:-}" ]; then
|
||||||
|
ARGS+=(-Dsonar.javascript.lcov.reportPaths="${SONAR_LCOV}")
|
||||||
|
fi
|
||||||
|
sonar-scanner "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Quality Gate check
|
||||||
|
id: quality-gate
|
||||||
|
run: |
|
||||||
|
echo "Waiting for SonarQube to process the analysis..."
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
RESPONSE=$(curl -sf -u "${SCAN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/qualitygates/project_status?projectKey=${SONAR_PROJECT_KEY}" || true)
|
||||||
|
STATUS=$(echo "$RESPONSE" | jq -r '.projectStatus.status' 2>/dev/null || echo "NONE")
|
||||||
|
if [[ "$STATUS" =~ ^(OK|ERROR|WARN)$ ]]; then break; fi
|
||||||
|
echo " Status: ${STATUS:-pending} — retrying in 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
echo " Quality Gate: $STATUS"
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
|
||||||
|
echo "$RESPONSE" | jq -r '
|
||||||
|
.projectStatus.conditions[] |
|
||||||
|
if .status == "ERROR" then " ❌ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))"
|
||||||
|
elif .status == "WARN" then " ⚠️ \(.metricKey): \(.actualValue) (threshold: \(.errorThreshold), comparator: \(.comparator))"
|
||||||
|
else " ✅ \(.metricKey): \(.actualValue)"
|
||||||
|
end'
|
||||||
|
|
||||||
|
echo "══════════════════════════════════════════"
|
||||||
|
|
||||||
|
FAILED=$(echo "$RESPONSE" | jq '[.projectStatus.conditions[] | select(.status == "ERROR")] | length')
|
||||||
|
if [[ "$FAILED" -gt 0 ]]; then
|
||||||
|
echo "::error::Quality Gate FAILED — $FAILED metric(s) did not meet threshold"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify Backstage on quality gate failure
|
||||||
|
if: always() && steps.quality-gate.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
echo "--- Backstage notification debug ---"
|
||||||
|
echo "BACKSTAGE_URL: ${BACKSTAGE_URL:-<not set>}"
|
||||||
|
echo "BACKSTAGE_CLEANUP_TOKEN set: $([[ -n "$BACKSTAGE_CLEANUP_TOKEN" ]] && echo yes || echo no)"
|
||||||
|
echo "SONAR_PROJECT_KEY: ${SONAR_PROJECT_KEY:-<not set>}"
|
||||||
|
echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-<not set>}"
|
||||||
|
|
||||||
|
if [[ -z "$BACKSTAGE_URL" ]]; then
|
||||||
|
echo "::error::BACKSTAGE_URL is not set — cannot send notification"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ -z "$BACKSTAGE_CLEANUP_TOKEN" ]]; then
|
||||||
|
echo "::error::BACKSTAGE_CLEANUP_TOKEN is not set — cannot send notification"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Built with jq rather than a quoted heredoc-by-hand. The previous version
|
||||||
|
# interpolated shell variables into a \"-escaped string and got one line wrong --
|
||||||
|
# `"description": "Quality gate failed for ...` was unescaped, so bash closed the
|
||||||
|
# string early and tried to run `gate` as a command. Nobody saw it because this
|
||||||
|
# step only runs when the quality gate fails. jq also escapes the branch name and
|
||||||
|
# project key, which are attacker-adjacent (a branch may contain a quote).
|
||||||
|
PAYLOAD=$(jq -nc \
|
||||||
|
--arg project "${SONAR_PROJECT_KEY}" \
|
||||||
|
--arg branch "${GITHUB_HEAD_REF}" \
|
||||||
|
--arg link "${SONAR_HOST_URL}/dashboard?id=${SONAR_PROJECT_KEY}" \
|
||||||
|
'{
|
||||||
|
recipients: { type: "entity", entityRef: "group:default/platform-engineering" },
|
||||||
|
payload: {
|
||||||
|
title: "SonarQube Quality Gate Failed",
|
||||||
|
description: "Quality gate failed for \($project) on branch \($branch).",
|
||||||
|
link: $link,
|
||||||
|
severity: "high",
|
||||||
|
topic: "sonarqube-quality-gate"
|
||||||
|
}
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "Sending notification to: ${BACKSTAGE_URL}/api/notifications"
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o /tmp/bs-notify-response.json -w "%{http_code}" \
|
||||||
|
-X POST "${BACKSTAGE_URL}/api/notifications" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${BACKSTAGE_CLEANUP_TOKEN}" \
|
||||||
|
-d "${PAYLOAD}")
|
||||||
|
|
||||||
|
echo "HTTP response code: ${HTTP_CODE}"
|
||||||
|
echo "Response body:"
|
||||||
|
cat /tmp/bs-notify-response.json 2>/dev/null || echo "<empty response>"
|
||||||
|
|
||||||
|
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
|
||||||
|
echo "✅ Backstage notification sent"
|
||||||
|
else
|
||||||
|
echo "⚠️ Backstage notification failed (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Revoke scan token
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
# Revoke THIS run's token, never the bare `gitea-scan-<projectKey>` name -- that
|
||||||
|
# name belongs to no single job, and revoking it here is what killed a concurrent
|
||||||
|
# job's live credential. TOKEN_NAME is empty if bootstrap never ran, in which case
|
||||||
|
# there is nothing to revoke and a blank name would be a no-op request at best.
|
||||||
|
if [[ -z "${TOKEN_NAME:-}" ]]; then
|
||||||
|
echo "No scan token was generated — nothing to revoke."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
curl -sf -X POST \
|
||||||
|
-u "${SONAR_ADMIN_TOKEN}:" \
|
||||||
|
"${SONAR_HOST_URL}/api/user_tokens/revoke" \
|
||||||
|
--data-urlencode "name=${TOKEN_NAME}" \
|
||||||
|
&& echo "✅ Scan token revoked (${TOKEN_NAME})" \
|
||||||
|
|| echo "⚠️ Token revocation failed"
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# compiled output
|
||||||
|
dist
|
||||||
|
tmp
|
||||||
|
/out-tsc
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# misc
|
||||||
|
/.sass-cache
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
.env
|
||||||
|
|
||||||
|
# System Files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
.nx/cache
|
||||||
9
.platform/initialized.md
Normal file
9
.platform/initialized.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Platform Initialized
|
||||||
|
|
||||||
|
This file marks the repository as platform-initialized for the Humanitec v2 deploy workflow.
|
||||||
|
|
||||||
|
Created by the Backstage Application Renovation Factory template.
|
||||||
|
|
||||||
|
**Profile**: `db-only`
|
||||||
|
**Component**: `reno-realworld-5`
|
||||||
|
**Project**: `reno-realworld-5`
|
||||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Add files here to ignore them from prettier formatting
|
||||||
|
/dist
|
||||||
|
/coverage
|
||||||
|
/.nx/cache
|
||||||
3
.prettierrc
Normal file
3
.prettierrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"nrwl.angular-console",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"firsttris.vscode-jest-runner",
|
||||||
|
"dbaeumer.vscode-eslint"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# This file is generated by Nx.
|
||||||
|
#
|
||||||
|
# Build the docker image with `npx nx docker-build api`.
|
||||||
|
# Tip: Modify "docker-build" options in project.json to change docker build args.
|
||||||
|
#
|
||||||
|
# Run the container with `docker run -p 3000:3000 -t api`.
|
||||||
|
FROM docker.io/node:lts-alpine
|
||||||
|
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system api && \
|
||||||
|
adduser --system -G api api
|
||||||
|
|
||||||
|
COPY dist/api api
|
||||||
|
RUN chown -R api:api .
|
||||||
|
|
||||||
|
# You can remove this install step if you build with `--bundle` option.
|
||||||
|
# The bundled output will include external dependencies.
|
||||||
|
RUN npm --prefix api --omit=dev -f install
|
||||||
|
|
||||||
|
CMD [ "node", "api" ]
|
||||||
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 
|
||||||
|
|
||||||
|
[](https://travis-ci.org/anishkny/node-express-realworld-example-app)
|
||||||
|
|
||||||
|
> ### Example Node (Express + Prisma) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) API spec.
|
||||||
|
|
||||||
|
<a href="https://thinkster.io/tutorials/node-json-api" target="_blank"><img width="454" src="https://raw.githubusercontent.com/gothinkster/realworld/master/media/learn-btn-hr.png" /></a>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Run the following command to install dependencies:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
This project depends on some environment variables.
|
||||||
|
If you are running this project locally, create a `.env` file at the root for these variables.
|
||||||
|
Your host provider should included a feature to set them there directly to avoid exposing them.
|
||||||
|
|
||||||
|
Here are the required ones:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=
|
||||||
|
JWT_SECRET=
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate your Prisma client
|
||||||
|
|
||||||
|
Run the following command to generate the Prisma Client which will include types based on your database schema:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply any SQL migration script
|
||||||
|
|
||||||
|
Run the following command to create/update your database based on existing sql migration scripts:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the project
|
||||||
|
|
||||||
|
Run the following command to run the project:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npx nx serve api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Seed the database
|
||||||
|
|
||||||
|
The project includes a seed script to populate the database:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npx prisma db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy on a remote server
|
||||||
|
|
||||||
|
Run the following command to:
|
||||||
|
- install dependencies
|
||||||
|
- apply any new migration sql scripts
|
||||||
|
- run the server
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm ci && npx prisma migrate deploy && node dist/api/main.js
|
||||||
|
```
|
||||||
67
catalog-info.yaml
Normal file
67
catalog-info.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
|
||||||
|
apiVersion: backstage.io/v1alpha1
|
||||||
|
kind: Component
|
||||||
|
metadata:
|
||||||
|
name: reno-realworld-5
|
||||||
|
description: 'reno-realworld-5 — renovated onto the crucible platform orchestrator'
|
||||||
|
annotations:
|
||||||
|
gitea.kyndemo.live/project-slug: validate/reno-realworld-5
|
||||||
|
gitea.kyndemo.live/repo-slug: validate/reno-realworld-5
|
||||||
|
# The orchestrator names each environment's namespace ns-xxxxx at deploy time, so a
|
||||||
|
# namespace can never be known here -- pinning one only hides the workload. Omitted
|
||||||
|
# deliberately: the Kubernetes plugin then searches every namespace it is given.
|
||||||
|
#
|
||||||
|
# kubernetes-id matches the label the score-workload module propagates from the Score
|
||||||
|
# metadata.labels block. The previous selector, app.humanitec.io/name=..., was a
|
||||||
|
# Humanitec SaaS label that nothing on this platform has ever set.
|
||||||
|
backstage.io/kubernetes-id: reno-realworld-5
|
||||||
|
backstage.io/techdocs-ref: dir:.
|
||||||
|
# Vestigial key names, live values. These drive the Orchestrator tab, which reads
|
||||||
|
# humanitec.dev/orgId and humanitec.dev/projectId; renaming the keys would break every
|
||||||
|
# entity already in the catalog, so the names stay and the values point at crucible.
|
||||||
|
humanitec.dev/orgId: crucible
|
||||||
|
# THE PROJECT IS THE APPLICATION. This was `apps-cluster`, a single project shared by
|
||||||
|
# every renovated app, which is why the Orchestrator tab on any one component listed the
|
||||||
|
# whole estate -- the tab shows a project's environments, and every app's environment
|
||||||
|
# lived in that one project. Now each app owns a project, so the tab shows this app's
|
||||||
|
# stages and nothing else. The deploy workflow creates the project, its runner rule and
|
||||||
|
# its environments on first deploy.
|
||||||
|
humanitec.dev/projectId: reno-realworld-5
|
||||||
|
# The environments are now STAGES of this application -- dev, staging, prod -- rather
|
||||||
|
# than one environment named after the component.
|
||||||
|
humanitec.dev/appId: reno-realworld-5
|
||||||
|
cjot.io/target-domain: apps
|
||||||
|
sonarqube.org/project-key: reno-realworld-5
|
||||||
|
grafana/grafana-instance: "default"
|
||||||
|
grafana/alert-label-selector: "app=reno-realworld-5"
|
||||||
|
grafana/dashboard-selector: "uid == 'otel-app-observability-v2'"
|
||||||
|
grafana.com/dashboard-url: "https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=reno-realworld-5"
|
||||||
|
tags:
|
||||||
|
- platform-orchestrator
|
||||||
|
- renovation
|
||||||
|
- postgresql
|
||||||
|
links:
|
||||||
|
# console.humanitec.dev is the dead SaaS. This is the live crucible console, deep-linked
|
||||||
|
# to the per-application environment the deploy workflow creates.
|
||||||
|
- url: https://console.dev.orchestrator.crucible.kyndemo.live/orgs/crucible/projects/reno-realworld-5/environments/dev
|
||||||
|
title: Orchestrator Console
|
||||||
|
icon: dashboard
|
||||||
|
- url: https://reno-realworld-5.apps.dev.crucible.kyndemo.live
|
||||||
|
title: Live Application
|
||||||
|
icon: web
|
||||||
|
- url: https://gitea.kyndemo.live/validate/reno-realworld-5
|
||||||
|
title: Source Repository
|
||||||
|
icon: github
|
||||||
|
- url: https://gitea.kyndemo.live/validate/reno-realworld-5/actions
|
||||||
|
title: CI/CD Pipelines
|
||||||
|
icon: code
|
||||||
|
- url: https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=reno-realworld-5
|
||||||
|
title: Grafana Dashboard
|
||||||
|
icon: dashboard
|
||||||
|
spec:
|
||||||
|
type: service
|
||||||
|
lifecycle: experimental
|
||||||
|
owner: platform-engineering
|
||||||
|
dependsOn:
|
||||||
|
- resource:default/cjot-aks
|
||||||
|
- resource:default/ho-v2-postgresql
|
||||||
11
config/nohttp/allowlist.lines
Normal file
11
config/nohttp/allowlist.lines
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Platform-scaffolded internal cluster URLs (not reachable over HTTPS)
|
||||||
|
// These use Kubernetes service DNS which only supports plain HTTP inside the cluster
|
||||||
|
^http://.*\.svc\.cluster\.local.*$
|
||||||
|
|
||||||
|
// OpenTelemetry collector endpoints (gRPC/HTTP on internal cluster network)
|
||||||
|
^http://otel-collector.*$
|
||||||
|
^http://.*otel.*$
|
||||||
|
|
||||||
|
// k6 load test target URLs (internal service mesh)
|
||||||
|
^http://frontend\..*$
|
||||||
|
^http://localhost.*$
|
||||||
16
e2e/.eslintrc.json
Normal file
16
e2e/.eslintrc.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"ignorePatterns": ["!**/*"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"extends": ["plugin:@nx/typescript"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.js", "*.jsx"],
|
||||||
|
"extends": ["plugin:@nx/javascript"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plugins": ["@nx"]
|
||||||
|
}
|
||||||
19
e2e/jest.config.ts
Normal file
19
e2e/jest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
export default {
|
||||||
|
displayName: 'e2e',
|
||||||
|
preset: '../jest.preset.js',
|
||||||
|
globalSetup: '<rootDir>/src/support/global-setup.ts',
|
||||||
|
globalTeardown: '<rootDir>/src/support/global-teardown.ts',
|
||||||
|
setupFiles: ['<rootDir>/src/support/test-setup.ts'],
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[tj]s$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
|
coverageDirectory: '../coverage/e2e',
|
||||||
|
};
|
||||||
20
e2e/project.json
Normal file
20
e2e/project.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "e2e",
|
||||||
|
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"implicitDependencies": ["api"],
|
||||||
|
"projectType": "application",
|
||||||
|
"targets": {
|
||||||
|
"e2e": {
|
||||||
|
"executor": "@nx/jest:jest",
|
||||||
|
"outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"],
|
||||||
|
"options": {
|
||||||
|
"jestConfig": "e2e/jest.config.ts",
|
||||||
|
"passWithNoTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint",
|
||||||
|
"outputs": ["{options.outputFile}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
e2e/src/server/server.spec.ts
Normal file
10
e2e/src/server/server.spec.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
describe('GET /', () => {
|
||||||
|
it('should return a message', async () => {
|
||||||
|
const res = await axios.get(`/`);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.data).toEqual({ message: 'Hello API' });
|
||||||
|
});
|
||||||
|
});
|
||||||
10
e2e/src/support/global-setup.ts
Normal file
10
e2e/src/support/global-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
var __TEARDOWN_MESSAGE__: string;
|
||||||
|
|
||||||
|
module.exports = async function () {
|
||||||
|
// Start services that that the app needs to run (e.g. database, docker-compose, etc.).
|
||||||
|
console.log('\nSetting up...\n');
|
||||||
|
|
||||||
|
// Hint: Use `globalThis` to pass variables to global teardown.
|
||||||
|
globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n';
|
||||||
|
};
|
||||||
7
e2e/src/support/global-teardown.ts
Normal file
7
e2e/src/support/global-teardown.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
module.exports = async function () {
|
||||||
|
// Put clean up logic here (e.g. stopping services, docker-compose, etc.).
|
||||||
|
// Hint: `globalThis` is shared between setup and teardown.
|
||||||
|
console.log(globalThis.__TEARDOWN_MESSAGE__);
|
||||||
|
};
|
||||||
10
e2e/src/support/test-setup.ts
Normal file
10
e2e/src/support/test-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
module.exports = async function () {
|
||||||
|
// Configure axios for tests to use.
|
||||||
|
const host = process.env.HOST ?? 'localhost';
|
||||||
|
const port = process.env.PORT ?? '3000';
|
||||||
|
axios.defaults.baseURL = `http://${host}:${port}`;
|
||||||
|
};
|
||||||
13
e2e/tsconfig.json
Normal file
13
e2e/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
10
e2e/tsconfig.spec.json
Normal file
10
e2e/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"jest.config.ts", "src/**/*.ts"]
|
||||||
|
}
|
||||||
15
jest.config.ts
Normal file
15
jest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
export default {
|
||||||
|
displayName: 'api',
|
||||||
|
preset: './jest.preset.js',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
|
coverageDirectory: './coverage/api',
|
||||||
|
testMatch: [
|
||||||
|
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
|
||||||
|
'<rootDir>/src/**/*(*.)@(spec|test).[jt]s?(x)',
|
||||||
|
],
|
||||||
|
};
|
||||||
3
jest.preset.js
Normal file
3
jest.preset.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const nxPreset = require('@nx/jest/preset').default;
|
||||||
|
|
||||||
|
module.exports = { ...nxPreset };
|
||||||
46
nx.json
Normal file
46
nx.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||||
|
"targetDefaults": {
|
||||||
|
"build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"inputs": ["production", "^production"]
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"cache": true,
|
||||||
|
"inputs": [
|
||||||
|
"default",
|
||||||
|
"{workspaceRoot}/.eslintrc.json",
|
||||||
|
"{workspaceRoot}/.eslintignore",
|
||||||
|
"{workspaceRoot}/eslint.config.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@nx/jest:jest": {
|
||||||
|
"cache": true,
|
||||||
|
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
|
||||||
|
"options": {
|
||||||
|
"passWithNoTests": true
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"ci": {
|
||||||
|
"ci": true,
|
||||||
|
"codeCoverage": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"namedInputs": {
|
||||||
|
"default": ["{projectRoot}/**/*", "sharedGlobals"],
|
||||||
|
"production": [
|
||||||
|
"default",
|
||||||
|
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
|
||||||
|
"!{projectRoot}/tsconfig.spec.json",
|
||||||
|
"!{projectRoot}/jest.config.[jt]s",
|
||||||
|
"!{projectRoot}/src/test-setup.[jt]s",
|
||||||
|
"!{projectRoot}/test-setup.[jt]s",
|
||||||
|
"!{projectRoot}/.eslintrc.json",
|
||||||
|
"!{projectRoot}/eslint.config.js"
|
||||||
|
],
|
||||||
|
"sharedGlobals": []
|
||||||
|
}
|
||||||
|
}
|
||||||
9898
package-lock.json
generated
Normal file
9898
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "@api/source",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"start": "nx serve",
|
||||||
|
"build": "nx build",
|
||||||
|
"test": "nx test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node --transpile-only --compiler-options {\"module\":\"CommonJS\"} src/prisma/seed.ts",
|
||||||
|
"schema": "src/prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ngneat/falso": "^7.1.1",
|
||||||
|
"@prisma/client": "^4.16.1",
|
||||||
|
"axios": "^1.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "~4.18.1",
|
||||||
|
"express-jwt": "^8.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"slugify": "^1.6.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nx/esbuild": "17.2.6",
|
||||||
|
"@nx/eslint": "17.2.6",
|
||||||
|
"@nx/eslint-plugin": "17.2.6",
|
||||||
|
"@nx/jest": "17.2.6",
|
||||||
|
"@nx/js": "17.2.6",
|
||||||
|
"@nx/node": "17.2.6",
|
||||||
|
"@nx/workspace": "17.2.6",
|
||||||
|
"@swc-node/register": "~1.6.7",
|
||||||
|
"@swc/core": "~1.3.85",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "~4.17.13",
|
||||||
|
"@types/jest": "^29.4.0",
|
||||||
|
"@types/node": "18.16.9",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.9.1",
|
||||||
|
"@typescript-eslint/parser": "^6.9.1",
|
||||||
|
"esbuild": "^0.19.2",
|
||||||
|
"eslint": "~8.48.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"jest": "^29.4.1",
|
||||||
|
"jest-environment-node": "^29.4.1",
|
||||||
|
"jest-mock-extended": "^3.0.5",
|
||||||
|
"nx": "17.2.6",
|
||||||
|
"prettier": "^2.6.2",
|
||||||
|
"prisma": "^4.16.1",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-node": "10.9.1",
|
||||||
|
"typescript": "~5.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
project-logo.png
Normal file
BIN
project-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
75
project.json
Normal file
75
project.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"$schema": "node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"projectType": "application",
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@nx/esbuild:esbuild",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"defaultConfiguration": "production",
|
||||||
|
"options": {
|
||||||
|
"platform": "node",
|
||||||
|
"outputPath": "dist/api",
|
||||||
|
"format": ["cjs"],
|
||||||
|
"bundle": false,
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": ["src/assets/**/**"],
|
||||||
|
"generatePackageJson": true,
|
||||||
|
"esbuildOptions": {
|
||||||
|
"sourcemap": true,
|
||||||
|
"outExtension": {
|
||||||
|
".js": ".js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"development": {},
|
||||||
|
"production": {
|
||||||
|
"generateLockfile": true,
|
||||||
|
"esbuildOptions": {
|
||||||
|
"sourcemap": false,
|
||||||
|
"outExtension": {
|
||||||
|
".js": ".js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"executor": "@nx/js:node",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "api:build"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "api:build:development"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "api:build:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nx/eslint:lint",
|
||||||
|
"outputs": ["{options.outputFile}"],
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["./src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@nx/jest:jest",
|
||||||
|
"outputs": ["{workspaceRoot}/coverage/{projectName}"],
|
||||||
|
"options": {
|
||||||
|
"jestConfig": "jest.config.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"docker-build": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"command": "docker build -f Dockerfile . -t api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
58
score.yaml
Normal file
58
score.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
apiVersion: score.dev/v1b1
|
||||||
|
metadata:
|
||||||
|
name: reno-realworld-5
|
||||||
|
labels:
|
||||||
|
app: reno-realworld-5
|
||||||
|
|
||||||
|
containers:
|
||||||
|
main:
|
||||||
|
image: .
|
||||||
|
variables:
|
||||||
|
# The Watcher's OTel work lands in overlays/otel/, which only ArgoCD reads. On the
|
||||||
|
# orchestrator path nothing consumes that overlay, so without these variables the
|
||||||
|
# renovated app emits no telemetry at all and never appears in Grafana.
|
||||||
|
OTEL_SERVICE_NAME: "reno-realworld-5"
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.monitoring.svc.cluster.local:4318"
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES: "service.name=reno-realworld-5"
|
||||||
|
OTEL_METRICS_EXPORTER: "otlp"
|
||||||
|
OTEL_TRACES_EXPORTER: "otlp"
|
||||||
|
OTEL_LOGS_EXPORTER: "none"
|
||||||
|
DATABASE_URL: "postgresql://${resources.db.username}:${resources.db.password}@${resources.db.host}:${resources.db.port}/${resources.db.name}"
|
||||||
|
|
||||||
|
service:
|
||||||
|
ports:
|
||||||
|
web:
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
|
|
||||||
|
resources:
|
||||||
|
env:
|
||||||
|
type: environment
|
||||||
|
|
||||||
|
# Gives the renovated workload a public HTTPS URL. Without this the deploy still goes
|
||||||
|
# green and the pod still runs -- there is simply no Ingress, no certificate and no
|
||||||
|
# hostname, so the demo's payoff (curl the renovated app) has nothing to hit.
|
||||||
|
#
|
||||||
|
# hostname is hardcoded to the apps domain deliberately: it is the only domain the apps
|
||||||
|
# cluster serves, and both the wildcard A record (*.apps.dev) and the cert-manager
|
||||||
|
# ClusterIssuer are scoped to exactly it.
|
||||||
|
#
|
||||||
|
# service_port is the Score `service.ports.web.port` below (80), NOT the container port.
|
||||||
|
# An Ingress naming a Service or port that does not exist fails at neither plan nor
|
||||||
|
# apply -- it surfaces only as nginx answering 503.
|
||||||
|
#
|
||||||
|
# CAVEAT: modernization-factory's generate_score_yaml rewrites BOTH `port` and
|
||||||
|
# `targetPort` to the detected application port whenever that port is not 8080. An app
|
||||||
|
# on 3000 therefore ends up with a Service on 3000 and this Ingress pointing at a port
|
||||||
|
# that no longer exists. Apps already listening on 8080 (Spring PetClinic among them)
|
||||||
|
# skip that patch entirely and are unaffected.
|
||||||
|
ingress:
|
||||||
|
type: workload-ingress
|
||||||
|
params:
|
||||||
|
name: reno-realworld-5
|
||||||
|
hostname: reno-realworld-5.apps.dev.crucible.kyndemo.live
|
||||||
|
service_name: reno-realworld-5
|
||||||
|
service_port: 80
|
||||||
|
db:
|
||||||
|
type: postgres
|
||||||
12
src/app/models/http-exception.model.ts
Normal file
12
src/app/models/http-exception.model.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class HttpException extends Error {
|
||||||
|
errorCode: number;
|
||||||
|
constructor(
|
||||||
|
errorCode: number,
|
||||||
|
public readonly message: string | any,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HttpException;
|
||||||
243
src/app/routes/article/article.controller.ts
Normal file
243
src/app/routes/article/article.controller.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import auth from '../auth/auth';
|
||||||
|
import {
|
||||||
|
addComment,
|
||||||
|
createArticle,
|
||||||
|
deleteArticle,
|
||||||
|
deleteComment,
|
||||||
|
favoriteArticle,
|
||||||
|
getArticle,
|
||||||
|
getArticles,
|
||||||
|
getCommentsByArticle,
|
||||||
|
getFeed,
|
||||||
|
unfavoriteArticle,
|
||||||
|
updateArticle,
|
||||||
|
} from './article.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated articles
|
||||||
|
* @auth optional
|
||||||
|
* @route {GET} /articles
|
||||||
|
* @queryparam offset number of articles dismissed from the first one
|
||||||
|
* @queryparam limit number of articles returned
|
||||||
|
* @queryparam tag
|
||||||
|
* @queryparam author
|
||||||
|
* @queryparam favorited
|
||||||
|
* @returns articles: list of articles
|
||||||
|
*/
|
||||||
|
router.get('/articles', auth.optional, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const result = await getArticles(req.query, req.auth?.user?.id);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated feed articles
|
||||||
|
* @auth required
|
||||||
|
* @route {GET} /articles/feed
|
||||||
|
* @returns articles list of articles
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/articles/feed',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const result = await getFeed(
|
||||||
|
Number(req.query.offset),
|
||||||
|
Number(req.query.limit),
|
||||||
|
req.auth?.user?.id,
|
||||||
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create article
|
||||||
|
* @route {POST} /articles
|
||||||
|
* @bodyparam title
|
||||||
|
* @bodyparam description
|
||||||
|
* @bodyparam body
|
||||||
|
* @bodyparam tagList list of tags
|
||||||
|
* @returns article created article
|
||||||
|
*/
|
||||||
|
router.post('/articles', auth.required, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const article = await createArticle(req.body.article, req.auth?.user?.id);
|
||||||
|
res.status(201).json({ article });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique article
|
||||||
|
* @auth optional
|
||||||
|
* @route {GET} /article/:slug
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @returns article
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/articles/:slug',
|
||||||
|
auth.optional,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const article = await getArticle(req.params.slug, req.auth?.user?.id);
|
||||||
|
res.json({ article });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update article
|
||||||
|
* @auth required
|
||||||
|
* @route {PUT} /articles/:slug
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @bodyparam title new title
|
||||||
|
* @bodyparam description new description
|
||||||
|
* @bodyparam body new content
|
||||||
|
* @returns article updated article
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
'/articles/:slug',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const article = await updateArticle(req.body.article, req.params.slug, req.auth?.user?.id);
|
||||||
|
res.json({ article });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete article
|
||||||
|
* @auth required
|
||||||
|
* @route {DELETE} /article/:id
|
||||||
|
* @param slug slug of the article
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/articles/:slug',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await deleteArticle(req.params.slug, req.auth?.user!.id);
|
||||||
|
res.sendStatus(204);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comments from an article
|
||||||
|
* @auth optional
|
||||||
|
* @route {GET} /articles/:slug/comments
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @returns comments list of comments
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/articles/:slug/comments',
|
||||||
|
auth.optional,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const comments = await getCommentsByArticle(req.params.slug, req.auth?.user?.id);
|
||||||
|
res.json({ comments });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add comment to article
|
||||||
|
* @auth required
|
||||||
|
* @route {POST} /articles/:slug/comments
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @bodyparam body content of the comment
|
||||||
|
* @returns comment created comment
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/articles/:slug/comments',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const comment = await addComment(req.body.comment.body, req.params.slug, req.auth?.user?.id);
|
||||||
|
res.json({ comment });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete comment
|
||||||
|
* @auth required
|
||||||
|
* @route {DELETE} /articles/:slug/comments/:id
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @param id id of the comment
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/articles/:slug/comments/:id',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
await deleteComment(Number(req.params.id), req.auth?.user?.id);
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favorite article
|
||||||
|
* @auth required
|
||||||
|
* @route {POST} /articles/:slug/favorite
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @returns article favorited article
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/articles/:slug/favorite',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const article = await favoriteArticle(req.params.slug, req.auth?.user?.id);
|
||||||
|
res.json({ article });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unfavorite article
|
||||||
|
* @auth required
|
||||||
|
* @route {DELETE} /articles/:slug/favorite
|
||||||
|
* @param slug slug of the article (based on the title)
|
||||||
|
* @returns article unfavorited article
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/articles/:slug/favorite',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const article = await unfavoriteArticle(req.params.slug, req.auth?.user?.id);
|
||||||
|
res.json({ article });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
16
src/app/routes/article/article.mapper.ts
Normal file
16
src/app/routes/article/article.mapper.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import authorMapper from './author.mapper';
|
||||||
|
|
||||||
|
const articleMapper = (article: any, id?: number) => ({
|
||||||
|
slug: article.slug,
|
||||||
|
title: article.title,
|
||||||
|
description: article.description,
|
||||||
|
body: article.body,
|
||||||
|
tagList: article.tagList.map((tag: any) => tag.name),
|
||||||
|
createdAt: article.createdAt,
|
||||||
|
updatedAt: article.updatedAt,
|
||||||
|
favorited: article.favoritedBy.some((item: any) => item.id === id),
|
||||||
|
favoritesCount: article.favoritedBy.length,
|
||||||
|
author: authorMapper(article.author, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default articleMapper;
|
||||||
10
src/app/routes/article/article.model.ts
Normal file
10
src/app/routes/article/article.model.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Comment } from './comment.model';
|
||||||
|
|
||||||
|
export interface Article {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
comments: Comment[];
|
||||||
|
favorited: boolean;
|
||||||
|
}
|
||||||
652
src/app/routes/article/article.service.ts
Normal file
652
src/app/routes/article/article.service.ts
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import slugify from 'slugify';
|
||||||
|
import prisma from '../../../prisma/prisma-client';
|
||||||
|
import HttpException from '../../models/http-exception.model';
|
||||||
|
import profileMapper from '../profile/profile.utils';
|
||||||
|
import articleMapper from './article.mapper';
|
||||||
|
import { Tag } from '../tag/tag.model';
|
||||||
|
|
||||||
|
const buildFindAllQuery = (query: any, id: number | undefined) => {
|
||||||
|
const queries: any = [];
|
||||||
|
const orAuthorQuery = [];
|
||||||
|
const andAuthorQuery = [];
|
||||||
|
|
||||||
|
orAuthorQuery.push({
|
||||||
|
demo: {
|
||||||
|
equals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
orAuthorQuery.push({
|
||||||
|
id: {
|
||||||
|
equals: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('author' in query) {
|
||||||
|
andAuthorQuery.push({
|
||||||
|
username: {
|
||||||
|
equals: query.author,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorQuery = {
|
||||||
|
author: {
|
||||||
|
OR: orAuthorQuery,
|
||||||
|
AND: andAuthorQuery,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
queries.push(authorQuery);
|
||||||
|
|
||||||
|
if ('tag' in query) {
|
||||||
|
queries.push({
|
||||||
|
tagList: {
|
||||||
|
some: {
|
||||||
|
name: query.tag,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('favorited' in query) {
|
||||||
|
queries.push({
|
||||||
|
favoritedBy: {
|
||||||
|
some: {
|
||||||
|
username: {
|
||||||
|
equals: query.favorited,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getArticles = async (query: any, id?: number) => {
|
||||||
|
const andQueries = buildFindAllQuery(query, id);
|
||||||
|
const articlesCount = await prisma.article.count({
|
||||||
|
where: {
|
||||||
|
AND: andQueries,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const articles = await prisma.article.findMany({
|
||||||
|
where: { AND: andQueries },
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
skip: Number(query.offset) || 0,
|
||||||
|
take: Number(query.limit) || 10,
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
articles: articles.map((article: any) => articleMapper(article, id)),
|
||||||
|
articlesCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFeed = async (offset: number, limit: number, id: number) => {
|
||||||
|
const articlesCount = await prisma.article.count({
|
||||||
|
where: {
|
||||||
|
author: {
|
||||||
|
followedBy: { some: { id: id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const articles = await prisma.article.findMany({
|
||||||
|
where: {
|
||||||
|
author: {
|
||||||
|
followedBy: { some: { id: id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
skip: offset || 0,
|
||||||
|
take: limit || 10,
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
articles: articles.map((article: any) => articleMapper(article, id)),
|
||||||
|
articlesCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createArticle = async (article: any, id: number) => {
|
||||||
|
const { title, description, body, tagList } = article;
|
||||||
|
const tags = Array.isArray(tagList) ? tagList : [];
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
throw new HttpException(422, { errors: { title: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description) {
|
||||||
|
throw new HttpException(422, { errors: { description: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = `${slugify(title)}-${id}`;
|
||||||
|
|
||||||
|
const existingTitle = await prisma.article.findUnique({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTitle) {
|
||||||
|
throw new HttpException(422, { errors: { title: ['must be unique'] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
authorId,
|
||||||
|
id: articleId,
|
||||||
|
...createdArticle
|
||||||
|
} = await prisma.article.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
body,
|
||||||
|
slug,
|
||||||
|
tagList: {
|
||||||
|
connectOrCreate: tags.map((tag: string) => ({
|
||||||
|
create: { name: tag },
|
||||||
|
where: { name: tag },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
connect: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return articleMapper(createdArticle, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getArticle = async (slug: string, id?: number) => {
|
||||||
|
const article = await prisma.article.findUnique({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw new HttpException(404, { errors: { article: ['not found'] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return articleMapper(article, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectArticlesTags = async (slug: string) => {
|
||||||
|
await prisma.article.update({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
tagList: {
|
||||||
|
set: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateArticle = async (article: any, slug: string, id: number) => {
|
||||||
|
let newSlug = null;
|
||||||
|
|
||||||
|
const existingArticle = await await prisma.article.findFirst({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingArticle) {
|
||||||
|
throw new HttpException(404, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingArticle.author.id !== id) {
|
||||||
|
throw new HttpException(403, {
|
||||||
|
message: 'You are not authorized to update this article',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article.title) {
|
||||||
|
newSlug = `${slugify(article.title)}-${id}`;
|
||||||
|
|
||||||
|
if (newSlug !== slug) {
|
||||||
|
const existingTitle = await prisma.article.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: newSlug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTitle) {
|
||||||
|
throw new HttpException(422, { errors: { title: ['must be unique'] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagList =
|
||||||
|
Array.isArray(article.tagList) && article.tagList?.length
|
||||||
|
? article.tagList.map((tag: string) => ({
|
||||||
|
create: { name: tag },
|
||||||
|
where: { name: tag },
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await disconnectArticlesTags(slug);
|
||||||
|
|
||||||
|
const updatedArticle = await prisma.article.update({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(article.title ? { title: article.title } : {}),
|
||||||
|
...(article.body ? { body: article.body } : {}),
|
||||||
|
...(article.description ? { description: article.description } : {}),
|
||||||
|
...(newSlug ? { slug: newSlug } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
tagList: {
|
||||||
|
connectOrCreate: tagList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return articleMapper(updatedArticle, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteArticle = async (slug: string, id: number) => {
|
||||||
|
const existingArticle = await await prisma.article.findFirst({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingArticle) {
|
||||||
|
throw new HttpException(404, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingArticle.author.id !== id) {
|
||||||
|
throw new HttpException(403, {
|
||||||
|
message: 'You are not authorized to delete this article',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.article.delete({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCommentsByArticle = async (slug: string, id?: number) => {
|
||||||
|
const queries = [];
|
||||||
|
|
||||||
|
queries.push({
|
||||||
|
author: {
|
||||||
|
demo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
queries.push({
|
||||||
|
author: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await prisma.article.findUnique({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
comments: {
|
||||||
|
where: {
|
||||||
|
OR: queries,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
body: true,
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = comments?.comments.map((comment: any) => ({
|
||||||
|
...comment,
|
||||||
|
author: {
|
||||||
|
username: comment.author.username,
|
||||||
|
bio: comment.author.bio,
|
||||||
|
image: comment.author.image,
|
||||||
|
following: comment.author.followedBy.some((follow: any) => follow.id === id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addComment = async (body: string, slug: string, id: number) => {
|
||||||
|
if (!body) {
|
||||||
|
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = await prisma.article.findUnique({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const comment = await prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
body,
|
||||||
|
article: {
|
||||||
|
connect: {
|
||||||
|
id: article?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
connect: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: comment.id,
|
||||||
|
createdAt: comment.createdAt,
|
||||||
|
updatedAt: comment.updatedAt,
|
||||||
|
body: comment.body,
|
||||||
|
author: {
|
||||||
|
username: comment.author.username,
|
||||||
|
bio: comment.author.bio,
|
||||||
|
image: comment.author.image,
|
||||||
|
following: comment.author.followedBy.some((follow: any) => follow.id === id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteComment = async (id: number, userId: number) => {
|
||||||
|
const comment = await prisma.comment.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
author: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
throw new HttpException(404, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.author.id !== userId) {
|
||||||
|
throw new HttpException(403, {
|
||||||
|
message: 'You are not authorized to delete this comment',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.comment.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const favoriteArticle = async (slugPayload: string, id: number) => {
|
||||||
|
const { _count, ...article } = await prisma.article.update({
|
||||||
|
where: {
|
||||||
|
slug: slugPayload,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
favoritedBy: {
|
||||||
|
connect: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...article,
|
||||||
|
author: profileMapper(article.author, id),
|
||||||
|
tagList: article?.tagList.map((tag: Tag) => tag.name),
|
||||||
|
favorited: article.favoritedBy.some((favorited: any) => favorited.id === id),
|
||||||
|
favoritesCount: _count?.favoritedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unfavoriteArticle = async (slugPayload: string, id: number) => {
|
||||||
|
const { _count, ...article } = await prisma.article.update({
|
||||||
|
where: {
|
||||||
|
slug: slugPayload,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
favoritedBy: {
|
||||||
|
disconnect: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tagList: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
favoritedBy: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
favoritedBy: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...article,
|
||||||
|
author: profileMapper(article.author, id),
|
||||||
|
tagList: article?.tagList.map((tag: Tag) => tag.name),
|
||||||
|
favorited: article.favoritedBy.some((favorited: any) => favorited.id === id),
|
||||||
|
favoritesCount: _count?.favoritedBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
12
src/app/routes/article/author.mapper.ts
Normal file
12
src/app/routes/article/author.mapper.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { User } from '../auth/user.model';
|
||||||
|
|
||||||
|
const authorMapper = (author: any, id?: number) => ({
|
||||||
|
username: author.username,
|
||||||
|
bio: author.bio,
|
||||||
|
image: author.image,
|
||||||
|
following: id
|
||||||
|
? author?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default authorMapper;
|
||||||
9
src/app/routes/article/comment.model.ts
Normal file
9
src/app/routes/article/comment.model.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Article } from './article.model';
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
body: string;
|
||||||
|
article?: Article;
|
||||||
|
}
|
||||||
70
src/app/routes/auth/auth.controller.ts
Normal file
70
src/app/routes/auth/auth.controller.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import auth from './auth';
|
||||||
|
import { createUser, getCurrentUser, login, updateUser } from './auth.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an user
|
||||||
|
* @auth none
|
||||||
|
* @route {POST} /users
|
||||||
|
* @bodyparam user User
|
||||||
|
* @returns user User
|
||||||
|
*/
|
||||||
|
router.post('/users', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await createUser({ ...req.body.user, demo: false });
|
||||||
|
res.status(201).json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login
|
||||||
|
* @auth none
|
||||||
|
* @route {POST} /users/login
|
||||||
|
* @bodyparam user User
|
||||||
|
* @returns user User
|
||||||
|
*/
|
||||||
|
router.post('/users/login', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await login(req.body.user);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user
|
||||||
|
* @auth required
|
||||||
|
* @route {GET} /user
|
||||||
|
* @returns user User
|
||||||
|
*/
|
||||||
|
router.get('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser(req.auth?.user?.id);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user
|
||||||
|
* @auth required
|
||||||
|
* @route {PUT} /user
|
||||||
|
* @bodyparam user User
|
||||||
|
* @returns user User
|
||||||
|
*/
|
||||||
|
router.put('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await updateUser(req.body.user, req.auth?.user?.id);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
183
src/app/routes/auth/auth.service.ts
Normal file
183
src/app/routes/auth/auth.service.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { RegisterInput } from './register-input.model';
|
||||||
|
import prisma from '../../../prisma/prisma-client';
|
||||||
|
import HttpException from '../../models/http-exception.model';
|
||||||
|
import { RegisteredUser } from './registered-user.model';
|
||||||
|
import generateToken from './token.utils';
|
||||||
|
import { User } from './user.model';
|
||||||
|
|
||||||
|
const checkUserUniqueness = async (email: string, username: string) => {
|
||||||
|
const existingUserByEmail = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingUserByUsername = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
username,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUserByEmail || existingUserByUsername) {
|
||||||
|
throw new HttpException(422, {
|
||||||
|
errors: {
|
||||||
|
...(existingUserByEmail ? { email: ['has already been taken'] } : {}),
|
||||||
|
...(existingUserByUsername ? { username: ['has already been taken'] } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (input: RegisterInput): Promise<RegisteredUser> => {
|
||||||
|
const email = input.email?.trim();
|
||||||
|
const username = input.username?.trim();
|
||||||
|
const password = input.password?.trim();
|
||||||
|
const { image, bio, demo } = input;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new HttpException(422, { errors: { email: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
throw new HttpException(422, { errors: { username: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new HttpException(422, { errors: { password: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkUserUniqueness(email, username);
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
...(image ? { image } : {}),
|
||||||
|
...(bio ? { bio } : {}),
|
||||||
|
...(demo ? { demo } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
token: generateToken(user.id),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (userPayload: any) => {
|
||||||
|
const email = userPayload.email?.trim();
|
||||||
|
const password = userPayload.password?.trim();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new HttpException(422, { errors: { email: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new HttpException(422, { errors: { password: ["can't be blank"] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
password: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const match = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
bio: user.bio,
|
||||||
|
image: user.image,
|
||||||
|
token: generateToken(user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(403, {
|
||||||
|
errors: {
|
||||||
|
'email or password': ['is invalid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentUser = async (id: number) => {
|
||||||
|
const user = (await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
})) as User;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
token: generateToken(user.id),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (userPayload: any, id: number) => {
|
||||||
|
const { email, username, password, image, bio } = userPayload;
|
||||||
|
let hashedPassword;
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(email ? { email } : {}),
|
||||||
|
...(username ? { username } : {}),
|
||||||
|
...(password ? { password: hashedPassword } : {}),
|
||||||
|
...(image ? { image } : {}),
|
||||||
|
...(bio ? { bio } : {}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
bio: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
token: generateToken(user.id),
|
||||||
|
};
|
||||||
|
};
|
||||||
28
src/app/routes/auth/auth.ts
Normal file
28
src/app/routes/auth/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { expressjwt as jwt } from 'express-jwt';
|
||||||
|
import * as express from 'express';
|
||||||
|
|
||||||
|
const getTokenFromHeaders = (req: express.Request): string | null => {
|
||||||
|
if (
|
||||||
|
(req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Token') ||
|
||||||
|
(req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer')
|
||||||
|
) {
|
||||||
|
return req.headers.authorization.split(' ')[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = {
|
||||||
|
required: jwt({
|
||||||
|
secret: process.env.JWT_SECRET || 'superSecret',
|
||||||
|
getToken: getTokenFromHeaders,
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}),
|
||||||
|
optional: jwt({
|
||||||
|
secret: process.env.JWT_SECRET || 'superSecret',
|
||||||
|
credentialsRequired: false,
|
||||||
|
getToken: getTokenFromHeaders,
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default auth;
|
||||||
8
src/app/routes/auth/register-input.model.ts
Normal file
8
src/app/routes/auth/register-input.model.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface RegisterInput {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
image?: string;
|
||||||
|
bio?: string;
|
||||||
|
demo?: boolean;
|
||||||
|
}
|
||||||
8
src/app/routes/auth/registered-user.model.ts
Normal file
8
src/app/routes/auth/registered-user.model.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface RegisteredUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
bio: string | null;
|
||||||
|
image: string | null;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
8
src/app/routes/auth/token.utils.ts
Normal file
8
src/app/routes/auth/token.utils.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const generateToken = (id: number): string =>
|
||||||
|
jwt.sign({ user: { id } }, process.env.JWT_SECRET || 'superSecret', {
|
||||||
|
expiresIn: '60d',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default generateToken;
|
||||||
9
src/app/routes/auth/user-request.d.ts
vendored
Normal file
9
src/app/routes/auth/user-request.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
declare namespace Express {
|
||||||
|
export interface Request {
|
||||||
|
auth?: {
|
||||||
|
user?: {
|
||||||
|
id?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/routes/auth/user.model.ts
Normal file
17
src/app/routes/auth/user.model.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Article } from '../article/article.model';
|
||||||
|
import { Comment } from '../article/comment.model';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
bio: string | null;
|
||||||
|
image: any | null;
|
||||||
|
articles: Article[];
|
||||||
|
favorites: Article[];
|
||||||
|
followedBy: User[];
|
||||||
|
following: User[];
|
||||||
|
comments: Comment[];
|
||||||
|
demo: boolean;
|
||||||
|
}
|
||||||
67
src/app/routes/profile/profile.controller.ts
Normal file
67
src/app/routes/profile/profile.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import auth from '../auth/auth';
|
||||||
|
import { followUser, getProfile, unfollowUser } from './profile.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile
|
||||||
|
* @auth optional
|
||||||
|
* @route {GET} /profiles/:username
|
||||||
|
* @param username string
|
||||||
|
* @returns profile
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/profiles/:username',
|
||||||
|
auth.optional,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const profile = await getProfile(req.params.username, req.auth?.user?.id);
|
||||||
|
res.json({ profile });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow user
|
||||||
|
* @auth required
|
||||||
|
* @route {POST} /profiles/:username/follow
|
||||||
|
* @param username string
|
||||||
|
* @returns profile
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/profiles/:username/follow',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const profile = await followUser(req.params?.username, req.auth?.user?.id);
|
||||||
|
res.json({ profile });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unfollow user
|
||||||
|
* @auth required
|
||||||
|
* @route {DELETE} /profiles/:username/follow
|
||||||
|
* @param username string
|
||||||
|
* @returns profiles
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
'/profiles/:username/follow',
|
||||||
|
auth.required,
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const profile = await unfollowUser(req.params.username, req.auth?.user?.id);
|
||||||
|
res.json({ profile });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
6
src/app/routes/profile/profile.model.ts
Normal file
6
src/app/routes/profile/profile.model.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Profile {
|
||||||
|
username: string;
|
||||||
|
bio: string;
|
||||||
|
image: string;
|
||||||
|
following: boolean;
|
||||||
|
}
|
||||||
60
src/app/routes/profile/profile.service.ts
Normal file
60
src/app/routes/profile/profile.service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import prisma from '../../../prisma/prisma-client';
|
||||||
|
import profileMapper from './profile.utils';
|
||||||
|
import HttpException from '../../models/http-exception.model';
|
||||||
|
|
||||||
|
export const getProfile = async (usernamePayload: string, id?: number) => {
|
||||||
|
const profile = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
username: usernamePayload,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new HttpException(404, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return profileMapper(profile, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const followUser = async (usernamePayload: string, id: number) => {
|
||||||
|
const profile = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
username: usernamePayload,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
followedBy: {
|
||||||
|
connect: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return profileMapper(profile, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unfollowUser = async (usernamePayload: string, id: number) => {
|
||||||
|
const profile = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
username: usernamePayload,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
followedBy: {
|
||||||
|
disconnect: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
followedBy: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return profileMapper(profile, id);
|
||||||
|
};
|
||||||
13
src/app/routes/profile/profile.utils.ts
Normal file
13
src/app/routes/profile/profile.utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { User } from '../auth/user.model';
|
||||||
|
import { Profile } from './profile.model';
|
||||||
|
|
||||||
|
const profileMapper = (user: any, id: number | undefined): Profile => ({
|
||||||
|
username: user.username,
|
||||||
|
bio: user.bio,
|
||||||
|
image: user.image,
|
||||||
|
following: id
|
||||||
|
? user?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default profileMapper;
|
||||||
13
src/app/routes/routes.ts
Normal file
13
src/app/routes/routes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import tagsController from './tag/tag.controller';
|
||||||
|
import articlesController from './article/article.controller';
|
||||||
|
import authController from './auth/auth.controller';
|
||||||
|
import profileController from './profile/profile.controller';
|
||||||
|
|
||||||
|
const api = Router()
|
||||||
|
.use(tagsController)
|
||||||
|
.use(articlesController)
|
||||||
|
.use(profileController)
|
||||||
|
.use(authController);
|
||||||
|
|
||||||
|
export default Router().use('/api', api);
|
||||||
22
src/app/routes/tag/tag.controller.ts
Normal file
22
src/app/routes/tag/tag.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextFunction, Request, Response, Router } from 'express';
|
||||||
|
import auth from '../auth/auth';
|
||||||
|
import getTags from './tag.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top 10 popular tags
|
||||||
|
* @auth optional
|
||||||
|
* @route {GET} /api/tags
|
||||||
|
* @returns tags list of tag names
|
||||||
|
*/
|
||||||
|
router.get('/tags', auth.optional, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tags = await getTags(req.auth?.user?.id);
|
||||||
|
res.json({ tags });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
3
src/app/routes/tag/tag.model.ts
Normal file
3
src/app/routes/tag/tag.model.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface Tag {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
40
src/app/routes/tag/tag.service.ts
Normal file
40
src/app/routes/tag/tag.service.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import prisma from '../../../prisma/prisma-client';
|
||||||
|
import { Tag } from './tag.model';
|
||||||
|
|
||||||
|
const getTags = async (id?: number): Promise<string[]> => {
|
||||||
|
const queries = [];
|
||||||
|
queries.push({ demo: true });
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
queries.push({
|
||||||
|
id: {
|
||||||
|
equals: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: {
|
||||||
|
articles: {
|
||||||
|
some: {
|
||||||
|
author: {
|
||||||
|
OR: queries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
articles: {
|
||||||
|
_count: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tags.map((tag: Tag) => tag.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getTags;
|
||||||
BIN
src/assets/images/demo-avatar.png
Normal file
BIN
src/assets/images/demo-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/images/smiley-cyrus.jpeg
Normal file
BIN
src/assets/images/smiley-cyrus.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
19
src/checkstyle/nohttp-checkstyle-suppressions.xml
Normal file
19
src/checkstyle/nohttp-checkstyle-suppressions.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!DOCTYPE suppressions PUBLIC
|
||||||
|
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
|
||||||
|
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
|
||||||
|
<suppressions>
|
||||||
|
<!-- Upstream defaults -->
|
||||||
|
<suppress files="node_modules[\\/].*" checks=".*"/>
|
||||||
|
<suppress files="node[\\/].*" checks=".*"/>
|
||||||
|
<suppress files="build[\\/].*" checks=".*"/>
|
||||||
|
<suppress files="target[\\/].*" checks=".*"/>
|
||||||
|
<suppress files=".+\.(jar|git|ico|p12|gif|jks|jpg|svg|log)" checks="NoHttp"/>
|
||||||
|
|
||||||
|
<!-- Platform-scaffolded paths that use internal Kubernetes service URLs (http only) -->
|
||||||
|
<suppress files="k6[\\/].*" checks="NoHttp"/>
|
||||||
|
<suppress files="overlays[\\/].*" checks="NoHttp"/>
|
||||||
|
<suppress files="docs[\\/].*" checks="NoHttp"/>
|
||||||
|
<suppress files="config[\\/].*" checks="NoHttp"/>
|
||||||
|
<suppress files="(^|[\\/])score\.yaml$" checks="NoHttp"/>
|
||||||
|
</suppressions>
|
||||||
57
src/main.ts
Normal file
57
src/main.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import * as bodyParser from 'body-parser';
|
||||||
|
import routes from './app/routes/routes';
|
||||||
|
import HttpException from './app/models/http-exception.model';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
app.use(routes);
|
||||||
|
|
||||||
|
// Serves images
|
||||||
|
app.use(express.static(__dirname + '/assets'));
|
||||||
|
|
||||||
|
app.get('/', (req: express.Request, res: express.Response) => {
|
||||||
|
res.json({ status: 'API is running on /api' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
app.use(
|
||||||
|
(
|
||||||
|
err: Error | HttpException,
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response,
|
||||||
|
next: express.NextFunction,
|
||||||
|
) => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (err && err.name === 'UnauthorizedError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'missing authorization credentials',
|
||||||
|
});
|
||||||
|
// @ts-ignore
|
||||||
|
} else if (err && err.errorCode) {
|
||||||
|
// @ts-ignore
|
||||||
|
res.status(err.errorCode).json(err.message);
|
||||||
|
} else if (err) {
|
||||||
|
res.status(500).json(err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server activation
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.info(`server up on port ${PORT}`);
|
||||||
|
});
|
||||||
114
src/prisma/migrations/20210924225358_initial/migration.sql
Normal file
114
src/prisma/migrations/20210924225358_initial/migration.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Article" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"authorId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ArticleTags" (
|
||||||
|
"articleId" INTEGER NOT NULL,
|
||||||
|
"tagId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("articleId","tagId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Comment" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"articleId" INTEGER NOT NULL,
|
||||||
|
"authorId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tag" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"image" TEXT DEFAULT E'https://realworld-temp-api.herokuapp.com/images/smiley-cyrus.jpeg',
|
||||||
|
"bio" TEXT,
|
||||||
|
"demo" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_UserFavorites" (
|
||||||
|
"A" INTEGER NOT NULL,
|
||||||
|
"B" INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_UserFollows" (
|
||||||
|
"A" INTEGER NOT NULL,
|
||||||
|
"B" INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Article.slug_unique" ON "Article"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User.username_unique" ON "User"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "_UserFavorites_AB_unique" ON "_UserFavorites"("A", "B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_UserFavorites_B_index" ON "_UserFavorites"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "_UserFollows_AB_unique" ON "_UserFollows"("A", "B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_UserFollows_B_index" ON "_UserFollows"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Article" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Comment" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `ArticleTags` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_articleId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_tagId_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "ArticleTags";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_ArticleToTag" (
|
||||||
|
"A" INTEGER NOT NULL,
|
||||||
|
"B" INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "_ArticleToTag_AB_unique" ON "_ArticleToTag"("A", "B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "image" SET DEFAULT E'https://api.realworld.io/images/smiley-cyrus.jpeg';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "Article.slug_unique" RENAME TO "Article_slug_key";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "Tag.name_unique" RENAME TO "Tag_name_key";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "User.email_unique" RENAME TO "User_email_key";
|
||||||
|
|
||||||
|
-- RenameIndex
|
||||||
|
ALTER INDEX "User.username_unique" RENAME TO "User_username_key";
|
||||||
3
src/prisma/migrations/migration_lock.toml
Normal file
3
src/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
23
src/prisma/prisma-client.ts
Normal file
23
src/prisma/prisma-client.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface Global {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add prisma to the NodeJS global type
|
||||||
|
interface CustomNodeJsGlobal extends NodeJS.Global {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent multiple instances of Prisma Client in development
|
||||||
|
declare const global: CustomNodeJsGlobal;
|
||||||
|
|
||||||
|
const prisma = global.prisma || new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
global.prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
56
src/prisma/schema.prisma
Normal file
56
src/prisma/schema.prisma
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = []
|
||||||
|
}
|
||||||
|
|
||||||
|
model Article {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
body String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now())
|
||||||
|
tagList Tag[]
|
||||||
|
author User @relation("UserArticles", fields: [authorId], onDelete: Cascade, references: [id])
|
||||||
|
authorId Int
|
||||||
|
favoritedBy User[] @relation("UserFavorites")
|
||||||
|
comments Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now())
|
||||||
|
body String
|
||||||
|
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||||
|
articleId Int
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
authorId Int
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
articles Article[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
username String @unique
|
||||||
|
password String
|
||||||
|
image String? @default("https://api.realworld.io/images/smiley-cyrus.jpeg")
|
||||||
|
bio String?
|
||||||
|
articles Article[] @relation("UserArticles")
|
||||||
|
favorites Article[] @relation("UserFavorites")
|
||||||
|
followedBy User[] @relation("UserFollows")
|
||||||
|
following User[] @relation("UserFollows")
|
||||||
|
comments Comment[]
|
||||||
|
demo Boolean @default(false)
|
||||||
|
}
|
||||||
66
src/prisma/seed.ts
Normal file
66
src/prisma/seed.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
randEmail,
|
||||||
|
randFullName,
|
||||||
|
randLines,
|
||||||
|
randParagraph,
|
||||||
|
randPassword, randPhrase,
|
||||||
|
randWord
|
||||||
|
} from '@ngneat/falso';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { RegisteredUser } from '../app/routes/auth/registered-user.model';
|
||||||
|
import { createUser } from '../app/routes/auth/auth.service';
|
||||||
|
import { addComment, createArticle } from '../app/routes/article/article.service';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export const generateUser = async (): Promise<RegisteredUser> =>
|
||||||
|
createUser({
|
||||||
|
username: randFullName(),
|
||||||
|
email: randEmail(),
|
||||||
|
password: randPassword(),
|
||||||
|
image: 'https://api.realworld.io/images/demo-avatar.png',
|
||||||
|
demo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateArticle = async (id: number) =>
|
||||||
|
createArticle(
|
||||||
|
{
|
||||||
|
title: randPhrase(),
|
||||||
|
description: randParagraph(),
|
||||||
|
body: randLines({ length: 10 }).join(' '),
|
||||||
|
tagList: randWord({ length: 4 }),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateComment = async (id: number, slug: string) =>
|
||||||
|
addComment(randParagraph(), slug, id);
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
try {
|
||||||
|
const users = await Promise.all(Array.from({length: 12}, () => generateUser()));
|
||||||
|
users?.map(user => user);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for await (const user of users) {
|
||||||
|
const articles = await Promise.all(Array.from({length: 12}, () => generateArticle(user.id)));
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for await (const article of articles) {
|
||||||
|
await Promise.all(users.map(userItem => generateComment(userItem.id, article.slug)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
17
src/tests/prisma-mock.ts
Normal file
17
src/tests/prisma-mock.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
import prisma from '../prisma/prisma-client';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
jest.mock('../prisma/prisma-client', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: mockDeep<PrismaClient>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReset(prismaMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default prismaMock;
|
||||||
144
src/tests/services/article.service.test.ts
Normal file
144
src/tests/services/article.service.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import prismaMock from '../prisma-mock';
|
||||||
|
import {
|
||||||
|
deleteComment,
|
||||||
|
favoriteArticle,
|
||||||
|
unfavoriteArticle,
|
||||||
|
} from '../../app/routes/article/article.service';
|
||||||
|
|
||||||
|
describe('ArticleService', () => {
|
||||||
|
describe('deleteComment', () => {
|
||||||
|
test('should throw an error ', () => {
|
||||||
|
// Given
|
||||||
|
const id = 123;
|
||||||
|
const idUser = 456;
|
||||||
|
|
||||||
|
// When
|
||||||
|
// @ts-ignore
|
||||||
|
prismaMock.comment.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(deleteComment(id, idUser)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('favoriteArticle', () => {
|
||||||
|
test('should return the favorited article', async () => {
|
||||||
|
// Given
|
||||||
|
const slug = 'How-to-train-your-dragon';
|
||||||
|
const username = 'RealWorld';
|
||||||
|
|
||||||
|
const mockedUserResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedArticleResponse = {
|
||||||
|
id: 123,
|
||||||
|
slug: 'How-to-train-your-dragon',
|
||||||
|
title: 'How to train your dragon',
|
||||||
|
description: '',
|
||||||
|
body: '',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
authorId: 456,
|
||||||
|
tagList: [],
|
||||||
|
favoritedBy: [],
|
||||||
|
author: {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
followedBy: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
// @ts-ignore
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
|
||||||
|
// @ts-ignore
|
||||||
|
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(favoriteArticle(slug, mockedUserResponse.id)).resolves.toHaveProperty(
|
||||||
|
'favoritesCount',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error if no user is found', async () => {
|
||||||
|
// Given
|
||||||
|
const id = 123;
|
||||||
|
const slug = 'how-to-train-your-dragon';
|
||||||
|
const username = 'RealWorld';
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(favoriteArticle(slug, id)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('unfavoriteArticle', () => {
|
||||||
|
test('should return the unfavorited article', async () => {
|
||||||
|
// Given
|
||||||
|
const slug = 'How-to-train-your-dragon';
|
||||||
|
const username = 'RealWorld';
|
||||||
|
|
||||||
|
const mockedUserResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedArticleResponse = {
|
||||||
|
id: 123,
|
||||||
|
slug: 'How-to-train-your-dragon',
|
||||||
|
title: 'How to train your dragon',
|
||||||
|
description: '',
|
||||||
|
body: '',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
authorId: 456,
|
||||||
|
tagList: [],
|
||||||
|
favoritedBy: [],
|
||||||
|
author: {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
followedBy: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
|
||||||
|
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(unfavoriteArticle(slug, mockedUserResponse.id)).resolves.toHaveProperty(
|
||||||
|
'favoritesCount',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error if no user is found', async () => {
|
||||||
|
// Given
|
||||||
|
const id = 123;
|
||||||
|
const slug = 'how-to-train-your-dragon';
|
||||||
|
const username = 'RealWorld';
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(unfavoriteArticle(slug, id)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
254
src/tests/services/auth.service.test.ts
Normal file
254
src/tests/services/auth.service.test.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { createUser, getCurrentUser, login, updateUser } from '../../app/routes/auth/auth.service';
|
||||||
|
import prismaMock from '../prisma-mock';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
describe('createUser', () => {
|
||||||
|
test('should create new user ', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
// @ts-ignore
|
||||||
|
prismaMock.user.create.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(createUser(user)).resolves.toHaveProperty('token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error when creating new user with empty username ', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
id: 123,
|
||||||
|
username: ' ',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { username: ["can't be blank"] } });
|
||||||
|
await expect(createUser(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error when creating new user with empty email ', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: ' ',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { email: ["can't be blank"] } });
|
||||||
|
await expect(createUser(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error when creating new user with empty password ', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: ' ',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { password: ["can't be blank"] } });
|
||||||
|
await expect(createUser(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an exception when creating a new user with already existing user on same username ', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedExistingUser = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedExistingUser);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = { email: ['has already been taken'] }.toString();
|
||||||
|
await expect(createUser(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
test('should return a token', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(user.password, 10);
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: hashedPassword,
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(login(user)).resolves.toHaveProperty('token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error when the email is empty', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
email: ' ',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { email: ["can't be blank"] } });
|
||||||
|
await expect(login(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error when the password is empty', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: ' ',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { password: ["can't be blank"] } });
|
||||||
|
await expect(login(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error when no user is found', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { 'email or password': ['is invalid'] } });
|
||||||
|
await expect(login(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error if the password is wrong', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash('4321', 10);
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'Gerome',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: hashedPassword,
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
const error = String({ errors: { 'email or password': ['is invalid'] } });
|
||||||
|
await expect(login(user)).rejects.toThrow(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCurrentUser', () => {
|
||||||
|
test('should return a token', async () => {
|
||||||
|
// Given
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(getCurrentUser(id)).resolves.toHaveProperty('token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateUser', () => {
|
||||||
|
test('should return a token', async () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.update.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(updateUser(user, user.id)).resolves.toHaveProperty('token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
145
src/tests/services/profile.service.test.ts
Normal file
145
src/tests/services/profile.service.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import prismaMock from '../prisma-mock';
|
||||||
|
import { followUser, getProfile, unfollowUser } from '../../app/routes/profile/profile.service';
|
||||||
|
|
||||||
|
describe('ProfileService', () => {
|
||||||
|
describe('getProfile', () => {
|
||||||
|
test('should return a following property', async () => {
|
||||||
|
// Given
|
||||||
|
const username = 'RealWorld';
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
followedBy: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
// @ts-ignore
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(getProfile(username, id)).resolves.toHaveProperty('following');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw an error if no user is found', async () => {
|
||||||
|
// Given
|
||||||
|
const username = 'RealWorld';
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(getProfile(username, id)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('followUser', () => {
|
||||||
|
test('shoud return a following property', async () => {
|
||||||
|
// Given
|
||||||
|
const usernamePayload = 'AnotherUser';
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
const mockedAuthUser = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
followedBy: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'AnotherUser',
|
||||||
|
email: 'another@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
followedBy: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
|
||||||
|
prismaMock.user.update.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(followUser(usernamePayload, id)).resolves.toHaveProperty('following');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shoud throw an error if no user is found', async () => {
|
||||||
|
// Given
|
||||||
|
const usernamePayload = 'AnotherUser';
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(followUser(usernamePayload, id)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unfollowUser', () => {
|
||||||
|
test('shoud return a following property', async () => {
|
||||||
|
// Given
|
||||||
|
const usernamePayload = 'AnotherUser';
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
const mockedAuthUser = {
|
||||||
|
id: 123,
|
||||||
|
username: 'RealWorld',
|
||||||
|
email: 'realworld@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
followedBy: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedResponse = {
|
||||||
|
id: 123,
|
||||||
|
username: 'AnotherUser',
|
||||||
|
email: 'another@me',
|
||||||
|
password: '1234',
|
||||||
|
bio: null,
|
||||||
|
image: null,
|
||||||
|
token: '',
|
||||||
|
demo: false,
|
||||||
|
followedBy: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
|
||||||
|
prismaMock.user.update.mockResolvedValue(mockedResponse);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(unfollowUser(usernamePayload, id)).resolves.toHaveProperty('following');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shoud throw an error if no user is found', async () => {
|
||||||
|
// Given
|
||||||
|
const usernamePayload = 'AnotherUser';
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
// When
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expect(unfollowUser(usernamePayload, id)).rejects.toThrowError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
6
src/tests/services/tag.service.test.ts
Normal file
6
src/tests/services/tag.service.test.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
describe('TagService', () => {
|
||||||
|
describe('getTags', () => {
|
||||||
|
// TODO : prismaMock.tag.groupBy.mockResolvedValue(mockedResponse) doesn't work
|
||||||
|
test.todo('should return a list of strings');
|
||||||
|
});
|
||||||
|
});
|
||||||
79
src/tests/utils/profile.utils.test.ts
Normal file
79
src/tests/utils/profile.utils.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import profileMapper from '../../app/routes/profile/profile.utils';
|
||||||
|
|
||||||
|
describe('ProfileUtils', () => {
|
||||||
|
describe('profileMapper', () => {
|
||||||
|
test('should return a profile', () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: 'My happy life',
|
||||||
|
image: null,
|
||||||
|
followedBy: [],
|
||||||
|
};
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
// When
|
||||||
|
const expected = {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: 'My happy life',
|
||||||
|
image: null,
|
||||||
|
following: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(profileMapper(user, id)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a profile followed by the user', () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: 'My happy life',
|
||||||
|
image: null,
|
||||||
|
followedBy: [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
// When
|
||||||
|
const expected = {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: 'My happy life',
|
||||||
|
image: null,
|
||||||
|
following: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(profileMapper(user, id)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return a profile not followed by the user', () => {
|
||||||
|
// Given
|
||||||
|
const user = {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: 'My happy life',
|
||||||
|
image: null,
|
||||||
|
followedBy: [
|
||||||
|
{
|
||||||
|
username: 'NotRealWorld',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const id = 123;
|
||||||
|
|
||||||
|
// When
|
||||||
|
const expected = {
|
||||||
|
username: 'RealWorld',
|
||||||
|
bio: 'My happy life',
|
||||||
|
image: null,
|
||||||
|
following: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(profileMapper(user, id)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
17
tsconfig.app.json
Normal file
17
tsconfig.app.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"jest.config.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/tests/**/*",
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "es2015",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["es2020", "dom"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"skipDefaultLibCheck": true,
|
||||||
|
"baseUrl": "./api",
|
||||||
|
"paths": {},
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "tmp"]
|
||||||
|
}
|
||||||
14
tsconfig.spec.json
Normal file
14
tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"jest.config.ts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user