qa-environment-aspire (#2)
Reviewed-on: #2 Co-authored-by: James Wampler <james@wamp.dev> Co-committed-by: James Wampler <james@wamp.dev>
This commit was merged in pull request #2.
This commit is contained in:
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
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.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
|
||||
- Set `langVersion` to latest in all csproj files; enable nullable
|
||||
- Organize code by feature/area, not type (e.g. `features` namespace)
|
||||
- New features need unit tests covering as much logic as possible
|
||||
- Any modified file: evaluate for missing test coverage
|
||||
- New features need unit tests covering as much logic as possible (both nunit and jest)
|
||||
- Any modified file: evaluate for missing test coverage and that all tests pass
|
||||
|
||||
# Coding
|
||||
- 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-
|
||||
- No "Mock" in mocked object names
|
||||
- No Arrange/Act/Assert comments
|
||||
- All tests should pass before commit
|
||||
|
||||
## 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
|
||||
- Plan generated from `docs/<name>.md` → save as `docs/<name>_plan.md`
|
||||
- Plan implemented from `docs/<name>.md` → save summary as `docs/<name>_output.md`
|
||||
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md`
|
||||
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md`
|
||||
|
||||
## 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"
|
||||
/>
|
||||
</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">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
:loading="isDeleting"
|
||||
:disabled="deleteConfirmName !== feature?.name"
|
||||
data-testid="delete-feature-btn"
|
||||
@click="onDelete"
|
||||
>
|
||||
@@ -440,6 +450,8 @@ const settingsForm = ref({
|
||||
defaultEnabled: false,
|
||||
});
|
||||
|
||||
const deleteConfirmName = ref('');
|
||||
|
||||
const rules = {
|
||||
required: (v: string) => !!v || 'Required',
|
||||
nameFormat: (v: string) =>
|
||||
@@ -481,6 +493,7 @@ watch(
|
||||
newSegmentId.value = null;
|
||||
newSegmentEnabled.value = true;
|
||||
newSegmentPriority.value = 1;
|
||||
deleteConfirmName.value = '';
|
||||
syncSettingsForm(props.feature);
|
||||
|
||||
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';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -102,7 +102,7 @@ const router = createRouter({
|
||||
|
||||
// ─── Auth Guard ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.beforeEach((to) => {
|
||||
export function authGuard(to: RouteLocationNormalized) {
|
||||
const isAuthenticated = !!getAccessToken();
|
||||
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
|
||||
const isPublic = to.matched.some((record) => record.meta.public);
|
||||
@@ -116,6 +116,8 @@ router.beforeEach((to) => {
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
router.beforeEach(authGuard);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,51 +1,274 @@
|
||||
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
|
||||
// a real HTTP stack. These tests focus on the exported token-management helpers
|
||||
// that the interceptors and auth store both depend on.
|
||||
// The response interceptor drives its retry/refresh flow entirely through the
|
||||
// apiClient axios instance, so we replace its transport (`adapter`) with a
|
||||
// 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(() => {
|
||||
clearAuthTokens();
|
||||
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(() => {
|
||||
clearAuthTokens();
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
describe('WhenTokensAreSet', () => {
|
||||
it('ThenGetAccessTokenReturnsTheAccessToken', () => {
|
||||
setAuthTokens('access-abc', 'refresh-xyz');
|
||||
describe('Token management', () => {
|
||||
describe('WhenTokensAreSet', () => {
|
||||
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', () => {
|
||||
it('ThenGetAccessTokenReturnsNull', () => {
|
||||
setAuthTokens('access-abc', 'refresh-xyz');
|
||||
clearAuthTokens();
|
||||
describe('Request interceptor', () => {
|
||||
describe('WhenAnAccessTokenIsSet', () => {
|
||||
it('ThenTheRequestCarriesABearerAuthorizationHeader', async () => {
|
||||
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', () => {
|
||||
it('ThenGetAccessTokenReturnsNull', () => {
|
||||
setAuthTokens(null, null);
|
||||
describe('Response interceptor - 401 refresh flow', () => {
|
||||
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => {
|
||||
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', () => {
|
||||
it('ThenGetAccessTokenReturnsTheLatestToken', () => {
|
||||
setAuthTokens('first-token', 'first-refresh');
|
||||
setAuthTokens('second-token', 'second-refresh');
|
||||
describe('WhenTheRefreshRequestItselfFails', () => {
|
||||
it('ThenTokensAreClearedAndTheUserIsRedirectedToLogin', async () => {
|
||||
setAuthTokens('expired-access-token', 'invalid-refresh-token');
|
||||
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 { createRouter, createMemoryHistory } from 'vue-router';
|
||||
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
|
||||
const Stub = { template: '<div />' };
|
||||
@@ -15,11 +16,17 @@ jest.mock('@/api/client', () => ({
|
||||
import { getAccessToken } from '@/api/client';
|
||||
|
||||
function buildRouter() {
|
||||
return createRouter({
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', name: 'Login', 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: '/',
|
||||
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', () => {
|
||||
@@ -44,21 +54,10 @@ describe('Router auth guard', () => {
|
||||
});
|
||||
|
||||
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
|
||||
it('ThenTheyAreRedirectedToLogin', async () => {
|
||||
it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => {
|
||||
jest.mocked(getAccessToken).mockReturnValue(null);
|
||||
|
||||
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');
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Login');
|
||||
@@ -71,17 +70,6 @@ describe('Router auth guard', () => {
|
||||
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
||||
|
||||
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('/login');
|
||||
|
||||
@@ -94,19 +82,55 @@ describe('Router auth guard', () => {
|
||||
jest.mocked(getAccessToken).mockReturnValue('valid-token');
|
||||
|
||||
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');
|
||||
|
||||
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 { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import SegmentsView from '@/views/SegmentsView.vue';
|
||||
@@ -129,7 +129,8 @@ describe('SegmentsView', () => {
|
||||
const wrapper = mountView();
|
||||
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();
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'SegmentEditor' });
|
||||
@@ -154,13 +155,6 @@ describe('SegmentsView', () => {
|
||||
|
||||
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 flushPromises();
|
||||
|
||||
@@ -181,9 +175,6 @@ describe('SegmentsView', () => {
|
||||
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
|
||||
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 flushPromises();
|
||||
|
||||
|
||||
@@ -102,9 +102,10 @@ describe('SettingsView', () => {
|
||||
});
|
||||
|
||||
describe('WhenInviteMemberIsClicked', () => {
|
||||
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
|
||||
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
|
||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]);
|
||||
it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => {
|
||||
jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([
|
||||
{ email: 'jane@example.com', success: true, error: null },
|
||||
]);
|
||||
|
||||
const contextStore = useContextStore();
|
||||
contextStore.setOrganization(mockOrg);
|
||||
@@ -112,13 +113,19 @@ describe('SettingsView', () => {
|
||||
const wrapper = mountView();
|
||||
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.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();
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
|
||||
@@ -261,6 +270,8 @@ describe('SettingsView', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
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();
|
||||
|
||||
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.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 flushPromises();
|
||||
|
||||
@@ -471,6 +479,8 @@ describe('SettingsView', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
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();
|
||||
|
||||
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