Compare commits

..

14 Commits

Author SHA1 Message Date
James Wampler
6c815a6688 Add feature-usage tracking and dashboard charts
Instruments FeatureEvaluationService with System.Diagnostics.Metrics (Counter + IMeterFactory). Accumulates per-feature/day evaluation counts in-process, flushed every 30s to a new FeatureUsageDaily Postgres table via PostgreSQL ON CONFLICT upsert. Caches dashboard query results for 180s in IMemoryCache. New GET /api/v1/environments/{id}/usage endpoint returns top-10 features last day and 14-day per-day breakdown. Admin dashboard shows two ApexCharts bar charts: top features and evaluations by day with per-flag hover tooltip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:45:56 -07:00
James Wampler
b59b56575f Fix identity trait add and persistence bugs
Remove Key from UpsertTraitRequest body (comes from URL route). Emit 'updated' event from IdentityDetail on trait mutations and sync parent. Reload segment data on tab activation and dialog open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:45:45 -07:00
James Wampler
cbf1aba219 Fix v-btn-toggle overlap in dialogs; add segment override icon to features grid
Replace v-btn-toggle with manual button pairs in RuleGroupEditor and
FeatureValueEditor — VSlideGroup (used internally) measures zero width
inside teleported dialogs, causing buttons to overlap and show scroll arrows.

Add donut chart icon next to feature names in the features grid when segment
overrides exist; clicking opens FeatureDetail directly on the Segments tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:25:50 -07:00
James Wampler
d555e3b2d2 Wrap long method signatures and remove unused using to comply with 220-char line limit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:31:03 -07:00
James Wampler
c0a1f4d194 Persist and restore selected project and environment across page reloads
- Add refreshOrganization/refreshProject/refreshEnvironment to context store — update value and localStorage without clearing child selections
- OrgSelector: use refreshOrganization when same org is already stored, preventing the chain that was clearing project and environment
- ProjectSelector: watch org ID (not object ref) so refresh doesn't retrigger; add autoSelectProject to restore stored project or auto-select single project
- EnvironmentTabBar: watch project ID; skip env-clear on initial mount; add autoSelectEnvironment to restore stored env or fall back to first
- Fix test fixtures missing isPrimary field; update EnvironmentTabBar tests to match v-select implementation; add tests for restore behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:26:19 -07:00
James Wampler
0531e1943f Add primary organization flag with auto-selection on dashboard
Adds IsPrimary to OrganizationUser (per-user flag, one primary per user).
First org created is automatically set as primary. Selecting a different
org via the UI calls PUT /organisations/{id}/primary to persist the choice
server-side. Dashboard auto-selects: localStorage org (refreshed) →
primary org → single org when no explicit selection exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 10:00:09 -07:00
James Wampler
671c041764 Fix register form layout and validation shift
Fix first/last name field overlap by using no-gutters row with
responsive padding. Override global hideDetails:'auto' Vuetify default
with :hide-details="false" so the details area is always rendered,
preventing 22px layout shift when validation messages appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:14:31 -07:00
James Wampler
e51e28d411 Add admin Vite app to Aspire AppHost
Registers the Vue admin as an Aspire resource via AddViteApp, wired to
the API so Aspire injects service discovery env vars that the existing
Vite proxy config already reads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:46:52 -07:00
James Wampler
e3b8cd9067 Fix build errors and enable TreatWarningsAsErrors across all projects
Remove duplicate source files from old ApiKeys/, Authentication/, and
Authorization/ folders that were already migrated to Common/Security/.
Update test namespace usings to match. Pin EFC Relational 10.0.5 to
resolve MSB3277 version conflict with Npgsql's transitive dependency.
Add LangVersion and TreatWarningsAsErrors to AppHost and ServiceDefaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:19:59 -07:00
James Wampler
570ab363c2 WIP 2026-06-10 13:06:55 -07:00
James Wampler
4621e8380b Add MicCheck.Api.Tests.Integration project
NUnit integration tests that target a running API by URL and seed/clean
data directly in Postgres. Settings (BaseUrl, DbConnectionString) come
from .runsettings or environment variables so tests can be pointed at
any environment. HTTP requests live in HttpFiles/*.http; assertion
failures include a fresh DB snapshot of the relevant rows. First test
covers the flag-enabled path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 19:12:47 -07:00
James Wampler
d3b36956e4 New project file, added Aspire, moved security 2026-06-03 18:44:48 -07:00
James Wampler
4c6df1f037 WIP 2026-04-18 19:45:16 -07:00
James Wampler
7545441a3e UX consistancy changes. 2026-04-16 18:41:01 -07:00
256 changed files with 3210 additions and 12472 deletions

View File

@@ -1,7 +0,0 @@
**/bin/
**/obj/
**/node_modules/
**/dist/
.git/
.husky/
docs/

View File

