Compare commits
11 commits
2e140cd8d8
...
a95903ed05
| Author | SHA1 | Date | |
|---|---|---|---|
| a95903ed05 | |||
| efb932903b | |||
| 4241f5ba29 | |||
| 102e5db507 | |||
| dc02728af9 | |||
| 3ba8646091 | |||
| d737c07f42 | |||
| c15c13fd38 | |||
| d457c766c3 | |||
| 22d9ad5136 | |||
| 89f448bd42 |
15 changed files with 577 additions and 222 deletions
|
|
@ -8,57 +8,76 @@ Read `ai/shared.md` first. This file covers backend-specific rules.
|
||||||
|
|
||||||
**Location:** `backend/`.
|
**Location:** `backend/`.
|
||||||
|
|
||||||
The application is still close to the Laravel starter structure. Do not
|
The backend follows a domain-oriented structure. Existing areas use entities,
|
||||||
introduce a domain architecture, repository layer, service layer, or other
|
DTOs, repository interfaces, Eloquent implementations, use cases, and test
|
||||||
abstraction before the codebase and requested behavior justify it. Match
|
fakes. Extend those patterns when adding behavior to an established area. Do
|
||||||
existing Laravel conventions and inspect similar code before adding a new
|
not add speculative layers or interfaces that the requested behavior does not
|
||||||
pattern.
|
need.
|
||||||
|
|
||||||
## Laravel patterns
|
The Vue application remains a separate project under `frontend/website/`.
|
||||||
|
Keep frontend source, dependencies, builds, and delivery out of the backend
|
||||||
|
unless the user explicitly asks to integrate them. The backend root route is
|
||||||
|
intentionally unclaimed; the built-in health endpoint is `/up`.
|
||||||
|
|
||||||
- Keep controllers thin. Put reusable business behavior in an appropriately
|
## Code patterns
|
||||||
named application or domain class once the behavior warrants extraction.
|
|
||||||
- Use dedicated request validation rather than validating substantial payloads
|
- Inspect similar domain code before adding a new entity, DTO, repository,
|
||||||
inline in controllers.
|
use case, controller action, or fake.
|
||||||
- Let unexpected exceptions reach Laravel's exception handler. Catch only
|
- Entities own domain state and behavior and expose descriptive methods.
|
||||||
exceptions that can be handled meaningfully at the current boundary.
|
- DTOs are explicit data containers for creation or transfer between seams.
|
||||||
|
- Repository interfaces define domain-facing persistence operations. Keep
|
||||||
|
Eloquent details in their implementations and register bindings in the
|
||||||
|
application provider.
|
||||||
|
- Put reusable business behavior in a use case. Give a use case a request DTO
|
||||||
|
when it accepts a payload; call `execute()` directly when it has no input.
|
||||||
|
- Document use-case exceptions with `@throws` when callers are expected to
|
||||||
|
handle them.
|
||||||
|
- Keep controllers thin. Translate HTTP input to use-case input and domain
|
||||||
|
output to the response contract.
|
||||||
|
- Catch only documented, expected exceptions at the controller boundary. Let
|
||||||
|
unexpected exceptions reach Laravel's exception handler.
|
||||||
|
- Fake repositories are in-memory implementations used by unit tests. Return
|
||||||
|
a new entity instance from create and lookup methods rather than exposing a
|
||||||
|
stored reference.
|
||||||
- Use Eloquent relationships and query scopes consistently rather than
|
- Use Eloquent relationships and query scopes consistently rather than
|
||||||
duplicating query fragments.
|
duplicating query fragments.
|
||||||
- Avoid speculative interfaces and abstractions with only one trivial
|
|
||||||
implementation.
|
|
||||||
- The Vue application is a separate project under `frontend/website/`.
|
|
||||||
- Keep frontend source, dependencies, builds, and delivery out of the backend
|
|
||||||
unless the user explicitly asks to integrate them.
|
|
||||||
- The backend root route is intentionally unclaimed. The built-in health
|
|
||||||
endpoint is `/up`.
|
|
||||||
|
|
||||||
## Tests
|
## Unit tests
|
||||||
|
|
||||||
- Follow the existing PHPUnit organization under `tests/Unit/` and
|
- Follow the existing organization under `tests/Unit/<Area>/`.
|
||||||
`tests/Feature/`.
|
- Use fake repositories and fake collaborators for the behavior under test.
|
||||||
- Prefer `PHPUnit\Framework\TestCase` when a test only exercises plain PHP.
|
Construct unrelated dependency entities directly instead of routing them
|
||||||
- Extend `Tests\TestCase` only when the test needs Laravel's container,
|
through additional repositories.
|
||||||
facades, database, routing, or HTTP kernel.
|
- Test use-case branches at the use-case seam. Do not repeat every branch in
|
||||||
- HTTP feature tests extend `Tests\TestCase`.
|
controller or HTTP tests.
|
||||||
- Use `RefreshDatabase` when a test reads or writes database state.
|
- Plain entities, value objects, use cases, middleware, and controller units
|
||||||
- Assert behavior at the appropriate seam:
|
should extend `PHPUnit\Framework\TestCase` when they do not need Laravel.
|
||||||
- Unit tests cover isolated business behavior and edge cases.
|
- Extend `Tests\TestCase` only when a test needs Laravel's container, facades,
|
||||||
- Feature tests cover routing, middleware, validation, persistence, and
|
database, routing, or HTTP kernel.
|
||||||
response shape.
|
- Reserve direct Eloquent repository feature tests for persistence mapping,
|
||||||
- Do not duplicate every business branch through the HTTP layer when unit
|
query behavior, or database constraints that cannot be proven through a
|
||||||
coverage already proves it. Feature tests should focus on wiring and the
|
plain unit test.
|
||||||
public contract.
|
|
||||||
|
|
||||||
## Test database
|
## Feature tests
|
||||||
|
|
||||||
|
- Feature tests exercise the HTTP seam: routing, middleware, real Eloquent
|
||||||
|
bindings, cookies, persistence, validation, and response shape.
|
||||||
|
- Feature tests are additive to unit tests. Prefer a happy path and the
|
||||||
|
relevant authorization guard instead of duplicating all business branches.
|
||||||
|
- Place endpoint tests under `tests/Feature/<Area>/`, extend
|
||||||
|
`Tests\TestCase`, and use `RefreshDatabase` when database state is involved.
|
||||||
- Development and runtime use PostgreSQL through the local Unix socket.
|
- Development and runtime use PostgreSQL through the local Unix socket.
|
||||||
- PHPUnit intentionally uses SQLite `:memory:` as configured in
|
PHPUnit uses SQLite `:memory:` as configured in `phpunit.xml`, so feature
|
||||||
`phpunit.xml`.
|
tests are self-contained and do not need the worktree stack.
|
||||||
- Feature tests are self-contained and do not require the process-compose
|
|
||||||
PostgreSQL service.
|
|
||||||
- Never point `RefreshDatabase` tests at the development PostgreSQL database.
|
- Never point `RefreshDatabase` tests at the development PostgreSQL database.
|
||||||
- Keep mail set to the PHPUnit `array` transport unless a test explicitly
|
- Keep mail on PHPUnit's `array` transport unless a test explicitly exercises
|
||||||
exercises a real mail integration.
|
a real mail integration.
|
||||||
|
- Authentication uses a custom database-session cookie, not a Laravel guard.
|
||||||
|
`actingAs()` does not apply. Credentialed requests use
|
||||||
|
`withCredentials()` and `withUnencryptedCookie()` because API middleware
|
||||||
|
reads the raw cookie.
|
||||||
|
- Read an unencrypted response cookie with
|
||||||
|
`$response->getCookie($name, false)`.
|
||||||
|
|
||||||
## PHP rules
|
## PHP rules
|
||||||
|
|
||||||
|
|
@ -80,7 +99,7 @@ pattern.
|
||||||
- Do not add production repository or model APIs solely to make seeding
|
- Do not add production repository or model APIs solely to make seeding
|
||||||
convenient.
|
convenient.
|
||||||
- Use existing lookup methods for cross-seeder relationships. When a group of
|
- Use existing lookup methods for cross-seeder relationships. When a group of
|
||||||
records only makes sense together, keep them in one seeder and retain local
|
records only makes sense together, keep it in one seeder and retain local
|
||||||
references.
|
references.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
@ -99,15 +118,12 @@ pattern.
|
||||||
- Once a production database exists, replace this policy with additive,
|
- Once a production database exists, replace this policy with additive,
|
||||||
forward-only migrations.
|
forward-only migrations.
|
||||||
|
|
||||||
## Before completing backend work
|
## Backend workflow
|
||||||
|
|
||||||
- Run the focused test during development.
|
- Run the focused PHPUnit test while developing.
|
||||||
- Run the full test suite before completion:
|
- Use `just backend-types-check` for Larastan and `just backend-test` for the
|
||||||
|
full PHPUnit suite during iteration.
|
||||||
```sh
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
|
|
||||||
```
|
|
||||||
|
|
||||||
- Run the Composer static-analysis scripts.
|
|
||||||
- Fix failures caused by the change. Report unrelated baseline failures
|
- Fix failures caused by the change. Report unrelated baseline failures
|
||||||
precisely rather than hiding them or expanding scope without authorization.
|
precisely rather than expanding scope silently.
|
||||||
|
- The shared `just test-all` command is the required completion gate. Focused
|
||||||
|
backend recipes never replace it.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ Read `ai/shared.md` first. This file covers frontend-specific rules.
|
||||||
|
|
||||||
## Project context
|
## Project context
|
||||||
|
|
||||||
**Stack:** Vue 3.5, TypeScript 6, Vite 8, Vue Router 5, Pinia 4, npm.
|
**Stack:** Vue 3.5, TypeScript 6, Vite 8, Vue Router 5, Pinia 4, Zod 4,
|
||||||
|
Cypress 15, npm.
|
||||||
|
|
||||||
**Location:** `frontend/website/`.
|
**Location:** `frontend/website/`.
|
||||||
|
|
||||||
|
|
@ -12,18 +13,16 @@ The frontend is a standalone application. Keep its source, dependencies,
|
||||||
development server, and production build independent from the backend unless
|
development server, and production build independent from the backend unless
|
||||||
the user explicitly requests integration.
|
the user explicitly requests integration.
|
||||||
|
|
||||||
The scaffold uses:
|
The application currently has route-level views, reusable authentication
|
||||||
|
components, a Pinia authentication store, Zod schemas, API URL handling, and
|
||||||
|
Cypress end-to-end specs. The main entry points are:
|
||||||
|
|
||||||
- `src/App.vue` as the root component.
|
- `src/App.vue` for the root component.
|
||||||
- `src/main.ts` to create the app and install the router and Pinia.
|
- `src/main.ts` for app creation, Pinia, the router, and global styles.
|
||||||
- `src/router/index.ts` for routes.
|
- `src/router/index.ts` for routes and authentication guards.
|
||||||
- `src/stores/` for Pinia stores.
|
- `src/stores/` for Pinia stores and API boundaries.
|
||||||
- `@` as an alias for `src/` in both Vite and TypeScript.
|
- `src/views/` and `src/components/` for route and reusable UI.
|
||||||
|
- `@` as the `src/` alias in Vite and TypeScript.
|
||||||
There are no views, shared components, API layer, or configured test suite
|
|
||||||
yet. Cypress is installed as a frontend development dependency, but there is
|
|
||||||
no Cypress configuration or npm test script. Do not invent an architecture
|
|
||||||
before requested behavior establishes one.
|
|
||||||
|
|
||||||
## Package management and commands
|
## Package management and commands
|
||||||
|
|
||||||
|
|
@ -36,32 +35,14 @@ Install dependencies in a fresh checkout or worktree:
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
To start only the development server on the port assigned by the shell hook:
|
`npm run format` and `npm run lint` rewrite files. Their `format:check` and
|
||||||
|
`lint:check` counterparts are non-mutating completion checks. `npm run build`
|
||||||
|
runs type checking and the production build; generated `dist/` output is
|
||||||
|
ignored.
|
||||||
|
|
||||||
```sh
|
`process-compose` starts the frontend as part of the complete development
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" \
|
stack. The frontend is directly accessible on `VITE_PORT`; Caddy serves the
|
||||||
npm run dev -- \
|
backend and does not proxy the frontend.
|
||||||
--host 127.0.0.1 \
|
|
||||||
--port "$VITE_PORT" \
|
|
||||||
--strictPort
|
|
||||||
```
|
|
||||||
|
|
||||||
`process-compose` starts the frontend as part of the full development stack.
|
|
||||||
The frontend remains directly accessible on `VITE_PORT`; Caddy does not proxy
|
|
||||||
it.
|
|
||||||
|
|
||||||
The available validation commands are:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run format
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
`npm run format` and `npm run lint` rewrite files. Review the resulting diff.
|
|
||||||
`npm run build` runs type checking and the production build. Build output
|
|
||||||
under `dist/` is generated and ignored.
|
|
||||||
|
|
||||||
## Vue conventions
|
## Vue conventions
|
||||||
|
|
||||||
|
|
@ -72,58 +53,69 @@ under `dist/` is generated and ignored.
|
||||||
`src/router/index.ts`.
|
`src/router/index.ts`.
|
||||||
- Keep page, component, composable, and store responsibilities distinct.
|
- Keep page, component, composable, and store responsibilities distinct.
|
||||||
- Prefer small components with explicit props and emitted events.
|
- Prefer small components with explicit props and emitted events.
|
||||||
- Keep component styles scoped until the project adopts a deliberate global
|
|
||||||
styling system.
|
|
||||||
- Use setup-style Pinia stores named `useXxxStore`.
|
- Use setup-style Pinia stores named `useXxxStore`.
|
||||||
- Inspect similar files before introducing a new component, composable,
|
- Keep application-wide resets and base styles in `src/styles/main.css`.
|
||||||
store, or data-access pattern.
|
Keep component and view styles scoped and match established Attainly visual
|
||||||
|
patterns.
|
||||||
|
- Inspect similar files before introducing a component, composable, store,
|
||||||
|
route, or data-access pattern.
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript and runtime validation
|
||||||
|
|
||||||
- Preserve the strict TypeScript configuration and
|
- Preserve strict TypeScript and `noUncheckedIndexedAccess`.
|
||||||
`noUncheckedIndexedAccess`.
|
- Do not use `any`. Model unknown external values as `unknown`, then parse or
|
||||||
- Do not use `any`. Model unknown external values as `unknown`, then narrow or
|
narrow them.
|
||||||
validate them.
|
- Zod schemas are the source of truth for runtime payloads. Define a schema
|
||||||
- Derive types from runtime schemas if the project adopts a schema library.
|
and derive its TypeScript type with `z.infer` instead of maintaining a
|
||||||
Do not maintain a hand-written type that can drift from its schema.
|
parallel hand-written interface.
|
||||||
- Validate payloads at trust boundaries, especially backend responses and
|
- Parse every backend response at the store or API boundary before placing it
|
||||||
user-submitted forms.
|
in application state.
|
||||||
- Keep request and response types close to the API or store boundary that
|
- Keep read and write schemas separate when their shapes differ.
|
||||||
owns them.
|
- Validate user-submitted forms with a Zod object schema when the form has
|
||||||
- Keep the `@` alias aligned across Vite, TypeScript, and any future test
|
meaningful validation rules. Surface field errors from the schema rather
|
||||||
configuration.
|
than maintaining parallel regular expressions and error logic.
|
||||||
|
- Keep request and response schemas and types near the API or store boundary
|
||||||
|
that owns them.
|
||||||
|
- Keep the `@` alias aligned across Vite, TypeScript, and future test tooling.
|
||||||
|
|
||||||
## State and API access
|
## State and API access
|
||||||
|
|
||||||
- Use Pinia for shared client state. Keep component-local state in components.
|
- Use Pinia for shared client state. Keep component-local state in components.
|
||||||
- Keep server requests and response transformation at an API or store
|
- Keep requests, response parsing, and response transformation at an API or
|
||||||
boundary, not scattered through presentation components.
|
store boundary, not in presentation components.
|
||||||
- Represent loading, empty, success, validation-error, and unexpected-error
|
- Represent loading, empty, success, validation-error, and unexpected-error
|
||||||
states explicitly.
|
states explicitly.
|
||||||
- Do not cast unchecked JSON directly to an application interface.
|
- Do not cast unchecked JSON to an application interface.
|
||||||
- Keep read and write payload types separate when their shapes differ.
|
- Send cookie-backed API requests with the established credentials behavior.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Cypress is installed, but no frontend test configuration or test script
|
- Cypress is configured under `cypress/` and runs through `npm run test:e2e`
|
||||||
exists yet.
|
or `just frontend-cypress-run`.
|
||||||
|
- Prefer the cheapest test seam that proves the behavior. Cypress covers
|
||||||
|
routing, browser forms, authentication flows, request wiring, and responsive
|
||||||
|
behavior.
|
||||||
|
- Mock backend calls in frontend-focused Cypress tests. Use the worktree
|
||||||
|
backend only for a deliberately end-to-end integration scenario.
|
||||||
|
- Assert both the request contract and the rendered response behavior when a
|
||||||
|
spec intercepts an API call.
|
||||||
|
- Keep mock payloads synchronized with exported store types and Zod schemas.
|
||||||
|
Add typed builders when payloads repeat across specs, and parse builder
|
||||||
|
output through the exported schema when practical.
|
||||||
|
- Authentication requests are mockable like every other frontend boundary.
|
||||||
|
Backend persistence, cookie creation, middleware, and mail behavior belong
|
||||||
|
in PHPUnit tests.
|
||||||
|
- No Vitest unit or component suite is configured. Do not claim that coverage.
|
||||||
|
If new pure logic or component behavior cannot be proved economically with
|
||||||
|
Cypress, establish the smallest appropriate Vitest setup test-first.
|
||||||
|
|
||||||
- Do not invent test commands or claim frontend tests passed.
|
## Frontend workflow
|
||||||
- New frontend behavior must still follow the shared test-first workflow.
|
|
||||||
Establish the smallest appropriate test setup before implementing behavior
|
|
||||||
that needs it.
|
|
||||||
- Unit tests should cover pure transformations, composables, and store logic.
|
|
||||||
- Component tests should cover rendering, events, form behavior, and
|
|
||||||
conditional UI.
|
|
||||||
- End-to-end tests should cover routing, multi-page flows, and request wiring.
|
|
||||||
- Prefer the cheapest layer that proves the behavior.
|
|
||||||
- Mock backend requests in frontend tests. Do not retest backend persistence,
|
|
||||||
validation, authentication, or mail behavior through the frontend.
|
|
||||||
|
|
||||||
## Before completing frontend work
|
- Run the focused Cypress spec while developing when browser behavior changes.
|
||||||
|
- Run `npm run format` and `npm run lint` before committing frontend changes,
|
||||||
- Run the focused test while developing once test tooling exists.
|
then review every rewrite.
|
||||||
- Run the formatter, linter, type checker, production build, and every
|
- Use the focused `just frontend-*` recipes for development feedback.
|
||||||
configured test script affected by the change.
|
- The shared `just test-all` command is the required completion gate. Focused
|
||||||
- Do not claim a green gate when a command fails. Report a baseline or
|
frontend checks never replace it.
|
||||||
environmental failure precisely.
|
- Do not claim a green gate when a command fails. Report baseline or
|
||||||
|
environmental failures precisely.
|
||||||
|
|
|
||||||
157
ai/shared.md
157
ai/shared.md
|
|
@ -39,6 +39,24 @@ Use judgment for changes that cannot meaningfully be test-driven, such as
|
||||||
documentation-only edits or declarative environment configuration. Validate
|
documentation-only edits or declarative environment configuration. Validate
|
||||||
those changes with the most relevant parser, formatter, dry run, or check.
|
those changes with the most relevant parser, formatter, dry run, or check.
|
||||||
|
|
||||||
|
## Maintaining these instructions
|
||||||
|
|
||||||
|
- Treat user steering as durable when it corrects or establishes a reusable
|
||||||
|
project rule for workflow, architecture, conventions, validation, safety,
|
||||||
|
or scope.
|
||||||
|
- When durable steering is received during work, update the appropriate
|
||||||
|
`ai/*.md` file in the active worktree before completing the task. Do not
|
||||||
|
wait for a separate request to maintain the instructions.
|
||||||
|
- Put repository-wide rules in `shared.md` and stack-specific rules in the
|
||||||
|
matching backend or frontend guide.
|
||||||
|
- Merge new guidance into the existing rule set. Keep it concise, remove
|
||||||
|
duplication, and resolve conflicts between shared and stack-specific text.
|
||||||
|
- Do not persist task-specific scope, temporary directions, secrets,
|
||||||
|
environment incidents, or instructions that conflict with higher-priority
|
||||||
|
guidance.
|
||||||
|
- Commit a durable instruction update separately from implementation unless
|
||||||
|
the task itself is solely an instruction change.
|
||||||
|
|
||||||
## Approval discipline
|
## Approval discipline
|
||||||
|
|
||||||
- Treat dependency installation, tests, static analysis, formatting, linting,
|
- Treat dependency installation, tests, static analysis, formatting, linting,
|
||||||
|
|
@ -64,58 +82,42 @@ those changes with the most relevant parser, formatter, dry run, or check.
|
||||||
- A worktree owns its own isolated stack. The flake shell hook assigns a
|
- A worktree owns its own isolated stack. The flake shell hook assigns a
|
||||||
deterministic port offset and creates worktree-local PostgreSQL state.
|
deterministic port offset and creates worktree-local PostgreSQL state.
|
||||||
- Start a worktree stack only when runtime or integration validation requires
|
- Start a worktree stack only when runtime or integration validation requires
|
||||||
it. Start it once from the worktree root, reuse it throughout validation,
|
it. Start it once, reuse it throughout validation, and stop it once when
|
||||||
and stop it once when finished.
|
finished.
|
||||||
- Do not start any service for PHPUnit, frontend formatting, linting, type
|
- PHPUnit, frontend formatting, linting, type checking, and production builds
|
||||||
checking, or production builds.
|
do not require services. The Cypress completion suite does require the
|
||||||
- When authorized worktree stack control is necessary, operate it directly.
|
worktree stack.
|
||||||
Do not ask the user to start or stop worktree services.
|
- When worktree stack control is necessary, operate it directly. Do not ask
|
||||||
- For non-interactive use, start the stack detached and stop it when finished,
|
the user to start or stop worktree services.
|
||||||
as shown below.
|
- Never use `process-compose -t=false` for a detached stack. It can leave an
|
||||||
- Do not use `process-compose -t=false` for a detached stack. It can leave an
|
|
||||||
orphaned PostgreSQL process holding the data directory.
|
orphaned PostgreSQL process holding the data directory.
|
||||||
- Non-interactive agent shells do not automatically load direnv. Bare project
|
- Non-interactive agent shells do not automatically load direnv. Bare project
|
||||||
commands can use missing tools, default ports, or paths from the main
|
commands can use missing tools, default ports, or paths from the main
|
||||||
checkout.
|
checkout.
|
||||||
- Run project tooling that depends on the repository development environment
|
- Run PHP, Composer, Artisan, npm, tests, builds, database clients, and
|
||||||
through direnv. This includes PHP, Composer, Artisan, npm, tests, builds,
|
services through the development environment.
|
||||||
database clients, and services.
|
- Resolve the direnv target from the worktree containing the current working
|
||||||
- Resolve the direnv target from the worktree containing the agent's current
|
directory. Never target the main checkout or a different worktree:
|
||||||
working directory. Never target the main checkout or a different worktree:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" <command>
|
direnv exec "$(git rev-parse --show-toplevel)" <command>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Git and environment-neutral read-only file inspection do not need the
|
- Git and environment-neutral read-only inspection do not need direnv.
|
||||||
direnv wrapper.
|
- Start and stop a worktree stack from its root:
|
||||||
- Worktree stack examples:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D
|
direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
|
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
- Run backend commands from `backend/`, or explicitly change into it in the
|
- Run backend commands from `backend/` and frontend commands from
|
||||||
command. The direnv target remains the worktree root.
|
`frontend/website/`. The direnv target remains the worktree root.
|
||||||
- Run frontend commands from `frontend/website/`.
|
|
||||||
- `process-compose` starts the frontend on the worktree's assigned port. To
|
|
||||||
start only the frontend:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" \
|
|
||||||
npm run dev -- \
|
|
||||||
--host 127.0.0.1 \
|
|
||||||
--port "$VITE_PORT" \
|
|
||||||
--strictPort
|
|
||||||
```
|
|
||||||
|
|
||||||
- When a normally valid check fails because a required service is down,
|
- When a normally valid check fails because a required service is down,
|
||||||
surface the environmental failure. Do not skip the check or silently switch
|
surface the environmental failure. Do not skip the check, change databases,
|
||||||
to a different database or service.
|
or claim the work is complete.
|
||||||
- PHPUnit is self-contained and uses in-memory SQLite. It does not require the
|
- PHPUnit is self-contained and uses in-memory SQLite. It does not require the
|
||||||
PostgreSQL stack unless a future test is explicitly designed as a real
|
PostgreSQL stack unless a future test explicitly targets real PostgreSQL.
|
||||||
PostgreSQL integration test.
|
|
||||||
|
|
||||||
## Code style
|
## Code style
|
||||||
|
|
||||||
|
|
@ -155,14 +157,13 @@ those changes with the most relevant parser, formatter, dry run, or check.
|
||||||
- Do not include drive-by formatter or linter changes. Restore them or land
|
- Do not include drive-by formatter or linter changes. Restore them or land
|
||||||
them as a separate formatting commit.
|
them as a separate formatting commit.
|
||||||
- If a check fails on untouched code, do not bundle an unrelated fix. Report
|
- If a check fails on untouched code, do not bundle an unrelated fix. Report
|
||||||
the pre-existing failure or handle it as its own explicitly scoped change.
|
the baseline failure or handle it as its own explicitly scoped change.
|
||||||
|
|
||||||
## Branching
|
## Branching
|
||||||
|
|
||||||
- Never implement features directly in the main checkout or on
|
- Never implement features directly in the main checkout or on
|
||||||
`master`/`main`.
|
`master`/`main`.
|
||||||
- Use a dedicated worktree under
|
- Use a dedicated worktree under `<repo-root>/.worktrees/<branch>`.
|
||||||
`<repo-root>/.worktrees/<branch>`.
|
|
||||||
- Create it with:
|
- Create it with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -183,10 +184,10 @@ those changes with the most relevant parser, formatter, dry run, or check.
|
||||||
```
|
```
|
||||||
|
|
||||||
- Never symlink `backend/vendor` or `frontend/website/node_modules` from
|
- Never symlink `backend/vendor` or `frontend/website/node_modules` from
|
||||||
another checkout. Dependency paths and generated files must remain
|
another checkout. Dependencies and generated files must remain
|
||||||
worktree-local.
|
worktree-local.
|
||||||
- The shell hook installs backend dependencies but does not install frontend
|
- The shell hook installs backend dependencies but not frontend dependencies.
|
||||||
dependencies. From `frontend/website/`, provision them with:
|
Install frontend dependencies from `frontend/website/`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
||||||
|
|
@ -196,59 +197,35 @@ Do not push anything. Make commits as the TDD workflow requires.
|
||||||
|
|
||||||
## Before completing a change
|
## Before completing a change
|
||||||
|
|
||||||
Run the smallest relevant checks while iterating, then run every repository
|
A change is not complete until the worktree stack is ready and the unified
|
||||||
gate affected by the change.
|
gate passes against that worktree:
|
||||||
|
|
||||||
### Backend
|
1. Start the stack detached from the worktree root:
|
||||||
|
|
||||||
- Run tests from `backend/`:
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" \
|
||||||
|
process-compose up -D
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
2. Poll `process-compose process list` until every process is running and
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
|
ready.
|
||||||
```
|
3. Run the complete gate from the worktree root:
|
||||||
|
|
||||||
- Run the Composer checks defined by `backend/composer.json`:
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" just test-all
|
||||||
|
```
|
||||||
|
|
||||||
```sh
|
4. Do not hand-assemble a substitute from focused commands. `test-all` runs
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" composer types:check
|
frontend format and lint checks, frontend type checking, Larastan, the
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" composer test
|
production build, PHPUnit, and Cypress in fail-fast order.
|
||||||
```
|
5. Everything must pass before completion. Report exact baseline or
|
||||||
|
environmental failures rather than hiding them.
|
||||||
|
6. If the stack was started only for validation, stop it when finished:
|
||||||
|
|
||||||
- Do not claim a green gate when a command fails. If the failure predates the
|
```sh
|
||||||
change, report the precise baseline failure.
|
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
|
||||||
|
```
|
||||||
|
|
||||||
### Frontend
|
Focused `just` recipes are for iteration only. For Nix or shell-hook changes,
|
||||||
|
also run `nix fmt` and `nix flake check`. For service configuration changes,
|
||||||
- Run these commands from `frontend/website/`:
|
also run `process-compose --dry-run`.
|
||||||
|
|
||||||
```sh
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run format
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
- The formatter and linters rewrite files. Review their changes before
|
|
||||||
committing.
|
|
||||||
- No frontend test runner is configured yet. Do not claim unit, component, or
|
|
||||||
end-to-end test coverage until the relevant scripts exist and pass.
|
|
||||||
|
|
||||||
### Environment and integration
|
|
||||||
|
|
||||||
- For Nix or shell-hook changes, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" nix fmt
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" nix flake check
|
|
||||||
```
|
|
||||||
|
|
||||||
- For service configuration changes, run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" \
|
|
||||||
process-compose --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
- When a change affects runtime wiring, start the worktree's stack and verify
|
|
||||||
the relevant endpoint or service against that worktree.
|
|
||||||
- If you started the stack only for validation, stop it before finishing.
|
|
||||||
|
|
|
||||||
17
backend/app/Auth/UseCases/Logout/Logout.php
Normal file
17
backend/app/Auth/UseCases/Logout/Logout.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth\UseCases\Logout;
|
||||||
|
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
|
||||||
|
class Logout
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SessionRepository $sessionRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function execute(string $token): void
|
||||||
|
{
|
||||||
|
$this->sessionRepository->deleteByToken($token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||||
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
||||||
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
|
||||||
use App\Auth\UseCases\CreateSession\CreateSession;
|
use App\Auth\UseCases\CreateSession\CreateSession;
|
||||||
|
use App\Auth\UseCases\Logout\Logout;
|
||||||
use App\Exceptions\BadRequestException;
|
use App\Exceptions\BadRequestException;
|
||||||
use App\Exceptions\UnauthorizedException;
|
use App\Exceptions\UnauthorizedException;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
|
@ -19,6 +20,7 @@ class AuthController extends Controller
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private AuthenticateUser $authenticateUser,
|
private AuthenticateUser $authenticateUser,
|
||||||
private CreateSession $createSession,
|
private CreateSession $createSession,
|
||||||
|
private Logout $logout,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
|
|
@ -71,6 +73,28 @@ class AuthController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = $request->cookie(AuthMiddleware::COOKIE_NAME);
|
||||||
|
if (is_string($token) && $token !== '') {
|
||||||
|
$this->logout->execute($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new JsonResponse(null, 204);
|
||||||
|
|
||||||
|
return $response->withCookie(Cookie::create(
|
||||||
|
name: AuthMiddleware::COOKIE_NAME,
|
||||||
|
value: '',
|
||||||
|
expire: 1,
|
||||||
|
path: '/',
|
||||||
|
domain: null,
|
||||||
|
secure: false,
|
||||||
|
httpOnly: true,
|
||||||
|
raw: false,
|
||||||
|
sameSite: Cookie::SAMESITE_LAX,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{id: int, email: string}
|
* @return array{id: int, email: string}
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,5 @@ Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
Route::middleware(AuthMiddleware::class)->group(function (): void {
|
||||||
Route::get('/me', [AuthController::class, 'me']);
|
Route::get('/me', [AuthController::class, 'me']);
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
58
backend/tests/Feature/Auth/LogoutEndpointTest.php
Normal file
58
backend/tests/Feature/Auth/LogoutEndpointTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LogoutEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_logout_deletes_session_and_clears_cookie(): void
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable(
|
||||||
|
'2026-07-31T12:00:00',
|
||||||
|
new DateTimeZone('UTC'),
|
||||||
|
);
|
||||||
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
));
|
||||||
|
app(SessionRepository::class)->create(new CreateSessionDto(
|
||||||
|
token: 'session-token',
|
||||||
|
user: $user,
|
||||||
|
createdAt: $now,
|
||||||
|
expiresAt: $now->modify('+7 days'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$response = $this->withCredentials()
|
||||||
|
->withUnencryptedCookie(
|
||||||
|
AuthMiddleware::COOKIE_NAME,
|
||||||
|
'session-token',
|
||||||
|
)->postJson('/api/logout');
|
||||||
|
|
||||||
|
$response->assertNoContent();
|
||||||
|
$response->assertCookieExpired(AuthMiddleware::COOKIE_NAME);
|
||||||
|
$this->assertNull(
|
||||||
|
app(SessionRepository::class)->findByToken('session-token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_logout_rejects_a_request_without_a_cookie(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/logout');
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(401)
|
||||||
|
->assertExactJson(['error' => 'unauthenticated']);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/tests/Unit/Auth/UseCases/LogoutTest.php
Normal file
58
backend/tests/Unit/Auth/UseCases/LogoutTest.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth\UseCases;
|
||||||
|
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\UseCases\Logout\Logout;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tests\Fakes\FakeSessionRepository;
|
||||||
|
|
||||||
|
class LogoutTest extends TestCase
|
||||||
|
{
|
||||||
|
private FakeSessionRepository $sessionRepository;
|
||||||
|
|
||||||
|
private Logout $useCase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->sessionRepository = new FakeSessionRepository;
|
||||||
|
$this->useCase = new Logout($this->sessionRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_existing_token_session_is_removed(): void
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable(
|
||||||
|
'2026-07-31T12:00:00',
|
||||||
|
new DateTimeZone('UTC'),
|
||||||
|
);
|
||||||
|
$this->sessionRepository->create(new CreateSessionDto(
|
||||||
|
token: 'session-token',
|
||||||
|
user: new User(
|
||||||
|
id: 7,
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
passwordHash: 'hashed-password',
|
||||||
|
),
|
||||||
|
createdAt: $now,
|
||||||
|
expiresAt: $now->modify('+7 days'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->useCase->execute('session-token');
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
$this->sessionRepository->findByToken('session-token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_token_does_not_throw(): void
|
||||||
|
{
|
||||||
|
$this->useCase->execute('unknown-token');
|
||||||
|
|
||||||
|
$this->assertNull(
|
||||||
|
$this->sessionRepository->findByToken('unknown-token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ namespace Tests\Unit\Http\Controllers;
|
||||||
|
|
||||||
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
|
||||||
use App\Auth\UseCases\CreateSession\CreateSession;
|
use App\Auth\UseCases\CreateSession\CreateSession;
|
||||||
|
use App\Auth\UseCases\Logout\Logout;
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
@ -45,9 +46,11 @@ class AuthControllerTest extends TestCase
|
||||||
new DateTimeZone('UTC'),
|
new DateTimeZone('UTC'),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
$logout = new Logout($this->sessionRepository);
|
||||||
$this->controller = new AuthController(
|
$this->controller = new AuthController(
|
||||||
$authenticateUser,
|
$authenticateUser,
|
||||||
$createSession,
|
$createSession,
|
||||||
|
$logout,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +120,37 @@ class AuthControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_logout_deletes_session_and_clears_cookie(): void
|
||||||
|
{
|
||||||
|
$this->createUser('correct-password');
|
||||||
|
$this->controller->login(new Request([
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
'password' => 'correct-password',
|
||||||
|
]));
|
||||||
|
$request = new Request;
|
||||||
|
$request->cookies->set(
|
||||||
|
AuthMiddleware::COOKIE_NAME,
|
||||||
|
'session-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->controller->logout($request);
|
||||||
|
|
||||||
|
$this->assertSame(204, $response->getStatusCode());
|
||||||
|
$this->assertNull(
|
||||||
|
$this->sessionRepository->findByToken('session-token'),
|
||||||
|
);
|
||||||
|
$cookies = $response->headers->getCookies();
|
||||||
|
$this->assertCount(1, $cookies);
|
||||||
|
$this->assertSame(
|
||||||
|
AuthMiddleware::COOKIE_NAME,
|
||||||
|
$cookies[0]->getName(),
|
||||||
|
);
|
||||||
|
$this->assertSame('', $cookies[0]->getValue());
|
||||||
|
$this->assertSame(1, $cookies[0]->getExpiresTime());
|
||||||
|
$this->assertTrue($cookies[0]->isHttpOnly());
|
||||||
|
$this->assertSame('lax', $cookies[0]->getSameSite());
|
||||||
|
}
|
||||||
|
|
||||||
private function createUser(string $password): void
|
private function createUser(string $password): void
|
||||||
{
|
{
|
||||||
$this->userRepository->create(new CreateUserDto(
|
$this->userRepository->create(new CreateUserDto(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,40 @@ const authenticatedUser = {
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function interceptLogoutFlow(): void {
|
||||||
|
let authenticated = true
|
||||||
|
|
||||||
|
cy.intercept('GET', '**/api/me', (request) => {
|
||||||
|
if (authenticated) {
|
||||||
|
request.alias = 'me'
|
||||||
|
request.reply({
|
||||||
|
statusCode: 200,
|
||||||
|
body: { user: authenticatedUser },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request.alias = 'loggedOutMe'
|
||||||
|
request.reply({
|
||||||
|
statusCode: 401,
|
||||||
|
body: { error: 'unauthenticated' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
cy.intercept('POST', '**/api/logout', (request) => {
|
||||||
|
expect(request.headers.accept).to.equal('application/json')
|
||||||
|
authenticated = false
|
||||||
|
request.reply({ statusCode: 204 })
|
||||||
|
}).as('logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitDashboardAndLogout(): void {
|
||||||
|
cy.visit('/dashboard')
|
||||||
|
cy.wait('@me')
|
||||||
|
cy.contains('button', 'Log out').click()
|
||||||
|
cy.wait('@logout')
|
||||||
|
cy.wait('@loggedOutMe')
|
||||||
|
}
|
||||||
|
|
||||||
describe('session authentication', () => {
|
describe('session authentication', () => {
|
||||||
it('restores an authenticated session on a protected route', () => {
|
it('restores an authenticated session on a protected route', () => {
|
||||||
cy.intercept('GET', '**/api/me', {
|
cy.intercept('GET', '**/api/me', {
|
||||||
|
|
@ -42,6 +76,18 @@ describe('session authentication', () => {
|
||||||
cy.location('pathname').should('equal', '/dashboard')
|
cy.location('pathname').should('equal', '/dashboard')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('redirects a restored session away from the home route', () => {
|
||||||
|
cy.intercept('GET', '**/api/me', {
|
||||||
|
statusCode: 200,
|
||||||
|
body: { user: authenticatedUser },
|
||||||
|
}).as('me')
|
||||||
|
|
||||||
|
cy.visit('/')
|
||||||
|
cy.wait('@me')
|
||||||
|
|
||||||
|
cy.location('pathname').should('equal', '/dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects a malformed authenticated-user response', () => {
|
it('rejects a malformed authenticated-user response', () => {
|
||||||
cy.intercept('GET', '**/api/me', {
|
cy.intercept('GET', '**/api/me', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
@ -53,4 +99,23 @@ describe('session authentication', () => {
|
||||||
|
|
||||||
cy.location('pathname').should('equal', '/login')
|
cy.location('pathname').should('equal', '/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs out and redirects to login', () => {
|
||||||
|
interceptLogoutFlow()
|
||||||
|
|
||||||
|
visitDashboardAndLogout()
|
||||||
|
|
||||||
|
cy.location('pathname').should('equal', '/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps protected routes inaccessible after logout', () => {
|
||||||
|
interceptLogoutFlow()
|
||||||
|
visitDashboardAndLogout()
|
||||||
|
|
||||||
|
cy.visit('/dashboard')
|
||||||
|
cy.wait('@loggedOutMe')
|
||||||
|
|
||||||
|
cy.location('pathname').should('equal', '/login')
|
||||||
|
cy.location('search').should('include', 'redirect=/dashboard')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,14 @@
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
"test:e2e": "cypress run",
|
"test:e2e": "cypress run",
|
||||||
"lint": "run-s \"lint:*\"",
|
"lint": "run-s lint:oxlint lint:eslint",
|
||||||
"lint:oxlint": "oxlint . --fix",
|
"lint:oxlint": "oxlint . --fix",
|
||||||
"lint:eslint": "eslint . --fix --cache",
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
"format": "oxfmt src/"
|
"lint:check": "run-s lint:oxlint:check lint:eslint:check",
|
||||||
|
"lint:oxlint:check": "oxlint .",
|
||||||
|
"lint:eslint:check": "eslint . --cache",
|
||||||
|
"format": "oxfmt src/",
|
||||||
|
"format:check": "oxfmt --check src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ const router = createRouter({
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: () => import('@/views/HomeView.vue'),
|
component: () => import('@/views/HomeView.vue'),
|
||||||
|
meta: {
|
||||||
|
guestOnly: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,20 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/api/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -108,5 +122,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
login,
|
login,
|
||||||
|
logout,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import BrandWordmark from '@/components/BrandWordmark.vue'
|
import BrandWordmark from '@/components/BrandWordmark.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await authStore.logout()
|
||||||
|
await router.push({ name: 'login' })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="dashboard-page">
|
<main class="dashboard-page">
|
||||||
<header>
|
<header>
|
||||||
<BrandWordmark theme="dark" />
|
<BrandWordmark theme="dark" />
|
||||||
|
<button type="button" class="logout-button" @click="handleLogout">Log out</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -29,10 +41,41 @@ import BrandWordmark from '@/components/BrandWordmark.vue'
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
|
display: flex;
|
||||||
width: min(100%, 76rem);
|
width: min(100%, 76rem);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.7rem 1.1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
color: #fffdf7;
|
||||||
|
background: #183a31;
|
||||||
|
box-shadow: 0 0.55rem 1.2rem rgb(24 58 49 / 14%);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 750;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:hover {
|
||||||
|
background: #285c4e;
|
||||||
|
box-shadow: 0 0.7rem 1.4rem rgb(24 58 49 / 18%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-button:focus-visible {
|
||||||
|
outline: 3px solid rgb(86 127 112 / 34%);
|
||||||
|
outline-offset: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
width: min(100%, 42rem);
|
width: min(100%, 42rem);
|
||||||
margin: clamp(6rem, 18vh, 12rem) auto 0;
|
margin: clamp(6rem, 18vh, 12rem) auto 0;
|
||||||
|
|
|
||||||
48
justfile
48
justfile
|
|
@ -3,5 +3,53 @@ set shell := ["bash", "-c"]
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|
||||||
|
# Full completion gate. Start the worktree stack before running it because
|
||||||
|
# Cypress exercises the frontend and its backend wiring.
|
||||||
|
test-all:
|
||||||
|
@echo "==> frontend format + lint checks"
|
||||||
|
just frontend-format-check
|
||||||
|
just frontend-lint-check
|
||||||
|
@echo "==> frontend type check"
|
||||||
|
just frontend-type-check
|
||||||
|
@echo "==> backend static analysis"
|
||||||
|
just backend-types-check
|
||||||
|
@echo "==> frontend production build"
|
||||||
|
just frontend-build
|
||||||
|
@echo "==> backend tests"
|
||||||
|
just backend-test
|
||||||
|
@echo "==> frontend Cypress tests"
|
||||||
|
just frontend-cypress-run
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
|
||||||
|
backend-test *args:
|
||||||
|
cd backend && php artisan test {{args}}
|
||||||
|
|
||||||
|
backend-types-check:
|
||||||
|
cd backend && composer types:check
|
||||||
|
|
||||||
fresh:
|
fresh:
|
||||||
cd backend && php artisan migrate:fresh --seed
|
cd backend && php artisan migrate:fresh --seed
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
|
||||||
|
frontend-format:
|
||||||
|
cd frontend/website && npm run format
|
||||||
|
|
||||||
|
frontend-format-check:
|
||||||
|
cd frontend/website && npm run format:check
|
||||||
|
|
||||||
|
frontend-lint:
|
||||||
|
cd frontend/website && npm run lint
|
||||||
|
|
||||||
|
frontend-lint-check:
|
||||||
|
cd frontend/website && npm run lint:check
|
||||||
|
|
||||||
|
frontend-type-check:
|
||||||
|
cd frontend/website && npm run type-check
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
cd frontend/website && npm run build-only
|
||||||
|
|
||||||
|
frontend-cypress-run:
|
||||||
|
cd frontend/website && npm run test:e2e
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue