qa-environment-aspire #2
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# CI/CD pipeline for MicCheck. Read by both Gitea Actions and GitHub Actions
|
||||||
|
# (both look under .github/workflows/). Every non-checkout step just invokes a
|
||||||
|
# bash script under scripts/ci/, so the entire pipeline is reproducible by
|
||||||
|
# running the same scripts locally - no marketplace build/test/push actions.
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: ./scripts/ci/build.sh
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: ./scripts/ci/test.sh
|
||||||
|
|
||||||
|
- name: Build Docker images
|
||||||
|
run: ./scripts/ci/docker-build.sh
|
||||||
|
|
||||||
|
- name: Push Docker images
|
||||||
|
run: ./scripts/ci/docker-push.sh
|
||||||
|
|
||||||
|
deploy-qa:
|
||||||
|
needs: build-and-push
|
||||||
|
runs-on: [self-hosted, qa]
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
|
||||||
|
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to QA
|
||||||
|
run: ./scripts/ci/deploy-qa.sh
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -408,7 +408,6 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
# VS Code files for those working on multiple tools
|
# VS Code files for those working on multiple tools
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
1
.husky/pre-push
Executable file
1
.husky/pre-push
Executable file
@@ -0,0 +1 @@
|
|||||||
|
exec ./scripts/ci/prepush.sh
|
||||||
15
.vscode/settings.json
vendored
15
.vscode/settings.json
vendored
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"sqltools.connections": [
|
|
||||||
{
|
|
||||||
"ssh": "Disabled",
|
|
||||||
"previewLimit": 50,
|
|
||||||
"server": "localhost",
|
|
||||||
"port": 5432,
|
|
||||||
"askForPassword": true,
|
|
||||||
"driver": "PostgreSQL",
|
|
||||||
"name": "MicCheck - local",
|
|
||||||
"database": "miccheck",
|
|
||||||
"username": "miccheck"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -18,8 +18,8 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
|
|||||||
- Use latest LTS .NET + latest supported nuget packages for that version
|
- Use latest LTS .NET + latest supported nuget packages for that version
|
||||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||||
- Organize code by feature/area, not type (e.g. `features` namespace)
|
- Organize code by feature/area, not type (e.g. `features` namespace)
|
||||||
- New features need unit tests covering as much logic as possible
|
- New features need unit tests covering as much logic as possible (both nunit and jest)
|
||||||
- Any modified file: evaluate for missing test coverage
|
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||||
|
|
||||||
# Coding
|
# Coding
|
||||||
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
|
||||||
@@ -37,13 +37,13 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, pr
|
|||||||
- Any modified file: evaluate for missing test coverage-
|
- Any modified file: evaluate for missing test coverage-
|
||||||
- No "Mock" in mocked object names
|
- No "Mock" in mocked object names
|
||||||
- No Arrange/Act/Assert comments
|
- No Arrange/Act/Assert comments
|
||||||
|
- All tests should pass before commit
|
||||||
|
|
||||||
## Claude
|
## Claude
|
||||||
- Plans = `.md` files in `docs/`. Admin → `docs/admin/`, API → `docs/api/`
|
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/`
|
||||||
- Split large plans into discrete chunks — each buildable + committable independently
|
- Split large plans into discrete chunks — each buildable + committable independently
|
||||||
- Plan generated from `docs/<name>.md` → save as `docs/<name>_plan.md`
|
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
|
||||||
- Plan implemented from `docs/<name>.md` → save summary as `docs/<name>_output.md`
|
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
||||||
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
|
|
||||||
|
|||||||
12
deploy/qa/.env.example
Normal file
12
deploy/qa/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Copy to .env (or export in CI) and fill in real values.
|
||||||
|
# Used by scripts/ci/*.sh and deploy/qa/docker-compose.qa.yml.
|
||||||
|
|
||||||
|
# Gitea container registry
|
||||||
|
REGISTRY=gitea.example.com
|
||||||
|
REGISTRY_OWNER=your-org-or-user
|
||||||
|
REGISTRY_USER=ci-bot
|
||||||
|
REGISTRY_TOKEN=changeme
|
||||||
|
|
||||||
|
# QA environment
|
||||||
|
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
|
||||||
|
QA_ADMIN_PORT=3001
|
||||||
61
deploy/qa/docker-compose.qa.yml
Normal file
61
deploy/qa/docker-compose.qa.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: miccheck-qa
|
||||||
|
|
||||||
|
# Dedicated, network-isolated QA stack. Not connected to the dev compose
|
||||||
|
# stack's network - runs entirely on its own bridge network below, and the
|
||||||
|
# database has no published host port.
|
||||||
|
services:
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: miccheck
|
||||||
|
POSTGRES_USER: miccheck
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
volumes:
|
||||||
|
- miccheck-qa-pgdata:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- qa
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ${API_IMAGE}:qa
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
|
ASPNETCORE_URLS: http://+:8080
|
||||||
|
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password"
|
||||||
|
Jwt__SecretKey: ${JWT_SECRET_KEY}
|
||||||
|
Jwt__Issuer: MicCheck
|
||||||
|
Jwt__Audience: MicCheck
|
||||||
|
networks:
|
||||||
|
- qa
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
admin:
|
||||||
|
image: ${ADMIN_IMAGE}:qa
|
||||||
|
ports:
|
||||||
|
- "${QA_ADMIN_PORT:-3001}:80"
|
||||||
|
networks:
|
||||||
|
- qa
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
networks:
|
||||||
|
qa:
|
||||||
|
name: miccheck-qa-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
miccheck-qa-pgdata:
|
||||||
|
name: miccheck-qa-pgdata
|
||||||
28
package-lock.json
generated
Normal file
28
package-lock.json
generated
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "mic-check",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mic-check",
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "^9.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/husky": {
|
||||||
|
"version": "9.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
|
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"husky": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/typicode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "mic-check",
|
||||||
|
"private": true,
|
||||||
|
"description": "Repo-root package - hosts the Husky pre-push hook only.",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "^9.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
scripts/ci/build.sh
Executable file
18
scripts/ci/build.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compiles the API (and its dependents) and builds the admin SPA.
|
||||||
|
# Acts as the compile gate before tests/image builds run. TreatWarningsAsErrors
|
||||||
|
# is enabled across the solution, so this also fails on any compiler warning.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
|
||||||
|
log "Restoring and publishing MicCheck.Api (Release)"
|
||||||
|
dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release
|
||||||
|
|
||||||
|
log "Installing admin dependencies (npm ci)"
|
||||||
|
npm --prefix src/admin ci
|
||||||
|
|
||||||
|
log "Building admin SPA (vite build)"
|
||||||
|
npm --prefix src/admin run build
|
||||||
|
|
||||||
|
log "build.sh complete"
|
||||||
37
scripts/ci/deploy-qa.sh
Executable file
37
scripts/ci/deploy-qa.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploys the freshly-pushed :qa images to the dedicated, network-isolated QA
|
||||||
|
# stack and recreates the miccheck database in an empty state. Safe/idempotent
|
||||||
|
# to re-run: `down -v` removes the Postgres data volume, and the API's own
|
||||||
|
# startup logic (EF Core migrations + idempotent dev seeding) rebuilds it.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
image_names
|
||||||
|
registry_login
|
||||||
|
|
||||||
|
export API_IMAGE ADMIN_IMAGE
|
||||||
|
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
|
||||||
|
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
|
||||||
|
|
||||||
|
COMPOSE="docker compose -p miccheck-qa -f deploy/qa/docker-compose.qa.yml"
|
||||||
|
|
||||||
|
log "Pulling latest :qa images"
|
||||||
|
$COMPOSE pull
|
||||||
|
|
||||||
|
log "Tearing down existing QA stack and wiping the database volume"
|
||||||
|
$COMPOSE down -v
|
||||||
|
|
||||||
|
log "Starting QA stack"
|
||||||
|
$COMPOSE up -d
|
||||||
|
|
||||||
|
log "Waiting for API health check via admin proxy on port $QA_ADMIN_PORT"
|
||||||
|
attempts=30
|
||||||
|
until curl -fsS "http://localhost:${QA_ADMIN_PORT}/api/v1/health" >/dev/null 2>&1; do
|
||||||
|
attempts=$((attempts - 1))
|
||||||
|
if [[ "$attempts" -le 0 ]]; then
|
||||||
|
fail "QA stack did not become healthy in time"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
log "QA environment deployed and healthy at http://localhost:${QA_ADMIN_PORT}"
|
||||||
23
scripts/ci/docker-build.sh
Executable file
23
scripts/ci/docker-build.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds self-contained CI images for the API and admin apps and tags them
|
||||||
|
# with both the current git sha and "qa" (the tag the QA compose stack pulls).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
image_names
|
||||||
|
|
||||||
|
log "Building $API_IMAGE:$GIT_SHA / :qa"
|
||||||
|
docker build \
|
||||||
|
-f src/api/MicCheck.Api/Dockerfile.ci \
|
||||||
|
-t "$API_IMAGE:$GIT_SHA" \
|
||||||
|
-t "$API_IMAGE:qa" \
|
||||||
|
.
|
||||||
|
|
||||||
|
log "Building $ADMIN_IMAGE:$GIT_SHA / :qa"
|
||||||
|
docker build \
|
||||||
|
-f src/admin/Dockerfile.ci \
|
||||||
|
-t "$ADMIN_IMAGE:$GIT_SHA" \
|
||||||
|
-t "$ADMIN_IMAGE:qa" \
|
||||||
|
src/admin
|
||||||
|
|
||||||
|
log "docker-build.sh complete"
|
||||||
17
scripts/ci/docker-push.sh
Executable file
17
scripts/ci/docker-push.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pushes the images built by docker-build.sh (git-sha and qa tags) to the
|
||||||
|
# Gitea container registry.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
image_names
|
||||||
|
registry_login
|
||||||
|
|
||||||
|
for tag in "$GIT_SHA" qa; do
|
||||||
|
log "Pushing $API_IMAGE:$tag"
|
||||||
|
docker push "$API_IMAGE:$tag"
|
||||||
|
log "Pushing $ADMIN_IMAGE:$tag"
|
||||||
|
docker push "$ADMIN_IMAGE:$tag"
|
||||||
|
done
|
||||||
|
|
||||||
|
log "docker-push.sh complete"
|
||||||
46
scripts/ci/lib.sh
Executable file
46
scripts/ci/lib.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for scripts/ci/*.sh.
|
||||||
|
# Every script in this directory is meant to run identically in CI and on a
|
||||||
|
# developer's machine - no Gitea/GitHub-specific built-in actions, just bash.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Repo root, regardless of caller's cwd.
|
||||||
|
CI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
# Registry configuration. All values come from the environment (CI secrets or
|
||||||
|
# a developer's shell) - nothing is hardcoded, per project convention.
|
||||||
|
REGISTRY="${REGISTRY:-}"
|
||||||
|
REGISTRY_OWNER="${REGISTRY_OWNER:-}"
|
||||||
|
REGISTRY_USER="${REGISTRY_USER:-}"
|
||||||
|
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||||
|
|
||||||
|
GIT_SHA="$(git -C "$CI_ROOT" rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
require_registry_vars() {
|
||||||
|
[[ -n "$REGISTRY" ]] || fail "REGISTRY env var is required (e.g. gitea.example.com)"
|
||||||
|
[[ -n "$REGISTRY_OWNER" ]] || fail "REGISTRY_OWNER env var is required (e.g. your gitea org/user)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Populates API_IMAGE / ADMIN_IMAGE, e.g. gitea.example.com/james/miccheck-api
|
||||||
|
image_names() {
|
||||||
|
require_registry_vars
|
||||||
|
API_IMAGE="$REGISTRY/$REGISTRY_OWNER/miccheck-api"
|
||||||
|
ADMIN_IMAGE="$REGISTRY/$REGISTRY_OWNER/miccheck-admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
registry_login() {
|
||||||
|
require_registry_vars
|
||||||
|
[[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images"
|
||||||
|
[[ -n "$REGISTRY_TOKEN" ]] || fail "REGISTRY_TOKEN env var is required to push images"
|
||||||
|
log "Logging in to $REGISTRY as $REGISTRY_USER"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
}
|
||||||
23
scripts/ci/prepush.sh
Executable file
23
scripts/ci/prepush.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Husky pre-push hook body. Compiles everything and runs the fast test suites
|
||||||
|
# (no Docker, no registry) so broken code/tests never leave the workstation.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
|
||||||
|
log "Building full solution (Debug)"
|
||||||
|
dotnet build MicCheck.slnx -c Debug
|
||||||
|
|
||||||
|
log "Running MicCheck.Api.Tests.Unit"
|
||||||
|
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Debug
|
||||||
|
|
||||||
|
log "Installing admin dependencies (npm ci)"
|
||||||
|
npm --prefix src/admin ci
|
||||||
|
|
||||||
|
log "Running admin Jest tests"
|
||||||
|
npm --prefix src/admin test
|
||||||
|
|
||||||
|
log "Building admin SPA (vite build)"
|
||||||
|
npm --prefix src/admin run build
|
||||||
|
|
||||||
|
log "prepush.sh complete - ok to push"
|
||||||
15
scripts/ci/test.sh
Executable file
15
scripts/ci/test.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the fast test suites: .NET unit tests (EF InMemory, no DB/Docker needed)
|
||||||
|
# and the admin Jest suite. Integration tests are intentionally excluded here -
|
||||||
|
# they require a live API + Postgres (see readme in the Integration test project).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
|
||||||
|
log "Running MicCheck.Api.Tests.Unit"
|
||||||
|
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx
|
||||||
|
|
||||||
|
log "Running admin Jest tests"
|
||||||
|
npm --prefix src/admin test
|
||||||
|
|
||||||
|
log "test.sh complete"
|
||||||
19
src/admin/Dockerfile.ci
Normal file
19
src/admin/Dockerfile.ci
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Self-contained CI/QA image for the admin SPA. Unlike the dev Dockerfile
|
||||||
|
# (which serves a host-built ./dist via bind mount), this builds the SPA
|
||||||
|
# inside the image so it can be pushed to a registry and run standalone.
|
||||||
|
#
|
||||||
|
# Build context is src/admin:
|
||||||
|
# docker build -f src/admin/Dockerfile.ci -t miccheck-admin:qa src/admin
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -274,12 +274,22 @@
|
|||||||
data-testid="settings-default-enabled-toggle"
|
data-testid="settings-default-enabled-toggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="deleteConfirmName"
|
||||||
|
:label="`Type '${feature?.name}' to confirm deletion`"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-3"
|
||||||
|
hide-details
|
||||||
|
data-testid="delete-confirm-input"
|
||||||
|
/>
|
||||||
<div class="d-flex justify-space-between align-center">
|
<div class="d-flex justify-space-between align-center">
|
||||||
<v-btn
|
<v-btn
|
||||||
color="error"
|
color="error"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
|
:disabled="deleteConfirmName !== feature?.name"
|
||||||
data-testid="delete-feature-btn"
|
data-testid="delete-feature-btn"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
>
|
||||||
@@ -440,6 +450,8 @@ const settingsForm = ref({
|
|||||||
defaultEnabled: false,
|
defaultEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteConfirmName = ref('');
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (v: string) => !!v || 'Required',
|
required: (v: string) => !!v || 'Required',
|
||||||
nameFormat: (v: string) =>
|
nameFormat: (v: string) =>
|
||||||
@@ -481,6 +493,7 @@ watch(
|
|||||||
newSegmentId.value = null;
|
newSegmentId.value = null;
|
||||||
newSegmentEnabled.value = true;
|
newSegmentEnabled.value = true;
|
||||||
newSegmentPriority.value = 1;
|
newSegmentPriority.value = 1;
|
||||||
|
deleteConfirmName.value = '';
|
||||||
syncSettingsForm(props.feature);
|
syncSettingsForm(props.feature);
|
||||||
|
|
||||||
editedValue.value = props.featureState?.value ?? null;
|
editedValue.value = props.featureState?.value ?? null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
import { createRouter, createWebHistory, RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
|
||||||
import { getAccessToken } from '@/api/client';
|
import { getAccessToken } from '@/api/client';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
@@ -102,7 +102,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
// ─── Auth Guard ───────────────────────────────────────────────────────────────
|
// ─── Auth Guard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
export function authGuard(to: RouteLocationNormalized) {
|
||||||
const isAuthenticated = !!getAccessToken();
|
const isAuthenticated = !!getAccessToken();
|
||||||
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
|
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
|
||||||
const isPublic = to.matched.some((record) => record.meta.public);
|
const isPublic = to.matched.some((record) => record.meta.public);
|
||||||
@@ -116,6 +116,8 @@ router.beforeEach((to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
router.beforeEach(authGuard);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,51 +1,274 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||||
import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
|
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import apiClient, { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
|
||||||
|
import type { TokenResponse } from '@/types/api';
|
||||||
|
|
||||||
// The interceptor internals rely on axios, which is hard to unit-test without
|
// The response interceptor drives its retry/refresh flow entirely through the
|
||||||
// a real HTTP stack. These tests focus on the exported token-management helpers
|
// apiClient axios instance, so we replace its transport (`adapter`) with a
|
||||||
// that the interceptors and auth store both depend on.
|
// fake one we control. This exercises the real interceptor chain end-to-end
|
||||||
|
// (request auth header, 401 detection, refresh, retry, queueing) rather than
|
||||||
|
// just the token-storage helpers. The refresh call itself goes through the
|
||||||
|
// raw `axios.post` (not the apiClient instance, to avoid interceptor
|
||||||
|
// recursion), so that's mocked separately via jest.spyOn.
|
||||||
|
|
||||||
|
function unauthorizedError(config: InternalAxiosRequestConfig): AxiosError {
|
||||||
|
return new AxiosError(
|
||||||
|
'Request failed with status code 401',
|
||||||
|
'ERR_BAD_REQUEST',
|
||||||
|
config,
|
||||||
|
undefined,
|
||||||
|
{ status: 401, statusText: 'Unauthorized', data: {}, headers: {}, config },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverError(config: InternalAxiosRequestConfig): AxiosError {
|
||||||
|
return new AxiosError(
|
||||||
|
'Request failed with status code 500',
|
||||||
|
'ERR_BAD_RESPONSE',
|
||||||
|
config,
|
||||||
|
undefined,
|
||||||
|
{ status: 500, statusText: 'Internal Server Error', data: {}, headers: {}, config },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse: TokenResponse = {
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
refreshToken: 'new-refresh-token',
|
||||||
|
expiresAt: '2026-01-01T01:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ApiClient', () => {
|
||||||
|
let originalLocation: Location;
|
||||||
|
|
||||||
describe('ApiClient token management', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearAuthTokens();
|
clearAuthTokens();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
|
||||||
|
originalLocation = window.location;
|
||||||
|
// jsdom's window.location.href setter throws "not implemented" - stub it
|
||||||
|
// out so the redirect-on-refresh-failure path can be asserted on.
|
||||||
|
delete (window as any).location;
|
||||||
|
window.location = { ...originalLocation, href: '' } as Location;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearAuthTokens();
|
clearAuthTokens();
|
||||||
|
window.location = originalLocation;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WhenTokensAreSet', () => {
|
describe('Token management', () => {
|
||||||
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
|
describe('WhenTokensAreSet', () => {
|
||||||
setAuthTokens('access-abc', 'refresh-xyz');
|
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
|
||||||
|
setAuthTokens('access-abc', 'refresh-xyz');
|
||||||
|
|
||||||
expect(getAccessToken()).toBe('access-abc');
|
expect(getAccessToken()).toBe('access-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTokensAreCleared', () => {
|
||||||
|
it('ThenGetAccessTokenReturnsNull', () => {
|
||||||
|
setAuthTokens('access-abc', 'refresh-xyz');
|
||||||
|
clearAuthTokens();
|
||||||
|
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenSetAuthTokensIsCalledWithNull', () => {
|
||||||
|
it('ThenGetAccessTokenReturnsNull', () => {
|
||||||
|
setAuthTokens(null, null);
|
||||||
|
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTokensAreUpdated', () => {
|
||||||
|
it('ThenGetAccessTokenReturnsTheLatestToken', () => {
|
||||||
|
setAuthTokens('first-token', 'first-refresh');
|
||||||
|
setAuthTokens('second-token', 'second-refresh');
|
||||||
|
|
||||||
|
expect(getAccessToken()).toBe('second-token');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WhenTokensAreCleared', () => {
|
describe('Request interceptor', () => {
|
||||||
it('ThenGetAccessTokenReturnsNull', () => {
|
describe('WhenAnAccessTokenIsSet', () => {
|
||||||
setAuthTokens('access-abc', 'refresh-xyz');
|
it('ThenTheRequestCarriesABearerAuthorizationHeader', async () => {
|
||||||
clearAuthTokens();
|
setAuthTokens('access-abc', 'refresh-xyz');
|
||||||
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
|
||||||
|
data: {}, status: 200, statusText: 'OK', headers: {}, config,
|
||||||
|
}));
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
expect(getAccessToken()).toBeNull();
|
await apiClient.get('/v1/whatever');
|
||||||
|
|
||||||
|
expect(adapter.mock.calls[0][0].headers.Authorization).toBe('Bearer access-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenNoAccessTokenIsSet', () => {
|
||||||
|
it('ThenTheRequestHasNoAuthorizationHeader', async () => {
|
||||||
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
|
||||||
|
data: {}, status: 200, statusText: 'OK', headers: {}, config,
|
||||||
|
}));
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
await apiClient.get('/v1/whatever');
|
||||||
|
|
||||||
|
expect(adapter.mock.calls[0][0].headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WhenSetAuthTokensIsCalledWithNull', () => {
|
describe('Response interceptor - 401 refresh flow', () => {
|
||||||
it('ThenGetAccessTokenReturnsNull', () => {
|
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
|
||||||
setAuthTokens(null, null);
|
it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => {
|
||||||
|
setAuthTokens('expired-access-token', 'valid-refresh-token');
|
||||||
|
|
||||||
expect(getAccessToken()).toBeNull();
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
if (config.headers.Authorization === 'Bearer new-access-token') {
|
||||||
|
return { data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config };
|
||||||
|
}
|
||||||
|
throw unauthorizedError(config);
|
||||||
|
});
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
const postSpy = jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
|
||||||
|
|
||||||
|
const response = await apiClient.get('/v1/protected');
|
||||||
|
|
||||||
|
expect(postSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/v1/auth/refresh'),
|
||||||
|
{ token: 'valid-refresh-token' },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(response.data).toEqual({ ok: true });
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(2);
|
||||||
|
expect(getAccessToken()).toBe('new-access-token');
|
||||||
|
expect(localStorage.getItem('mic_access_token')).toBe('new-access-token');
|
||||||
|
expect(localStorage.getItem('mic_refresh_token')).toBe('new-refresh-token');
|
||||||
|
expect(localStorage.getItem('mic_expires_at')).toBe(tokenResponse.expiresAt);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('WhenTokensAreUpdated', () => {
|
describe('WhenTheRefreshRequestItselfFails', () => {
|
||||||
it('ThenGetAccessTokenReturnsTheLatestToken', () => {
|
it('ThenTokensAreClearedAndTheUserIsRedirectedToLogin', async () => {
|
||||||
setAuthTokens('first-token', 'first-refresh');
|
setAuthTokens('expired-access-token', 'invalid-refresh-token');
|
||||||
setAuthTokens('second-token', 'second-refresh');
|
localStorage.setItem('mic_access_token', 'expired-access-token');
|
||||||
|
localStorage.setItem('mic_refresh_token', 'invalid-refresh-token');
|
||||||
|
localStorage.setItem('mic_expires_at', '2026-01-01T00:00:00Z');
|
||||||
|
|
||||||
expect(getAccessToken()).toBe('second-token');
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
throw unauthorizedError(config);
|
||||||
|
});
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
jest.spyOn(axios, 'post').mockRejectedValue(new Error('refresh endpoint unreachable'));
|
||||||
|
|
||||||
|
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(getAccessToken()).toBeNull();
|
||||||
|
expect(localStorage.getItem('mic_access_token')).toBeNull();
|
||||||
|
expect(localStorage.getItem('mic_refresh_token')).toBeNull();
|
||||||
|
expect(localStorage.getItem('mic_expires_at')).toBeNull();
|
||||||
|
expect(window.location.href).toBe('/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenThereIsNoRefreshTokenAvailable', () => {
|
||||||
|
it('ThenTheOriginalErrorIsRejectedWithoutAttemptingRefresh', async () => {
|
||||||
|
setAuthTokens(null, null);
|
||||||
|
|
||||||
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
throw unauthorizedError(config);
|
||||||
|
});
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
const postSpy = jest.spyOn(axios, 'post');
|
||||||
|
|
||||||
|
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(postSpy).not.toHaveBeenCalled();
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenARequestFailsWithANonAuthError', () => {
|
||||||
|
it('ThenTheErrorIsRejectedWithoutAttemptingRefresh', async () => {
|
||||||
|
setAuthTokens('access-abc', 'refresh-xyz');
|
||||||
|
|
||||||
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
throw serverError(config);
|
||||||
|
});
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
const postSpy = jest.spyOn(axios, 'post');
|
||||||
|
|
||||||
|
await expect(apiClient.get('/v1/whatever')).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(postSpy).not.toHaveBeenCalled();
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenARetriedRequestFailsWith401Again', () => {
|
||||||
|
it('ThenTheErrorIsRejectedWithoutLoopingForever', async () => {
|
||||||
|
setAuthTokens('expired-access-token', 'valid-refresh-token');
|
||||||
|
|
||||||
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig & { _retried?: boolean }) => {
|
||||||
|
if (config._retried) {
|
||||||
|
throw unauthorizedError(config);
|
||||||
|
}
|
||||||
|
throw unauthorizedError(config);
|
||||||
|
});
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
jest.spyOn(axios, 'post').mockResolvedValue({ data: tokenResponse });
|
||||||
|
|
||||||
|
await expect(apiClient.get('/v1/protected')).rejects.toThrow();
|
||||||
|
|
||||||
|
// One failed attempt + one retry after refresh; the retry itself
|
||||||
|
// is marked _retried so a second 401 does not trigger another refresh.
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenMultipleRequestsFailWith401WhileARefreshIsAlreadyInFlight', () => {
|
||||||
|
it('ThenAllRequestsAreQueuedAndRetriedWithTheRefreshedToken', async () => {
|
||||||
|
setAuthTokens('expired-access-token', 'valid-refresh-token');
|
||||||
|
|
||||||
|
let resolveRefresh!: (value: { data: TokenResponse }) => void;
|
||||||
|
const refreshPromise = new Promise<{ data: TokenResponse }>((resolve) => {
|
||||||
|
resolveRefresh = resolve;
|
||||||
|
});
|
||||||
|
jest.spyOn(axios, 'post').mockReturnValue(refreshPromise as any);
|
||||||
|
|
||||||
|
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
|
||||||
|
if (config.headers.Authorization === 'Bearer new-access-token') {
|
||||||
|
return { data: { url: config.url }, status: 200, statusText: 'OK', headers: {}, config };
|
||||||
|
}
|
||||||
|
throw unauthorizedError(config);
|
||||||
|
});
|
||||||
|
apiClient.defaults.adapter = adapter;
|
||||||
|
|
||||||
|
const first = apiClient.get('/v1/first');
|
||||||
|
const second = apiClient.get('/v1/second');
|
||||||
|
|
||||||
|
// Let both initial 401s resolve before the refresh completes.
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
resolveRefresh({ data: tokenResponse });
|
||||||
|
|
||||||
|
const [firstResponse, secondResponse] = await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(firstResponse.data).toEqual({ url: '/v1/first' });
|
||||||
|
expect(secondResponse.data).toEqual({ url: '/v1/second' });
|
||||||
|
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||||
|
import ConditionEditor from '@/components/segments/ConditionEditor.vue';
|
||||||
|
import type { SegmentCondition } from '@/types/api';
|
||||||
|
|
||||||
|
function mountEditor(condition: SegmentCondition) {
|
||||||
|
return mount(ConditionEditor, { props: { condition } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConditionEditor', () => {
|
||||||
|
describe('WhenThePropertyFieldIsChanged', () => {
|
||||||
|
it('ThenUpdateConditionIsEmittedWithTheNewProperty', async () => {
|
||||||
|
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
await (wrapper.findComponent('[data-testid="condition-property"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'country');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'country', operator: 'Equal', value: 'beta' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheOperatorIsChanged', () => {
|
||||||
|
it('ThenUpdateConditionIsEmittedWithTheNewOperator', async () => {
|
||||||
|
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
await (wrapper.findComponent('[data-testid="condition-operator"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'NotEqual');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'plan', operator: 'NotEqual', value: 'beta' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueFieldIsChanged', () => {
|
||||||
|
it('ThenUpdateConditionIsEmittedWithTheNewValue', async () => {
|
||||||
|
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
await (wrapper.findComponent('[data-testid="condition-value"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'gamma');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'plan', operator: 'Equal', value: 'gamma' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheOperatorNeedsNoValue', () => {
|
||||||
|
it.each(['IsTrue', 'IsFalse', 'IsSet', 'IsNotSet'] as const)(
|
||||||
|
'ThenNeitherTheValueFieldNorThePercentageSliderIsShownFor %s',
|
||||||
|
(operator) => {
|
||||||
|
const condition: SegmentCondition = { property: 'plan', operator, value: '' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="condition-value"]').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('[data-testid="condition-percentage-slider"]').exists()).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheOperatorIsPercentageSplit', () => {
|
||||||
|
it('ThenThePercentageSliderIsShownInsteadOfTheValueField', () => {
|
||||||
|
const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: '25' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="condition-percentage-slider"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="condition-value"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenAnInvalidStoredValueRendersAsZero', () => {
|
||||||
|
const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: 'not-a-number' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
const slider = wrapper.findComponent('[data-testid="condition-percentage-slider"]') as VueWrapper<any>;
|
||||||
|
expect(slider.props('modelValue')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenMovingTheSliderEmitsUpdateConditionWithTheValueAsAString', async () => {
|
||||||
|
const condition: SegmentCondition = { property: 'userId', operator: 'PercentageSplit', value: '25' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
await (wrapper.findComponent('[data-testid="condition-percentage-slider"]') as VueWrapper<any>).vm.$emit('update:modelValue', 60);
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:condition')![0]).toEqual([{ property: 'userId', operator: 'PercentageSplit', value: '60' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheRemoveButtonIsClicked', () => {
|
||||||
|
it('ThenRemoveIsEmitted', async () => {
|
||||||
|
const condition: SegmentCondition = { property: 'plan', operator: 'Equal', value: 'beta' };
|
||||||
|
const wrapper = mountEditor(condition);
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="remove-condition-btn"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('remove')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
216
src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts
Normal file
216
src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||||
|
import RuleGroupEditor from '@/components/segments/RuleGroupEditor.vue';
|
||||||
|
import ConditionEditor from '@/components/segments/ConditionEditor.vue';
|
||||||
|
import type { SegmentRule } from '@/types/api';
|
||||||
|
|
||||||
|
function mountEditor(rule: SegmentRule, removable = false) {
|
||||||
|
return mount(RuleGroupEditor, { props: { rule, removable } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RuleGroupEditor', () => {
|
||||||
|
describe('WhenTheRuleTypeIsAll', () => {
|
||||||
|
it('ThenTheAndButtonIsHighlighted', () => {
|
||||||
|
const wrapper = mountEditor({ type: 'All', conditions: [] });
|
||||||
|
|
||||||
|
expect(wrapper.findComponent('[data-testid="rule-type-and"]').props('variant')).toBe('flat');
|
||||||
|
expect(wrapper.findComponent('[data-testid="rule-type-or"]').props('variant')).toBe('outlined');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheRuleTypeIsAny', () => {
|
||||||
|
it('ThenTheOrButtonIsHighlighted', () => {
|
||||||
|
const wrapper = mountEditor({ type: 'Any', conditions: [] });
|
||||||
|
|
||||||
|
expect(wrapper.findComponent('[data-testid="rule-type-and"]').props('variant')).toBe('outlined');
|
||||||
|
expect(wrapper.findComponent('[data-testid="rule-type-or"]').props('variant')).toBe('flat');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheOrButtonIsClicked', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithTypeAny', async () => {
|
||||||
|
const wrapper = mountEditor({ type: 'All', conditions: [] });
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="rule-type-or"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{ type: 'Any', conditions: [] }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheAndButtonIsClicked', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithTypeAll', async () => {
|
||||||
|
const wrapper = mountEditor({ type: 'Any', conditions: [] });
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="rule-type-and"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{ type: 'All', conditions: [] }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenRemovableIsFalse', () => {
|
||||||
|
it('ThenTheRemoveButtonIsNotShown', () => {
|
||||||
|
const wrapper = mountEditor({ type: 'All', conditions: [] }, false);
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="remove-rule-group-btn"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenRemovableIsTrue', () => {
|
||||||
|
it('ThenTheRemoveButtonIsShownAndEmitsRemoveWhenClicked', async () => {
|
||||||
|
const wrapper = mountEditor({ type: 'All', conditions: [] }, true);
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="remove-rule-group-btn"]').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="remove-rule-group-btn"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('remove')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheRuleHasConditions', () => {
|
||||||
|
it('ThenAConditionEditorIsRenderedPerCondition', () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [
|
||||||
|
{ property: 'plan', operator: 'Equal', value: 'beta' },
|
||||||
|
{ property: 'country', operator: 'Equal', value: 'US' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.findAllComponents(ConditionEditor)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAConditionIsUpdated', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithThatConditionReplacedInPlace', async () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [
|
||||||
|
{ property: 'plan', operator: 'Equal', value: 'beta' },
|
||||||
|
{ property: 'country', operator: 'Equal', value: 'US' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditionEditors = wrapper.findAllComponents(ConditionEditor);
|
||||||
|
await conditionEditors[1].vm.$emit('update:condition', { property: 'country', operator: 'Equal', value: 'CA' });
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{
|
||||||
|
type: 'All',
|
||||||
|
conditions: [
|
||||||
|
{ property: 'plan', operator: 'Equal', value: 'beta' },
|
||||||
|
{ property: 'country', operator: 'Equal', value: 'CA' },
|
||||||
|
],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAConditionIsRemoved', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithThatConditionFilteredOut', async () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [
|
||||||
|
{ property: 'plan', operator: 'Equal', value: 'beta' },
|
||||||
|
{ property: 'country', operator: 'Equal', value: 'US' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditionEditors = wrapper.findAllComponents(ConditionEditor);
|
||||||
|
await conditionEditors[0].vm.$emit('remove');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{
|
||||||
|
type: 'All',
|
||||||
|
conditions: [{ property: 'country', operator: 'Equal', value: 'US' }],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAddConditionIsClicked', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithANewEmptyConditionAppended', async () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="add-condition-btn"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{
|
||||||
|
type: 'All',
|
||||||
|
conditions: [
|
||||||
|
{ property: 'plan', operator: 'Equal', value: 'beta' },
|
||||||
|
{ property: '', operator: 'Equal', value: '' },
|
||||||
|
],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAddGroupIsClicked', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithANewChildGroupAppended', async () => {
|
||||||
|
const wrapper = mountEditor({ type: 'All', conditions: [] });
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="add-group-btn"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{
|
||||||
|
type: 'All',
|
||||||
|
conditions: [],
|
||||||
|
childRules: [{ type: 'All', conditions: [] }],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheRuleHasChildRules', () => {
|
||||||
|
it('ThenANestedRuleGroupEditorIsRenderedPerChildAndMarkedRemovable', () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [],
|
||||||
|
childRules: [{ type: 'Any', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nested = wrapper.findAllComponents(RuleGroupEditor);
|
||||||
|
expect(nested).toHaveLength(1);
|
||||||
|
expect(nested[0].props('rule')).toEqual({ type: 'Any', conditions: [{ property: 'plan', operator: 'Equal', value: 'beta' }] });
|
||||||
|
expect(nested[0].props('removable')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenANestedRuleGroupIsUpdated', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithThatChildReplacedInPlace', async () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [],
|
||||||
|
childRules: [{ type: 'Any', conditions: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nested = wrapper.findAllComponents(RuleGroupEditor);
|
||||||
|
const updatedChild: SegmentRule = { type: 'All', conditions: [{ property: 'x', operator: 'Equal', value: 'y' }] };
|
||||||
|
await nested[0].vm.$emit('update:rule', updatedChild);
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{
|
||||||
|
type: 'All',
|
||||||
|
conditions: [],
|
||||||
|
childRules: [updatedChild],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenANestedRuleGroupIsRemoved', () => {
|
||||||
|
it('ThenUpdateRuleIsEmittedWithThatChildFilteredOut', async () => {
|
||||||
|
const wrapper = mountEditor({
|
||||||
|
type: 'All',
|
||||||
|
conditions: [],
|
||||||
|
childRules: [
|
||||||
|
{ type: 'Any', conditions: [] },
|
||||||
|
{ type: 'All', conditions: [] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nested = wrapper.findAllComponents(RuleGroupEditor);
|
||||||
|
await nested[0].vm.$emit('remove');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:rule')![0]).toEqual([{
|
||||||
|
type: 'All',
|
||||||
|
conditions: [],
|
||||||
|
childRules: [{ type: 'All', conditions: [] }],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
import { setActivePinia, createPinia } from 'pinia';
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
import { setAuthTokens, clearAuthTokens } from '@/api/client';
|
import { clearAuthTokens } from '@/api/client';
|
||||||
|
import { authGuard } from '@/router';
|
||||||
|
|
||||||
// Minimal stub components for route testing
|
// Minimal stub components for route testing
|
||||||
const Stub = { template: '<div />' };
|
const Stub = { template: '<div />' };
|
||||||
@@ -15,11 +16,17 @@ jest.mock('@/api/client', () => ({
|
|||||||
import { getAccessToken } from '@/api/client';
|
import { getAccessToken } from '@/api/client';
|
||||||
|
|
||||||
function buildRouter() {
|
function buildRouter() {
|
||||||
return createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } },
|
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } },
|
||||||
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } },
|
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: '/accept-invite/:token',
|
||||||
|
name: 'AcceptInvite',
|
||||||
|
component: Stub,
|
||||||
|
meta: { public: true, allowAuthenticated: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: Stub,
|
component: Stub,
|
||||||
@@ -31,6 +38,9 @@ function buildRouter() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// Wire up the real guard exported by the app router, not a re-implementation.
|
||||||
|
router.beforeEach(authGuard);
|
||||||
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Router auth guard', () => {
|
describe('Router auth guard', () => {
|
||||||
@@ -44,21 +54,10 @@ describe('Router auth guard', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
|
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
|
||||||
it('ThenTheyAreRedirectedToLogin', async () => {
|
it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => {
|
||||||
jest.mocked(getAccessToken).mockReturnValue(null);
|
jest.mocked(getAccessToken).mockReturnValue(null);
|
||||||
|
|
||||||
const router = buildRouter();
|
const router = buildRouter();
|
||||||
|
|
||||||
// Wire up the same guard logic as the real router
|
|
||||||
router.beforeEach((to) => {
|
|
||||||
const isAuthenticated = !!getAccessToken();
|
|
||||||
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
|
|
||||||
const isPublic = to.matched.some((r) => r.meta.public);
|
|
||||||
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
|
|
||||||
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await router.push('/features');
|
await router.push('/features');
|
||||||
|
|
||||||
expect(router.currentRoute.value.name).toBe('Login');
|
expect(router.currentRoute.value.name).toBe('Login');
|
||||||
@@ -71,17 +70,6 @@ describe('Router auth guard', () => {
|
|||||||
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
||||||
|
|
||||||
const router = buildRouter();
|
const router = buildRouter();
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
|
||||||
const isAuthenticated = !!getAccessToken();
|
|
||||||
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
|
|
||||||
const isPublic = to.matched.some((r) => r.meta.public);
|
|
||||||
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
|
|
||||||
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start authenticated on dashboard, then try to go to login
|
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
await router.push('/login');
|
await router.push('/login');
|
||||||
|
|
||||||
@@ -94,19 +82,55 @@ describe('Router auth guard', () => {
|
|||||||
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
||||||
|
|
||||||
const router = buildRouter();
|
const router = buildRouter();
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
|
||||||
const isAuthenticated = !!getAccessToken();
|
|
||||||
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
|
|
||||||
const isPublic = to.matched.some((r) => r.meta.public);
|
|
||||||
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
|
|
||||||
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await router.push('/features');
|
await router.push('/features');
|
||||||
|
|
||||||
expect(router.currentRoute.value.name).toBe('Features');
|
expect(router.currentRoute.value.name).toBe('Features');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WhenUnauthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
|
||||||
|
it('ThenNavigationSucceeds', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue(null);
|
||||||
|
|
||||||
|
const router = buildRouter();
|
||||||
|
await router.push('/accept-invite/some-token');
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('AcceptInvite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAuthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
|
||||||
|
it('ThenTheyAreNotRedirectedToDashboard', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
||||||
|
|
||||||
|
const router = buildRouter();
|
||||||
|
await router.push('/accept-invite/some-token');
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('AcceptInvite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAuthenticatedUserNavigatesToRegister', () => {
|
||||||
|
it('ThenTheyAreRedirectedToDashboardBecauseRegisterHasNoAllowAuthenticatedException', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
||||||
|
|
||||||
|
const router = buildRouter();
|
||||||
|
await router.push('/');
|
||||||
|
await router.push('/register');
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('Dashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenUnauthenticatedUserNavigatesToAProtectedRouteWithAQueryString', () => {
|
||||||
|
it('ThenTheFullPathIncludingTheQueryIsPreservedInTheRedirect', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue(null);
|
||||||
|
|
||||||
|
const router = buildRouter();
|
||||||
|
await router.push('/features?tag=beta');
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('Login');
|
||||||
|
expect(router.currentRoute.value.query.redirect).toBe('/features?tag=beta');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
113
src/admin/tests/unit/stores/theme.spec.ts
Normal file
113
src/admin/tests/unit/stores/theme.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { useThemeStore } from '@/stores/theme';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mic_theme';
|
||||||
|
|
||||||
|
function mockMatchMedia(initialMatches: boolean) {
|
||||||
|
let changeHandler: ((e: { matches: boolean }) => void) | undefined;
|
||||||
|
const mql = {
|
||||||
|
matches: initialMatches,
|
||||||
|
media: '(prefers-color-scheme: dark)',
|
||||||
|
addEventListener: jest.fn((event: string, handler: (e: { matches: boolean }) => void) => {
|
||||||
|
if (event === 'change') changeHandler = handler;
|
||||||
|
}),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
};
|
||||||
|
window.matchMedia = jest.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
|
return {
|
||||||
|
triggerChange: (matches: boolean) => changeHandler?.({ matches }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThemeStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenNoModeIsStoredAndSystemPrefersLight', () => {
|
||||||
|
it('ThenModeDefaultsToSystemAndEffectiveThemeIsLight', () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
expect(themeStore.mode).toBe('system');
|
||||||
|
expect(themeStore.effectiveTheme).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenNoModeIsStoredAndSystemPrefersDark', () => {
|
||||||
|
it('ThenEffectiveThemeIsDark', () => {
|
||||||
|
mockMatchMedia(true);
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
expect(themeStore.mode).toBe('system');
|
||||||
|
expect(themeStore.effectiveTheme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAModeIsAlreadyStored', () => {
|
||||||
|
it('ThenTheStoredModeIsUsedAsTheInitialMode', () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'dark');
|
||||||
|
mockMatchMedia(false);
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
expect(themeStore.mode).toBe('dark');
|
||||||
|
expect(themeStore.effectiveTheme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenSetModeIsCalledWithLight', () => {
|
||||||
|
it('ThenModeAndEffectiveThemeAreLightAndPersistedToStorage', () => {
|
||||||
|
mockMatchMedia(true);
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
themeStore.setMode('light');
|
||||||
|
|
||||||
|
expect(themeStore.mode).toBe('light');
|
||||||
|
expect(themeStore.effectiveTheme).toBe('light');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenSetModeIsCalledWithDark', () => {
|
||||||
|
it('ThenModeAndEffectiveThemeAreDarkAndPersistedToStorage', () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
themeStore.setMode('dark');
|
||||||
|
|
||||||
|
expect(themeStore.mode).toBe('dark');
|
||||||
|
expect(themeStore.effectiveTheme).toBe('dark');
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenModeIsSystemAndTheOperatingSystemThemeChanges', () => {
|
||||||
|
it('ThenEffectiveThemeReactsToTheChange', () => {
|
||||||
|
const { triggerChange } = mockMatchMedia(false);
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
expect(themeStore.effectiveTheme).toBe('light');
|
||||||
|
|
||||||
|
triggerChange(true);
|
||||||
|
|
||||||
|
expect(themeStore.effectiveTheme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenModeIsExplicitAndTheOperatingSystemThemeChanges', () => {
|
||||||
|
it('ThenEffectiveThemeIsUnaffected', () => {
|
||||||
|
const { triggerChange } = mockMatchMedia(false);
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
themeStore.setMode('light');
|
||||||
|
|
||||||
|
triggerChange(true);
|
||||||
|
|
||||||
|
expect(themeStore.effectiveTheme).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/admin/tests/unit/utils/validation.spec.ts
Normal file
52
src/admin/tests/unit/utils/validation.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { validateEmail } from '@/utils/validation';
|
||||||
|
|
||||||
|
describe('validateEmail', () => {
|
||||||
|
describe('WhenTheValueIsEmpty', () => {
|
||||||
|
it('ThenItReturnsAnEmailRequiredMessage', () => {
|
||||||
|
expect(validateEmail('')).toBe('Email required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueIsOnlyWhitespace', () => {
|
||||||
|
it('ThenItReturnsAnEmailRequiredMessage', () => {
|
||||||
|
expect(validateEmail(' ')).toBe('Email required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueIsAValidEmail', () => {
|
||||||
|
it('ThenItReturnsTrue', () => {
|
||||||
|
expect(validateEmail('jane@example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueHasSurroundingWhitespace', () => {
|
||||||
|
it('ThenItIsTrimmedBeforeValidationAndReturnsTrue', () => {
|
||||||
|
expect(validateEmail(' jane@example.com ')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueIsMissingTheAtSymbol', () => {
|
||||||
|
it('ThenItReturnsAnInvalidEmailMessage', () => {
|
||||||
|
expect(validateEmail('jane.example.com')).toBe('Invalid email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueIsMissingTheDomain', () => {
|
||||||
|
it('ThenItReturnsAnInvalidEmailMessage', () => {
|
||||||
|
expect(validateEmail('jane@example')).toBe('Invalid email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueContainsSpaces', () => {
|
||||||
|
it('ThenItReturnsAnInvalidEmailMessage', () => {
|
||||||
|
expect(validateEmail('jane doe@example.com')).toBe('Invalid email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheValueHasMultipleAtSymbols', () => {
|
||||||
|
it('ThenItReturnsAnInvalidEmailMessage', () => {
|
||||||
|
expect(validateEmail('jane@doe@example.com')).toBe('Invalid email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
src/admin/tests/unit/views/AcceptInviteView.spec.ts
Normal file
84
src/admin/tests/unit/views/AcceptInviteView.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils';
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
|
import AcceptInviteView from '@/views/AcceptInviteView.vue';
|
||||||
|
|
||||||
|
jest.mock('@/api/organizations');
|
||||||
|
jest.mock('@/api/client', () => ({
|
||||||
|
setAuthTokens: jest.fn(),
|
||||||
|
clearAuthTokens: jest.fn(),
|
||||||
|
getAccessToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as orgsApi from '@/api/organizations';
|
||||||
|
import { getAccessToken } from '@/api/client';
|
||||||
|
|
||||||
|
async function mountView(token = 'invite-token-123') {
|
||||||
|
const routes = [
|
||||||
|
{ path: '/accept-invite/:token', name: 'AcceptInvite', component: AcceptInviteView },
|
||||||
|
{ path: '/dashboard', component: { template: '<div />' } },
|
||||||
|
{ path: '/login', name: 'Login', component: { template: '<div />' } },
|
||||||
|
];
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||||
|
router.push(`/accept-invite/${token}`);
|
||||||
|
await router.isReady();
|
||||||
|
return { wrapper: mount(AcceptInviteView, { global: { plugins: [router] } }), router };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AcceptInviteView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheUserIsNotAuthenticated', () => {
|
||||||
|
it('ThenTheSignInPromptIsShownAndAcceptInviteIsNotCalled', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue(null);
|
||||||
|
|
||||||
|
const { wrapper } = await mountView();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Sign in to accept invite');
|
||||||
|
expect(orgsApi.acceptInvite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenTheSignInButtonNavigatesToLoginWithARedirectQuery', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue(null);
|
||||||
|
|
||||||
|
const { wrapper, router } = await mountView('invite-token-123');
|
||||||
|
await flushPromises();
|
||||||
|
const pushSpy = jest.spyOn(router, 'push');
|
||||||
|
|
||||||
|
await wrapper.find('button').trigger('click');
|
||||||
|
|
||||||
|
expect(pushSpy).toHaveBeenCalledWith({
|
||||||
|
name: 'Login',
|
||||||
|
query: { redirect: '/accept-invite/invite-token-123' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheUserIsAuthenticatedAndTheInviteIsAccepted', () => {
|
||||||
|
it('ThenTheSuccessStateIsShown', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue('access-token');
|
||||||
|
jest.mocked(orgsApi.acceptInvite).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { wrapper } = await mountView('invite-token-123');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(orgsApi.acceptInvite).toHaveBeenCalledWith('invite-token-123');
|
||||||
|
expect(wrapper.text()).toContain("You're in!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheUserIsAuthenticatedButTheInviteIsInvalid', () => {
|
||||||
|
it('ThenTheErrorStateIsShown', async () => {
|
||||||
|
jest.mocked(getAccessToken).mockReturnValue('access-token');
|
||||||
|
jest.mocked(orgsApi.acceptInvite).mockRejectedValue(new Error('Invalid token'));
|
||||||
|
|
||||||
|
const { wrapper } = await mountView('bad-token');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Invalid invite link');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/admin/tests/unit/views/LoginView.spec.ts
Normal file
129
src/admin/tests/unit/views/LoginView.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils';
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
|
import LoginView from '@/views/LoginView.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
jest.mock('@/api/auth');
|
||||||
|
jest.mock('@/api/client', () => ({
|
||||||
|
setAuthTokens: jest.fn(),
|
||||||
|
clearAuthTokens: jest.fn(),
|
||||||
|
getAccessToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as authApi from '@/api/auth';
|
||||||
|
|
||||||
|
const tokenResponse = {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresAt: '2026-01-01T01:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mountView() {
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/register', component: { template: '<div />' } },
|
||||||
|
];
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||||
|
return { wrapper: mount(LoginView, { global: { plugins: [router] } }), router };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillAndSubmit(wrapper: ReturnType<typeof mount>, email: string, password: string) {
|
||||||
|
await wrapper.find('[data-testid="email-input"] input').setValue(email);
|
||||||
|
await wrapper.find('[data-testid="password-input"] input').setValue(password);
|
||||||
|
await wrapper.find('[data-testid="login-form"]').trigger('submit.prevent');
|
||||||
|
await flushPromises();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LoginView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheFormIsEmpty', () => {
|
||||||
|
it('ThenSubmitDoesNotCallLogin', async () => {
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="login-form"]').trigger('submit.prevent');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(authApi.login).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheEmailIsInvalid', () => {
|
||||||
|
it('ThenSubmitDoesNotCallLogin', async () => {
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, 'not-an-email', 'password123');
|
||||||
|
|
||||||
|
expect(authApi.login).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenCredentialsAreValidAndLoginSucceeds', () => {
|
||||||
|
it('ThenAuthStoreLoginIsCalledAndUserIsRedirectedHome', async () => {
|
||||||
|
jest.mocked(authApi.login).mockResolvedValue(tokenResponse);
|
||||||
|
|
||||||
|
const { wrapper, router } = mountView();
|
||||||
|
const pushSpy = jest.spyOn(router, 'push');
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, 'jane@example.com', 'password123');
|
||||||
|
|
||||||
|
expect(authApi.login).toHaveBeenCalledWith({ email: 'jane@example.com', password: 'password123' });
|
||||||
|
expect(pushSpy).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenAuthStoreBecomesAuthenticated', async () => {
|
||||||
|
jest.mocked(authApi.login).mockResolvedValue(tokenResponse);
|
||||||
|
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, 'jane@example.com', 'password123');
|
||||||
|
|
||||||
|
expect(authStore.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenLoginFails', () => {
|
||||||
|
it('ThenAnErrorMessageIsShownAndUserIsNotRedirected', async () => {
|
||||||
|
jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
|
||||||
|
|
||||||
|
const { wrapper, router } = mountView();
|
||||||
|
const pushSpy = jest.spyOn(router, 'push');
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, 'jane@example.com', 'wrong-password');
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="login-error"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain('Invalid email or password');
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenTheSubmitButtonIsNoLongerLoading', async () => {
|
||||||
|
jest.mocked(authApi.login).mockRejectedValue(new Error('Unauthorized'));
|
||||||
|
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, 'jane@example.com', 'wrong-password');
|
||||||
|
|
||||||
|
const submitBtn = wrapper.findComponent('[data-testid="login-submit"]');
|
||||||
|
expect(submitBtn.props('loading')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenThePasswordVisibilityIconIsClicked', () => {
|
||||||
|
it('ThenThePasswordFieldTypeToggles', async () => {
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
const passwordInput = () => wrapper.find('[data-testid="password-input"] input');
|
||||||
|
expect(passwordInput().attributes('type')).toBe('password');
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="password-input"] .v-field__append-inner i').trigger('click');
|
||||||
|
|
||||||
|
expect(passwordInput().attributes('type')).toBe('text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
155
src/admin/tests/unit/views/RegisterView.spec.ts
Normal file
155
src/admin/tests/unit/views/RegisterView.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils';
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
|
import RegisterView from '@/views/RegisterView.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
jest.mock('@/api/auth');
|
||||||
|
jest.mock('@/api/client', () => ({
|
||||||
|
setAuthTokens: jest.fn(),
|
||||||
|
clearAuthTokens: jest.fn(),
|
||||||
|
getAccessToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as authApi from '@/api/auth';
|
||||||
|
|
||||||
|
const tokenResponse = {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresAt: '2026-01-01T01:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mountView() {
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: { template: '<div />' } },
|
||||||
|
{ path: '/login', component: { template: '<div />' } },
|
||||||
|
];
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes });
|
||||||
|
return { wrapper: mount(RegisterView, { global: { plugins: [router] } }), router };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterFields {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
organizationName: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFields: RegisterFields = {
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
organizationName: 'Acme',
|
||||||
|
password: 'password123',
|
||||||
|
confirmPassword: 'password123',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fillAndSubmit(wrapper: ReturnType<typeof mount>, fields: Partial<RegisterFields> = {}) {
|
||||||
|
const values = { ...validFields, ...fields };
|
||||||
|
await wrapper.find('[data-testid="first-name-input"] input').setValue(values.firstName);
|
||||||
|
await wrapper.find('[data-testid="last-name-input"] input').setValue(values.lastName);
|
||||||
|
await wrapper.find('[data-testid="email-input"] input').setValue(values.email);
|
||||||
|
await wrapper.find('[data-testid="org-name-input"] input').setValue(values.organizationName);
|
||||||
|
await wrapper.find('[data-testid="password-input"] input').setValue(values.password);
|
||||||
|
await wrapper.find('[data-testid="confirm-password-input"] input').setValue(values.confirmPassword);
|
||||||
|
await wrapper.find('[data-testid="register-form"]').trigger('submit.prevent');
|
||||||
|
await flushPromises();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RegisterView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheFormIsEmpty', () => {
|
||||||
|
it('ThenSubmitDoesNotCallRegister', async () => {
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="register-form"]').trigger('submit.prevent');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(authApi.register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenThePasswordIsTooShort', () => {
|
||||||
|
it('ThenSubmitDoesNotCallRegister', async () => {
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, { password: 'short', confirmPassword: 'short' });
|
||||||
|
|
||||||
|
expect(authApi.register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTheConfirmPasswordDoesNotMatch', () => {
|
||||||
|
it('ThenSubmitDoesNotCallRegister', async () => {
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper, { confirmPassword: 'somethingElse123' });
|
||||||
|
|
||||||
|
expect(authApi.register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAllFieldsAreValidAndRegisterSucceeds', () => {
|
||||||
|
it('ThenAuthStoreRegisterIsCalledWithFormValuesAndUserIsRedirectedHome', async () => {
|
||||||
|
jest.mocked(authApi.register).mockResolvedValue(tokenResponse);
|
||||||
|
|
||||||
|
const { wrapper, router } = mountView();
|
||||||
|
const pushSpy = jest.spyOn(router, 'push');
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper);
|
||||||
|
|
||||||
|
expect(authApi.register).toHaveBeenCalledWith({
|
||||||
|
email: 'jane@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Doe',
|
||||||
|
organizationName: 'Acme',
|
||||||
|
});
|
||||||
|
expect(pushSpy).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenAuthStoreBecomesAuthenticated', async () => {
|
||||||
|
jest.mocked(authApi.register).mockResolvedValue(tokenResponse);
|
||||||
|
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper);
|
||||||
|
|
||||||
|
expect(authStore.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenRegisterFails', () => {
|
||||||
|
it('ThenAnErrorMessageIsShownAndUserIsNotRedirected', async () => {
|
||||||
|
jest.mocked(authApi.register).mockRejectedValue(new Error('Email already registered'));
|
||||||
|
|
||||||
|
const { wrapper, router } = mountView();
|
||||||
|
const pushSpy = jest.spyOn(router, 'push');
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper);
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="register-error"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain('Registration failed');
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenTheSubmitButtonIsNoLongerLoading', async () => {
|
||||||
|
jest.mocked(authApi.register).mockRejectedValue(new Error('Email already registered'));
|
||||||
|
|
||||||
|
const { wrapper } = mountView();
|
||||||
|
|
||||||
|
await fillAndSubmit(wrapper);
|
||||||
|
|
||||||
|
const submitBtn = wrapper.findComponent('[data-testid="register-submit"]');
|
||||||
|
expect(submitBtn.props('loading')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
import { mount, flushPromises } from '@vue/test-utils';
|
||||||
import { setActivePinia, createPinia } from 'pinia';
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
import SegmentsView from '@/views/SegmentsView.vue';
|
import SegmentsView from '@/views/SegmentsView.vue';
|
||||||
@@ -129,7 +129,8 @@ describe('SegmentsView', () => {
|
|||||||
const wrapper = mountView();
|
const wrapper = mountView();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
await wrapper.find('[data-testid="edit-segment-1"]').trigger('click');
|
const row = wrapper.findAll('tbody tr').find((r) => r.text().includes('beta_users'));
|
||||||
|
await row!.trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
|
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
|
||||||
@@ -154,13 +155,6 @@ describe('SegmentsView', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true);
|
||||||
|
|
||||||
// Confirm button should be disabled before typing the name
|
|
||||||
expect(wrapper.find('[data-testid="confirm-delete-btn"]').attributes('disabled')).toBeDefined();
|
|
||||||
|
|
||||||
// Type segment name to enable button
|
|
||||||
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
|
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
|
|
||||||
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
@@ -181,9 +175,6 @@ describe('SegmentsView', () => {
|
|||||||
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
|
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
|
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
|
|
||||||
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
|
|||||||
@@ -102,9 +102,10 @@ describe('SettingsView', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('WhenInviteMemberIsClicked', () => {
|
describe('WhenInviteMemberIsClicked', () => {
|
||||||
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
|
it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => {
|
||||||
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
|
jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([
|
||||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]);
|
{ email: 'jane@example.com', success: true, error: null },
|
||||||
|
]);
|
||||||
|
|
||||||
const contextStore = useContextStore();
|
const contextStore = useContextStore();
|
||||||
contextStore.setOrganization(mockOrg);
|
contextStore.setOrganization(mockOrg);
|
||||||
@@ -112,13 +113,19 @@ describe('SettingsView', () => {
|
|||||||
const wrapper = mountView();
|
const wrapper = mountView();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', '42');
|
await wrapper.find('[data-testid="add-member-btn"]').trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
await wrapper.find('[data-testid="invite-member-btn"]').trigger('click');
|
await (wrapper.findComponent('[data-testid="add-member-email-0"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'jane@example.com');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="confirm-add-members-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' });
|
expect(orgsApi.inviteOrganizationMembersByEmail).toHaveBeenCalledWith(
|
||||||
|
mockOrg.id,
|
||||||
|
{ invites: [{ email: 'jane@example.com', role: 'User' }] },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,6 +143,8 @@ describe('SettingsView', () => {
|
|||||||
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true);
|
||||||
|
|
||||||
await wrapper.find('[data-testid="remove-member-5"]').trigger('click');
|
await wrapper.find('[data-testid="remove-member-5"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
|
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
|
||||||
@@ -261,6 +270,8 @@ describe('SettingsView', () => {
|
|||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
await wrapper.find('[data-testid="remove-permission-5"]').trigger('click');
|
await wrapper.find('[data-testid="remove-permission-5"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5);
|
expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5);
|
||||||
@@ -343,9 +354,6 @@ describe('SettingsView', () => {
|
|||||||
await wrapper.find('[data-testid="delete-env-100"]').trigger('click');
|
await wrapper.find('[data-testid="delete-env-100"]').trigger('click');
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
await (wrapper.findComponent('[data-testid="delete-env-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Development');
|
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
|
|
||||||
await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click');
|
await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
@@ -471,6 +479,8 @@ describe('SettingsView', () => {
|
|||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click');
|
await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);
|
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);
|
||||||
|
|||||||
23
src/api/MicCheck.Api/Dockerfile.ci
Normal file
23
src/api/MicCheck.Api/Dockerfile.ci
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Self-contained CI/QA image for MicCheck.Api. Unlike the dev Dockerfile
|
||||||
|
# (which expects a host-published /app to be bind-mounted), this builds the
|
||||||
|
# app inside the image so it can be pushed to a registry and run standalone.
|
||||||
|
#
|
||||||
|
# Build context must be the repo root (needs ServiceDefaults + the API project):
|
||||||
|
# docker build -f src/api/MicCheck.Api/Dockerfile.ci -t miccheck-api:qa .
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj src/MicCheck.ServiceDefaults/
|
||||||
|
COPY src/api/MicCheck.Api/MicCheck.Api.csproj src/api/MicCheck.Api/
|
||||||
|
RUN dotnet restore src/api/MicCheck.Api/MicCheck.Api.csproj
|
||||||
|
|
||||||
|
COPY src/MicCheck.ServiceDefaults/ src/MicCheck.ServiceDefaults/
|
||||||
|
COPY src/api/MicCheck.Api/ src/api/MicCheck.Api/
|
||||||
|
RUN dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release -o /out --no-restore
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out .
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]
|
||||||
Reference in New Issue
Block a user