@@ -1,97 +0,0 @@
# 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.
#
# Gitea (origin) is the internal/testing remote and runs the full pipeline:
# build, test, docker push, deploy-to-qa, smoke test. GitHub is the public
# mirror and only needs to prove the code builds and tests pass - it has no
# registry secrets and no [self-hosted, qa] runner, so the docker push and
# deploy/smoke jobs are skipped there via the `github.server_url` check
# below (identical on both engines: https://github.com on GitHub, the Gitea
# instance URL on Gitea).
name: CI
on:
push:
paths-ignore: [badges/**]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: write
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: Coverage report
run: ./scripts/ci/coverage.sh
- name: Publish coverage badge
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/ci/publish-coverage-badge.sh
- name: Build Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-build.sh
- name: Push Docker images
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
run: ./scripts/ci/docker-push.sh
deploy-qa:
needs: build-and-push
if: github.server_url != 'https://github.com' && github.ref_name == 'main'
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 }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node
# in PATH, so checkout plain git instead of via marketplace action.
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Deploy to QA
run: ./scripts/ci/deploy-qa.sh
smoke-qa:
needs: deploy-qa
if: github.server_url != 'https://github.com'
runs-on: [self-hosted, qa]
env:
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
steps:
# actions/checkout@v4 is a Node-based action; this runner has no node
# in PATH, so checkout plain git instead of via marketplace action.
- name: Checkout
run: |
git init -q .
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}"
git checkout -q FETCH_HEAD
- name: Smoke test QA deployment
run: ./scripts/ci/smoke-qa.sh

12
.gitignore vendored
View File

@@ -408,6 +408,7 @@ FodyWeavers.xsd
# VS Code files for those working on multiple tools # VS Code files for those working on multiple tools
.vscode/* .vscode/*
!.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
@@ -438,11 +439,6 @@ dist-ssr/
# TypeScript incremental build cache # TypeScript incremental build cache
*.tsbuildinfo *.tsbuildinfo
# Playwright
src/admin/test-results/
src/admin/playwright-report/
src/admin/e2e/.auth/
# Jest test coverage reports # Jest test coverage reports
coverage/ coverage/
.nyc_output/ .nyc_output/
@@ -473,9 +469,3 @@ lerna-debug.log*
# Windows thumbnail cache # Windows thumbnail cache
Thumbs.db Thumbs.db
ehthumbs.db ehthumbs.db
# Self-installed .NET SDK (scripts/ci/lib.sh ensure_dotnet, used when a CI runner lacks the SDK)
/.dotnet/
# Self-installed reportgenerator CLI (scripts/ci/lib.sh ensure_reportgenerator)
/.dotnet-tools/

View File

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

15
.vscode/settings.json vendored Executable file
View File

@@ -0,0 +1,15 @@
{
"sqltools.connections": [
{
"ssh": "Disabled",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"askForPassword": true,
"driver": "PostgreSQL",
"name": "MicCheck - local",
"database": "miccheck",
"username": "miccheck"
}
]
}

View File

@@ -1,10 +1,10 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) in this repo. Guidance for Claude Code (claude.ai/code) when working in this repo.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, projects, environments. MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments.
## Planned Structure ## Planned Structure
@@ -18,36 +18,32 @@ MicCheck: open source. asp.net, c#, typescript, VueJS. Manage feature flags, pro
- Use latest LTS .NET + latest supported nuget packages for that version - Use latest LTS .NET + latest supported nuget packages for that version
- Set `langVersion` to latest in all csproj files; enable nullable - Set `langVersion` to latest in all csproj files; enable nullable
- Organize code by feature/area, not type (e.g. `features` namespace) - Organize code by feature/area, not type (e.g. `features` namespace)
- New features need unit tests covering logic as much as possible (both nunit and jest) - New features need unit tests covering as much logic as possible
- Modified file: check missing test coverage, all tests pass - Any modified file: evaluate for missing test coverage
# Coding # Coding
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper - Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Match formatting/style from `.editorconfig`
- Wrap lines at 220 chars, single line if fewer - Wrap lines at 220 characters, leave single line if fewer
- Interfaces implemented by single class bottom of class file. Interface w/ multiple implementations → separate file. - Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- No tuples for return types. Prefer records or classes for multiple values - Do not use tuples for return types. Prefer records or classes for multiple values
- No `sealed` - Do not use `sealed` on classes-
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible. - Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Min 70% code coverage, target 90%. Unit tests focus end-user scenarios first.
- Don't write tests just for coverage. Call out missing coverage rather than cover stuff not valuable to end user.
- Code not cleanly unit-testable → mark `[ExcludeFromCodeCoverage]` or exclude namespace from coverage in .runsettings file
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` - BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears`
- Mock external deps w/ Moq - Mock external deps with Moq
- Mock EntityFramework DBContexts via extracted interface + Moq. Don't rely on InMemory provider. - New features need unit tests covering as much logic as possible
- New features need unit tests covering logic as much as possible - Any modified file: evaluate for missing test coverage-
- Modified file: check missing test coverage
- No "Mock" in mocked object names - No "Mock" in mocked object names
- No Arrange/Act/Assert comments - No Arrange/Act/Assert comments
- All tests pass before commit
## Claude ## Claude
- Plans = `.md` files in `docs/plans/`. Admin → `docs/plans/admin/`, API → `docs/plans/api/` - Plans = `.md` files in `docs/`. Admin → `docs/admin/`, API → `docs/api/`
- Split large plans into discrete chunks — each buildable + committable independently - Split large plans into discrete chunks — each buildable + committable independently
- Plan generated from `docs/plans/<name>.md` → save as `docs/plans/<name>_plan.md` - Plan generated from `docs/<name>.md` → save as `docs/<name>_plan.md`
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md` - Plan implemented from `docs/<name>.md` → save summary as `docs/<name>_output.md`
## Stack ## Stack
`.gitignore` set for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack change.
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.

55
CLAUDE.original.md Normal file → Executable file
View File

@@ -1,53 +1,44 @@
# CLAUDE.md # CLAUDE.md
Guidance for Claude Code (claude.ai/code) when working in this repo. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project ## Project
MicCheck: open source. asp.net, c#, typescript, VueJS. Manages feature flags, projects, environments. MicCheck is an open source project written in asp.net, c#, typescript and VueJS that manages feature flags, their projects, and their environments.
## Planned Structure ## Planned Structure
- `src/admin/`VueJS admin components - `src/admin/`administrative components in VueJS
- `src/api/` - .NET API + REST endpoints - `src/api/` - api and restful endpoints in .NET
- `tests/` — test suite - `tests/` — test suite
- `docs/` — documentation - `docs/` — documentation
## Best Practices ## Best Practices
- Use latest LTS .NET + latest supported nuget packages for that version - Ensure all projects are using the latest LTS version of .NET as well as the latest supported nuget packages for that .NET version
- Set `langVersion` to latest in all csproj files; enable nullable - Ensure that langVersion is set to latest in all csproj files and nullable is enabled
- Organize code by feature/area, not type (e.g. `features` namespace) - Code in all projects should be organized by feature or area instead of type. (e.g. a features namespace with all feature related code in it or in child namespaces of it)
- New features need unit tests covering as much logic as possible (both nunit and jest) - All new feature requests should include corresponding unit tests that cover as much of the logic as possible.
- Any modified file: evaluate for missing test coverage and that all tests pass - Any file modified should be evaluated for potential test cases and missing coverage areas.
# Coding # Coding
- Descriptive names for all classes/methods. No generic names: Provider, Manager, Helper - Use descriptive names for all classes and method created. Avoid generic names like Provider, Manager, Helper
- Match formatting/style from `.editorconfig` - Coding should match formating and style rules in the .editorconfig file
- Wrap lines at 220 characters, leave single line if fewer
- Place interfaces that are implemented by a single class at the bottom of the class file. An interface with multiple implementations of an interface should be in a seperate file.
- Do not use tuples for return types. Prefer records or classes for multiple values
- Do not use `sealed`
- Use `record` for data objects, `class` for objects with behavior. Avoid mutable state where possible.
## Testing ## Testing
- Require a minimum of 70% code coverage with a target of 90%. Unit tests should focus on end-user scenarios first. - Write unit tests in a BDD style, testing as much code end-to-end as possible without without touching external resources like databases or file systems (e.g. WhenAUserDoesSomething_ThenAThingAppears)
- Do not write tests for just to increase code coverage. Call out lack of test coverage rather than covering something that isn't valuable to the end user. - Mock any external dependencies using Moq.
- Code that can not be cleanly unit tested should be marked with [ExcludeFromCodeCoverage] or have it's namespace excluded from code coverage. - Do not name any mocked objects with the word Mock in them
- BDD-style unit tests, end-to-end as possible, no external resources (DB, filesystem). e.g. `WhenAUserDoesSomething_ThenAThingAppears` - Do not include any Arrange / Act / Assert comments in the code
- Mock external deps with Moq
- Mock EntityFramework DBContexts with an extracted interface and Moq. Do not rely on InMemory provider.
- New features need unit tests covering as much logic as possible
- 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 ## Claude
- Plans = `.md` files in `docs/plans/`. Admin `docs/plans/admin/`, API `docs/plans/api/` - Create all plans as .md file located in the `docs/` folder. Admin in `docs/admin/` and API in `docs/api/`
- Split large plans into discrete chunks — each buildable + committable independently - Divide up large plans into discrete chucks of functionality so each can be built and committed independently.
- Plan generated from `docs/plans/<name>.md` save as `docs/plans/<name>_plan.md` - When generating a plan from a .md file in /docs/ save the plan in the same folder with the same filename minus the .md extention, but with a _plan.md at the end
- Plan implemented from `docs/plans/<name>.md` → save summary as `docs/plans/<name>_output.md` - When implementing a plan from a .md file in /docs/ save the summary of the plan in the same folder with the same filename minus the .md extention, but with a _output.md at the end
## Stack ## Stack
`.gitignore` configured for .NET/Visual Studio (C#, NuGet, MSBuild). Update if stack changes.
The `.gitignore` is configured for a .NET/Visual Studio project (C#, NuGet, MSBuild). If this changes, update this file accordingly.

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 James Wampler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -3,6 +3,7 @@
<File Path=".editorconfig" /> <File Path=".editorconfig" />
<File Path=".gitignore" /> <File Path=".gitignore" />
<File Path="CLAUDE.md" /> <File Path="CLAUDE.md" />
<File Path="docker-compose.yml" />
<File Path="readme.md" /> <File Path="readme.md" />
</Folder> </Folder>
<Folder Name="/src/"> <Folder Name="/src/">

View File

@@ -1,138 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.10.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">68.9%</text><text class="" x="132.5" y="14">68.9%</text>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -1,13 +0,0 @@
# 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
POSTGRES_PASSWORD=change-this-to-a-random-password
QA_ADMIN_PORT=3001

View File

@@ -1,66 +0,0 @@
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. The API has
# no published host port; Postgres is bound to DB_BIND_HOST (docker's bridge
# gateway IP, computed by deploy-qa.sh) so the smoke-qa CI job - itself a
# sibling container on that same default bridge - can seed/inspect data
# directly, while it stays unreachable off-box (unlike binding to 0.0.0.0).
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
ports:
- "${DB_BIND_HOST:-127.0.0.1}:55432:5432"
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=${POSTGRES_PASSWORD}"
Jwt__SecretKey: ${JWT_SECRET_KEY}
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
networks:
- qa
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || 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

View File

@@ -3,19 +3,27 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Building solution..." build_api() {
dotnet build "$SCRIPT_DIR/MicCheck.slnx" echo "Building API..."
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
echo "API done."
}
echo "Installing admin dependencies..." build_admin() {
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent echo "Building admin..."
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
npm --prefix "$SCRIPT_DIR/src/admin" run build
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
echo "Admin done."
}
echo "Building admin..." case "${1:-all}" in
npm --prefix "$SCRIPT_DIR/src/admin" run build api) build_api ;;
admin) build_admin ;;
echo "Running .NET unit tests..." all) build_api && build_admin ;;
dotnet test "$SCRIPT_DIR/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj" *)
echo "Usage: $0 [api|admin|all]"
echo "Running Jest tests..." exit 1
npm --prefix "$SCRIPT_DIR/src/admin" test ;;
esac
echo "Build and tests complete."

60
docker-compose.yml Executable file
View File

@@ -0,0 +1,60 @@
services:
# ── PostgreSQL ───────────────────────────────────────────────────────────────
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: miccheck
POSTGRES_USER: miccheck
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
interval: 5s
timeout: 5s
retries: 10
# ── .NET API ─────────────────────────────────────────────────────────────────
api:
build:
context: .
dockerfile: src/api/MicCheck.Api/Dockerfile
ports:
- "8080:8080"
volumes:
- ./src/api/MicCheck.Api/publish:/app
environment:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://+:8080
ConnectionStrings__DefaultConnection: "Host=db;Database=miccheck;Username=miccheck;Password=password"
Jwt__SecretKey: "miccheck-dev-secret-key-change-in-production!!"
Jwt__Issuer: MicCheck
Jwt__Audience: MicCheck
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 0"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ── Vue Admin Site (nginx) ───────────────────────────────────────────────────
admin:
build:
context: src/admin
dockerfile: Dockerfile
ports:
- "3000:80"
volumes:
- ./src/admin/dist:/usr/share/nginx/html
depends_on:
api:
condition: service_started
volumes:
postgres_data:

View File

@@ -1,46 +0,0 @@
# Integration + Playwright smoke suite — implementation summary
Implemented per `integration-smoke_plan.md`. All chunks built, verified against a real local Postgres + API + Vite stack, and the two pre-existing test suites (243 API unit tests, 306 admin Jest tests) still pass.
## Chunk A — Deterministic dev/QA seed admin user + fixed env key
- `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`: now also creates a seed admin user (`admin@miccheck.local` / `MicCheckQa!2026`, Admin role on the `Default` org) and gives the seeded `Development` environment a fixed API key (`env-qa-development`). Still gated behind `IsDevelopment()` in `Program.cs` and the existing idempotency guard.
- New unit tests: `tests/api/MicCheck.Api.Tests.Unit/Data/DatabaseSeederTests.cs` (5 tests).
- Verified live: booted the API against local Postgres, logged in via `POST /api/v1/auth/login` with the seed creds (200 + tokens), and read `/api/v1/flags` with the fixed Development key (200, empty array as expected).
## Chunk B — Expanded API integration suite
Added to `tests/api/MicCheck.Api.Tests.Integration`, reusing the existing black-box HTTP+Npgsql harness:
- `Flags/FlagDisabledTests.cs` — disabled-flag variant of the existing enabled test.
- `Flags/FlagsApiAuthorizationTests.cs` — missing/unknown environment key → 401.
- `Environments/EnvironmentDocumentTests.cs` + `HttpFiles/EnvironmentDocument.http` — SDK bootstrap document endpoint.
- `Identities/IdentityOverrideSeed.cs` + `Identities/IdentityOverrideTests.cs` + `HttpFiles/Identity.http` — identity-override-beats-environment-default precedence.
- `Auth/AuthSeed.cs` + `Auth/AuthTests.cs` + `HttpFiles/Auth.http` — login liveness (valid creds → tokens, bad password → 401). Added `Microsoft.Extensions.Identity.Core` package reference so the seed can hash a password with the same `PasswordHasher<T>` the API uses, without a `ProjectReference` to the API (kept the black-box convention).
- Fixed a latent substring bug (`[..40]` on a string shorter than 40 chars) copied into the new seeds; left the pre-existing `FlagSeed.cs` alone since none of its callers hit the short-string case.
- Verified live: all 8 tests pass against a real Postgres + `dotnet run` API (`dotnet test ... --logger "console;verbosity=normal"` → 8/8 passed).
## Chunk C — Playwright admin e2e suite
New `src/admin/e2e/` (kept out of Jest's `roots`/`testMatch`, no config change needed since `e2e/` isn't under `src/` or `tests/`):
- `playwright.config.ts`, `e2e/global-setup.ts` (logs in once via the real `/login` form with the seed creds, saves `storageState`).
- `e2e/login.e2e.ts`, `e2e/navigation.e2e.ts`, `e2e/context-selection.e2e.ts`, `e2e/features.e2e.ts` (view, create, toggle).
- `package.json`: added `@playwright/test` devDependency and `e2e` / `e2e:install` scripts.
- Two bugs found and fixed while running against the real app: sidebar nav items are clickable `div`s, not `<a>` links (fixed the locator); a newly-created feature's toggle was clicked before its feature-state fetch settled, causing Vuetify's switch to visually revert (added a `waitForLoadState('networkidle')`).
- Verified live: all 6 tests pass against the real Vite dev server + local API (`npx playwright test` → 6/6 passed).
## Chunk D — CI post-deploy smoke job
- `deploy/qa/docker-compose.qa.yml`: bound Postgres to `127.0.0.1:55432` on the QA host so the integration harness can seed/clean directly (off-box still unreachable).
- `scripts/ci/smoke-qa.sh` (new): runs the API integration suite and the Playwright suite against `http://localhost:${QA_ADMIN_PORT}`.
- `scripts/ci/lib.sh`: added `ensure_node()` (mirrors `ensure_dotnet()`) — **the self-hosted `qa` runner has no Node.js today** (confirmed by the existing "avoid actions/checkout, it's Node-based" comment in `deploy-qa.sh`), so `smoke-qa.sh` needed a way to bootstrap `npm`/`npx` the same way it already bootstraps `dotnet`.
- `.github/workflows/ci.yml`: new `smoke-qa` job, `needs: deploy-qa`, runs on `[self-hosted, qa]`, checks out via plain git (matching `deploy-qa`), runs `smoke-qa.sh`.
### Known follow-up risk (not resolved, flagging for the user)
`npm run e2e:install` runs `playwright install --with-deps chromium`, which needs root/passwordless-sudo to apt-install browser OS dependencies. This could not be verified against the actual self-hosted `qa` runner (only tested locally, where `--with-deps` failed for lack of a sudo TTY — the browser itself still installed fine and tests ran). If the `qa` runner also lacks passwordless sudo, the first CI run of `smoke-qa` may fail on that step; the fix would be a one-time manual `sudo npx playwright install-deps chromium` on the runner, or granting the CI user passwordless sudo for that command.
## Verification performed this session
- `dotnet test tests/api/MicCheck.Api.Tests.Unit` — 243/243 passed.
- `dotnet test tests/api/MicCheck.Api.Tests.Integration` against real local Postgres + API — 8/8 passed.
- `npm --prefix src/admin test` (Jest) — 306/306 passed, 28 suites, no collision with `e2e/`.
- `npx playwright test` (from `src/admin`) against real local Vite dev server + API — 6/6 passed.
- `docker compose -f deploy/qa/docker-compose.qa.yml config` — valid.
- `bash -n` on `smoke-qa.sh` and `lib.sh` — valid.
## Not yet done
- The `smoke-qa` CI job has not run on the actual self-hosted `qa` runner (no access to it from this session) — first real run should be watched, especially the Node bootstrap and the Playwright OS-deps risk above.

View File

@@ -1,75 +0,0 @@
# Plan: Integration + Playwright smoke suite that doubles as post-deploy QA validation
## Context
Today the repo has **one** integration test (`FlagEnabledTests` — seeds an enabled flag in Postgres, hits `GET /api/v1/flags`, asserts enabled) and **zero** browser/e2e tests. Unit coverage is already deep for the risky logic — flag/feature evaluation, segment evaluation (28 tests), webhooks, auth token/API-key/permission logic, audit. Per the 8090% unit / 1020% integration rule, we do **not** re-test that logic at integration. Instead we add a thin layer covering the **most important, most visible, real-Postgres-backed** flows that unit tests (all InMemory EF) cannot prove, and wire it to run **after `deploy-qa`** so every QA deploy is validated end-to-end.
Decisions locked with the user:
- **E2E hits the real deployed stack** (not mocked) — `http://localhost:${QA_ADMIN_PORT}` in CI, `http://localhost:5173` local dev.
- **Add a fixed dev/QA seed admin user** (QA DB is wiped `down -v` each deploy and reseeded on startup; seeding is already `IsDevelopment()`-guarded and QA runs `ASPNETCORE_ENVIRONMENT=Development`).
- **Add a post-deploy `smoke-qa` CI job** running the API integration suite + Playwright against QA.
Key constraints discovered:
- Integration harness is **black-box**: HTTP to a running API + **direct Npgsql** seed/cleanup (`Common/TestDatabase.cs`). It spins nothing up; needs a live API **and** direct DB reachability.
- QA compose (`deploy/qa/docker-compose.qa.yml`) publishes **only** the admin port; API + Postgres are network-internal. The smoke job runs on the **same self-hosted `qa` host** (localhost). So the DB port must be reachable from the host for direct seeding → publish Postgres bound to `127.0.0.1` on the QA host.
- `/health` + `/alive` + Scalar/OpenAPI are **Development-only** (`ServiceDefaults/Extensions.cs:113`). QA is Development so `/health` works there, but smoke assertions should lean on real auth/flag reads, not `/health`.
- Seeder (`Data/DatabaseSeeder.cs`) currently creates Org → Project → 3 Environments with **random** `env-{guid}` API keys and **no user**. For HTTP-only smoke we make the admin user + one env key **deterministic**.
## Implementation — 4 independently buildable/committable chunks
### Chunk A — Deterministic dev/QA seed admin user + fixed env key
Files: `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`, unit test in `tests/api/MicCheck.Api.Tests.Unit/Data/`.
- Inject `IPasswordHasher<User>` into `DatabaseSeeder` (mirror `AuthService.RegisterAsync` at `Common/Security/Authorization/AuthService.cs:54-82`: create `User{ IsActive=true, PasswordHash=hasher.HashPassword(...) }`, then `OrganizationUser{ Role=OrganizationRole.Admin }` linking it to the seeded `Default` org).
- Creds: `admin@miccheck.local` / `MicCheckQa!2026` (login verify has no complexity rule, so any value works; keep it in one shared constants spot referenced by tests).
- Give the seeded **Development** environment a **deterministic** `ApiKey` (e.g. `env-qa-development`) instead of a random guid; leave Staging/Production random. Lets HTTP-only smoke read `/flags` with a known key without direct DB access.
- Keep the existing `if (Organizations.AnyAsync) return;` idempotency guard — user + env are created in the same first-run block. No prod risk: invocation is already gated by `app.Environment.IsDevelopment()` (`Program.cs:160-166`).
- Unit test: seed against InMemory EF twice → asserts admin user exists once, is Admin on Default org, password verifies, Development env key is the fixed value.
### Chunk B — Expand API integration suite (`tests/api/MicCheck.Api.Tests.Integration`)
Reuse the existing harness verbatim: `Common/FlagApiHttpClient.cs` (drives `###`-delimited `.http` files with `{{var}}` substitution), `Common/TestDatabase.cs` (Npgsql seed/cleanup + `SnapshotRowsAsync` for failure dumps), `Common/IntegrationTestSettings.cs` (env-var / `.runsettings` config), and the `FlagSeed` INSERT…RETURNING + cascade-delete-by-Organization pattern (`Flags/FlagSeed.cs`). Keep DTOs redefined locally (no `ProjectReference` to the API) per the current convention. New `.http` files under `HttpFiles/` are auto-copied (`csproj` `CopyToOutputDirectory=PreserveNewest`).
Add these high-value scenarios (each the real Postgres wire contract, not re-covered logic):
1. **Flag disabled** — seed `Enabled=false`; assert `/flags` reports the feature `Enabled=false` (complements the existing enabled test; cheap variant of `FlagSeed`).
2. **Environment-document bootstrap** — new `HttpFiles/EnvironmentDocument.http` (`GET /api/v1/environment-document`, `X-Environment-Key`); assert 200 and the seeded feature state + project segment are present (shape: `Environments/EnvironmentDocumentResponse.cs`). This is the edge/client-SDK bootstrap path — high blast radius, untested.
3. **Identity override precedence** — new seed adding an identity-override FeatureState (and/or a segment override) + `HttpFiles/Identity.http` (`POST /api/v1/identity` with identifier, `X-Environment-Key`); assert the evaluated value reflects identity > segment > env-default precedence (`Features/FeatureEvaluationService.cs:43-146`). Richest runtime logic against real data.
4. **Auth liveness** — new `HttpFiles/Auth.http` (`POST /api/v1/auth/login`): bad creds → 401; the deterministic seed creds → 200 + tokens. Proves app + DB + auth are up without depending on `/health`.
5. **Unauthorized guard**`GET /api/v1/flags` with no / wrong `X-Environment-Key` → 401 (validates `EnvironmentKeyAuthenticationHandler`).
### Chunk C — Playwright admin e2e suite (new)
Location `src/admin/e2e/` (distinct from Jest, which grabs `*.spec.ts` under `tests/`; name specs `*.e2e.ts` and set Jest `roots`/Playwright `testDir` so they never collide). Add dev deps `@playwright/test`, a `playwright.config.ts` (`testDir: 'e2e'`, `baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173'`, chromium project, `globalSetup` for auth). Add `package.json` scripts: `"e2e": "playwright test"`, `"e2e:install": "playwright install --with-deps chromium"`.
The app is already richly instrumented with `data-testid` — drive real UI, no selectors guesswork. Auth: `globalSetup` logs in once via the real `/login` form (`login-form`/`email-input`/`password-input`/`login-submit`) with the Chunk-A seed creds and saves `storageState` for reuse.
Journeys (ordered most→least important; the seed provides `Default` org, `My Project`, and `Development/Staging/Production` envs, so context is selectable without creating anything):
1. **Login** — valid creds redirect off `/login`; invalid creds surface `login-error` (`views/LoginView.vue`).
2. **App shell + nav** — sidebar renders and routes (Dashboard/Features/Segments/Identities/Audit Logs/Settings) navigate (`layouts/components/NavItems.vue`).
3. **Select project + environment context**`project-selector`/`project-select` + `environment-tab-bar`/`env-select` (`components/nav/ProjectSelector.vue`, `EnvironmentTabBar.vue`); required before feature screens work (context persists to localStorage via `stores/context.ts`).
4. **View feature flags**`features-table` renders for the selected project (`views/FeaturesView.vue`).
5. **Create a feature flag**`create-feature-btn``FeatureDialog` (`feature-name-input`, `feature-type-select`, `feature-dialog-save`) → row appears (`components/features/FeatureDialog.vue`, API `api/features.ts`). Exercises full write path to real DB.
6. **Toggle a feature flag** — row switch `toggle-${id}` (or `feature-enabled-toggle` in `FeatureDetail.vue`); reload → state persisted (validates `api/featureStates.ts` → real Postgres).
Secondary/optional (add if cheap): create project, create environment via the sidebar dialogs.
### Chunk D — CI post-deploy smoke job
Files: `deploy/qa/docker-compose.qa.yml`, `scripts/ci/smoke-qa.sh` (new), `.github/workflows/ci.yml`.
- **DB reachability**: add `ports: ["127.0.0.1:55432:5432"]` to the `db` service in the QA compose (localhost-bound only — the runner host is the sole consumer; not exposed off-box). Lets the integration harness seed Postgres directly.
- New `scripts/ci/smoke-qa.sh` (matches the repo's "every CI step is a reproducible script" convention):
- API integration: `dotnet test tests/api/MicCheck.Api.Tests.Integration -c Release --logger trx` with env `MICCHECK_API_BASE_URL=http://localhost:${QA_ADMIN_PORT}` and `MICCHECK_DB_CONNECTION_STRING=Host=localhost;Port=55432;Database=miccheck;Username=miccheck;Password=password`.
- Playwright: `npm --prefix src/admin ci` (or reuse install), `npm --prefix src/admin run e2e:install`, then `E2E_BASE_URL=http://localhost:${QA_ADMIN_PORT} npm --prefix src/admin run e2e`.
- `.github/workflows/ci.yml`: add job `smoke-qa` (`needs: deploy-qa`, `runs-on: [self-hosted, qa]`, env `QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}`) that checks out (plain-git step, matching `deploy-qa`) and runs `./scripts/ci/smoke-qa.sh`. Deploy is now gated by real end-to-end validation.
## Files touched (summary)
- Modify: `src/api/MicCheck.Api/Data/DatabaseSeeder.cs`, `deploy/qa/docker-compose.qa.yml`, `.github/workflows/ci.yml`, `src/admin/package.json`.
- Add (API tests): `HttpFiles/EnvironmentDocument.http`, `HttpFiles/Identity.http`, `HttpFiles/Auth.http`, new `*Tests.cs` + seed helpers under `Flags/` (or a new `Identities/`, `Environments/`, `Auth/` folder) in `tests/api/MicCheck.Api.Tests.Integration`.
- Add (unit): seeder test under `tests/api/MicCheck.Api.Tests.Unit/Data/`.
- Add (e2e): `src/admin/playwright.config.ts`, `src/admin/e2e/*.e2e.ts`, `src/admin/e2e/global-setup.ts`.
- Add (CI): `scripts/ci/smoke-qa.sh`.
- Per CLAUDE.md, also drop this plan at `docs/plans/api/integration-smoke_plan.md` and an `_output.md` summary after implementation.
## Verification
- **Chunk A**: `dotnet test tests/api/MicCheck.Api.Tests.Unit` (new seeder test green). Boot API locally (`docker compose up`), confirm login with seed creds returns tokens and the Development env key equals the fixed value.
- **Chunk B**: bring up local stack (Postgres on `:5432`, API on `:5000`), `dotnet test tests/api/MicCheck.Api.Tests.Integration --settings local.runsettings` — all new scenarios green; failure messages show live DB snapshots.
- **Chunk C**: `npm --prefix src/admin run serve` (API on :5000), `E2E_BASE_URL=http://localhost:5173 npm --prefix src/admin run e2e` — all journeys pass headed and headless.
- **Chunk D**: push to `build-runner-fix`; watch CI — `smoke-qa` runs after `deploy-qa`, both `dotnet test` and Playwright green against `http://localhost:${QA_ADMIN_PORT}`. Confirm a deliberately-broken deploy (e.g. bad DB creds) makes `smoke-qa` fail.

28
package-lock.json generated
View File

@@ -1,28 +0,0 @@
{
"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"
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"name": "mic-check",
"private": true,
"description": "Repo-root package - hosts the Husky pre-push hook only.",
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.1.7"
}
}

View File

@@ -1,47 +0,0 @@
# MicCheck
[![GitHub CI](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/mic-check/actions/workflows/ci.yml)
[![Gitea CI](https://git.wampler.us/wamplerj/mic-check/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/mic-check/actions?workflow=ci.yml)
![Coverage](badges/coverage.svg)
Open source feature flag management platform. Manage projects, environments, feature flags, segments, and identities across your apps.
Built with .NET (API) and Vue.js + Vuetify (admin UI).
## Structure
```
src/
api/MicCheck.Api/ .NET API — REST endpoints, organized by feature (Features, Projects, Environments,
Segments, Identities, Organizations, Webhooks, Audit, Users)
admin/ Vue.js + Vuetify admin SPA
MicCheck.AppHost/ .NET Aspire orchestration host for local dev
MicCheck.ServiceDefaults/ Shared .NET Aspire service defaults (telemetry, health checks)
tests/
api/MicCheck.Api.Tests.Unit/ Unit tests
api/MicCheck.Api.Tests.Integration/ Integration tests
docs/
admin/ Admin implementation plans/output docs
api/ API implementation plans/output docs
```
Code in the API is organized by feature area (e.g. `Features`, `Segments`, `Identities`) rather than by technical layer.
## Running locally
`MicCheck.AppHost` (.NET Aspire) orchestrates the API, admin app, and supporting services for local development.
```sh
./dev-build.sh
aspire run --project src/MicCheck.AppHost
```
## Testing
```sh
dotnet test
```
## License
MIT — see [LICENSE](LICENSE).

View File

@@ -1,20 +0,0 @@
#!/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"
ensure_dotnet
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"

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Merges the .NET (coverlet/Cobertura) and admin (Jest/lcov) coverage output
# produced by test.sh into one report via reportgenerator, prints a summary,
# appends a build-report summary when running under Actions, and refreshes
# the coverage badge committed at badges/coverage.svg. Readme embeds that
# badge via a relative path, which resolves on both GitHub and Gitea since
# the same repo content is pushed to both remotes.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_reportgenerator
REPORT_DIR="$CI_ROOT/coverage/report"
log "Merging coverage reports with reportgenerator"
reportgenerator \
-reports:"coverage/dotnet/**/coverage.cobertura.xml;src/admin/coverage/lcov.info" \
-targetdir:"$REPORT_DIR" \
-reporttypes:"Badges;MarkdownSummaryGithub;TextSummary"
cat "$REPORT_DIR/Summary.txt"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY"
fi
mkdir -p "$CI_ROOT/badges"
cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg"
log "coverage.sh complete"

View File

@@ -1,49 +0,0 @@
#!/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 POSTGRES_PASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}"
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
# Bind narrowly to docker's bridge gateway IP rather than 0.0.0.0: reachable
# from the smoke-qa job container (a sibling on the default bridge), but not
# exposed on the host's public interface.
export DB_BIND_HOST="$(docker_bridge_gateway)"
[[ -n "$DB_BIND_HOST" ]] || fail "could not determine docker bridge gateway IP to bind the QA db port"
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"
# Checked with `docker compose exec` rather than curling the published host
# port: CI runs this script inside a runner container on its own bridge
# network, where "localhost:$QA_ADMIN_PORT" is the runner's own loopback, not
# the docker host's - it can never reach a host-published port. Exec'ing into
# the admin container and curling its own localhost sidesteps that entirely.
attempts=30
until $COMPOSE exec -T admin curl -fsS http://localhost/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}"

View File

@@ -1,23 +0,0 @@
#!/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"

View File

@@ -1,17 +0,0 @@
#!/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"

View File

@@ -1,160 +0,0 @@
#!/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"
}
# Installs .NET into $CI_ROOT/.dotnet via the vendored dotnet-install.sh if
# `dotnet` isn't already on PATH, then prepends it to PATH for this process.
# Keeps bare runners (no SDK preinstalled) working the same as a dev machine.
ensure_dotnet() {
if command -v dotnet > /dev/null 2>&1; then
return 0
fi
local install_dir="$CI_ROOT/.dotnet"
if [[ ! -x "$install_dir/dotnet" ]]; then
log "dotnet not found on PATH; installing .NET SDK via dotnet-install.sh"
bash "$CI_ROOT/dotnet-install.sh" --channel LTS --install-dir "$install_dir"
fi
export PATH="$install_dir:$PATH"
export DOTNET_ROOT="$install_dir"
ensure_dotnet_native_deps
}
# The .NET runtime is a native ELF binary that dynamically links libstdc++/libgcc.
# Bare/minimal images (e.g. the act hostexecutor container) may lack them entirely,
# which fails as an obscure symbol-relocation error rather than "command not found".
ensure_dotnet_native_deps() {
if ldconfig -p 2>/dev/null | grep -q 'libstdc++\.so\.6'; then
return 0
fi
log "libstdc++.so.6 missing; installing native runtime deps for dotnet"
local sudo_cmd=""
if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then
sudo_cmd="sudo"
fi
if command -v apt-get > /dev/null 2>&1; then
$sudo_cmd apt-get update -y
$sudo_cmd apt-get install -y --no-install-recommends libstdc++6 libgcc-s1 libicu-dev ca-certificates
elif command -v apk > /dev/null 2>&1; then
# Alpine/musl runner - dotnet-install.sh falls back to the linux-musl-x64 SDK
# here, which still wants a real libstdc++/libgcc (not just gcompat).
$sudo_cmd apk add --no-cache libstdc++ libgcc icu-libs ca-certificates
else
fail "libstdc++.so.6 missing and neither apt-get nor apk is available; install a C++ runtime manually on this runner"
fi
}
# Installs Node.js into $CI_ROOT/.node from the official prebuilt tarball if
# `npm` isn't already on PATH, then prepends it to PATH for this process.
# Mirrors ensure_dotnet() above - keeps bare runners (no Node preinstalled,
# e.g. the self-hosted qa runner) working the same as a dev machine.
ensure_node() {
if command -v npm > /dev/null 2>&1; then
return 0
fi
# nodejs.org only ships glibc binaries; on a musl/Alpine runner (same one
# dotnet-install.sh detects and picks the linux-musl-x64 SDK for) that
# tarball fails to exec at all ("env: can't execute 'node'"). Prefer the
# distro's own package on musl instead of a broken glibc download.
if [[ ! -x "$CI_ROOT/.node/bin/node" ]] && command -v apk > /dev/null 2>&1; then
log "node not found on PATH; installing via apk (musl runner)"
local sudo_cmd=""
if [[ "$(id -u)" -ne 0 ]] && command -v sudo > /dev/null 2>&1; then
sudo_cmd="sudo"
fi
$sudo_cmd apk add --no-cache nodejs npm
return 0
fi
local node_version="22.14.0"
local install_dir="$CI_ROOT/.node"
if [[ ! -x "$install_dir/bin/node" ]]; then
log "node not found on PATH; installing Node.js v$node_version"
local tarball="node-v${node_version}-linux-x64"
local url="https://nodejs.org/dist/v${node_version}/${tarball}.tar.xz"
if command -v curl > /dev/null 2>&1; then
curl -fsSL "$url" -o "/tmp/${tarball}.tar.xz"
elif command -v wget > /dev/null 2>&1; then
wget -q "$url" -O "/tmp/${tarball}.tar.xz"
else
fail "neither curl nor wget found on PATH; cannot download Node.js"
fi
mkdir -p "$install_dir"
tar -xJf "/tmp/${tarball}.tar.xz" -C "$install_dir" --strip-components=1
rm -f "/tmp/${tarball}.tar.xz"
fi
export PATH="$install_dir/bin:$PATH"
}
# Installs the dotnet-reportgenerator-globaltool CLI (merges coverlet/Jest
# coverage output into badges + build-summary markdown) into $CI_ROOT/.dotnet-tools
# if it isn't already on PATH. Mirrors ensure_dotnet()/ensure_node() above.
ensure_reportgenerator() {
if command -v reportgenerator > /dev/null 2>&1; then
return 0
fi
local tool_dir="$CI_ROOT/.dotnet-tools"
if [[ ! -x "$tool_dir/reportgenerator" ]]; then
log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool"
dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir"
fi
export PATH="$tool_dir:$PATH"
}
# The IP address on which a container published on 0.0.0.0/<gateway-ip> is
# reachable from a sibling container on docker's default bridge network (i.e.
# the docker host's bridge-side address, not its public interface). Used to
# bind QA's db port narrowly - reachable by the smoke-qa job container, not
# exposed off-box the way 0.0.0.0 would be.
docker_bridge_gateway() {
docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null \
|| ip route show default 2>/dev/null | awk '/default/ {print $3; exit}'
}
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
}

