diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5250e0c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Ignore node modules and vendor to keep image lean +node_modules/ +vendor/ + +# Ignore git +.git + +# Ignore environment files +.env +.env.* + +# Docker files +Dockerfile* +.dockerignore + +# Logs and temporary +*.log +coverage/ + +# IDEs +.vscode/ +.idea/ + +# Other +.DS_Store diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..26749d1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,12 @@ +# Ignore node modules and build outputs +node_modules/ +dist/ +build/ +coverage/ + +# Ignore minified bundles +*.min.js + +# IDEs +.vscode/ +.idea/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d329d10 --- /dev/null +++ b/.npmignore @@ -0,0 +1,15 @@ +# Files and folders to exclude from npm packages +node_modules/ +dist/ +build/ +coverage/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Environment +.env +.env.* + +# OS files +.DS_Store diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a4670ff..071a523 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,50 +1,141 @@ -# [PROJECT_NAME] Constitution - + + +# Pixel Donation Grid Constitution ## Core Principles -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - +### Trust First (MUST) +Payments, allocations, and state transitions MUST be auditable and correct. All +monetary actions and allocation changes MUST be recorded atomically and +immutably in the database with provider references and timestamps. The system +MUST compute and verify prices server-side; client-provided prices are not trusted. -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - +### Fast Public Page (MUST) +The public view MUST load a single cached master image plus minimal metadata +only. Public pages MUST NOT construct the master composition on request or make +users wait for rendering. Cache-busting MUST use a `master_version` token. -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - +### Simple UX (SHOULD) +The primary donation flow SHOULD be: select → preview → pay → render. The UI +flow MUST minimize steps and show clear state for `draft`, `pending`, `paid`, +`rendering`, and `rendered` states. -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - +### Moderation-ready (SHOULD) +Uploads SHOULD be held for review when moderation is enabled; the system MUST +support both immediate-render and moderation-hold modes. Moderation decisions +MUST be auditable and reversible (refund/deny paths documented). -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - +### Mobile Usable (SHOULD) +Interaction controls (pan/zoom/selection) SHOULD work on mobile. The UI MUST +provide precise selection aids (snap-to-grid and pixel preview) and degrade +gracefully on small screens. -## [SECTION_2_NAME] - +## Architecture Rules (NON-NEGOTIABLE) -[SECTION_2_CONTENT] - +1. Inertia-first: prefer server-rendered Inertia pages and avoid a separate REST + API unless a real justification exists. +2. Server state machine (source of truth): `draft` → `pending` → `paid` → + `rendering` → `rendered`. Failure/cancel states: `failed`, `cancelled`, + `refunded`. +3. Image compositing and heavy processing MUST run in queued Jobs; web + requests MUST never perform CPU-bound image composition. +4. Idempotency: webhooks and Jobs MUST be safe to retry (store provider event + IDs and order render idempotency keys). Jobs must exit safely if order is + already `rendered`. +5. No double booking: cell reservations MUST be enforced at the DB level using + unique constraints and transactional locking; UI checks are supplementary. +6. Price computation MUST occur server-side based on `cell_count × price_per_cell`. -## [SECTION_3_NAME] - +## Data Model Principles -[SECTION_3_CONTENT] - +- `orders`: store `x, y, w, h` in cell units, `status`, `user_id`/`email`, + `amount`, `currency`, `provider`, `provider_session_id`, `provider_payment_id`, + `image_path`, `link_url`, timestamps and an audit trail. +- `cell_reservations` (`cell_x`, `cell_y`, `order_id`, `expires_at`): TTL based + reservations (e.g., 10 minutes) plus a cleanup command; DB constraints must + prevent multiple reservations for the same cell at the same time. +- `master_images`: store `path`, `version` (integer), `updated_at`; increment + `version` after each successful render for cache-busting. + +Coordinates MUST be stored in cell units; UI converts to pixels using configured +cell size. Reservations MUST expire and be removable by a maintenance command. + +## Security Rules + +1. Use Form Requests for all inputs and validate strictly. +2. Upload safety: allow only `jpg`, `png`, `webp`; enforce max file size and + max dimensions; strip metadata where feasible; store files via Laravel + Storage and never accept client paths. +3. `link_url` MUST be validated and normalized (prefer `https`); disallow + dangerous schemes (javascript: etc.). +4. Webhooks: verify signatures and persist provider event IDs to prevent replay. +5. Rate limit sensitive endpoints (upload, checkout creation) to mitigate abuse. + +## Frontend Rules + +1. Do NOT render thousands of DOM nodes for the grid. Use canvas / Konva for + drawing and hit-testing. +2. Support hover highlight, drag selection rectangle, unavailable/reserved + cell rendering, and client preview of uploaded image inside selection. +3. Master image URL MUST include `?v=` token returned by server. + +## Rendering Contract (Job: ProcessOrderRender) + +1. Job loads current master image and user image from Storage. +2. Resize/crop user image to selection pixel size derived from cell size. +3. Composite user image onto master at pixel (x,y). Use lossless steps when + possible and preserve master metadata where required. +4. Write new master image (or write new version) and increment `master_images.version`. +5. Mark order `rendered` and persist `master_version` change in a single + transactional update. + +Idempotency: If the order is already `rendered`, the Job exits with success and +no-op. On failure, mark order `failed`, log details, and surface a manual retry +path. + +## Quality Gates + +- Tests required before merge for core flows: pricing, locking (concurrency), + webhook idempotency. +- Feature test required: order pending → webhook paid → queued Job render → + `rendered` and `master_version` incremented. +- All changes touching payment/locking/render logic MUST include automated tests + (Pest) that reproduce race conditions where applicable. + +## Workflow & Review + +- Follow Laravel conventions: thin controllers, Form Requests for validation, + business logic in Services/Jobs, image composition in Jobs. +- Document key decisions under `/docs/decisions/*.md`. +- Use small, reviewable commits and add tests when modifying core flows. ## Governance - -[GOVERNANCE_RULES] - +Amendments: Proposals to change this constitution MUST be documented in a PR +explaining the rationale and migration plan. Amendments require approval from +project maintainers and a test plan. Versioning follows semantic rules: -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +- MAJOR: incompatible governance or principle removals/rewrites. +- MINOR: new principle or materially expanded guidance. +- PATCH: clarifications, typos, or non-semantic refinements. + +Compliance: All PRs touching core flows (payments, locking, rendering) MUST +include a short checklist referencing this constitution and the related tests. + +**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03 diff --git a/.specify/templates/plan-template.md b/.specify/templates/plan-template.md index 6a8bfc6..ebf87b1 100644 --- a/.specify/templates/plan-template.md +++ b/.specify/templates/plan-template.md @@ -27,11 +27,25 @@ ## Technical Context **Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] **Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] -## Constitution Check +## Constitution Check (REQUIRED) -*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* +Before implementation, confirm this plan complies with `constitution.md`. -[Gates determined based on constitution file] +**Impact area(s):** ☐ Payments ☐ Locking/Reservations ☐ Rendering/Compositing ☐ Upload/Security ☐ Public UX + +### Compliance Checklist +- [ ] Inertia-first: no unnecessary REST/API split +- [ ] Server is source of truth (no trusting client price/availability) +- [ ] No double booking enforced at DB level (unique/locking strategy defined) +- [ ] Webhook + job flow is idempotent and retry-safe +- [ ] Image compositing runs in a Queue Job (never in-request) +- [ ] Cache busting uses `master_version` (or equivalent), not only timestamps +- [ ] Upload validation rules included (mime, size, dimensions, storage) + +**Decision log:** (if deviating, explain why and what replaces it) +- Deviation: … +- Rationale: … +- Mitigation: … ## Project Structure diff --git a/.specify/templates/spec-template.md b/.specify/templates/spec-template.md index c67d914..8f0e891 100644 --- a/.specify/templates/spec-template.md +++ b/.specify/templates/spec-template.md @@ -5,6 +5,47 @@ # Feature Specification: [FEATURE NAME] **Status**: Draft **Input**: User description: "$ARGUMENTS" +## Constitution Alignment (REQUIRED) +This spec MUST comply with `constitution.md`. If not, document deviation + mitigation here. + +### Mandatory Invariants +- [ ] Server computes price; client is display-only +- [ ] DB-level prevention of double booking +- [ ] Webhooks + rendering pipeline are idempotent +- [ ] Rendering occurs in queued job(s) + +## Success Criteria (MUST) +Define measurable outcomes for "done". +- Performance: + - [ ] Public page loads master image + metadata within ___ ms on ___ connection +- Correctness: + - [ ] No double booking possible under concurrency + - [ ] Paid orders render exactly once (idempotent) +- UX: + - [ ] User can select cells, preview, and pay end-to-end +- Security: + - [ ] Upload rules enforced (type/size/dimensions), safe link validation + +## Acceptance Scenarios (MUST) +Write at least 3 end-to-end scenarios (Gherkin-style preferred). + +### Scenario 1: Reserve → Pay → Render +Given … +When … +Then … + +### Scenario 2: Concurrent selection conflict +Given … +When … +Then … + +### Scenario 3: Webhook replay / retry +Given … +When … +Then … + +## Scope (See above for file contents. You may not need to search or read the file again.) + ## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Public viewer + Live selection (Priority: P1) + +Any visitor can open the public grid page, pan/zoom the master image, draw a rectangular selection (multi-cell), and see the live cell count and computed total price. The selection is client-only for now; no reservation or checkout is created. + +**Why this priority**: Validates UI complexity (grid, pan/zoom, selection) early and delivers immediate visible value. + +**Independent Test**: Load the public page, perform selection on desktop and mobile, verify cell count and price calculation match server-provided `price_per_cell`. + +**Acceptance Scenarios**: + +1. **Given** the public master image is visible, **When** the user drags to select cells, **Then** the sidebar updates with cell count and total price. +2. **Given** mobile device, **When** user pinches/drag to select, **Then** selection precision is maintained and preview shows correctly. + +--- + +### User Story 2 - Upload preview modal (Priority: P2) + +User can open a modal from the sidebar, select an image file and optional link, and see a client-side preview composited into the selection rectangle. The modal does not persist anything to the server in this slice. + +**Independent Test**: Select an image and link, verify preview scales/crops to selection proportion and displays correctly. + +--- + +### User Story 3 - Availability overlay (Priority: P3) + +Server exposes a lightweight endpoint returning currently occupied cell coordinates. For the vertical slice this may return an empty list (stub) but the client must render any occupied cells in a distinct overlay. + +**Independent Test**: Call the availability endpoint and verify the overlay marks returned coordinates as unavailable. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Selection partially outside bounds: selection is clamped to image bounds and cell counts reflect clamped area. +- Very large selection on mobile: UI prevents selections beyond a maximum configured cell count and shows a warning. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Public page MUST return `master_image_url` and `master_version`. +- **FR-002**: Client MUST be able to request `price_per_cell` from the server; client computes `cell_count × price_per_cell` for display only. +- **FR-003**: Client MUST render an interactive grid overlay and allow rectangle selection with pan/zoom support. +- **FR-004**: Client MUST provide an upload + link modal with client-side preview scaled to selection. +- **FR-005**: Server MUST expose an availability endpoint returning occupied cells (MVP: can return empty set). +- **FR-006**: All endpoints MUST validate inputs and rate-limit sensitive endpoints. + +### Key Entities *(include if feature involves data)* + +- **MasterImage**: `path`, `version` (for cache-busting) +- **Availability**: list of `{cell_x, cell_y}` entries returned by endpoint + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Public page loads master image and metadata within 2000ms on a 4G connection (approximate). +- **SC-002**: Selection cell coordinates reported by client match server cell unit grid (pixel-accurate within 1 cell). +- **SC-003**: Mobile pinch/drag interaction completes selection without lost events on iPhone 14 Pro viewport. +- **SC-004**: Price calculation displays correctly using server-provided `price_per_cell`. + +## Assumptions + +- `cell_size` (in pixels) is a server-configured value provided to the client. +- Availability API may return empty set for MVP; production will enforce DB-level locks later. + +## Out of Scope + +- Creating orders, reservations, payments, rendering jobs, or persisting uploaded images. + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"] +- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"] +- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"] +- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"] +- **FR-005**: System MUST [behavior, e.g., "log all security events"] + +*Example of marking unclear requirements:* + +- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?] +- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified] + +### Key Entities *(include if feature involves data)* + +- **[Entity 1]**: [What it represents, key attributes without implementation] +- **[Entity 2]**: [What it represents, relationships to other entities] + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"] +- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"] +- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"] +- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"] diff --git a/specs/001-public-grid-viewer/tasks.md b/specs/001-public-grid-viewer/tasks.md new file mode 100644 index 0000000..9c2f9f7 --- /dev/null +++ b/specs/001-public-grid-viewer/tasks.md @@ -0,0 +1,112 @@ +--- +description: "Task list for Public Grid Viewer (vertical slice)" +--- + +# Tasks: Public Grid Viewer + Live Selection + +**Input**: Design docs from `specs/001-public-grid-viewer/` (spec.md, plan.md) + +## Phase 1: Setup (Shared Infrastructure) + +- [ ] T001 [P] Create config/pixel_grid.php in config/pixel_grid.php (defines `cell_size`, `price_per_cell`, `master_path`) +- [ ] T002 [P] Add sample master image to storage/app/public/master/master.png and ensure `php artisan storage:link` (path: storage/app/public/master/master.png) +- [ ] T003 [P] Create lightweight `MasterImage` model at app/Models/MasterImage.php and optional migration at database/migrations/*_create_master_images_table.php +- [ ] T004 [P] Add `PublicGridController` skeleton at app/Http/Controllers/PublicGridController.php and route placeholder in routes/web.php + - [X] T001 [P] Create config/pixel_grid.php in config/pixel_grid.php (defines `cell_size`, `price_per_cell`, `master_path`) + - [X] T002 [P] Add sample master image to storage/app/public/master/master.png and ensure `php artisan storage:link` (path: storage/app/public/master/master.png) + - [X] T003 [P] Create lightweight `MasterImage` model at app/Models/MasterImage.php and optional migration at database/migrations/*_create_master_images_table.php + - [X] T004 [P] Add `PublicGridController` skeleton at app/Http/Controllers/PublicGridController.php and route placeholder in routes/web.php + +--- + +## Phase 2: Foundational API (Blocking prerequisites) + +- [ ] T005 [P] [US1] Implement `GET /api/grid/meta` in app/Http/Controllers/PublicGridController.php; route in routes/web.php — returns `{ master_image_url, master_version, cell_size }` +- [ ] T006 [P] [US1] Implement `GET /api/grid/price` in app/Http/Controllers/PublicGridController.php — returns `{ price_per_cell }` (read from config/pixel_grid.php) +- [ ] T007 [P] [US1] Implement `GET /api/grid/availability` in app/Http/Controllers/PublicGridController.php — returns list of occupied `{cell_x,cell_y}` (MVP: empty array) +- [ ] T008 [P] Add rate-limiting middleware to sensitive endpoints (routes/web.php or RouteServiceProvider) +- [ ] T009 [P] [US1] Add Pest tests: tests/Feature/PublicGridMetaTest.php and tests/Feature/PriceCalculationTest.php verifying endpoints and JSON schema + - [X] T005 [P] [US1] Implement `GET /api/grid/meta` in app/Http/Controllers/PublicGridController.php; route in routes/web.php — returns `{ master_image_url, master_version, cell_size }` + - [X] T006 [P] [US1] Implement `GET /api/grid/price` in app/Http/Controllers/PublicGridController.php — returns `{ price_per_cell }` (read from config/pixel_grid.php) + - [X] T007 [P] [US1] Implement `GET /api/grid/availability` in app/Http/Controllers/PublicGridController.php — returns list of occupied `{cell_x,cell_y}` (MVP: empty array) + - [X] T008 [P] Add rate-limiting middleware to sensitive endpoints (routes/web.php or RouteServiceProvider) + - [X] T009 [P] [US1] Add Pest tests: tests/Feature/PublicGridMetaTest.php and tests/Feature/PriceCalculationTest.php verifying endpoints and JSON schema + +--- + +## Phase 3: User Story 1 - Public viewer + Live selection (Priority: P1) 🎯 MVP + +**Goal**: Interactive public page with pan/zoom, canvas grid, rectangle selection, sidebar price preview, and upload preview modal (no persistence) + +**Independent Test**: Load the Inertia page, make selections on desktop and mobile; verify cell count and `cell_count × price_per_cell` displayed equals expected value from `config/pixel_grid.php`. + +- [ ] T010 [US1] Create Inertia page at resources/js/Pages/PublicGrid/Index.vue and wire route to `PublicGridController@show` +- [ ] T011 [US1] Implement `resources/js/components/GridCanvas.vue` using Konva or Canvas API (pan/zoom, grid draw, hit-testing mapped to cell units) +- [ ] T012 [US1] Implement `resources/js/components/SelectionSidebar.vue` (shows cell count, queries `/api/grid/price`, shows total price, CTA opens modal) +- [ ] T013 [US1] Implement `resources/js/components/UploadModal.vue` with client-side image selection and preview composited into selection rectangle +- [ ] T014 [US1] Wire mobile touch handlers in `GridCanvas.vue` (pinch to zoom, drag to pan, touch selection) and test on iPhone viewport +- [ ] T015 [P] [US1] Add client-side clamp/validation: selection clamped to image bounds and max selection size enforced in GridCanvas.vue + - [X] T010 [US1] Create Inertia page at resources/js/Pages/PublicGrid/Index.vue and wire route to `PublicGridController@show` + - [X] T011 [US1] Implement `resources/js/components/GridCanvas.vue` using Konva or Canvas API (pan/zoom, grid draw, hit-testing mapped to cell units) + - [X] T012 [US1] Implement `resources/js/components/SelectionSidebar.vue` (shows cell count, queries `/api/grid/price`, shows total price, CTA opens modal) + - [X] T013 [US1] Implement `resources/js/components/UploadModal.vue` with client-side image selection and preview composited into selection rectangle + - [X] T014 [US1] Wire mobile touch handlers in `GridCanvas.vue` (pinch to zoom, drag to pan, touch selection) and test on iPhone viewport + - [X] T015 [P] [US1] Add client-side clamp/validation: selection clamped to image bounds and max selection size enforced in GridCanvas.vue + +--- + +## Phase 4: Tests & QA + +- [ ] T016 [US1] Add selection math feature test at tests/Feature/SelectionMathTest.php verifying client/server cell mapping (can be unit or integration with mocked cell_size) +- [ ] T017 [P] Create manual QA checklist at specs/001-public-grid-viewer/checklists/qa.md (mobile interactions, selection precision, upload preview) + - [X] T016 [US1] Add selection math feature test at tests/Feature/SelectionMathTest.php verifying client/server cell mapping (can be unit or integration with mocked cell_size) + - [ ] T017 [P] Create manual QA checklist at specs/001-public-grid-viewer/checklists/qa.md (mobile interactions, selection precision, upload preview) + +--- + +## Phase 5: Polish & Cross-Cutting Concerns + +- [ ] T018 [P] Add quickstart file specs/001-public-grid-viewer/quickstart.md with local dev steps +- [ ] T019 [P] Add design decision doc at docs/decisions/001-public-grid-viewer.md explaining canvas choice and DB-locking deferral +- [ ] T020 [P] Run `vendor/bin/pint` / code style checks and ensure tests pass locally + - [ ] T018 [P] Add quickstart file specs/001-public-grid-viewer/quickstart.md with local dev steps + - [ ] T019 [P] Add design decision doc at docs/decisions/001-public-grid-viewer.md explaining canvas choice and DB-locking deferral + - [ ] T020 [P] Run `vendor/bin/pint` / code style checks and ensure tests pass locally + +--- + +## Dependencies & Execution Order + +- Setup (Phase 1) must complete before Foundational (Phase 2) — Phase 1 tasks can be parallelized. +- Foundational (Phase 2) blocks User Story implementation — once T005..T009 pass, frontend work can begin. +- User Story tasks (Phase 3) depend on Foundational APIs (T005/T006) but UI components T011/T012 can be scaffolded in parallel and stub the API during development. + +## User Story Task Counts + +- Total tasks: 20 +- Tasks for US1: 9 (T005-T007, T009, T010-T015, T016) + +## Parallel Opportunities + +- Phase 1 tasks T001-T004 are parallelizable ([P]). +- Endpoint implementations T005-T007 and T008 rate-limiting can be parallelized across backend devs. +- Frontend components T011-T013 can be implemented in parallel by different developers. + +## Independent Test Criteria (per story) + +- US1: Selection precision verified by tests/Feature/SelectionMathTest.php and manual mobile QA; price display uses server `price_per_cell` (T006) — independent of payments. + +## Suggested MVP scope + +- Deliver Phases 1–3 for an internal demo: `PublicGrid` page with working selection and price preview (no persistence). This is the recommended MVP. + +## Implementation Strategy + +1. Implement Phase 1 & Phase 2 backend endpoints and tests (T001-T009). +2. Scaffold frontend pages and components (T010-T013) using mocked API responses. +3. Replace mocks with real endpoints and complete mobile handlers (T014-T015). +4. Run tests, perform QA, and add docs (T016-T019). + +*** + +*** End File diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 785ec80..dbb4297 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -81,4 +81,4 @@ ]); $response->assertTooManyRequests(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index d6f7ca6..a681f04 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -92,4 +92,4 @@ Event::assertNotDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index dc281ed..cca60e5 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -19,4 +19,4 @@ $response = $this->get(route('password.confirm')); $response->assertRedirect(route('login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index d684629..d507132 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -70,4 +70,4 @@ ]); $response->assertSessionHasErrors('email'); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index be6d7d6..64ea727 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -16,4 +16,4 @@ $this->assertAuthenticated(); $response->assertRedirect(route('dashboard', absolute: false)); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index 6de3042..e53e0fb 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -42,4 +42,4 @@ ->assertInertia(fn (Assert $page) => $page ->component('auth/TwoFactorChallenge') ); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php index 153ec53..b65c7c8 100644 --- a/tests/Feature/Auth/VerificationNotificationTest.php +++ b/tests/Feature/Auth/VerificationNotificationTest.php @@ -26,4 +26,4 @@ ->assertRedirect(route('dashboard', absolute: false)); Notification::assertNothingSent(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ConfirmReservationTest.php b/tests/Feature/ConfirmReservationTest.php new file mode 100644 index 0000000..e53fcf9 --- /dev/null +++ b/tests/Feature/ConfirmReservationTest.php @@ -0,0 +1,26 @@ + 'master.png', 'version' => 1]); + + $res = Reservation::create(['x' => 0, 'y' => 0, 'w' => 1, 'h' => 1, 'status' => 'held', 'reserved_until' => now()->addMinutes(5)]); + + $response = $this->postJson('/api/grid/confirm', ['reservation_id' => $res->id]); + $response->assertSuccessful(); + + expect(Reservation::find($res->id)->status)->toBe('confirmed'); +}); + +it('cannot confirm an expired reservation', function () { + MasterImage::create(['path' => 'master.png', 'version' => 1]); + + $res = Reservation::create(['x' => 0, 'y' => 0, 'w' => 1, 'h' => 1, 'status' => 'held', 'reserved_until' => now()->subMinutes(1)]); + + $response = $this->postJson('/api/grid/confirm', ['reservation_id' => $res->id]); + $response->assertStatus(409); + + expect(Reservation::find($res->id)->status)->toBe('expired'); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 7290183..b9974a1 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -13,4 +13,4 @@ $response = $this->get(route('dashboard')); $response->assertStatus(200); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 287e54f..0563a41 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -4,4 +4,4 @@ $response = $this->get(route('home')); $response->assertStatus(200); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ExpireReservationsCommandTest.php b/tests/Feature/ExpireReservationsCommandTest.php new file mode 100644 index 0000000..e2a2e96 --- /dev/null +++ b/tests/Feature/ExpireReservationsCommandTest.php @@ -0,0 +1,15 @@ +delete(); + + $r1 = Reservation::create(['x' => 0, 'y' => 0, 'w' => 1, 'h' => 1, 'status' => 'held', 'reserved_until' => now()->subMinutes(10)]); + $r2 = Reservation::create(['x' => 5, 'y' => 5, 'w' => 1, 'h' => 1, 'status' => 'held', 'reserved_until' => now()->addMinutes(10)]); + + $this->artisan('reservations:expire')->assertExitCode(0); + + expect(Reservation::find($r1->id)->status)->toBe('expired'); + expect(Reservation::find($r2->id)->status)->toBe('held'); +}); diff --git a/tests/Feature/PaymentChargeTest.php b/tests/Feature/PaymentChargeTest.php new file mode 100644 index 0000000..62b78e5 --- /dev/null +++ b/tests/Feature/PaymentChargeTest.php @@ -0,0 +1,22 @@ + 'master.png', 'version' => 1]); + + $res = Reservation::create(['x' => 0, 'y' => 0, 'w' => 2, 'h' => 2, 'status' => 'held', 'reserved_until' => now()->addMinutes(5)]); + + Bus::fake(); + + $response = $this->postJson('/api/payment/charge', ['reservation_id' => $res->id]); + $response->assertSuccessful(); + + expect(Reservation::find($res->id)->status)->toBe('confirmed'); + + Bus::assertDispatched(App\Jobs\CompositeImage::class, function ($job) use ($res) { + return $job->reservationId === $res->id; + }); +}); diff --git a/tests/Feature/PriceCalculationTest.php b/tests/Feature/PriceCalculationTest.php new file mode 100644 index 0000000..77842b9 --- /dev/null +++ b/tests/Feature/PriceCalculationTest.php @@ -0,0 +1,11 @@ +getJson('/api/grid/price'); + + $response->assertOk(); + $json = $response->json(); + + expect(array_key_exists('price_per_cell', $json))->toBeTrue(); + expect(is_int($json['price_per_cell']))->toBeTrue(); +}); diff --git a/tests/Feature/PublicGridMetaTest.php b/tests/Feature/PublicGridMetaTest.php new file mode 100644 index 0000000..a568b20 --- /dev/null +++ b/tests/Feature/PublicGridMetaTest.php @@ -0,0 +1,22 @@ +put('master/master.png', 'contents'); + + MasterImage::create(['path' => 'master/master.png', 'version' => 3]); + + $response = $this->getJson('/api/grid/meta'); + + $response->assertOk(); + $json = $response->json(); + + expect($json['master_image_url'])->toBeString(); + expect($json['cell_size'])->toBeInt(); + expect($json['master_version'])->toBe(3); +}); diff --git a/tests/Feature/ReserveSelectionTest.php b/tests/Feature/ReserveSelectionTest.php new file mode 100644 index 0000000..bb77e17 --- /dev/null +++ b/tests/Feature/ReserveSelectionTest.php @@ -0,0 +1,34 @@ +delete(); +}); + +it('creates a reservation when area is free', function () { + MasterImage::create(['path' => 'master.png', 'version' => 1]); + + $payload = ['x' => 10, 'y' => 10, 'w' => 20, 'h' => 20, 'offsetX' => 0, 'offsetY' => 0]; + + $response = $this->postJson('/api/grid/reserve', $payload); + $response->assertSuccessful(); + + $data = $response->json(); + expect($data['reservation_id'])->toBeInt(); + expect(Reservation::count())->toBe(1); +}); + +it('returns 409 when overlapping reservation exists', function () { + MasterImage::create(['path' => 'master.png', 'version' => 1]); + + // Existing reservation occupies cells 1..3 x 1..3 + Reservation::create(['x' => 1, 'y' => 1, 'w' => 3, 'h' => 3, 'status' => 'held', 'reserved_until' => now()->addMinutes(5)]); + + // Attempt overlapping reservation (pixel coords mapping to cell 2,2..) + // cellSize default 20 -> cell 2 starts at pixel 40 + $payload = ['x' => 40, 'y' => 40, 'w' => 40, 'h' => 40, 'offsetX' => 0, 'offsetY' => 0]; + $response = $this->postJson('/api/grid/reserve', $payload); + $response->assertStatus(409); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index a1c91ce..f13ccba 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -47,4 +47,4 @@ $response ->assertSessionHasErrors('current_password') ->assertRedirect(route('user-password.edit')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index b4c4c37..9f49e25 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -82,4 +82,4 @@ ->assertRedirect(route('profile.edit')); expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index ff56289..5266d94 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -76,4 +76,4 @@ ->withSession(['auth.password_confirmed_at' => time()]) ->get(route('two-factor.show')) ->assertForbidden(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ValidateSelectionTest.php b/tests/Feature/ValidateSelectionTest.php new file mode 100644 index 0000000..e72de51 --- /dev/null +++ b/tests/Feature/ValidateSelectionTest.php @@ -0,0 +1,28 @@ + 'master.png', 'version' => 1]); + + $payload = [ + 'x' => 50, + 'y' => 40, + 'w' => 60, + 'h' => 40, + 'offsetX' => 10, + 'offsetY' => 20, + ]; + + $response = $this->postJson('/api/grid/validate-selection', $payload); + + $response->assertSuccessful(); + $json = $response->json('selection'); + + expect($json['x'])->toBe(2); + expect($json['y'])->toBe(1); + expect($json['w'])->toBe(3); + expect($json['h'])->toBe(2); +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 27f3f87..44a4f33 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -2,4 +2,4 @@ test('that true is true', function () { expect(true)->toBeTrue(); -}); \ No newline at end of file +}); diff --git a/tests/Unit/ExpireReservationsCommandTest.php b/tests/Unit/ExpireReservationsCommandTest.php new file mode 100644 index 0000000..08b650c --- /dev/null +++ b/tests/Unit/ExpireReservationsCommandTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/tests/Unit/SelectionMathTest.php b/tests/Unit/SelectionMathTest.php new file mode 100644 index 0000000..140338b --- /dev/null +++ b/tests/Unit/SelectionMathTest.php @@ -0,0 +1,17 @@ +mapPixelsToCells(50, 40, 60, 40, 10, 20, 20); + + expect(is_array($result))->toBeTrue(); + expect($result['x'])->toBe(2); // (50-10)/20 = 2 + expect($result['y'])->toBe(1); // (40-20)/20 = 1 + expect($result['w'])->toBe(3); // 60/20 = 3 + expect($result['h'])->toBe(2); // 40/20 = 2 +});