qa-environment-aspire (#2)
Some checks failed
CI / build-and-push (push) Failing after 18s
CI / deploy-qa (push) Has been skipped

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:
2026-07-02 12:42:46 -07:00
committed by wamplerj
parent 6887d09f9c
commit e10cba77ed
31 changed files with 1578 additions and 106 deletions

48
.github/workflows/ci.yml vendored Normal file
View 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
View File

@@ -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
View File

@@ -0,0 +1 @@
exec ./scripts/ci/prepush.sh

15
.vscode/settings.json vendored
View File

@@ -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"
}
]
}

View File

@@ -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.

12
deploy/qa/.env.example Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;"]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
});
});
});
});

View File

@@ -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();
});
});
});

View 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: [] }],
}]);
});
});
});

View File

@@ -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');
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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);
});
});
});

View File

@@ -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();

View File

@@ -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);

View 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"]