View File

@@ -1,25 +0,0 @@
#!/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"
ensure_dotnet
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"

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# Commits the coverage badge refreshed by coverage.sh straight back to the
# branch that triggered this run, so readme.md's relative badges/coverage.svg
# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are
# default context env vars on both GitHub Actions and Gitea Actions (Gitea's
# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in
# explicitly from the workflow (${{ github.token }}) on both platforms.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit"
[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit"
[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit"
[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit"
if git diff --quiet -- badges/coverage.svg; then
log "badges/coverage.svg unchanged; nothing to publish"
exit 0
fi
git config user.name "miccheck-ci"
git config user.email "ci@miccheck.local"
git add badges/coverage.svg
git commit -m "chore: refresh coverage badge [skip ci]"
remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}"
log "publish-coverage-badge.sh complete"

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# Post-deploy validation for the QA environment: runs the API integration
# suite (real HTTP + real Postgres, seeding/cleaning up its own data) and the
# Playwright admin e2e suite against the just-deployed QA stack. Intended to
# run immediately after deploy-qa.sh, on the same self-hosted qa runner. This
# job runs in its own job container, a sibling of the QA stack's containers
# on docker's default bridge network - not "localhost" from the host's point
# of view - so it reaches published ports via the bridge gateway IP, same as
# deploy-qa.sh binds the db port to.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
cd "$CI_ROOT"
ensure_dotnet
ensure_node
QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
CI_HOST="$(docker_bridge_gateway)"
[[ -n "$CI_HOST" ]] || fail "could not determine docker bridge gateway IP to reach the QA stack"
BASE_URL="http://${CI_HOST}:${QA_ADMIN_PORT}"
log "Resolved docker host as $CI_HOST for reaching the QA stack's published ports"
export MICCHECK_API_BASE_URL="$BASE_URL"
export MICCHECK_DB_CONNECTION_STRING="${MICCHECK_DB_CONNECTION_STRING:-Host=$CI_HOST;Port=55432;Database=miccheck;Username=miccheck;Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD env var is required}}"
log "Running API integration suite against $BASE_URL"
dotnet test tests/api/MicCheck.Api.Tests.Integration/MicCheck.Api.Tests.Integration.csproj -c Release --logger trx
log "Installing admin e2e dependencies"
npm --prefix src/admin ci
# Playwright's bundled Chromium is a glibc binary and `--with-deps` only knows
# apt - neither works on this musl/Alpine runner. Run the e2e leg inside
# Microsoft's official Playwright image instead (glibc, browsers preinstalled
# at /ms-playwright), as a sibling container reachable at the same bridge
# gateway IP used above. Pin the image tag to the exact resolved
# @playwright/test version so the test runner and browser build match.
#
# This script itself runs inside the runner's own job container, talking to
# the host's docker daemon over a mounted socket (docker-outside-of-docker) -
# `docker run -v "$CI_ROOT:/work"` would ask the *host* daemon to bind-mount a
# path that only exists inside this job container, which fails. `docker cp`
# instead copies the files by content, sidestepping the path mismatch.
PW_VERSION="$(node -p "require('./src/admin/node_modules/@playwright/test/package.json').version")"
log "Running Playwright admin e2e suite against $BASE_URL (playwright:v$PW_VERSION-noble)"
PW_CONTAINER="$(docker create -e "E2E_BASE_URL=$BASE_URL" -w /work/src/admin "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" npm run e2e)"
docker cp "$CI_ROOT/." "$PW_CONTAINER:/work"
pw_status=0
docker start -a "$PW_CONTAINER" || pw_status=$?
docker rm -f "$PW_CONTAINER" > /dev/null
[[ "$pw_status" -eq 0 ]] || fail "Playwright e2e suite failed (exit $pw_status)"
log "smoke-qa.sh complete - QA environment validated end to end"

View File

@@ -1,26 +0,0 @@
#!/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"
ensure_dotnet
# --results-directory doesn't clear prior runs - it adds a new GUID folder
# alongside old ones every time. On a runner that reuses its workspace
# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past
# runs would otherwise get merged in by coverage.sh and silently skew the
# combined percentage.
rm -rf "$CI_ROOT/coverage/dotnet"
log "Running MicCheck.Api.Tests.Unit"
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx \
--collect:"XPlat Code Coverage" --results-directory "$CI_ROOT/coverage/dotnet" \
--settings tests/api/MicCheck.Api.Tests.Unit/coverlet.runsettings
log "Running admin Jest tests"
npm --prefix src/admin test -- --coverage --coverageReporters=lcov --coverageReporters=text-summary
log "test.sh complete"

View File

@@ -1,26 +1,14 @@
var builder = DistributedApplication.CreateBuilder(args); var builder = DistributedApplication.CreateBuilder(args);
// Pinned to match tests/api/MicCheck.Api.Tests.Integration/local.runsettings and var postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata").WithPgAdmin();
// src/admin's docker-compose defaults, so those fixed targets work whether the
// stack is run via `docker compose` or via this AppHost.
var postgresUser = builder.AddParameter("postgres-username", "miccheck");
var postgresPassword = builder.AddParameter("postgres-password", "password", secret: true);
var postgres = builder.AddPostgres("postgres", postgresUser, postgresPassword, port: 5432)
.WithDataVolume("miccheck-pgdata")
.WithPgAdmin();
var miccheckDb = postgres.AddDatabase("miccheck"); var miccheckDb = postgres.AddDatabase("miccheck");
var api = builder.AddProject<Projects.MicCheck_Api>("api") var api = builder.AddProject<Projects.MicCheck_Api>("api").WithReference(miccheckDb).WaitFor(miccheckDb);
.WithReference(miccheckDb)
.WaitFor(miccheckDb)
.WithHttpEndpoint(port: 5000, name: "http");
builder.AddViteApp("admin", "../admin", "serve") builder.AddViteApp("admin", "../admin", "serve")
.WithReference(api) .WithReference(api)
.WaitFor(api) .WaitFor(api)
.WithHttpEndpoint(port: 5173, name: "http")
.WithExternalHttpEndpoints(); .WithExternalHttpEndpoints();
builder.Build().Run(); builder.Build().Run();

View File

@@ -8,7 +8,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" /> <PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.2" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.0" /> <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.4.0" />
<PackageReference Include="MessagePack" Version="2.5.302" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -1,19 +0,0 @@
# 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

@@ -1,30 +0,0 @@
import { test, expect } from '@playwright/test';
import { authFile } from './global-setup';
test.use({ storageState: authFile });
/**
* The seeded DB (DatabaseSeeder) provides exactly one org, one project ("My Project"), and
* three environments (Development/Staging/Production), so the selectors auto-populate without
* needing to create anything first. Most feature screens require both a project and an
* environment to be selected before they render real content.
*/
test('project and environment context is selectable and persists across a reload', async ({ page }) => {
await page.goto('/features');
const projectSelect = page.getByTestId('project-select').locator('input');
const envSelect = page.getByTestId('env-select').locator('input');
await expect(projectSelect).not.toHaveValue('');
await expect(envSelect).not.toHaveValue('');
// Explicitly re-select the project via the dropdown to prove the selector itself works,
// not just the auto-select-on-load behavior.
await page.getByTestId('project-select').click();
await page.getByRole('option', { name: 'My Project' }).click();
await expect(projectSelect).toHaveValue('My Project');
await page.reload();
await expect(page.getByTestId('project-select').locator('input')).not.toHaveValue('');
await expect(page.getByTestId('env-select').locator('input')).not.toHaveValue('');
});

View File

@@ -1,47 +0,0 @@
import { test, expect } from '@playwright/test';
import { authFile } from './global-setup';
test.use({ storageState: authFile });
test('the features table renders for the selected project', async ({ page }) => {
await page.goto('/features');
await expect(page.getByTestId('features-table')).toBeVisible();
await expect(page.getByText('Select a project')).toHaveCount(0);
});
test('creating a feature adds it to the table and its toggle can be flipped', async ({ page }) => {
const featureName = `e2e_feature_${Date.now()}`;
await page.goto('/features');
await page.getByTestId('create-feature-btn').click();
await page.getByTestId('feature-name-input').locator('input').fill(featureName);
await page.getByTestId('feature-dialog-save').click();
const row = page.getByRole('row', { name: new RegExp(featureName) });
await expect(row).toBeVisible();
// Creating a feature triggers a fresh fetch of feature states for the current environment;
// wait for it to settle so the toggle below acts on real, loaded state rather than racing it.
await page.waitForLoadState('networkidle');
const toggle = row.locator('input[type="checkbox"]');
const wasChecked = await toggle.isChecked();
// Assert on the real PATCH landing, not just the switch's transient DOM state: if the
// feature-state map hasn't loaded for this row yet, the click handler no-ops silently and
// the switch briefly flashes the native checkbox state before Vue snaps it back - which
// reads as "toggled" for an instant even though nothing was ever sent to the API.
const patchResponse = page.waitForResponse(
(res) => res.request().method() === 'PATCH' && res.url().includes('/featurestate/') && res.ok(),
);
await toggle.click({ force: true });
await patchResponse;
await expect(toggle).toBeChecked({ checked: !wasChecked });
// Reload to confirm the toggle was persisted to the real API/DB, not just local state.
await page.reload();
const reloadedRow = page.getByRole('row', { name: new RegExp(featureName) });
await expect(reloadedRow.locator('input[type="checkbox"]')).toBeChecked({ checked: !wasChecked });
});

View File

@@ -1,30 +0,0 @@
import { chromium, type FullConfig } from '@playwright/test';
import path from 'node:path';
/**
* Deterministic dev/QA seed admin credentials — see DatabaseSeeder.SeedAdminEmail /
* SeedAdminPassword in src/api/MicCheck.Api/Data/DatabaseSeeder.cs. Seeding only ever runs in
* Development, which is what both local dev and the QA deployment run as.
*/
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026';
export const authFile = path.join(__dirname, '.auth', 'admin.json');
export default async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:5173';
const browser = await chromium.launch();
const page = await browser.newPage({ baseURL });
await page.goto('/login');
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD);
await page.getByTestId('login-submit').click();
// A successful login redirects off /login onto the authenticated shell.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page.context().storageState({ path: authFile });
await browser.close();
}

