From e10cba77ed5ab0bfe5d7879ac9ba170775b94ba1 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 12:42:46 -0700 Subject: [PATCH] qa-environment-aspire (#2) Reviewed-on: https://git.wampler.us/wamplerj/mic-check/pulls/2 Co-authored-by: James Wampler Co-committed-by: James Wampler --- .github/workflows/ci.yml | 48 ++++ .gitignore | 1 - .husky/pre-push | 1 + .vscode/settings.json | 15 - CLAUDE.md | 14 +- deploy/qa/.env.example | 12 + deploy/qa/docker-compose.qa.yml | 61 ++++ package-lock.json | 28 ++ package.json | 11 + scripts/ci/build.sh | 18 ++ scripts/ci/deploy-qa.sh | 37 +++ scripts/ci/docker-build.sh | 23 ++ scripts/ci/docker-push.sh | 17 ++ scripts/ci/lib.sh | 46 +++ scripts/ci/prepush.sh | 23 ++ scripts/ci/test.sh | 15 + src/admin/Dockerfile.ci | 19 ++ .../src/components/features/FeatureDetail.vue | 13 + src/admin/src/router/index.ts | 8 +- src/admin/tests/unit/api/client.spec.ts | 271 ++++++++++++++++-- .../segments/ConditionEditor.spec.ts | 94 ++++++ .../segments/RuleGroupEditor.spec.ts | 216 ++++++++++++++ src/admin/tests/unit/router/router.spec.ts | 94 +++--- src/admin/tests/unit/stores/theme.spec.ts | 113 ++++++++ src/admin/tests/unit/utils/validation.spec.ts | 52 ++++ .../tests/unit/views/AcceptInviteView.spec.ts | 84 ++++++ src/admin/tests/unit/views/LoginView.spec.ts | 129 +++++++++ .../tests/unit/views/RegisterView.spec.ts | 155 ++++++++++ .../tests/unit/views/SegmentsView.spec.ts | 15 +- .../tests/unit/views/SettingsView.spec.ts | 28 +- src/api/MicCheck.Api/Dockerfile.ci | 23 ++ 31 files changed, 1578 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100755 .husky/pre-push delete mode 100755 .vscode/settings.json create mode 100644 deploy/qa/.env.example create mode 100644 deploy/qa/docker-compose.qa.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/ci/build.sh create mode 100755 scripts/ci/deploy-qa.sh create mode 100755 scripts/ci/docker-build.sh create mode 100755 scripts/ci/docker-push.sh create mode 100755 scripts/ci/lib.sh create mode 100755 scripts/ci/prepush.sh create mode 100755 scripts/ci/test.sh create mode 100644 src/admin/Dockerfile.ci create mode 100644 src/admin/tests/unit/components/segments/ConditionEditor.spec.ts create mode 100644 src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts create mode 100644 src/admin/tests/unit/stores/theme.spec.ts create mode 100644 src/admin/tests/unit/utils/validation.spec.ts create mode 100644 src/admin/tests/unit/views/AcceptInviteView.spec.ts create mode 100644 src/admin/tests/unit/views/LoginView.spec.ts create mode 100644 src/admin/tests/unit/views/RegisterView.spec.ts create mode 100644 src/api/MicCheck.Api/Dockerfile.ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9878d8b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index e9fd4fc..555444c 100755 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..d8d14fe --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +exec ./scripts/ci/prepush.sh diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100755 index ef11067..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -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" - } - ] -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index eb52472..a269aa7 100755 --- a/CLAUDE.md +++ b/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/.md` → save as `docs/_plan.md` -- Plan implemented from `docs/.md` → save summary as `docs/_output.md` +- Plan generated from `docs/plans/.md` → save as `docs/plans/_plan.md` +- Plan implemented from `docs/plans/.md` → save summary as `docs/plans/_output.md` ## Stack - -`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. \ No newline at end of file +`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes. diff --git a/deploy/qa/.env.example b/deploy/qa/.env.example new file mode 100644 index 0000000..5fa2c5e --- /dev/null +++ b/deploy/qa/.env.example @@ -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 diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml new file mode 100644 index 0000000..01b3d24 --- /dev/null +++ b/deploy/qa/docker-compose.qa.yml @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5a28c3d --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4690341 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh new file mode 100755 index 0000000..9faa9a3 --- /dev/null +++ b/scripts/ci/build.sh @@ -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" diff --git a/scripts/ci/deploy-qa.sh b/scripts/ci/deploy-qa.sh new file mode 100755 index 0000000..cd62263 --- /dev/null +++ b/scripts/ci/deploy-qa.sh @@ -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}" diff --git a/scripts/ci/docker-build.sh b/scripts/ci/docker-build.sh new file mode 100755 index 0000000..be07457 --- /dev/null +++ b/scripts/ci/docker-build.sh @@ -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" diff --git a/scripts/ci/docker-push.sh b/scripts/ci/docker-push.sh new file mode 100755 index 0000000..36f6879 --- /dev/null +++ b/scripts/ci/docker-push.sh @@ -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" diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh new file mode 100755 index 0000000..372a771 --- /dev/null +++ b/scripts/ci/lib.sh @@ -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 +} diff --git a/scripts/ci/prepush.sh b/scripts/ci/prepush.sh new file mode 100755 index 0000000..b9ce38c --- /dev/null +++ b/scripts/ci/prepush.sh @@ -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" diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh new file mode 100755 index 0000000..5d7c5db --- /dev/null +++ b/scripts/ci/test.sh @@ -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" diff --git a/src/admin/Dockerfile.ci b/src/admin/Dockerfile.ci new file mode 100644 index 0000000..f720d40 --- /dev/null +++ b/src/admin/Dockerfile.ci @@ -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;"] diff --git a/src/admin/src/components/features/FeatureDetail.vue b/src/admin/src/components/features/FeatureDetail.vue index d8e7cd4..6a6a2e9 100755 --- a/src/admin/src/components/features/FeatureDetail.vue +++ b/src/admin/src/components/features/FeatureDetail.vue @@ -274,12 +274,22 @@ data-testid="settings-default-enabled-toggle" /> +
@@ -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; diff --git a/src/admin/src/router/index.ts b/src/admin/src/router/index.ts index 875e563..05c9c0d 100755 --- a/src/admin/src/router/index.ts +++ b/src/admin/src/router/index.ts @@ -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; diff --git a/src/admin/tests/unit/api/client.spec.ts b/src/admin/tests/unit/api/client.spec.ts index 38c2436..38932c6 100755 --- a/src/admin/tests/unit/api/client.spec.ts +++ b/src/admin/tests/unit/api/client.spec.ts @@ -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); + }); }); }); }); diff --git a/src/admin/tests/unit/components/segments/ConditionEditor.spec.ts b/src/admin/tests/unit/components/segments/ConditionEditor.spec.ts new file mode 100644 index 0000000..bf7a712 --- /dev/null +++ b/src/admin/tests/unit/components/segments/ConditionEditor.spec.ts @@ -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).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).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).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; + 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).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(); + }); + }); +}); diff --git a/src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts b/src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts new file mode 100644 index 0000000..decee4f --- /dev/null +++ b/src/admin/tests/unit/components/segments/RuleGroupEditor.spec.ts @@ -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: [] }], + }]); + }); + }); +}); diff --git a/src/admin/tests/unit/router/router.spec.ts b/src/admin/tests/unit/router/router.spec.ts index 1a63f2f..e1af7ce 100755 --- a/src/admin/tests/unit/router/router.spec.ts +++ b/src/admin/tests/unit/router/router.spec.ts @@ -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: '
' }; @@ -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'); + }); + }); }); diff --git a/src/admin/tests/unit/stores/theme.spec.ts b/src/admin/tests/unit/stores/theme.spec.ts new file mode 100644 index 0000000..3986c97 --- /dev/null +++ b/src/admin/tests/unit/stores/theme.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/admin/tests/unit/utils/validation.spec.ts b/src/admin/tests/unit/utils/validation.spec.ts new file mode 100644 index 0000000..853cb92 --- /dev/null +++ b/src/admin/tests/unit/utils/validation.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/admin/tests/unit/views/AcceptInviteView.spec.ts b/src/admin/tests/unit/views/AcceptInviteView.spec.ts new file mode 100644 index 0000000..c004e08 --- /dev/null +++ b/src/admin/tests/unit/views/AcceptInviteView.spec.ts @@ -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: '
' } }, + { path: '/login', name: 'Login', component: { template: '
' } }, + ]; + 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'); + }); + }); +}); diff --git a/src/admin/tests/unit/views/LoginView.spec.ts b/src/admin/tests/unit/views/LoginView.spec.ts new file mode 100644 index 0000000..68c228c --- /dev/null +++ b/src/admin/tests/unit/views/LoginView.spec.ts @@ -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: '
' } }, + { path: '/register', component: { template: '
' } }, + ]; + const router = createRouter({ history: createMemoryHistory(), routes }); + return { wrapper: mount(LoginView, { global: { plugins: [router] } }), router }; +} + +async function fillAndSubmit(wrapper: ReturnType, 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'); + }); + }); +}); diff --git a/src/admin/tests/unit/views/RegisterView.spec.ts b/src/admin/tests/unit/views/RegisterView.spec.ts new file mode 100644 index 0000000..e7a2ed7 --- /dev/null +++ b/src/admin/tests/unit/views/RegisterView.spec.ts @@ -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: '
' } }, + { path: '/login', component: { template: '
' } }, + ]; + 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, fields: Partial = {}) { + 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); + }); + }); +}); diff --git a/src/admin/tests/unit/views/SegmentsView.spec.ts b/src/admin/tests/unit/views/SegmentsView.spec.ts index 06d5cb4..9ac1430 100755 --- a/src/admin/tests/unit/views/SegmentsView.spec.ts +++ b/src/admin/tests/unit/views/SegmentsView.spec.ts @@ -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).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).vm.$emit('update:modelValue', 'beta_users'); - await wrapper.vm.$nextTick(); - await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); await flushPromises(); diff --git a/src/admin/tests/unit/views/SettingsView.spec.ts b/src/admin/tests/unit/views/SettingsView.spec.ts index 4d19021..6e88dfa 100755 --- a/src/admin/tests/unit/views/SettingsView.spec.ts +++ b/src/admin/tests/unit/views/SettingsView.spec.ts @@ -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).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).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).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); diff --git a/src/api/MicCheck.Api/Dockerfile.ci b/src/api/MicCheck.Api/Dockerfile.ci new file mode 100644 index 0000000..57fc0b5 --- /dev/null +++ b/src/api/MicCheck.Api/Dockerfile.ci @@ -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"]