View File

@@ -1,32 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Unauthenticated — does not use the shared storageState fixture (see global-setup.ts) since
* it exercises the login flow itself.
*/
test.use({ storageState: { cookies: [], origins: [] } });
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL ?? 'admin@miccheck.local';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'MicCheckQa!2026';
test('valid credentials sign the admin in and land on the authenticated shell', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
await page.getByTestId('password-input').locator('input').fill(ADMIN_PASSWORD);
await page.getByTestId('login-submit').click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
await expect(page.getByText('Features', { exact: true })).toBeVisible();
});
test('invalid credentials surface a login error and stay on the login page', async ({ page }) => {
await page.goto('/login');
await page.getByTestId('email-input').locator('input').fill(ADMIN_EMAIL);
await page.getByTestId('password-input').locator('input').fill('not-the-right-password');
await page.getByTestId('login-submit').click();
await expect(page.getByTestId('login-error')).toBeVisible();
await expect(page).toHaveURL(/\/login/);
});

View File

@@ -1,23 +0,0 @@
import { test, expect } from '@playwright/test';
import { authFile } from './global-setup';
test.use({ storageState: authFile });
test('the sidebar renders and each primary link navigates to its view', async ({ page }) => {
await page.goto('/dashboard');
const links: Array<[name: string, path: string]> = [
['Dashboard', '/dashboard'],
['Features', '/features'],
['Segments', '/segments'],
['Identities', '/identities'],
['Audit Logs', '/audit-logs'],
['Settings', '/settings'],
];
for (const [name, path] of links) {
// Sidebar entries are clickable divs (VerticalNavLink), not <a> elements, so match by text.
await page.locator('li').filter({ hasText: name }).first().click();
await expect(page).toHaveURL(new RegExp(`${path}$`));
}
});

View File

@@ -20,14 +20,6 @@ server {
proxy_read_timeout 30s; proxy_read_timeout 30s;
} }
# Proxy the API's health endpoint, which lives outside the /api prefix.
location = /health {
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_upstream http://api:8080;
proxy_pass $api_upstream/health;
proxy_http_version 1.1;
}
# SPA fallback — all other paths serve index.html so Vue Router handles them # SPA fallback — all other paths serve index.html so Vue Router handles them
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

View File

@@ -8,7 +8,6 @@
"name": "mic-check-admin", "name": "mic-check-admin",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0", "@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0", "@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0", "@iconify/vue": "^4.1.0",
@@ -26,7 +25,6 @@
"@babel/core": "^7.24.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0", "@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0", "@babel/preset-typescript": "^7.24.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.12.0", "@types/node": "^20.12.0",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
@@ -2004,14 +2002,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -3353,21 +3343,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -6012,15 +5987,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.6", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4", "hasown": "^2.0.2",
"mime-types": "^2.1.35" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -8429,10 +8405,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "3.15.0", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^1.0.7", "argparse": "^1.0.7",
"esprima": "^4.0.0" "esprima": "^4.0.0"
@@ -9449,50 +9426,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -7,12 +7,9 @@
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"serve": "vite", "serve": "vite",
"preview": "vite preview", "preview": "vite preview",
"test": "jest --passWithNoTests", "test": "jest --passWithNoTests"
"e2e": "playwright test",
"e2e:install": "playwright install --with-deps chromium"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0", "@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0", "@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0", "@iconify/vue": "^4.1.0",
@@ -30,7 +27,6 @@
"@babel/core": "^7.24.0", "@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0", "@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0", "@babel/preset-typescript": "^7.24.0",
"@playwright/test": "^1.61.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.12.0", "@types/node": "^20.12.0",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",

View File

@@ -1,29 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke-test suite for the admin SPA. Runs against a real, already-running deployment
* (local dev server or a deployed QA environment) — it does not mock the API and does not
* start any servers itself. See e2e/global-setup.ts for how authentication is bootstrapped.
*/
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.e2e.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
globalSetup: './e2e/global-setup.ts',
timeout: 30_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -1,4 +1,4 @@
$font-family-custom: "Inter Variable", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, $font-family-custom: "Inter", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto,
"Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* stylelint-disable length-zero-no-unit */ /* stylelint-disable length-zero-no-unit */
@forward "../../../base/libs/vuetify/variables" with ( @forward "../../../base/libs/vuetify/variables" with (

View File

@@ -3,7 +3,7 @@ import type { ApiKeyResponse, CreateApiKeyRequest, CreateApiKeyResponse } from '
export async function listApiKeys(organizationId: number): Promise<ApiKeyResponse[]> { export async function listApiKeys(organizationId: number): Promise<ApiKeyResponse[]> {
const { data } = await apiClient.get<ApiKeyResponse[]>( const { data } = await apiClient.get<ApiKeyResponse[]>(
`/v1/organisation/${organizationId}/api-keys`, `/v1/organisations/${organizationId}/api-keys`,
); );
return data; return data;
} }
@@ -13,12 +13,12 @@ export async function createApiKey(
request: CreateApiKeyRequest, request: CreateApiKeyRequest,
): Promise<CreateApiKeyResponse> { ): Promise<CreateApiKeyResponse> {
const { data } = await apiClient.post<CreateApiKeyResponse>( const { data } = await apiClient.post<CreateApiKeyResponse>(
`/v1/organisation/${organizationId}/api-keys`, `/v1/organisations/${organizationId}/api-keys`,
request, request,
); );
return data; return data;
} }
export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> { export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${organizationId}/api-key/${keyId}`); await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`);
} }

View File

@@ -17,7 +17,7 @@ export async function listAuditLogsByOrganization(
filter: AuditLogFilter = {}, filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> { ): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>( const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/organisation/${orgId}/audit-logs`, `/v1/organisations/${orgId}/audit-logs`,
{ params: toParams(filter) }, { params: toParams(filter) },
); );
return data; return data;
@@ -28,7 +28,7 @@ export async function listAuditLogsByProject(
filter: AuditLogFilter = {}, filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> { ): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>( const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/project/${projectId}/audit-logs`, `/v1/projects/${projectId}/audit-logs`,
{ params: toParams(filter) }, { params: toParams(filter) },
); );
return data; return data;

View File

@@ -16,7 +16,7 @@ export async function listEnvironments(projectId: number, page = 1, pageSize = 1
} }
export async function getEnvironment(apiKey: string): Promise<EnvironmentResponse> { export async function getEnvironment(apiKey: string): Promise<EnvironmentResponse> {
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environment/${apiKey}`); const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environments/${apiKey}`);
return data; return data;
} }
@@ -26,17 +26,17 @@ export async function createEnvironment(request: CreateEnvironmentRequest): Prom
} }
export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> { export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environment/${apiKey}`, request); const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environments/${apiKey}`, request);
return data; return data;
} }
export async function deleteEnvironment(apiKey: string): Promise<void> { export async function deleteEnvironment(apiKey: string): Promise<void> {
await apiClient.delete(`/v1/environment/${apiKey}`); await apiClient.delete(`/v1/environments/${apiKey}`);
} }
export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> { export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.post<EnvironmentResponse>( const { data } = await apiClient.post<EnvironmentResponse>(
`/v1/environment/${apiKey}/clone`, `/v1/environments/${apiKey}/clone`,
request, request,
); );
return data; return data;

View File

@@ -11,7 +11,7 @@ export async function listFeatureSegments(
featureId: number, featureId: number,
): Promise<FeatureSegmentResponse[]> { ): Promise<FeatureSegmentResponse[]> {
const { data } = await apiClient.get<FeatureSegmentResponse[]>( const { data } = await apiClient.get<FeatureSegmentResponse[]>(
`/v1/environment/${envApiKey}/feature/${featureId}/segments`, `/v1/environments/${envApiKey}/features/${featureId}/segments`,
); );
return data; return data;
} }
@@ -22,7 +22,7 @@ export async function createFeatureSegment(
request: CreateFeatureSegmentRequest, request: CreateFeatureSegmentRequest,
): Promise<FeatureSegmentResponse> { ): Promise<FeatureSegmentResponse> {
const { data } = await apiClient.post<FeatureSegmentResponse>( const { data } = await apiClient.post<FeatureSegmentResponse>(
`/v1/environment/${envApiKey}/feature/${featureId}/segments`, `/v1/environments/${envApiKey}/features/${featureId}/segments`,
request, request,
); );
return data; return data;
@@ -35,7 +35,7 @@ export async function updateFeatureSegment(
request: UpdateFeatureSegmentRequest, request: UpdateFeatureSegmentRequest,
): Promise<FeatureSegmentResponse> { ): Promise<FeatureSegmentResponse> {
const { data } = await apiClient.put<FeatureSegmentResponse>( const { data } = await apiClient.put<FeatureSegmentResponse>(
`/v1/environment/${envApiKey}/feature/${featureId}/segment/${id}`, `/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`,
request, request,
); );
return data; return data;
@@ -46,7 +46,7 @@ export async function deleteFeatureSegment(
featureId: number, featureId: number,
id: number, id: number,
): Promise<void> { ): Promise<void> {
await apiClient.delete(`/v1/environment/${envApiKey}/feature/${featureId}/segment/${id}`); await apiClient.delete(`/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`);
} }
export async function listIdentitySegments( export async function listIdentitySegments(
@@ -54,7 +54,7 @@ export async function listIdentitySegments(
identityId: number, identityId: number,
): Promise<SegmentSummaryResponse[]> { ): Promise<SegmentSummaryResponse[]> {
const { data } = await apiClient.get<SegmentSummaryResponse[]>( const { data } = await apiClient.get<SegmentSummaryResponse[]>(
`/v1/environment/${envApiKey}/identity/${identityId}/segments`, `/v1/environments/${envApiKey}/identities/${identityId}/segments`,
); );
return data; return data;
} }

View File

@@ -7,14 +7,14 @@ import type {
export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> { export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>( const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environment/${envApiKey}/featurestates`, `/v1/environments/${envApiKey}/featurestates`,
); );
return data; return data;
} }
export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> { export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> {
const { data } = await apiClient.get<FeatureStateResponse>( const { data } = await apiClient.get<FeatureStateResponse>(
`/v1/environment/${envApiKey}/featurestate/${id}`, `/v1/environments/${envApiKey}/featurestates/${id}`,
); );
return data; return data;
} }
@@ -25,7 +25,7 @@ export async function updateFeatureState(
request: UpdateFeatureStateRequest, request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> { ): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>( const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environment/${envApiKey}/featurestate/${id}`, `/v1/environments/${envApiKey}/featurestates/${id}`,
request, request,
); );
return data; return data;
@@ -37,7 +37,7 @@ export async function patchFeatureState(
request: PatchFeatureStateRequest, request: PatchFeatureStateRequest,
): Promise<FeatureStateResponse> { ): Promise<FeatureStateResponse> {
const { data } = await apiClient.patch<FeatureStateResponse>( const { data } = await apiClient.patch<FeatureStateResponse>(
`/v1/environment/${envApiKey}/featurestate/${id}`, `/v1/environments/${envApiKey}/featurestates/${id}`,
request, request,
); );
return data; return data;

View File

@@ -13,7 +13,7 @@ export async function listFeatures(
pageSize = 100, pageSize = 100,
): Promise<PaginatedResponse<FeatureResponse>> { ): Promise<PaginatedResponse<FeatureResponse>> {
const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>( const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>(
`/v1/project/${projectId}/features`, `/v1/projects/${projectId}/features`,
{ params: { page, pageSize } }, { params: { page, pageSize } },
); );
return data; return data;
@@ -21,7 +21,7 @@ export async function listFeatures(
export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> { export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> {
const { data } = await apiClient.get<FeatureResponse>( const { data } = await apiClient.get<FeatureResponse>(
`/v1/project/${projectId}/feature/${id}`, `/v1/projects/${projectId}/features/${id}`,
); );
return data; return data;
} }
@@ -31,7 +31,7 @@ export async function createFeature(
request: CreateFeatureRequest, request: CreateFeatureRequest,
): Promise<FeatureResponse> { ): Promise<FeatureResponse> {
const { data } = await apiClient.post<FeatureResponse>( const { data } = await apiClient.post<FeatureResponse>(
`/v1/project/${projectId}/features`, `/v1/projects/${projectId}/features`,
request, request,
); );
return data; return data;
@@ -43,7 +43,7 @@ export async function updateFeature(
request: UpdateFeatureRequest, request: UpdateFeatureRequest,
): Promise<FeatureResponse> { ): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>( const { data } = await apiClient.put<FeatureResponse>(
`/v1/project/${projectId}/feature/${id}`, `/v1/projects/${projectId}/features/${id}`,
request, request,
); );
return data; return data;
@@ -55,34 +55,12 @@ export async function patchFeature(
request: PatchFeatureRequest, request: PatchFeatureRequest,
): Promise<FeatureResponse> { ): Promise<FeatureResponse> {
const { data } = await apiClient.patch<FeatureResponse>( const { data } = await apiClient.patch<FeatureResponse>(
`/v1/project/${projectId}/feature/${id}`, `/v1/projects/${projectId}/features/${id}`,
request, request,
); );
return data; return data;
} }
export async function deleteFeature(projectId: number, id: number): Promise<void> { export async function deleteFeature(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/feature/${id}`); await apiClient.delete(`/v1/projects/${projectId}/features/${id}`);
}
export async function assignFeatureTag(
projectId: number,
featureId: number,
tagId: number,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/project/${projectId}/feature/${featureId}/tag/${tagId}`,
);
return data;
}
export async function removeFeatureTag(
projectId: number,
featureId: number,
tagId: number,
): Promise<FeatureResponse> {
const { data } = await apiClient.delete<FeatureResponse>(
`/v1/project/${projectId}/feature/${featureId}/tag/${tagId}`,
);
return data;
} }

View File

@@ -15,7 +15,7 @@ export async function listIdentities(
pageSize = 100, pageSize = 100,
): Promise<PaginatedResponse<IdentityResponse>> { ): Promise<PaginatedResponse<IdentityResponse>> {
const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>( const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>(
`/v1/environment/${envApiKey}/identities`, `/v1/environments/${envApiKey}/identities`,
{ params: { page, pageSize } }, { params: { page, pageSize } },
); );
return data; return data;
@@ -26,7 +26,7 @@ export async function createIdentity(
request: CreateIdentityRequest, request: CreateIdentityRequest,
): Promise<IdentityResponse> { ): Promise<IdentityResponse> {
const { data } = await apiClient.post<IdentityResponse>( const { data } = await apiClient.post<IdentityResponse>(
`/v1/environment/${envApiKey}/identities`, `/v1/environments/${envApiKey}/identities`,
request, request,
); );
return data; return data;
@@ -34,7 +34,7 @@ export async function createIdentity(
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> { export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
const { data } = await apiClient.get<IdentityResponse>( const { data } = await apiClient.get<IdentityResponse>(
`/v1/environment/${envApiKey}/identity/${id}`, `/v1/environments/${envApiKey}/identities/${id}`,
); );
return data; return data;
} }
@@ -46,7 +46,7 @@ export async function upsertIdentityTrait(
request: UpsertTraitRequest, request: UpsertTraitRequest,
): Promise<TraitResponse> { ): Promise<TraitResponse> {
const { data } = await apiClient.put<TraitResponse>( const { data } = await apiClient.put<TraitResponse>(
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`, `/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
request, request,
); );
return data; return data;
@@ -58,12 +58,12 @@ export async function deleteIdentityTrait(
key: string, key: string,
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`, `/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
); );
} }
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> { export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
await apiClient.delete(`/v1/environment/${envApiKey}/identity/${id}`); await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
} }
export async function getIdentityFeatureStates( export async function getIdentityFeatureStates(
@@ -71,7 +71,7 @@ export async function getIdentityFeatureStates(
identityId: number, identityId: number,
): Promise<FeatureStateResponse[]> { ): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>( const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environment/${envApiKey}/identity/${identityId}/featurestates`, `/v1/environments/${envApiKey}/identities/${identityId}/featurestates`,
); );
return data; return data;
} }
@@ -83,7 +83,7 @@ export async function setIdentityFeatureState(
request: UpdateFeatureStateRequest, request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> { ): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>( const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`, `/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
request, request,
); );
return data; return data;
@@ -95,6 +95,6 @@ export async function deleteIdentityFeatureState(
featureId: number, featureId: number,
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`, `/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
); );
} }

View File

@@ -20,7 +20,7 @@ export async function listOrganizations(page = 1, pageSize = 100): Promise<Organ
} }
export async function getOrganization(id: number): Promise<OrganizationResponse> { export async function getOrganization(id: number): Promise<OrganizationResponse> {
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisation/${id}`); const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisations/${id}`);
return data; return data;
} }
@@ -30,27 +30,27 @@ export async function createOrganization(request: CreateOrganizationRequest): Pr
} }
export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> { export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> {
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisation/${id}`, request); const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisations/${id}`, request);
return data; return data;
} }
export async function deleteOrganization(id: number): Promise<void> { export async function deleteOrganization(id: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${id}`); await apiClient.delete(`/v1/organisations/${id}`);
} }
export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> { export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> {
const { data } = await apiClient.get<OrganizationMemberResponse[]>( const { data } = await apiClient.get<OrganizationMemberResponse[]>(
`/v1/organisation/${organizationId}/users`, `/v1/organisations/${organizationId}/users`,
); );
return data; return data;
} }
export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> { export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> {
await apiClient.post(`/v1/organisation/${organizationId}/users/invite`, request); await apiClient.post(`/v1/organisations/${organizationId}/users/invite`, request);
} }
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> { export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${organizationId}/user/${userId}`); await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`);
} }
export async function inviteOrganizationMembersByEmail( export async function inviteOrganizationMembersByEmail(
@@ -58,7 +58,7 @@ export async function inviteOrganizationMembersByEmail(
request: InviteUsersByEmailRequest, request: InviteUsersByEmailRequest,
): Promise<InviteByEmailResult[]> { ): Promise<InviteByEmailResult[]> {
const { data } = await apiClient.post<InviteByEmailResult[]>( const { data } = await apiClient.post<InviteByEmailResult[]>(
`/v1/organisation/${organizationId}/users/invite-by-email`, `/v1/organisations/${organizationId}/users/invite-by-email`,
request, request,
); );
return data; return data;
@@ -66,14 +66,14 @@ export async function inviteOrganizationMembersByEmail(
export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> { export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.get<InviteTokenResponse>( const { data } = await apiClient.get<InviteTokenResponse>(
`/v1/organisation/${organizationId}/invite-link`, `/v1/organisations/${organizationId}/invite-link`,
); );
return data; return data;
} }
export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> { export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.post<InviteTokenResponse>( const { data } = await apiClient.post<InviteTokenResponse>(
`/v1/organisation/${organizationId}/invite-link/regenerate`, `/v1/organisations/${organizationId}/invite-link/regenerate`,
); );
return data; return data;
} }
@@ -83,5 +83,5 @@ export async function acceptInvite(token: string): Promise<void> {
} }
export async function setPrimaryOrganization(id: number): Promise<void> { export async function setPrimaryOrganization(id: number): Promise<void> {
await apiClient.put(`/v1/organisation/${id}/primary`); await apiClient.put(`/v1/organisations/${id}/primary`);
} }

View File

@@ -17,7 +17,7 @@ export async function listProjects(organizationId: number, page = 1, pageSize =
} }
export async function getProject(id: number): Promise<ProjectResponse> { export async function getProject(id: number): Promise<ProjectResponse> {
const { data } = await apiClient.get<ProjectResponse>(`/v1/project/${id}`); const { data } = await apiClient.get<ProjectResponse>(`/v1/projects/${id}`);
return data; return data;
} }
@@ -27,17 +27,17 @@ export async function createProject(request: CreateProjectRequest): Promise<Proj
} }
export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> { export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> {
const { data } = await apiClient.put<ProjectResponse>(`/v1/project/${id}`, request); const { data } = await apiClient.put<ProjectResponse>(`/v1/projects/${id}`, request);
return data; return data;
} }
export async function deleteProject(id: number): Promise<void> { export async function deleteProject(id: number): Promise<void> {
await apiClient.delete(`/v1/project/${id}`); await apiClient.delete(`/v1/projects/${id}`);
} }
export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> { export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> {
const { data } = await apiClient.get<UserPermissionResponse[]>( const { data } = await apiClient.get<UserPermissionResponse[]>(
`/v1/project/${projectId}/user-permissions`, `/v1/projects/${projectId}/user-permissions`,
); );
return data; return data;
} }
@@ -47,7 +47,7 @@ export async function setProjectUserPermissions(
request: SetUserPermissionsRequest, request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> { ): Promise<UserPermissionResponse> {
const { data } = await apiClient.post<UserPermissionResponse>( const { data } = await apiClient.post<UserPermissionResponse>(
`/v1/project/${projectId}/user-permissions`, `/v1/projects/${projectId}/user-permissions`,
request, request,
); );
return data; return data;
@@ -59,12 +59,12 @@ export async function updateProjectUserPermissions(
request: SetUserPermissionsRequest, request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> { ): Promise<UserPermissionResponse> {
const { data } = await apiClient.put<UserPermissionResponse>( const { data } = await apiClient.put<UserPermissionResponse>(
`/v1/project/${projectId}/user-permission/${userId}`, `/v1/projects/${projectId}/user-permissions/${userId}`,
request, request,
); );
return data; return data;
} }
export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> { export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/user-permission/${userId}`); await apiClient.delete(`/v1/projects/${projectId}/user-permissions/${userId}`);
} }

View File

@@ -8,7 +8,7 @@ import type {
export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise<SegmentResponse[]> { export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise<SegmentResponse[]> {
const { data } = await apiClient.get<PaginatedResponse<SegmentResponse>>( const { data } = await apiClient.get<PaginatedResponse<SegmentResponse>>(
`/v1/project/${projectId}/segments`, `/v1/projects/${projectId}/segments`,
{ params: { page, pageSize } }, { params: { page, pageSize } },
); );
return data.results; return data.results;
@@ -16,7 +16,7 @@ export async function listSegments(projectId: number, page = 1, pageSize = 100):
export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> { export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> {
const { data } = await apiClient.get<SegmentResponse>( const { data } = await apiClient.get<SegmentResponse>(
`/v1/project/${projectId}/segment/${id}`, `/v1/projects/${projectId}/segments/${id}`,
); );
return data; return data;
} }
@@ -26,7 +26,7 @@ export async function createSegment(
request: CreateSegmentRequest, request: CreateSegmentRequest,
): Promise<SegmentResponse> { ): Promise<SegmentResponse> {
const { data } = await apiClient.post<SegmentResponse>( const { data } = await apiClient.post<SegmentResponse>(
`/v1/project/${projectId}/segments`, `/v1/projects/${projectId}/segments`,
request, request,
); );
return data; return data;
@@ -38,12 +38,12 @@ export async function updateSegment(
request: UpdateSegmentRequest, request: UpdateSegmentRequest,
): Promise<SegmentResponse> { ): Promise<SegmentResponse> {
const { data } = await apiClient.put<SegmentResponse>( const { data } = await apiClient.put<SegmentResponse>(
`/v1/project/${projectId}/segment/${id}`, `/v1/projects/${projectId}/segments/${id}`,
request, request,
); );
return data; return data;
} }
export async function deleteSegment(projectId: number, id: number): Promise<void> { export async function deleteSegment(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/segment/${id}`); await apiClient.delete(`/v1/projects/${projectId}/segments/${id}`);
} }

View File

@@ -2,15 +2,15 @@ import apiClient from './client';
import type { TagResponse, CreateTagRequest } from '@/types/api'; import type { TagResponse, CreateTagRequest } from '@/types/api';
export async function listTags(projectId: number): Promise<TagResponse[]> { export async function listTags(projectId: number): Promise<TagResponse[]> {
const { data } = await apiClient.get<TagResponse[]>(`/v1/project/${projectId}/tags`); const { data } = await apiClient.get<TagResponse[]>(`/v1/projects/${projectId}/tags`);
return data; return data;
} }
export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> { export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> {
const { data } = await apiClient.post<TagResponse>(`/v1/project/${projectId}/tags`, request); const { data } = await apiClient.post<TagResponse>(`/v1/projects/${projectId}/tags`, request);
return data; return data;
} }
export async function deleteTag(projectId: number, id: number): Promise<void> { export async function deleteTag(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/tag/${id}`); await apiClient.delete(`/v1/projects/${projectId}/tags/${id}`);
} }

View File

@@ -2,6 +2,6 @@ import apiClient from './client';
import type { DashboardUsageResponse } from '@/types/api'; import type { DashboardUsageResponse } from '@/types/api';
export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> { export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> {
const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environment/${environmentId}/usage`, { params: { days } }); const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environments/${environmentId}/usage`, { params: { days } });
return data; return data;
} }

View File

@@ -2,20 +2,20 @@ import apiClient from './client';
import type { UserProfileResponse, UpdateProfileRequest, ChangePasswordRequest } from '@/types/api'; import type { UserProfileResponse, UpdateProfileRequest, ChangePasswordRequest } from '@/types/api';
export async function getMe(): Promise<UserProfileResponse> { export async function getMe(): Promise<UserProfileResponse> {
const { data } = await apiClient.get<UserProfileResponse>('/v1/user/me'); const { data } = await apiClient.get<UserProfileResponse>('/v1/users/me');
return data; return data;
} }
export async function updateMe(request: UpdateProfileRequest): Promise<UserProfileResponse> { export async function updateMe(request: UpdateProfileRequest): Promise<UserProfileResponse> {
const { data } = await apiClient.put<UserProfileResponse>('/v1/user/me', request); const { data } = await apiClient.put<UserProfileResponse>('/v1/users/me', request);
return data; return data;
} }
export async function changePassword(request: ChangePasswordRequest): Promise<void> { export async function changePassword(request: ChangePasswordRequest): Promise<void> {
await apiClient.post('/v1/user/me/change-password', request); await apiClient.post('/v1/users/me/change-password', request);
} }
export async function getUserById(id: number): Promise<UserProfileResponse> { export async function getUserById(id: number): Promise<UserProfileResponse> {
const { data } = await apiClient.get<UserProfileResponse>(`/v1/user/${id}`); const { data } = await apiClient.get<UserProfileResponse>(`/v1/users/${id}`);
return data; return data;
} }

View File

@@ -8,7 +8,7 @@ import type {
// ─── Organization webhooks ──────────────────────────────────────────────────── // ─── Organization webhooks ────────────────────────────────────────────────────
export async function listOrgWebhooks(orgId: number): Promise<WebhookResponse[]> { export async function listOrgWebhooks(orgId: number): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisation/${orgId}/webhooks`); const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisations/${orgId}/webhooks`);
return data; return data;
} }
@@ -17,7 +17,7 @@ export async function createOrgWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>( const { data } = await apiClient.post<WebhookResponse>(
`/v1/organisation/${orgId}/webhooks`, `/v1/organisations/${orgId}/webhooks`,
request, request,
); );
return data; return data;
@@ -29,21 +29,21 @@ export async function updateOrgWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>( const { data } = await apiClient.put<WebhookResponse>(
`/v1/organisation/${orgId}/webhook/${webhookId}`, `/v1/organisations/${orgId}/webhooks/${webhookId}`,
request, request,
); );
return data; return data;
} }
export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> { export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${orgId}/webhook/${webhookId}`); await apiClient.delete(`/v1/organisations/${orgId}/webhooks/${webhookId}`);
} }
// ─── Environment webhooks ───────────────────────────────────────────────────── // ─── Environment webhooks ─────────────────────────────────────────────────────
export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> { export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>( const { data } = await apiClient.get<WebhookResponse[]>(
`/v1/environment/${envApiKey}/webhooks`, `/v1/environments/${envApiKey}/webhooks`,
); );
return data; return data;
} }
@@ -53,7 +53,7 @@ export async function createEnvWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>( const { data } = await apiClient.post<WebhookResponse>(
`/v1/environment/${envApiKey}/webhooks`, `/v1/environments/${envApiKey}/webhooks`,
request, request,
); );
return data; return data;
@@ -65,14 +65,14 @@ export async function updateEnvWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>( const { data } = await apiClient.put<WebhookResponse>(
`/v1/environment/${envApiKey}/webhook/${webhookId}`, `/v1/environments/${envApiKey}/webhooks/${webhookId}`,
request, request,
); );
return data; return data;
} }
export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> { export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/environment/${envApiKey}/webhook/${webhookId}`); await apiClient.delete(`/v1/environments/${envApiKey}/webhooks/${webhookId}`);
} }
// ─── Delivery logs ──────────────────────────────────────────────────────────── // ─── Delivery logs ────────────────────────────────────────────────────────────
@@ -82,7 +82,7 @@ export async function listWebhookDeliveries(
webhookId: number, webhookId: number,
): Promise<WebhookDeliveryLogResponse[]> { ): Promise<WebhookDeliveryLogResponse[]> {
const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>( const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>(
`/v1/environment/${envApiKey}/webhook/${webhookId}/deliveries`, `/v1/environments/${envApiKey}/webhooks/${webhookId}/deliveries`,
); );
return data; return data;
} }

View File

@@ -95,7 +95,7 @@
<v-divider class="my-5" /> <v-divider class="my-5" />
<!-- Tags --> <!-- Tags -->
<TagsChipInput v-if="feature" :feature="feature" @updated="onTagsUpdated" /> <TagsChipInput />
</template> </template>
</v-tabs-window-item> </v-tabs-window-item>
@@ -274,22 +274,12 @@
data-testid="settings-default-enabled-toggle" data-testid="settings-default-enabled-toggle"
/> />
</div> </div>
<v-text-field
v-model="deleteConfirmName"
:label="`Type '${feature?.name}' to confirm deletion`"
variant="outlined"
density="compact"
class="mb-3"
hide-details
data-testid="delete-confirm-input"
/>
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<v-btn <v-btn
color="error" color="error"
variant="text" variant="text"
size="small" size="small"
:loading="isDeleting" :loading="isDeleting"
:disabled="deleteConfirmName !== feature?.name"
data-testid="delete-feature-btn" data-testid="delete-feature-btn"
@click="onDelete" @click="onDelete"
> >
@@ -450,8 +440,6 @@ const settingsForm = ref({
defaultEnabled: false, defaultEnabled: false,
}); });
const deleteConfirmName = ref('');
const rules = { const rules = {
required: (v: string) => !!v || 'Required', required: (v: string) => !!v || 'Required',
nameFormat: (v: string) => nameFormat: (v: string) =>
@@ -493,7 +481,6 @@ watch(
newSegmentId.value = null; newSegmentId.value = null;
newSegmentEnabled.value = true; newSegmentEnabled.value = true;
newSegmentPriority.value = 1; newSegmentPriority.value = 1;
deleteConfirmName.value = '';
syncSettingsForm(props.feature); syncSettingsForm(props.feature);
editedValue.value = props.featureState?.value ?? null; editedValue.value = props.featureState?.value ?? null;
@@ -571,10 +558,6 @@ async function onSaveSettings(): Promise<void> {
} }
} }
function onTagsUpdated(updated: FeatureResponse): void {
emit('updated', updated);
}
async function onDelete(): Promise<void> { async function onDelete(): Promise<void> {
if (!props.feature || deleteConfirmName.value !== props.feature.name) return; if (!props.feature || deleteConfirmName.value !== props.feature.name) return;
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;

View File

@@ -1,6 +1,19 @@
<template> <template>
<div data-testid="tags-chip-input"> <div data-testid="tags-chip-input">
<span class="text-body-2 font-weight-medium">Tags</span> <div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 font-weight-medium">Project Tags</span>
<v-btn
size="x-small"
variant="tonal"
color="primary"
prepend-icon="ri-add-line"
:loading="isCreating"
data-testid="create-tag-btn"
@click="showCreateForm = !showCreateForm"
>
New tag
</v-btn>
</div>
<v-alert <v-alert
v-if="errorMessage" v-if="errorMessage"
@@ -8,167 +21,114 @@
variant="tonal" variant="tonal"
density="compact" density="compact"
closable closable
class="my-2" class="mb-2"
@click:close="errorMessage = null" @click:close="errorMessage = null"
> >
{{ errorMessage }} {{ errorMessage }}
</v-alert> </v-alert>
<!-- Create form -->
<v-expand-transition>
<v-card v-if="showCreateForm" variant="outlined" rounded="lg" class="mb-3 pa-3">
<v-form ref="createFormRef" @submit.prevent="onCreateTag">
<div class="d-flex align-center ga-2">
<v-text-field
ref="tagLabelInputRef"
v-model="newTagLabel"
label="Tag name"
variant="outlined"
density="compact"
hide-details
:rules="[rules.required]"
data-testid="new-tag-label-input"
/>
<input
v-model="newTagColor"
type="color"
style="width:36px;height:36px;border:none;padding:2px;cursor:pointer;border-radius:4px"
title="Tag colour"
data-testid="new-tag-color-input"
@blur="focusTagLabel"
/>
<v-btn
icon="ri-check-line"
size="small"
color="primary"
variant="flat"
type="submit"
:loading="isCreating"
data-testid="confirm-create-tag-btn"
@click.prevent="onCreateTag"
/>
<v-btn
icon="ri-close-line"
size="small"
variant="text"
@click="showCreateForm = false"
/>
</div>
</v-form>
</v-card>
</v-expand-transition>
<!-- Tag chips -->
<div v-if="isLoading" class="d-flex justify-center py-3"> <div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" /> <v-progress-circular indeterminate size="20" width="2" color="primary" />
</div> </div>
<template v-else> <div v-else-if="tags.length > 0" class="d-flex flex-wrap ga-1" data-testid="tag-chips">
<div v-if="assignedTags.length > 0" class="d-flex flex-wrap ga-1 my-2" data-testid="tag-chips"> <v-chip
<v-chip v-for="tag in tags"
v-for="tag in assignedTags" :key="tag.id"
:key="tag.id" size="small"
size="small" closable
variant="flat" :color="tag.color"
closable :data-testid="`tag-chip-${tag.label}`"
:color="tag.color" @click:close="onDeleteTag(tag)"
:loading="togglingTagId === tag.id" >
:data-testid="`tag-chip-${tag.label}`" {{ tag.label }}
@click:close="onRemoveTag(tag)" </v-chip>
> </div>
{{ tag.label }}
</v-chip>
</div>
<p v-else class="text-body-2 text-medium-emphasis my-2" data-testid="tags-empty-message">
No tags assigned. Type a name below to add one.
</p>
<div class="d-flex align-center ga-2"> <p
<v-combobox v-else
v-model="newTagLabel" class="text-body-2 text-medium-emphasis"
:items="suggestions" data-testid="tags-empty-message"
label="Tag name" >
variant="outlined" No tags yet. Create one to organise your features.
density="compact" </p>
hide-details
hide-no-data
no-filter
data-testid="tag-name-input"
@keyup.enter="onAddTag"
/>
<button
type="button"
style="width:36px;height:36px;border:none;padding:0;cursor:pointer;border-radius:4px"
:style="{ background: newTagColor }"
title="Tag Color"
data-testid="tag-color-swatch"
@click="showColorDialog = true"
/>
<v-btn
icon="ri-add-line"
size="small"
color="primary"
variant="flat"
:disabled="!newTagLabel.trim()"
:loading="isSubmitting"
data-testid="add-tag-btn"
@click="onAddTag"
/>
</div>
</template>
<!-- Color picker dialog -->
<v-dialog v-model="showColorDialog" max-width="260" data-testid="color-dialog">
<v-card rounded="lg">
<v-card-title class="pa-4 pb-2 text-body-1">Tag Color</v-card-title>
<v-card-text class="pa-4 pt-0">
<div class="d-flex flex-wrap ga-2">
<button
v-for="color in presetColors"
:key="color"
type="button"
style="width:32px;height:32px;border:none;padding:0;cursor:pointer;border-radius:4px"
:style="{ background: color }"
:title="color"
:data-testid="`preset-color-${color}`"
@click="onSelectPresetColor(color)"
/>
<div style="position:relative;width:32px;height:32px">
<button
type="button"
class="d-flex align-center justify-center"
style="width:32px;height:32px;border:1px solid rgba(0,0,0,0.2);padding:0;cursor:pointer;border-radius:4px;background:conic-gradient(red,yellow,lime,aqua,blue,magenta,red)"
title="Custom Color"
data-testid="custom-color-trigger"
@click="customColorInputRef?.click()"
>
<v-icon size="16" color="white">ri-palette-line</v-icon>
</button>
<input
ref="customColorInputRef"
v-model="newTagColor"
type="color"
style="position:absolute;inset:0;width:32px;height:32px;opacity:0;pointer-events:none"
data-testid="custom-color-input"
@input="showColorDialog = false"
/>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, onMounted, watch, nextTick } from 'vue';
import { listTags, createTag } from '@/api/tags'; import { listTags, createTag, deleteTag } from '@/api/tags';
import { assignFeatureTag, removeFeatureTag } from '@/api/features';
import { useContextStore } from '@/stores/context'; import { useContextStore } from '@/stores/context';
import type { TagResponse, FeatureResponse } from '@/types/api'; import type { TagResponse } from '@/types/api';
const presetColors = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFC107', '#FF9800', '#795548',
];
interface Props {
feature: FeatureResponse;
}
const props = defineProps<Props>();
const emit = defineEmits<{
updated: [feature: FeatureResponse];
}>();
const contextStore = useContextStore(); const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]); const tags = ref<TagResponse[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const isSubmitting = ref(false); const isCreating = ref(false);
const togglingTagId = ref<number | null>(null); const showCreateForm = ref(false);
const newTagLabel = ref(''); const newTagLabel = ref('');
const newTagColor = ref('#1565C0'); const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const showColorDialog = ref(false); const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const customColorInputRef = ref<HTMLInputElement | null>(null); const tagLabelInputRef = ref<{ focus: () => void } | null>(null);
function onSelectPresetColor(color: string): void { function focusTagLabel(): void {
newTagColor.value = color; nextTick(() => tagLabelInputRef.value?.focus());
showColorDialog.value = false;
} }
const assignedTags = computed(() => props.feature.tags); watch(showCreateForm, (open) => {
if (open) focusTagLabel();
const suggestions = computed(() => {
const query = newTagLabel.value.trim().toLowerCase();
if (query.length < 2) return [];
const assignedIds = new Set(assignedTags.value.map((t) => t.id));
return tags.value
.filter((t) => !assignedIds.has(t.id) && t.label.toLowerCase().includes(query))
.map((t) => t.label);
}); });
function isAssigned(tag: TagResponse): boolean { const rules = {
return assignedTags.value.some((t) => t.id === tag.id); required: (v: string) => !!v || 'Tag name is required',
} };
async function fetchTags(): Promise<void> { async function fetchTags(): Promise<void> {
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;
@@ -184,46 +144,41 @@ async function fetchTags(): Promise<void> {
} }
} }
async function onAddTag(): Promise<void> { async function onCreateTag(): Promise<void> {
const label = newTagLabel.value.trim(); if (!createFormRef.value) return;
const projectId = contextStore.currentProject?.id; const { valid } = await createFormRef.value.validate();
if (!label || !projectId) return; if (!valid) return;
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
isCreating.value = true;
errorMessage.value = null; errorMessage.value = null;
isSubmitting.value = true;
try { try {
let tag = tags.value.find((t) => t.label.toLowerCase() === label.toLowerCase()); const created = await createTag(projectId, {
if (!tag) { label: newTagLabel.value.trim(),
tag = await createTag(projectId, { label, color: newTagColor.value }); color: newTagColor.value,
tags.value.push(tag); });
} tags.value.push(created);
if (!isAssigned(tag)) {
togglingTagId.value = tag.id;
const updated = await assignFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
}
newTagLabel.value = ''; newTagLabel.value = '';
newTagColor.value = '#1565C0'; newTagColor.value = '#1565C0';
showCreateForm.value = false;
} catch { } catch {
errorMessage.value = 'Failed to add tag. Please try again.'; errorMessage.value = 'Failed to create tag. Please try again.';
} finally { } finally {
isSubmitting.value = false; isCreating.value = false;
togglingTagId.value = null;
} }
} }
async function onRemoveTag(tag: TagResponse): Promise<void> { async function onDeleteTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;
if (!projectId) return; if (!projectId) return;
errorMessage.value = null; errorMessage.value = null;
togglingTagId.value = tag.id;
try { try {
const updated = await removeFeatureTag(projectId, props.feature.id, tag.id); await deleteTag(projectId, tag.id);
emit('updated', updated); tags.value = tags.value.filter((t) => t.id !== tag.id);
} catch { } catch {
errorMessage.value = 'Failed to remove tag. Please try again.'; errorMessage.value = 'Failed to delete tag. Please try again.';
} finally {
togglingTagId.value = null;
} }
} }

View File

@@ -1,164 +0,0 @@
<template>
<div data-testid="org-header-switcher">
<v-menu data-testid="org-header-menu">
<template #activator="{ props: menuProps }">
<a
href="#"
class="org-header-link text-body-2 font-weight-medium d-flex align-center"
data-testid="org-header-link"
v-bind="menuProps"
@click.prevent
>
<VIcon icon="ri-building-line" size="18" class="me-1" />
{{ contextStore.currentOrganization?.name ?? 'Select organization' }}
<VIcon icon="ri-arrow-down-s-line" size="18" />
</a>
</template>
<v-list density="compact" data-testid="org-header-list">
<v-list-item
v-for="org in organizations"
:key="org.id"
:title="org.name"
:active="org.id === contextStore.currentOrganization?.id"
:data-testid="`org-header-item-${org.id}`"
@click="onOrgSelected(org)"
/>
<v-divider v-if="organizations.length" class="my-1" />
<v-list-item
title="New Organization"
prepend-icon="ri-add-line"
data-testid="org-header-create-item"
@click="openCreateDialog"
/>
</v-list>
</v-menu>
<!-- Create Organization Dialog -->
<v-dialog v-model="showDialog" max-width="420" data-testid="create-org-dialog">
<v-card rounded="lg">
<v-card-title class="pa-5 pb-3">Create organization</v-card-title>
<v-card-text class="pa-5 pt-0">
<v-text-field
v-model="newOrgName"
label="Organization name"
variant="outlined"
density="compact"
autofocus
:error-messages="createError ? [createError] : []"
data-testid="org-name-input"
@keydown.enter="onCreateConfirm"
/>
</v-card-text>
<v-card-actions class="pa-5 pt-0">
<v-spacer />
<v-btn variant="text" data-testid="create-org-cancel-btn" @click="closeDialog">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
:disabled="!newOrgName.trim()"
data-testid="create-org-confirm-btn"
@click="onCreateConfirm"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useContextStore } from '@/stores/context';
import { listOrganizations, createOrganization, setPrimaryOrganization } from '@/api/organizations';
import type { OrganizationResponse } from '@/types/api';
const contextStore = useContextStore();
const organizations = ref<OrganizationResponse[]>([]);
const showDialog = ref(false);
const newOrgName = ref('');
const isSaving = ref(false);
const createError = ref('');
async function fetchOrganizations(): Promise<void> {
try {
organizations.value = await listOrganizations();
autoSelectOrganization();
} catch {
organizations.value = [];
}
}
function autoSelectOrganization(): void {
if (contextStore.currentOrganization) {
// Refresh stored org with latest data from API (name may have changed)
const fresh = organizations.value.find(o => o.id === contextStore.currentOrganization!.id);
if (fresh) contextStore.refreshOrganization(fresh);
return;
}
const primary = organizations.value.find(o => o.isPrimary);
if (primary) {
contextStore.setOrganization(primary);
return;
}
if (organizations.value.length === 1) {
contextStore.setOrganization(organizations.value[0]);
}
}
async function onOrgSelected(org: OrganizationResponse): Promise<void> {
contextStore.setOrganization(org);
try {
await setPrimaryOrganization(org.id);
organizations.value = organizations.value.map(o => ({ ...o, isPrimary: o.id === org.id }));
} catch {
// Non-critical — selection is already saved to localStorage
}
}
function openCreateDialog(): void {
newOrgName.value = '';
createError.value = '';
showDialog.value = true;
}
function closeDialog(): void {
showDialog.value = false;
}
async function onCreateConfirm(): Promise<void> {
const name = newOrgName.value.trim();
if (!name) return;
isSaving.value = true;
createError.value = '';
try {
const created = await createOrganization({ name });
organizations.value = [...organizations.value, created];
contextStore.setOrganization(created);
closeDialog();
} catch {
createError.value = 'Failed to create organization. Please try again.';
} finally {
isSaving.value = false;
}
}
onMounted(fetchOrganizations);
</script>
<style lang="scss" scoped>
.org-header-link {
text-decoration: none;
color: inherit;
white-space: nowrap;
}
</style>

View File

@@ -3,7 +3,6 @@ import NavItems from '@/layouts/components/NavItems.vue';
import Footer from '@/layouts/components/Footer.vue'; import Footer from '@/layouts/components/Footer.vue';
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'; import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue';
import UserProfile from '@/layouts/components/UserProfile.vue'; import UserProfile from '@/layouts/components/UserProfile.vue';
import OrgHeaderSwitcher from '@/components/nav/OrgHeaderSwitcher.vue';
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'; import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue';
</script> </script>
@@ -17,7 +16,6 @@ import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue';
<VSpacer /> <VSpacer />
<OrgHeaderSwitcher class="me-4" />
<NavbarThemeSwitcher class="me-1" /> <NavbarThemeSwitcher class="me-1" />
<UserProfile /> <UserProfile />
</div> </div>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'; import VerticalNavLink from '@layouts/components/VerticalNavLink.vue';
import VerticalNavSectionTitle from '@layouts/components/VerticalNavSectionTitle.vue'; import VerticalNavSectionTitle from '@layouts/components/VerticalNavSectionTitle.vue';
import OrgSelector from '@/components/nav/OrgSelector.vue';
import ProjectSelector from '@/components/nav/ProjectSelector.vue'; import ProjectSelector from '@/components/nav/ProjectSelector.vue';
import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue'; import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
</script> </script>
@@ -14,6 +15,7 @@ import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
<VerticalNavSectionTitle :item="{ heading: 'Context' }" /> <VerticalNavSectionTitle :item="{ heading: 'Context' }" />
<div class="nav-context-selectors px-4 d-flex flex-column ga-3"> <div class="nav-context-selectors px-4 d-flex flex-column ga-3">
<OrgSelector />
<ProjectSelector /> <ProjectSelector />
<EnvironmentTabBar /> <EnvironmentTabBar />
</div> </div>

View File

@@ -4,7 +4,6 @@ import VueApexCharts from 'vue3-apexcharts';
import App from './App.vue'; import App from './App.vue';
// Styles // Styles
import '@fontsource-variable/inter';
import '@core/scss/template/index.scss'; import '@core/scss/template/index.scss';
import '@layouts/styles/index.scss'; import '@layouts/styles/index.scss';
import router from './router'; import router from './router';

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory, RouteLocationNormalized, RouteRecordRaw } from 'vue-router'; import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import { getAccessToken } from '@/api/client'; import { getAccessToken } from '@/api/client';
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
@@ -102,7 +102,7 @@ const router = createRouter({
// ─── Auth Guard ─────────────────────────────────────────────────────────────── // ─── Auth Guard ───────────────────────────────────────────────────────────────
export function authGuard(to: RouteLocationNormalized) { router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken(); const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const isPublic = to.matched.some((record) => record.meta.public); const isPublic = to.matched.some((record) => record.meta.public);
@@ -116,8 +116,6 @@ export function authGuard(to: RouteLocationNormalized) {
} }
return true; return true;
} });
router.beforeEach(authGuard);
export default router; export default router;

View File

@@ -215,7 +215,6 @@ export interface FeatureResponse {
defaultEnabled: boolean; defaultEnabled: boolean;
projectId: number; projectId: number;
createdAt: string; createdAt: string;
tags: TagResponse[];
} }
export interface CreateFeatureRequest { export interface CreateFeatureRequest {

View File

@@ -53,7 +53,7 @@
<!-- Name + description --> <!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }"> <template #item.name="{ item }: { item: FeatureResponse }">
<div> <div>
<div class="d-flex flex-wrap align-center ga-1"> <div class="d-flex align-center ga-1">
<span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span> <span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span>
<v-tooltip <v-tooltip
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0" v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0"
@@ -72,21 +72,6 @@
/> />
</template> </template>
</v-tooltip> </v-tooltip>
<v-chip
v-for="tag in (item.tags ?? []).slice(0, 5)"
:key="tag.id"
size="x-small"
variant="flat"
:color="tag.color"
:data-testid="`feature-tag-chip-${item.id}-${tag.id}`"
>
{{ tag.label }}
</v-chip>
<span
v-if="(item.tags ?? []).length > 5"
class="text-caption text-medium-emphasis"
:title="`${(item.tags ?? []).length - 5} more`"
></span>
</div> </div>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0"> <p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }} {{ item.description }}

View File

@@ -1,274 +1,51 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'; import { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
import apiClient, { setAuthTokens, clearAuthTokens, getAccessToken } from '@/api/client';
import type { TokenResponse } from '@/types/api';
// The response interceptor drives its retry/refresh flow entirely through the // The interceptor internals rely on axios, which is hard to unit-test without
// apiClient axios instance, so we replace its transport (`adapter`) with a // a real HTTP stack. These tests focus on the exported token-management helpers
// fake one we control. This exercises the real interceptor chain end-to-end // that the interceptors and auth store both depend on.
// (request auth header, 401 detection, refresh, retry, queueing) rather than
// just the token-storage helpers. The refresh call itself goes through the
// raw `axios.post` (not the apiClient instance, to avoid interceptor
// recursion), so that's mocked separately via jest.spyOn.
function unauthorizedError(config: InternalAxiosRequestConfig): AxiosError {
return new AxiosError(
'Request failed with status code 401',
'ERR_BAD_REQUEST',
config,
undefined,
{ status: 401, statusText: 'Unauthorized', data: {}, headers: {}, config },
);
}
function serverError(config: InternalAxiosRequestConfig): AxiosError {
return new AxiosError(
'Request failed with status code 500',
'ERR_BAD_RESPONSE',
config,
undefined,
{ status: 500, statusText: 'Internal Server Error', data: {}, headers: {}, config },
);
}
const tokenResponse: TokenResponse = {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: '2026-01-01T01:00:00Z',
};
describe('ApiClient', () => {
let originalLocation: Location;
describe('ApiClient token management', () => {
beforeEach(() => { beforeEach(() => {
clearAuthTokens(); clearAuthTokens();
localStorage.clear(); localStorage.clear();
jest.restoreAllMocks();
originalLocation = window.location;
// jsdom's window.location.href setter throws "not implemented" - stub it
// out so the redirect-on-refresh-failure path can be asserted on.
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
}); });
afterEach(() => { afterEach(() => {
clearAuthTokens(); clearAuthTokens();
window.location = originalLocation;
}); });
describe('Token management', () => { describe('WhenTokensAreSet', () => {
describe('WhenTokensAreSet', () => { it('ThenGetAccessTokenReturnsTheAccessToken', () => {
it('ThenGetAccessTokenReturnsTheAccessToken', () => { setAuthTokens('access-abc', 'refresh-xyz');
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('Request interceptor', () => { describe('WhenTokensAreCleared', () => {
describe('WhenAnAccessTokenIsSet', () => { it('ThenGetAccessTokenReturnsNull', () => {
it('ThenTheRequestCarriesABearerAuthorizationHeader', async () => { setAuthTokens('access-abc', 'refresh-xyz');
setAuthTokens('access-abc', 'refresh-xyz'); clearAuthTokens();
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => ({
data: {}, status: 200, statusText: 'OK', headers: {}, config,
}));
apiClient.defaults.adapter = adapter;
await apiClient.get('/v1/whatever'); expect(getAccessToken()).toBeNull();
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('Response interceptor - 401 refresh flow', () => { describe('WhenSetAuthTokensIsCalledWithNull', () => {
describe('WhenARequestFailsWith401AndARefreshTokenExists', () => { it('ThenGetAccessTokenReturnsNull', () => {
it('ThenTheSessionIsRefreshedAndTheOriginalRequestIsRetried', async () => { setAuthTokens(null, null);
setAuthTokens('expired-access-token', 'valid-refresh-token');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => { expect(getAccessToken()).toBeNull();
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('WhenTheRefreshRequestItselfFails', () => { describe('WhenTokensAreUpdated', () => {
it('ThenTokensAreClearedAndTheUserIsRedirectedToLogin', async () => { it('ThenGetAccessTokenReturnsTheLatestToken', () => {
setAuthTokens('expired-access-token', 'invalid-refresh-token'); setAuthTokens('first-token', 'first-refresh');
localStorage.setItem('mic_access_token', 'expired-access-token'); setAuthTokens('second-token', 'second-refresh');
localStorage.setItem('mic_refresh_token', 'invalid-refresh-token');
localStorage.setItem('mic_expires_at', '2026-01-01T00:00:00Z');
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => { expect(getAccessToken()).toBe('second-token');
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

@@ -1,13 +1,12 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises, DOMWrapper, type VueWrapper } from '@vue/test-utils'; import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia'; import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import TagsChipInput from '@/components/features/TagsChipInput.vue'; import TagsChipInput from '@/components/features/TagsChipInput.vue';
import { useContextStore } from '@/stores/context'; import { useContextStore } from '@/stores/context';
import type { ProjectResponse, TagResponse, FeatureResponse } from '@/types/api'; import type { ProjectResponse, TagResponse } from '@/types/api';
jest.mock('@/api/tags'); jest.mock('@/api/tags');
jest.mock('@/api/features');
jest.mock('@/api/client', () => ({ jest.mock('@/api/client', () => ({
setAuthTokens: jest.fn(), setAuthTokens: jest.fn(),
clearAuthTokens: jest.fn(), clearAuthTokens: jest.fn(),
@@ -15,25 +14,18 @@ jest.mock('@/api/client', () => ({
})); }));
import * as tagsApi from '@/api/tags'; import * as tagsApi from '@/api/tags';
import * as featuresApi from '@/api/features';
const mockProject: ProjectResponse = { const mockProject: ProjectResponse = {
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z', id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
}; };
const backendTag: TagResponse = { id: 1, label: 'backend', color: '#ff0000', projectId: 10 }; const mockTags: TagResponse[] = [
const frontendTag: TagResponse = { id: 2, label: 'frontend', color: '#0000ff', projectId: 10 }; { id: 1, label: 'backend', color: '#ff0000', projectId: 10 },
const mockTags: TagResponse[] = [backendTag, frontendTag]; { id: 2, label: 'frontend', color: '#0000ff', projectId: 10 },
];
function mockFeature(tags: TagResponse[]): FeatureResponse { function mountComponent() {
return {
id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: null,
defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags,
};
}
function mountComponent(feature: FeatureResponse) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] }); const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] });
return mount(TagsChipInput, { props: { feature }, global: { plugins: [router] } }); return mount(TagsChipInput, { global: { plugins: [router] } });
} }
describe('TagsChipInput', () => { describe('TagsChipInput', () => {
@@ -48,173 +40,100 @@ describe('TagsChipInput', () => {
describe('WhenComponentMounts', () => { describe('WhenComponentMounts', () => {
it('ThenTagsAreFetchedFromApi', async () => { it('ThenTagsAreFetchedFromApi', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
mountComponent(mockFeature([])); mountComponent();
await flushPromises(); await flushPromises();
expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id); expect(tagsApi.listTags).toHaveBeenCalledWith(mockProject.id);
}); });
it('ThenAssignedTagChipsAreRendered', async () => { it('ThenExistingTagChipsAreRendered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([backendTag])); const wrapper = mountComponent();
await flushPromises(); await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true); expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(false); expect(wrapper.find('[data-testid="tag-chip-frontend"]').exists()).toBe(true);
}); });
it('ThenEmptyMessageIsShownWhenNoTagsAssigned', async () => { it('ThenEmptyMessageIsShownWhenNoTagsExist', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue([]);
const wrapper = mountComponent(mockFeature([])); const wrapper = mountComponent();
await flushPromises(); await flushPromises();
expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true); expect(wrapper.find('[data-testid="tags-empty-message"]').exists()).toBe(true);
}); });
}); });
describe('WhenAddingANewTagName', () => { describe('WhenCreateTagIsSubmitted', () => {
it('ThenCreateTagAndAssignAreBothCalled', async () => { it('ThenCreateTagApiIsCalled', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 }; const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
const updatedFeature = mockFeature([newTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag); jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([])); const wrapper = mountComponent();
await flushPromises(); await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>; // Open the create form
await input.vm.$emit('update:modelValue', 'api'); await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click'); await wrapper.vm.$nextTick();
// Fill in the label
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
// Submit
await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(tagsApi.createTag).toHaveBeenCalledWith(mockProject.id, expect.objectContaining({ label: 'api' })); expect(tagsApi.createTag).toHaveBeenCalledWith(
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, newTag.id); mockProject.id,
expect.objectContaining({ label: 'api' }),
);
}); });
it('ThenUpdatedEventIsEmitted', async () => { it('ThenNewTagAppearsInTheList', async () => {
const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 }; const newTag: TagResponse = { id: 3, label: 'api', color: '#00ff00', projectId: 10 };
const updatedFeature = mockFeature([newTag]); jest.mocked(tagsApi.listTags).mockResolvedValue([]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.createTag).mockResolvedValue(newTag); jest.mocked(tagsApi.createTag).mockResolvedValue(newTag);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([])); const wrapper = mountComponent();
await flushPromises(); await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>; await wrapper.find('[data-testid="create-tag-btn"]').trigger('click');
await input.vm.$emit('update:modelValue', 'api');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await flushPromises();
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]);
});
});
describe('WhenTypedNameMatchesAnExistingTag', () => {
it('ThenExistingTagIsAssignedInsteadOfCreatingANewOne', async () => {
const updatedFeature = mockFeature([backendTag]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(featuresApi.assignFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'backend');
await wrapper.find('[data-testid="add-tag-btn"]').trigger('click');
await flushPromises();
expect(tagsApi.createTag).not.toHaveBeenCalled();
expect(featuresApi.assignFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id);
});
});
describe('WhenTypingTwoOrMoreCharacters', () => {
it('ThenMatchingUnassignedTagsAreSuggested', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>;
await input.vm.$emit('update:modelValue', 'ba');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="new-tag-label-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'api');
expect(input.props('items')).toEqual(['backend']); await wrapper.find('[data-testid="confirm-create-tag-btn"]').trigger('click');
});
it('ThenNoSuggestionsAreShownBelowTwoCharacters', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises(); await flushPromises();
const input = wrapper.findComponent('[data-testid="tag-name-input"]') as VueWrapper<any>; expect(wrapper.find('[data-testid="tag-chip-api"]').exists()).toBe(true);
await input.vm.$emit('update:modelValue', 'b');
await wrapper.vm.$nextTick();
expect(input.props('items')).toEqual([]);
}); });
}); });
describe('WhenChoosingATagColour', () => { describe('WhenTagChipIsDeleted', () => {
it('ThenSwatchClickOpensTheColourDialog', async () => { it('ThenDeleteTagApiIsCalled', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags); jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([])); jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
await flushPromises();
expect(wrapper.find('[data-testid="color-dialog"]').exists()).toBe(false); const wrapper = mountComponent();
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
await flushPromises();
expect(wrapper.findComponent({ name: 'VDialog' }).props('modelValue')).toBe(true);
});
it('ThenFifteenPresetColoursAreOffered', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
await flushPromises();
const presets = new DOMWrapper(document.body).findAll('[data-testid^="preset-color-"]');
const distinctColors = new Set(presets.map((p) => p.attributes('data-testid')));
expect(distinctColors.size).toBe(15);
});
it('ThenSelectingAPresetUpdatesTheSwatchAndClosesTheDialog', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
const wrapper = mountComponent(mockFeature([]));
await flushPromises();
await wrapper.find('[data-testid="tag-color-swatch"]').trigger('click');
await flushPromises();
const presets = new DOMWrapper(document.body).findAll('[data-testid="preset-color-#4CAF50"]');
await presets[presets.length - 1].trigger('click');
await wrapper.vm.$nextTick();
await flushPromises();
expect(wrapper.find('[data-testid="tag-color-swatch"]').attributes('style')).toContain('background: rgb(76, 175, 80)');
});
});
describe('WhenTagChipIsClosed', () => {
it('ThenRemoveFeatureTagApiIsCalled', async () => {
const updatedFeature = mockFeature([]);
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(featuresApi.removeFeatureTag).mockResolvedValue(updatedFeature);
const wrapper = mountComponent(mockFeature([backendTag]));
await flushPromises(); await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>; const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close'); await chip.vm.$emit('click:close');
await flushPromises(); await flushPromises();
expect(featuresApi.removeFeatureTag).toHaveBeenCalledWith(mockProject.id, 1, backendTag.id); expect(tagsApi.deleteTag).toHaveBeenCalledWith(mockProject.id, mockTags[0].id);
expect(wrapper.emitted('updated')?.[0]).toEqual([updatedFeature]); });
it('ThenDeletedTagIsRemovedFromTheList', async () => {
jest.mocked(tagsApi.listTags).mockResolvedValue(mockTags);
jest.mocked(tagsApi.deleteTag).mockResolvedValue(undefined);
const wrapper = mountComponent();
await flushPromises();
const chip = wrapper.findComponent('[data-testid="tag-chip-backend"]') as VueWrapper<any>;
await chip.vm.$emit('click:close');
await flushPromises();
expect(wrapper.find('[data-testid="tag-chip-backend"]').exists()).toBe(false);
}); });
}); });
}); });

View File

@@ -1,94 +0,0 @@
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

@@ -1,216 +0,0 @@
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,8 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import { setActivePinia, createPinia } from 'pinia'; import { setActivePinia, createPinia } from 'pinia';
import { clearAuthTokens } from '@/api/client'; import { setAuthTokens, clearAuthTokens } from '@/api/client';
import { authGuard } from '@/router';
// Minimal stub components for route testing // Minimal stub components for route testing
const Stub = { template: '<div />' }; const Stub = { template: '<div />' };
@@ -16,17 +15,11 @@ jest.mock('@/api/client', () => ({
import { getAccessToken } from '@/api/client'; import { getAccessToken } from '@/api/client';
function buildRouter() { function buildRouter() {
const router = createRouter({ return createRouter({
history: createMemoryHistory(), history: createMemoryHistory(),
routes: [ routes: [
{ path: '/login', name: 'Login', component: Stub, meta: { public: true } }, { path: '/login', name: 'Login', component: Stub, meta: { public: true } },
{ path: '/register', name: 'Register', component: Stub, meta: { public: true } }, { path: '/register', name: 'Register', component: Stub, meta: { public: true } },
{
path: '/accept-invite/:token',
name: 'AcceptInvite',
component: Stub,
meta: { public: true, allowAuthenticated: true },
},
{ {
path: '/', path: '/',
component: Stub, component: Stub,
@@ -38,9 +31,6 @@ function buildRouter() {
}, },
], ],
}); });
// Wire up the real guard exported by the app router, not a re-implementation.
router.beforeEach(authGuard);
return router;
} }
describe('Router auth guard', () => { describe('Router auth guard', () => {
@@ -54,10 +44,21 @@ describe('Router auth guard', () => {
}); });
describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => { describe('WhenUnauthenticatedUserNavigatesToProtectedRoute', () => {
it('ThenTheyAreRedirectedToLoginWithARedirectQuery', async () => { it('ThenTheyAreRedirectedToLogin', async () => {
jest.mocked(getAccessToken).mockReturnValue(null); jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter(); const router = buildRouter();
// Wire up the same guard logic as the real router
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
await router.push('/features'); await router.push('/features');
expect(router.currentRoute.value.name).toBe('Login'); expect(router.currentRoute.value.name).toBe('Login');
@@ -70,6 +71,17 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token'); jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter(); const router = buildRouter();
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
// Start authenticated on dashboard, then try to go to login
await router.push('/'); await router.push('/');
await router.push('/login'); await router.push('/login');
@@ -82,55 +94,19 @@ describe('Router auth guard', () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token'); jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter(); const router = buildRouter();
router.beforeEach((to) => {
const isAuthenticated = !!getAccessToken();
const requiresAuth = to.matched.some((r) => r.meta.requiresAuth);
const isPublic = to.matched.some((r) => r.meta.public);
if (requiresAuth && !isAuthenticated) return { name: 'Login', query: { redirect: to.fullPath } };
if (isPublic && isAuthenticated) return { name: 'Dashboard' };
return true;
});
await router.push('/features'); await router.push('/features');
expect(router.currentRoute.value.name).toBe('Features'); expect(router.currentRoute.value.name).toBe('Features');
}); });
}); });
describe('WhenUnauthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
it('ThenNavigationSucceeds', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter();
await router.push('/accept-invite/some-token');
expect(router.currentRoute.value.name).toBe('AcceptInvite');
});
});
describe('WhenAuthenticatedUserNavigatesToAnAllowAuthenticatedRoute', () => {
it('ThenTheyAreNotRedirectedToDashboard', async () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
await router.push('/accept-invite/some-token');
expect(router.currentRoute.value.name).toBe('AcceptInvite');
});
});
describe('WhenAuthenticatedUserNavigatesToRegister', () => {
it('ThenTheyAreRedirectedToDashboardBecauseRegisterHasNoAllowAuthenticatedException', async () => {
jest.mocked(getAccessToken).mockReturnValue('valid-token');
const router = buildRouter();
await router.push('/');
await router.push('/register');
expect(router.currentRoute.value.name).toBe('Dashboard');
});
});
describe('WhenUnauthenticatedUserNavigatesToAProtectedRouteWithAQueryString', () => {
it('ThenTheFullPathIncludingTheQueryIsPreservedInTheRedirect', async () => {
jest.mocked(getAccessToken).mockReturnValue(null);
const router = buildRouter();
await router.push('/features?tag=beta');
expect(router.currentRoute.value.name).toBe('Login');
expect(router.currentRoute.value.query.redirect).toBe('/features?tag=beta');
});
});
}); });

View File

@@ -1,113 +0,0 @@
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

@@ -1,52 +0,0 @@
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

@@ -1,84 +0,0 @@
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

@@ -4,7 +4,7 @@ import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import FeaturesView from '@/views/FeaturesView.vue'; import FeaturesView from '@/views/FeaturesView.vue';
import { useContextStore } from '@/stores/context'; import { useContextStore } from '@/stores/context';
import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse, TagResponse } from '@/types/api'; import type { ProjectResponse, EnvironmentResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
jest.mock('@/api/features'); jest.mock('@/api/features');
jest.mock('@/api/featureStates'); jest.mock('@/api/featureStates');
@@ -25,13 +25,9 @@ const mockEnv: EnvironmentResponse = {
id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z', id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z',
}; };
const mockFeatures: FeatureResponse[] = [ const mockFeatures: FeatureResponse[] = [
{ id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] }, { id: 1, name: 'dark_mode', type: 'STANDARD', initialValue: null, description: 'Dark mode toggle', defaultEnabled: false, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
{ id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z', tags: [] }, { id: 2, name: 'new_checkout', type: 'STANDARD', initialValue: null, description: null, defaultEnabled: true, projectId: 10, createdAt: '2026-01-01T00:00:00Z' },
]; ];
function makeTag(id: number, label: string): TagResponse {
return { id, label, color: '#1565C0', projectId: 10, createdAt: '2026-01-01T00:00:00Z' };
}
const mockStates: FeatureStateResponse[] = [ const mockStates: FeatureStateResponse[] = [
{ id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null, enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }, { id: 101, featureId: 1, environmentId: 100, identityId: null, featureSegmentId: null, enabled: false, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
{ id: 102, featureId: 2, environmentId: 100, identityId: null, featureSegmentId: null, enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }, { id: 102, featureId: 2, environmentId: 100, identityId: null, featureSegmentId: null, enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
@@ -89,43 +85,6 @@ describe('FeaturesView', () => {
}); });
}); });
describe('WhenFeatureHasTags', () => {
it('ThenUpToFiveTagChipsAreShownAndExtraAreEllipsised', async () => {
const sixTags = [1, 2, 3, 4, 5, 6].map((id) => makeTag(id, `tag-${id}`));
const taggedFeature: FeatureResponse = { ...mockFeatures[0], tags: sixTags };
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 1, next: null, previous: null, results: [taggedFeature] });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
const chips = sixTags.slice(0, 5).map((tag) => wrapper.find(`[data-testid="feature-tag-chip-${taggedFeature.id}-${tag.id}"]`));
chips.forEach((chip) => expect(chip.exists()).toBe(true));
expect(wrapper.find(`[data-testid="feature-tag-chip-${taggedFeature.id}-6"]`).exists()).toBe(false);
expect(wrapper.text()).toContain('…');
});
it('ThenMissingTagsDoesNotThrow', async () => {
const untaggedFeature = { ...mockFeatures[0], tags: undefined } as unknown as FeatureResponse;
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 1, next: null, previous: null, results: [untaggedFeature] });
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="features-table"]').exists()).toBe(true);
expect(wrapper.text()).toContain(untaggedFeature.name);
});
});
describe('WhenToggleEnabledIsClicked', () => { describe('WhenToggleEnabledIsClicked', () => {
it('ThenPatchFeatureStateIsCalled', async () => { it('ThenPatchFeatureStateIsCalled', async () => {
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures }); jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });

View File

@@ -1,129 +0,0 @@
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

@@ -1,155 +0,0 @@
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 { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { mount, flushPromises } from '@vue/test-utils'; import { mount, flushPromises, type VueWrapper } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia'; import { setActivePinia, createPinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router'; import { createRouter, createMemoryHistory } from 'vue-router';
import SegmentsView from '@/views/SegmentsView.vue'; import SegmentsView from '@/views/SegmentsView.vue';
@@ -129,8 +129,7 @@ describe('SegmentsView', () => {
const wrapper = mountView(); const wrapper = mountView();
await flushPromises(); await flushPromises();
const row = wrapper.findAll('tbody tr').find((r) => r.text().includes('beta_users')); await wrapper.find('[data-testid="edit-segment-1"]').trigger('click');
await row!.trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const editor = wrapper.findComponent({ name: 'SegmentEditor' }); const editor = wrapper.findComponent({ name: 'SegmentEditor' });
@@ -155,6 +154,13 @@ describe('SegmentsView', () => {
expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true); expect(wrapper.find('[data-testid="delete-confirm-dialog"]').exists()).toBe(true);
// Confirm button should be disabled before typing the name
expect(wrapper.find('[data-testid="confirm-delete-btn"]').attributes('disabled')).toBeDefined();
// Type segment name to enable button
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
@@ -175,6 +181,9 @@ describe('SegmentsView', () => {
await wrapper.find('[data-testid="delete-segment-1"]').trigger('click'); await wrapper.find('[data-testid="delete-segment-1"]').trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'beta_users');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click'); await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();

View File

@@ -102,10 +102,9 @@ describe('SettingsView', () => {
}); });
describe('WhenInviteMemberIsClicked', () => { describe('WhenInviteMemberIsClicked', () => {
it('ThenInviteOrganizationMembersByEmailIsCalledWithEmail', async () => { it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
jest.mocked(orgsApi.inviteOrganizationMembersByEmail).mockResolvedValue([ jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
{ email: 'jane@example.com', success: true, error: null }, jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]);
]);
const contextStore = useContextStore(); const contextStore = useContextStore();
contextStore.setOrganization(mockOrg); contextStore.setOrganization(mockOrg);
@@ -113,19 +112,13 @@ describe('SettingsView', () => {
const wrapper = mountView(); const wrapper = mountView();
await flushPromises(); await flushPromises();
await wrapper.find('[data-testid="add-member-btn"]').trigger('click'); await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', '42');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="add-member-email-0"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'jane@example.com'); await wrapper.find('[data-testid="invite-member-btn"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-add-members-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(orgsApi.inviteOrganizationMembersByEmail).toHaveBeenCalledWith( expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' });
mockOrg.id,
{ invites: [{ email: 'jane@example.com', role: 'User' }] },
);
}); });
}); });
@@ -143,8 +136,6 @@ describe('SettingsView', () => {
expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true); expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true);
await wrapper.find('[data-testid="remove-member-5"]').trigger('click'); await wrapper.find('[data-testid="remove-member-5"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5); expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5);
@@ -270,8 +261,6 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="remove-permission-5"]').trigger('click'); await wrapper.find('[data-testid="remove-permission-5"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5); expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5);
@@ -354,6 +343,9 @@ describe('SettingsView', () => {
await wrapper.find('[data-testid="delete-env-100"]').trigger('click'); await wrapper.find('[data-testid="delete-env-100"]').trigger('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await (wrapper.findComponent('[data-testid="delete-env-confirm-input"]') as VueWrapper<any>).vm.$emit('update:modelValue', 'Development');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click'); await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click');
await flushPromises(); await flushPromises();
@@ -479,8 +471,6 @@ describe('SettingsView', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click'); await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click');
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
await flushPromises(); await flushPromises();
expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1); expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1);

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public record AuditLog public class AuditLog
{ {
public int Id { get; init; } public int Id { get; init; }
public required string ResourceType { get; init; } public required string ResourceType { get; init; }

View File

@@ -1,30 +1,33 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(MicCheckDbContext db)
{ {
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default) public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default) public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default) public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct) private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value); query = query.Where(l => l.CreatedAt >= filter.From.Value);
@@ -60,6 +63,6 @@ public class AuditLogQueryService(IMicCheckDbContext db)
user != null ? user.FirstName + " " + user.LastName : null)) user != null ? user.FirstName + " " + user.LastName : null))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<AuditLogResponse>(total, items); return (total, items);
} }
} }

View File

@@ -12,40 +12,48 @@ namespace MicCheck.Api.Audit;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1")]
public class AuditLogsController( public class AuditLogsController(
AuditLogQueryService auditLogQueryService, AuditLogQueryService auditLogQueryService,
OrganizationService organizationService, OrganizationService organizationService,
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("organisation/{id}/audit-logs")] [HttpGet("api/v1/organisations/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(int id, [FromQuery] AuditLogFilter filter, CancellationToken ct) public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound(); if (org is null) return NotFound();
var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
} }
[HttpGet("project/{projectId}/audit-logs")] [HttpGet("api/v1/projects/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(int projectId, [FromQuery] AuditLogFilter filter, CancellationToken ct) public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(projectId, ct); var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound(); if (project is null) return NotFound();
var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
} }
[HttpGet("environment/{apiKey}/audit-logs")] [HttpGet("api/v1/environments/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(string apiKey, [FromQuery] AuditLogFilter filter, CancellationToken ct) public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey,
[FromQuery] AuditLogFilter filter,
CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
} }
} }

View File

@@ -4,7 +4,7 @@ using MicCheck.Api.Webhooks;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) : IAuditService public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -12,7 +12,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
PropertyNamingPolicy = JsonNamingPolicy.CamelCase PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}; };
public async Task RecordAsync( public virtual async Task RecordAsync(
string resourceType, string resourceType,
string resourceId, string resourceId,
string action, string action,
@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions); }, JsonOptions);
} }
var actorUserId = GetCurrentUserId(); var actorUserId = ResolveActorUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -68,7 +68,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
} }
// Backward-compatible overload used by existing callers // Backward-compatible overload used by existing callers
public async Task LogAsync( public virtual async Task LogAsync(
string resourceType, string resourceType,
string resourceId, string resourceId,
string action, string action,
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
string? changes = null, string? changes = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var actorUserId = GetCurrentUserId(); var actorUserId = ResolveActorUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -112,33 +112,9 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}); });
} }
private int? GetCurrentUserId() private int? ResolveActorUserId()
{ {
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null; return int.TryParse(claim, out var id) ? id : null;
} }
} }
public interface IAuditService
{
Task RecordAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
object? before = null,
object? after = null,
CancellationToken ct = default);
Task LogAsync(
string resourceType,
string resourceId,
string action,
int organizationId,
int? projectId = null,
int? environmentId = null,
string? changes = null,
CancellationToken ct = default);
}

View File

@@ -1,12 +0,0 @@
namespace MicCheck.Api.Audit;
public static class DependencyRegistration
{
public static IServiceCollection AddAuditServices(this IServiceCollection services)
{
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<AuditLogQueryService>();
return services;
}
}

View File

@@ -1,65 +0,0 @@
using System.Text;
using MicCheck.Api.Common.Security.ApiKeys;
using MicCheck.Api.Common.Security.Authentication;
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common.Validation;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Common;
public static class DependencyRegistration
{
public static IServiceCollection AddCommonServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:SecretKey"]!))
};
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<AuthService>();
services.AddScoped<ApiKeyService>();
services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context => ValidationProblemResponseFactory.Create(context.ModelState);
});
return services;
}
}

View File

@@ -1,40 +0,0 @@
namespace MicCheck.Api.Common;
public static class Guard
{
public static void Null<T>(T t, string parameterName) where T : class
{
if (t is null)
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
}
public static void Empty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
{
if (collection == null || !collection.Any())
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Negative(int value, string parameterName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
}
public static void NegativeOrZero(int value, string parameterName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
}
public static void Default<T>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}

View File

@@ -1,3 +0,0 @@
namespace MicCheck.Api.Common;
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);

View File

@@ -1,3 +1,8 @@
namespace MicCheck.Api.Common; namespace MicCheck.Api.Common;
public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results); public record PaginatedResponse<T>(
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);

View File

@@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKey public class ApiKey
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Key { get; init; } public required string Key { get; set; }
public required string Prefix { get; init; } public required string Prefix { get; set; }
public required string Name { get; init; } public required string Name { get; set; }
public int OrganizationId { get; init; } public int OrganizationId { get; init; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }

View File

@@ -1,34 +1,33 @@
using System.Diagnostics.CodeAnalysis;
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via ApiKeyService unit tests.")]
public static class ApiKeyEndpoints public static class ApiKeyEndpoints
{ {
public static void MapApiKeyEndpoints(this WebApplication app) public static void MapApiKeyEndpoints(this WebApplication app)
{ {
var group = app var group = app
.MapGroup("/api/v1/organisation/{organizationId:int}") .MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin) .RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
.WithTags("ApiKeys"); .WithTags("ApiKeys");
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => group.MapPost("/", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct); var (key, rawKey) = await apiKeyService.CreateAsync(
organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt)); return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
}).WithName("CreateApiKey"); }).WithName("CreateApiKey");
group.MapGet("/api-keys", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) => group.MapGet("/", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var keys = await apiKeyService.ListAsync(organizationId, ct); var keys = await apiKeyService.ListAsync(organizationId, ct);
return Results.Ok(keys.Select(k => new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt))); return Results.Ok(keys.Select(k => new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
}).WithName("ListApiKeys"); }).WithName("ListApiKeys");
group.MapDelete("/api-key/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) => group.MapDelete("/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
await apiKeyService.RevokeAsync(organizationId, keyId, ct); await apiKeyService.RevokeAsync(organizationId, keyId, ct);
return Results.NoContent(); return Results.NoContent();

View File

@@ -1,3 +1,9 @@
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public record ApiKeyResponse(int Id, string Name, string Prefix, bool IsActive, DateTimeOffset? ExpiresAt, DateTimeOffset CreatedAt); public record ApiKeyResponse(
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);

View File

@@ -3,9 +3,9 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.ApiKeys; namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKeyService(IMicCheckDbContext db) public class ApiKeyService(MicCheckDbContext db)
{ {
public async Task<ApiKeyCreationResult> CreateAsync( public async Task<(ApiKey Key, string RawKey)> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{ {
var rawKey = ApiKeyHasher.GenerateKey(); var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(IMicCheckDbContext db)
db.ApiKeys.Add(apiKey); db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return new ApiKeyCreationResult(apiKey, rawKey); return (apiKey, rawKey);
} }
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,5 +48,3 @@ public class ApiKeyService(IMicCheckDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
public record ApiKeyCreationResult(ApiKey Key, string RawKey);

View File

@@ -8,7 +8,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db) public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "ApiKey"; public const string SchemeName = "ApiKey";

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace MicCheck.Api.Common.Security.Authentication; namespace MicCheck.Api.Common.Security.Authentication;
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IMicCheckDbContext db) public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
public const string SchemeName = "EnvironmentKey"; public const string SchemeName = "EnvironmentKey";

View File

@@ -1,9 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
[ExcludeFromCodeCoverage(Justification = "Minimal-API route registration; requires a live HTTP pipeline to exercise, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). Branch logic is covered via AuthService unit tests.")]
public static class AuthEndpoints public static class AuthEndpoints
{ {
public static void MapAuthEndpoints(this WebApplication app) public static void MapAuthEndpoints(this WebApplication app)

View File

@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class AuthService( public class AuthService(
IMicCheckDbContext db, MicCheckDbContext db,
ITokenService tokenService, ITokenService tokenService,
IPasswordHasher<User> passwordHasher) IPasswordHasher<User> passwordHasher)
{ {
@@ -81,9 +81,7 @@ public class AuthService(
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var organizationUsers = await db.OrganizationUsers.Where(ou => ou.UserId == user.Id).ToListAsync(ct); await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
foreach (var organizationUser in organizationUsers)
user.Organizations.Add(organizationUser);
var accessToken = tokenService.GenerateToken(user); var accessToken = tokenService.GenerateToken(user);
var refreshTokenValue = GenerateSecureToken(); var refreshTokenValue = GenerateSecureToken();

View File

@@ -2,4 +2,9 @@ using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement; public class ProjectPermissionRequirement : IAuthorizationRequirement
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirementHandler( public class ProjectPermissionRequirementHandler(
IMicCheckDbContext db, MicCheckDbContext db,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ProjectPermissionRequirement> : AuthorizationHandler<ProjectPermissionRequirement>
{ {

View File

@@ -1,13 +0,0 @@
namespace MicCheck.Api.Common.Validation;
public interface IModelValidator
{
ValidationResult Validate(object model);
}
public interface IModelValidator<in T> : IModelValidator
{
ValidationResult Validate(T model);
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
}

View File

@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace MicCheck.Api.Common.Validation;
public class ModelValidationActionFilter : IActionFilter
{
public void OnActionExecuting(ActionExecutingContext context)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null) continue;
var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType());
if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue;
var result = validator.Validate(argument);
foreach (var error in result.Errors)
context.ModelState.AddModelError(error.PropertyName, error.Message);
}
if (!context.ModelState.IsValid)
context.Result = ValidationProblemResponseFactory.Create(context.ModelState);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}

View File

@@ -1,20 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace MicCheck.Api.Common.Validation;
public static class ModelValidatorServiceCollectionExtensions
{
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
{
var registrations = typeof(TMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && !type.IsInterface)
.SelectMany(type => type.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
.Select(i => (Interface: i, Implementation: type)));
foreach (var (@interface, implementation) in registrations)
services.AddScoped(@interface, implementation);
return services;
}
}

Some files were not shown because too many files have changed in this diff Show More