Compare commits
No commits in common. "e265b0cb926ab24032b8e64032f5cf5c56368426" and "41f9c96d33eb32510cc97e5b02a508e14ac8cfd0" have entirely different histories.
e265b0cb92
...
41f9c96d33
23 changed files with 46 additions and 1538 deletions
|
|
@ -58,42 +58,30 @@ jobs:
|
||||||
|
|
||||||
static-tests:
|
static-tests:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
# COMPOSER-LESS (decision 4 / 5, PROJECT_CONTEXT.md) : PHPStan et PHPUnit
|
|
||||||
# tournent depuis leur .phar autonome telecharge ici, jamais via Composer.
|
|
||||||
# Versions epinglees pour des CI reproductibles (pas de "latest").
|
|
||||||
env:
|
|
||||||
PHPUNIT_VERSION: "11.5.2"
|
|
||||||
PHPSTAN_VERSION: "1.12.27"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: PHPStan (guarded)
|
- name: PHPStan (guarded)
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
if [ -f composer.json ] && [ -f phpstan.neon ]; then
|
||||||
if [ ! -f phpstan.neon ]; then
|
echo "phpstan config detected - running"
|
||||||
echo "PHPStan skipped: no phpstan.neon yet (activates in P2)"
|
apt-get update -qq && apt-get install -y -qq php-cli unzip git >/dev/null
|
||||||
exit 0
|
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||||
|
composer install --no-interaction --no-progress
|
||||||
|
vendor/bin/phpstan analyse --no-progress
|
||||||
|
else
|
||||||
|
echo "PHPStan skipped: no composer.json/phpstan.neon yet (activates in P2)"
|
||||||
fi
|
fi
|
||||||
echo "phpstan.neon detected - running PHPStan ${PHPSTAN_VERSION} via .phar"
|
|
||||||
apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null
|
|
||||||
# PHPUnit phar present pour que phpstan.neon (scanDirectories phar://phpunit.phar)
|
|
||||||
# resolve les symboles PHPUnit\Framework\* utilises sous tests/.
|
|
||||||
curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar
|
|
||||||
curl -sSL "https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar" -o phpstan.phar
|
|
||||||
php phpstan.phar --version
|
|
||||||
# memory_limit=-1 : l'analyse parallele depasse les 128M par defaut du php-cli.
|
|
||||||
php -d memory_limit=-1 phpstan.phar analyse --no-progress --error-format=raw
|
|
||||||
- name: PHPUnit (guarded)
|
- name: PHPUnit (guarded)
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
if [ -d tests ] && [ -f phpunit.xml ]; then
|
||||||
if [ ! -d tests ] || [ ! -f phpunit.xml ]; then
|
echo "phpunit config detected - running"
|
||||||
|
apt-get update -qq && apt-get install -y -qq php-cli >/dev/null
|
||||||
|
if [ -f vendor/bin/phpunit ]; then vendor/bin/phpunit; \
|
||||||
|
elif [ -f phpunit.phar ]; then php phpunit.phar; \
|
||||||
|
else echo "phpunit binary missing despite config" && exit 1; fi
|
||||||
|
else
|
||||||
echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)"
|
echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)"
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar"
|
|
||||||
apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null
|
|
||||||
curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar
|
|
||||||
php phpunit.phar --version
|
|
||||||
php phpunit.phar -c phpunit.xml
|
|
||||||
|
|
||||||
auto-merge:
|
auto-merge:
|
||||||
# Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR.
|
# Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR.
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -28,11 +28,8 @@ vendor/
|
||||||
composer.lock
|
composer.lock
|
||||||
composer.phar
|
composer.phar
|
||||||
|
|
||||||
# === Tests / Analyse statique (tooling via .phar autonome, sans Composer) ===
|
# === Tests ===
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.phpunit.cache/
|
|
||||||
/phpunit.phar
|
|
||||||
/phpstan.phar
|
|
||||||
/tests/_output/
|
/tests/_output/
|
||||||
/tests/_support/_generated/
|
/tests/_support/_generated/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- Wakdo — Seed 0001 : RBAC + reference data + admin user
|
|
||||||
-- =============================================================================
|
|
||||||
-- Purpose : Seed the foundational rows the back-office cannot boot without:
|
|
||||||
-- the 5 RBAC roles, the frozen catalogue of 23 permissions, the
|
|
||||||
-- default role/permission matrix, per-role visible order sources,
|
|
||||||
-- the 14 EU INCO allergens, and a single bootstrap admin user.
|
|
||||||
-- Source : docs/merise/dictionary.md (3.8 allergen, 3.15 role, 3.16
|
|
||||||
-- role_visible_source, 3.17 permission catalogue + default grants,
|
|
||||||
-- 3.18 role_permission), docs/merise/mct.md (operations 1-28),
|
|
||||||
-- docs/PROJECT_CONTEXT.md section 7 (role responsibilities) and
|
|
||||||
-- decision D5 (admin gets order.create / order.deliver ; manager
|
|
||||||
-- does NOT get order.cancel).
|
|
||||||
-- Phase : P2 — demo/reference seed, applied AFTER db/migrations/0001_init_schema.sql.
|
|
||||||
-- Target : MariaDB 11.4 LTS. Fed by db/seed.sh into the already-selected DB.
|
|
||||||
--
|
|
||||||
-- Notes:
|
|
||||||
-- - Statements are ordered so every FK resolves: role and permission first,
|
|
||||||
-- then role_permission / role_visible_source, then user (FK -> role).
|
|
||||||
-- - role_permission rows use subqueries on role.code and permission.code so
|
|
||||||
-- no surrogate ids are hardcoded (robust to AUTO_INCREMENT gaps).
|
|
||||||
-- - admin/manager get no role_visible_source rows: they have a global view of
|
|
||||||
-- all sources (the absence of rows means "no source filter applied").
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 1. role (5) — dictionary.md 3.15
|
|
||||||
-- order_source: counter/drive auto-tag their own source; admin/manager NULL
|
|
||||||
-- (they may create on behalf of any channel); kitchen NULL (read-only on
|
|
||||||
-- orders, never creates one).
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO role (code, label, description, default_route, order_source, is_active) VALUES
|
|
||||||
('admin', 'Administrator', 'Full back-office access: complete catalogue CRUD (incl. deletes), user/role/permission (RBAC) management, stock, stats, order create/deliver/cancel.', '/admin/dashboard', NULL, 1),
|
|
||||||
('manager', 'Manager', 'Catalogue create/update, ingredient and stock management (restock + inventory), statistics. No user/RBAC administration, no order cancellation.', '/admin/stats', NULL, 1),
|
|
||||||
('kitchen', 'Kitchen Staff', 'Read-only kitchen display (KDS) of paid orders sorted by paid_at ascending, plus inventory counting. Performs no order status transition.', '/kitchen/display', NULL, 1),
|
|
||||||
('counter', 'Counter Staff', 'Takes orders at the counter, delivers them to the customer, can cancel. Inventory counting. source auto-tagged as counter.', '/counter/orders', 'counter', 1),
|
|
||||||
('drive', 'Drive Staff', 'Takes orders at the drive-thru (intercom + headset), delivers them, can cancel. Inventory counting. source auto-tagged as drive.', '/drive/orders', 'drive', 1);
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 2. permission (23) — frozen catalogue, dictionary.md 3.17.
|
|
||||||
-- code format <resource>.<action>. The catalogue is fixed at the seed and
|
|
||||||
-- never created through the UI (only assigned to roles via MANAGE_RBAC).
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO permission (code, label, description) VALUES
|
|
||||||
('product.create', 'Create product', 'Create a new catalogue product.'),
|
|
||||||
('product.read', 'Read products', 'View products in the back-office and on order screens.'),
|
|
||||||
('product.update', 'Update product', 'Edit an existing product (name, price, VAT, availability, etc.).'),
|
|
||||||
('product.delete', 'Delete product', 'Permanently delete a product when no FK references block it.'),
|
|
||||||
('menu.create', 'Create menu', 'Create a new menu with its slot configuration.'),
|
|
||||||
('menu.read', 'Read menus', 'View menus, slots and slot options.'),
|
|
||||||
('menu.update', 'Update menu', 'Edit an existing menu and its slot configuration.'),
|
|
||||||
('menu.delete', 'Delete menu', 'Permanently delete a menu when no historical order references it.'),
|
|
||||||
('category.manage', 'Manage categories', 'Create, update or deactivate product/menu categories.'),
|
|
||||||
('ingredient.manage', 'Manage ingredients', 'Manage ingredients, product composition and allergen mapping.'),
|
|
||||||
('stock.read', 'Read stock', 'View ingredient stock levels and movement history.'),
|
|
||||||
('stock.count', 'Count stock', 'Record a physical inventory count (inventory correction).'),
|
|
||||||
('stock.manage', 'Manage stock', 'Record restocks (pack deliveries) and manage stock parameters.'),
|
|
||||||
('order.read', 'Read orders', 'View orders and the preparation display.'),
|
|
||||||
('order.create', 'Create order', 'Create an order at the counter or drive-thru.'),
|
|
||||||
('order.deliver', 'Deliver order', 'Mark a paid order as delivered (single-gesture handover).'),
|
|
||||||
('order.cancel', 'Cancel order', 'Cancel a pending or paid order (restocks ingredients if paid).'),
|
|
||||||
('user.create', 'Create user', 'Create a new back-office user.'),
|
|
||||||
('user.read', 'Read users', 'View the list and details of back-office users.'),
|
|
||||||
('user.update', 'Update user', 'Edit a back-office user (incl. password reset, RGPD anonymisation).'),
|
|
||||||
('user.deactivate', 'Deactivate user', 'Deactivate a back-office user without deleting the row.'),
|
|
||||||
('role.manage', 'Manage roles and RBAC', 'Manage roles, role/permission assignments and visible sources.'),
|
|
||||||
('stats.read', 'Read statistics', 'Access the statistics / KPI dashboard.');
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 3. role_permission — default matrix, dictionary.md 3.17 grants + PROJECT_CONTEXT
|
|
||||||
-- section 7 + decision D5. Subqueries on role.code / permission.code avoid
|
|
||||||
-- hardcoded ids.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
-- admin: ALL 23 permissions (cross join the admin role with the whole catalogue).
|
|
||||||
INSERT INTO role_permission (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM role r
|
|
||||||
CROSS JOIN permission p
|
|
||||||
WHERE r.code = 'admin';
|
|
||||||
|
|
||||||
-- manager: catalogue create/update + category/ingredient + full stock + stats.
|
|
||||||
-- NO order.* (incl. no order.cancel per D5), NO user/role admin.
|
|
||||||
INSERT INTO role_permission (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM role r
|
|
||||||
JOIN permission p ON p.code IN (
|
|
||||||
'product.create', 'product.read', 'product.update',
|
|
||||||
'menu.create', 'menu.read', 'menu.update',
|
|
||||||
'category.manage', 'ingredient.manage',
|
|
||||||
'stock.read', 'stock.count', 'stock.manage',
|
|
||||||
'user.read',
|
|
||||||
'stats.read'
|
|
||||||
)
|
|
||||||
WHERE r.code = 'manager';
|
|
||||||
|
|
||||||
-- kitchen: read-only orders + read-only catalogue + inventory (read + count).
|
|
||||||
INSERT INTO role_permission (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM role r
|
|
||||||
JOIN permission p ON p.code IN (
|
|
||||||
'product.read', 'menu.read',
|
|
||||||
'stock.read', 'stock.count',
|
|
||||||
'order.read'
|
|
||||||
)
|
|
||||||
WHERE r.code = 'kitchen';
|
|
||||||
|
|
||||||
-- counter: read catalogue + full order lifecycle (read/create/deliver/cancel)
|
|
||||||
-- + inventory (read + count).
|
|
||||||
INSERT INTO role_permission (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM role r
|
|
||||||
JOIN permission p ON p.code IN (
|
|
||||||
'product.read', 'menu.read',
|
|
||||||
'stock.read', 'stock.count',
|
|
||||||
'order.read', 'order.create', 'order.deliver', 'order.cancel'
|
|
||||||
)
|
|
||||||
WHERE r.code = 'counter';
|
|
||||||
|
|
||||||
-- drive: identical grant set to counter (read catalogue + full order lifecycle
|
|
||||||
-- + inventory). The source differs (auto-tagged drive), not the rights.
|
|
||||||
INSERT INTO role_permission (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM role r
|
|
||||||
JOIN permission p ON p.code IN (
|
|
||||||
'product.read', 'menu.read',
|
|
||||||
'stock.read', 'stock.count',
|
|
||||||
'order.read', 'order.create', 'order.deliver', 'order.cancel'
|
|
||||||
)
|
|
||||||
WHERE r.code = 'drive';
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 4. role_visible_source — dictionary.md 3.16.
|
|
||||||
-- kitchen sees all 3 sources; counter sees kiosk+counter; drive sees drive.
|
|
||||||
-- admin/manager: no rows -> global view (no source filter).
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO role_visible_source (role_id, source)
|
|
||||||
SELECT r.id, s.source
|
|
||||||
FROM role r
|
|
||||||
JOIN (
|
|
||||||
SELECT 'kitchen' AS role_code, 'kiosk' AS source UNION ALL
|
|
||||||
SELECT 'kitchen', 'counter' UNION ALL
|
|
||||||
SELECT 'kitchen', 'drive' UNION ALL
|
|
||||||
SELECT 'counter', 'kiosk' UNION ALL
|
|
||||||
SELECT 'counter', 'counter' UNION ALL
|
|
||||||
SELECT 'drive', 'drive'
|
|
||||||
) s ON s.role_code = r.code;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 5. allergen (14) — EU INCO Regulation (EU) No 1169/2011, Annex II.
|
|
||||||
-- dictionary.md 3.8. code = machine code (en), name = French display label.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO allergen (code, name, description) VALUES
|
|
||||||
('gluten', 'Gluten', 'Cereales contenant du gluten (ble, seigle, orge, avoine, epeautre, kamut) et produits a base de ces cereales.'),
|
|
||||||
('crustaceans', 'Crustaces', 'Crustaces et produits a base de crustaces.'),
|
|
||||||
('eggs', 'Oeufs', 'Oeufs et produits a base d''oeufs.'),
|
|
||||||
('fish', 'Poisson', 'Poissons et produits a base de poissons.'),
|
|
||||||
('peanuts', 'Arachides', 'Arachides et produits a base d''arachides.'),
|
|
||||||
('soybeans', 'Soja', 'Soja et produits a base de soja.'),
|
|
||||||
('milk', 'Lait', 'Lait et produits a base de lait (y compris le lactose).'),
|
|
||||||
('nuts', 'Fruits a coque', 'Fruits a coque : amandes, noisettes, noix, noix de cajou, de pecan, du Bresil, pistaches, noix de Macadamia.'),
|
|
||||||
('celery', 'Celeri', 'Celeri et produits a base de celeri.'),
|
|
||||||
('mustard', 'Moutarde', 'Moutarde et produits a base de moutarde.'),
|
|
||||||
('sesame', 'Graines de sesame', 'Graines de sesame et produits a base de graines de sesame.'),
|
|
||||||
('sulphites', 'Anhydride sulfureux et sulfites', 'Anhydride sulfureux et sulfites en concentration superieure a 10 mg/kg ou 10 mg/l (exprimes en SO2).'),
|
|
||||||
('lupin', 'Lupin', 'Lupin et produits a base de lupin.'),
|
|
||||||
('molluscs', 'Mollusques', 'Mollusques et produits a base de mollusques.');
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 6. user (1) — bootstrap administrator. dictionary.md 3.14.
|
|
||||||
-- role_id resolved from role.code = 'admin'. pin_hash NULL (no PIN set yet).
|
|
||||||
--
|
|
||||||
-- DEV password: WakdoAdmin2026! (argon2id hash below, generated via
|
|
||||||
-- `docker exec wakdo-app php -r 'echo password_hash("WakdoAdmin2026!",
|
|
||||||
-- PASSWORD_ARGON2ID);'`). MUST be changed in production — this is a known
|
|
||||||
-- demo credential and must never reach a real deployment as-is.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO user (email, password_hash, pin_hash, first_name, last_name, role_id, is_active)
|
|
||||||
SELECT
|
|
||||||
'admin@wakdo.local',
|
|
||||||
'$argon2id$v=19$m=65536,t=4,p=1$V3dVMi55cDVBYVZPMU1TRw$8iMoNyfC12t7V2CU+YgqwvEb3xNywm7PUSIoNMgRdvc',
|
|
||||||
NULL,
|
|
||||||
'Wakdo',
|
|
||||||
'Admin',
|
|
||||||
r.id,
|
|
||||||
1
|
|
||||||
FROM role r
|
|
||||||
WHERE r.code = 'admin';
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- Wakdo — Seed 0002 : Catalogue (reference / demo data)
|
|
||||||
-- =============================================================================
|
|
||||||
-- Purpose : Populate the Catalogue sub-domain (category, product, menu,
|
|
||||||
-- menu_slot, menu_slot_option) from the school JSON sources.
|
|
||||||
-- Sources : docs/merise/_sources/categories.json (9 categories)
|
|
||||||
-- docs/merise/_sources/produits.json (menus + 53 products)
|
|
||||||
-- src/public/borne/data/produits.json (cents prices + clean paths,
|
|
||||||
-- used for cross-check)
|
|
||||||
-- Phase : P2 — demo seed, assumes a fresh schema (0001_init_schema.sql).
|
|
||||||
--
|
|
||||||
-- Conventions:
|
|
||||||
-- - Monetary amounts are INT in CENTS (euros float x 100, rounded).
|
|
||||||
-- - vat_rate is per-mille: 100 = 10% (default), 55 = 5.5% for products in
|
|
||||||
-- resealable containers (bottled water, bottled juices) — dictionary note 9.
|
|
||||||
-- - image_path is a relative path under the public root, normalised to
|
|
||||||
-- assets/images/produits/<category>/<file>.png (dictionary note 8).
|
|
||||||
-- - Menus go to the `menu` table (NOT `product`); every other category goes
|
|
||||||
-- to `product`. The "burgers" category items are the anchor products that
|
|
||||||
-- menus reference via burger_product_id.
|
|
||||||
-- - price_maxi_cents = price_normal_cents + 150 (Maxi format, +1.50 EUR).
|
|
||||||
-- - Foreign keys are resolved by subquery on natural keys (slug / name)
|
|
||||||
-- rather than hardcoded ids.
|
|
||||||
-- - Insertion order respects FK dependencies:
|
|
||||||
-- category -> product -> menu -> menu_slot -> menu_slot_option.
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 1. category (9) — root table, source order = display_order
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO category (name, slug, image_path, display_order, is_active) VALUES
|
|
||||||
('menus', 'menus', 'assets/images/categories/menus.png', 1, 1),
|
|
||||||
('boissons', 'boissons', 'assets/images/categories/boissons.png', 2, 1),
|
|
||||||
('burgers', 'burgers', 'assets/images/categories/burgers.png', 3, 1),
|
|
||||||
('frites', 'frites', 'assets/images/categories/frites.png', 4, 1),
|
|
||||||
('encas', 'encas', 'assets/images/categories/encas.png', 5, 1),
|
|
||||||
('wraps', 'wraps', 'assets/images/categories/wraps.png', 6, 1),
|
|
||||||
('salades', 'salades', 'assets/images/categories/salades.png', 7, 1),
|
|
||||||
('desserts', 'desserts', 'assets/images/categories/desserts.png', 8, 1),
|
|
||||||
('sauces', 'sauces', 'assets/images/categories/sauces.png', 9, 1);
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 2. product — every non-menu item (53 rows)
|
|
||||||
-- category_id resolved via subquery on category.slug.
|
|
||||||
-- display_order follows source order within each category.
|
|
||||||
-- vat_rate defaults to 100; 55 only for resealable-container drinks
|
|
||||||
-- (Eau, Jus d'Orange, Jus de Pommes Bio) per dictionary note 9.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
-- 2.a burgers (anchor products for menus)
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Le 280', 680, 100, 'assets/images/produits/burgers/280.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty', 860, 100, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty Bacon', 890, 100, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Big Mac', 600, 100, 'assets/images/produits/burgers/bigmac.png', 1, 4),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'CBO', 890, 100, 'assets/images/produits/burgers/cbo.png', 1, 5),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'MC Chicken', 730, 100, 'assets/images/produits/burgers/mcchicken.png', 1, 6),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'MC Crispy', 530, 100, 'assets/images/produits/burgers/mccrispy.png', 1, 7),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'MC Fish', 485, 100, 'assets/images/produits/burgers/mcfish.png', 1, 8),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Royal Bacon', 510, 100, 'assets/images/produits/burgers/royalbacon.png', 1, 9),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Royal Cheese', 440, 100, 'assets/images/produits/burgers/royalcheese.png', 1, 10),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Royal Deluxe', 540, 100, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Signature BBQ Beef 2 viandes', 1140, 100, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12),
|
|
||||||
((SELECT id FROM category WHERE slug='burgers'), 'Signature Beef BBQ', 1030, 100, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13);
|
|
||||||
|
|
||||||
-- 2.b boissons (Eau + the two bottled juices are resealable-container = vat_rate 55)
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Coca Cola', 190, 100, 'assets/images/produits/boissons/coca-cola.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Coca Sans Sucres', 190, 100, 'assets/images/produits/boissons/coca-sans-sucres.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Eau', 100, 55, 'assets/images/produits/boissons/eau.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Fanta Orange', 190, 100, 'assets/images/produits/boissons/fanta.png', 1, 4),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Peche', 190, 100, 'assets/images/produits/boissons/ice-tea-peche.png', 1, 5),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Citron', 190, 100, 'assets/images/produits/boissons/the-vert-citron-sans-sucres.png', 1, 6),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Jus d''Orange', 210, 55, 'assets/images/produits/boissons/jus-orange.png', 1, 7),
|
|
||||||
((SELECT id FROM category WHERE slug='boissons'), 'Jus de Pommes Bio', 230, 55, 'assets/images/produits/boissons/jus-pomme-bio.png', 1, 8);
|
|
||||||
|
|
||||||
-- 2.c frites
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='frites'), 'Petite Frite', 145, 100, 'assets/images/produits/frites/petite-frite.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='frites'), 'Moyenne Frite', 275, 100, 'assets/images/produits/frites/moyenne-frite.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='frites'), 'Grande Frite', 350, 100, 'assets/images/produits/frites/grande-frite.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='frites'), 'Potatoes', 215, 100, 'assets/images/produits/frites/potatoes.png', 1, 4),
|
|
||||||
((SELECT id FROM category WHERE slug='frites'), 'Grande Potatoes', 340, 100, 'assets/images/produits/frites/grande-potatoes.png', 1, 5);
|
|
||||||
|
|
||||||
-- 2.d encas
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='encas'), 'Cheeseburger', 260, 100, 'assets/images/produits/encas/cheeseburger.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='encas'), 'Croc MCdo', 320, 100, 'assets/images/produits/encas/croc-mc-do.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='encas'), 'Nuggets x4', 420, 100, 'assets/images/produits/encas/nuggets-4.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='encas'), 'Nuggets x20', 1300, 100, 'assets/images/produits/encas/nuggets-20.png', 1, 4);
|
|
||||||
|
|
||||||
-- 2.e wraps
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Chevre', 310, 100, 'assets/images/produits/wraps/mcwrap-chevre.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Poulet Bacon', 330, 100, 'assets/images/produits/wraps/mcwrap-poulet-bacon.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Chevre', 260, 100, 'assets/images/produits/wraps/ptit-wrap-chevre.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Ranch', 260, 100, 'assets/images/produits/wraps/ptit-wrap-ranch.png', 1, 4);
|
|
||||||
|
|
||||||
-- 2.f salades
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='salades'), 'Petite Salade', 330, 100, 'assets/images/produits/salades/petite-salade.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='salades'), 'Cesar Classic', 880, 100, 'assets/images/produits/salades/salade-classic-caesar.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='salades'), 'Italienne Mozza', 880, 100, 'assets/images/produits/salades/salade-italian-mozza.png', 1, 3);
|
|
||||||
|
|
||||||
-- 2.g desserts
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Brownie', 260, 100, 'assets/images/produits/desserts/brownies.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake chocolat M&M''S', 310, 100, 'assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake Fraise', 310, 100, 'assets/images/produits/desserts/cheesecake-fraise.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Cookie', 320, 100, 'assets/images/produits/desserts/cookie.png', 1, 4),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Donut', 260, 100, 'assets/images/produits/desserts/doghnut.png', 1, 5),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Macarons', 270, 100, 'assets/images/produits/desserts/macarons.png', 1, 6),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'MC Fleury', 440, 100, 'assets/images/produits/desserts/mcfleury.png', 1, 7),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Muffin', 360, 100, 'assets/images/produits/desserts/muffin.png', 1, 8),
|
|
||||||
((SELECT id FROM category WHERE slug='desserts'), 'Sunday', 100, 100, 'assets/images/produits/desserts/sunday.png', 1, 9);
|
|
||||||
|
|
||||||
-- 2.h sauces
|
|
||||||
INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Classic Barbecue', 70, 100, 'assets/images/produits/sauces/classic-barbecue.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Classic Moutarde', 70, 100, 'assets/images/produits/sauces/classic-moutarde.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Creamy Deluxe', 70, 100, 'assets/images/produits/sauces/cremy-deluxe.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Ketchup', 70, 100, 'assets/images/produits/sauces/ketchup.png', 1, 4),
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Chinoise', 70, 100, 'assets/images/produits/sauces/sauce-chinoise.png', 1, 5),
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Curry', 70, 100, 'assets/images/produits/sauces/sauce-curry.png', 1, 6),
|
|
||||||
((SELECT id FROM category WHERE slug='sauces'), 'Pommes Frites', 70, 100, 'assets/images/produits/sauces/sauce-pommes-frite.png', 1, 7);
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 3. menu (13) — the "menus" category items.
|
|
||||||
-- category_id = the menus category.
|
|
||||||
-- burger_product_id resolved by matching the anchor burger name
|
|
||||||
-- ("Menu Le 280" -> product "Le 280", etc.).
|
|
||||||
-- price_normal_cents from source; price_maxi_cents = normal + 150.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, price_maxi_cents, image_path, is_available, display_order) VALUES
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Le 280'), 'Menu Le 280', 880, 1030, 'assets/images/produits/burgers/280.png', 1, 1),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty'), 'Menu Big Tasty', 1060, 1210, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty Bacon'), 'Menu Big Tasty Bacon', 1090, 1240, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Mac'), 'Menu Big Mac', 800, 950, 'assets/images/produits/burgers/bigmac.png', 1, 4),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='CBO'), 'Menu CBO', 1090, 1240, 'assets/images/produits/burgers/cbo.png', 1, 5),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Chicken'), 'Menu MC Chicken', 930, 1080, 'assets/images/produits/burgers/mcchicken.png', 1, 6),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Crispy'), 'Menu MC Crispy', 720, 870, 'assets/images/produits/burgers/mccrispy.png', 1, 7),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Fish'), 'Menu MC Fish', 720, 870, 'assets/images/produits/burgers/mcfish.png', 1, 8),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Bacon'), 'Menu Royal Bacon', 705, 855, 'assets/images/produits/burgers/royalbacon.png', 1, 9),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Cheese'), 'Menu Royal Cheese', 640, 790, 'assets/images/produits/burgers/royalcheese.png', 1, 10),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Deluxe'), 'Menu Royal Deluxe', 740, 890, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), 'Menu Signature BBQ Beef 2 viandes', 1350, 1500, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12),
|
|
||||||
((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature Beef BBQ'), 'Menu Signature Beef BBQ', 1190, 1340, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13);
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 4. menu_slot — three standard slots per menu:
|
|
||||||
-- drink (required), side (required), sauce (optional).
|
|
||||||
-- One INSERT per slot_type, fanning out over all 13 menus via SELECT.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order)
|
|
||||||
SELECT m.id, 'Boisson', 'drink', 1, 1
|
|
||||||
FROM menu m
|
|
||||||
JOIN category c ON c.id = m.category_id AND c.slug = 'menus';
|
|
||||||
|
|
||||||
INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order)
|
|
||||||
SELECT m.id, 'Accompagnement', 'side', 1, 2
|
|
||||||
FROM menu m
|
|
||||||
JOIN category c ON c.id = m.category_id AND c.slug = 'menus';
|
|
||||||
|
|
||||||
INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order)
|
|
||||||
SELECT m.id, 'Sauce', 'sauce', 0, 3
|
|
||||||
FROM menu m
|
|
||||||
JOIN category c ON c.id = m.category_id AND c.slug = 'menus';
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- 5. menu_slot_option — eligible products per slot:
|
|
||||||
-- drink slot -> all products in category 'boissons'
|
|
||||||
-- side slot -> all products in category 'frites'
|
|
||||||
-- sauce slot -> all products in category 'sauces'
|
|
||||||
-- Composite PK (menu_slot_id, product_id) is naturally satisfied: each
|
|
||||||
-- (slot, product) pair is unique because slots are unique per menu.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
INSERT INTO menu_slot_option (menu_slot_id, product_id)
|
|
||||||
SELECT ms.id, p.id
|
|
||||||
FROM menu_slot ms
|
|
||||||
JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='boissons')
|
|
||||||
WHERE ms.slot_type = 'drink';
|
|
||||||
|
|
||||||
INSERT INTO menu_slot_option (menu_slot_id, product_id)
|
|
||||||
SELECT ms.id, p.id
|
|
||||||
FROM menu_slot ms
|
|
||||||
JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='frites')
|
|
||||||
WHERE ms.slot_type = 'side';
|
|
||||||
|
|
||||||
INSERT INTO menu_slot_option (menu_slot_id, product_id)
|
|
||||||
SELECT ms.id, p.id
|
|
||||||
FROM menu_slot ms
|
|
||||||
JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='sauces')
|
|
||||||
WHERE ms.slot_type = 'sauce';
|
|
||||||
|
|
@ -229,7 +229,7 @@ Reseaux :
|
||||||
- Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h)
|
- Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h)
|
||||||
- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles)
|
- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles)
|
||||||
- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats
|
- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats
|
||||||
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; utilisateurs en **lecture seule** (`user.read`, pas de creation/modification/desactivation), pas d'acces RBAC
|
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; pas d'acces utilisateurs ni RBAC
|
||||||
- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire
|
- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire
|
||||||
- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire
|
- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire
|
||||||
- Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
|
- Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ multi-canal. Chaque acteur candidat est confronte au perimetre reel.
|
||||||
| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. |
|
| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. |
|
||||||
| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. |
|
| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. |
|
||||||
| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). |
|
| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). |
|
||||||
| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), utilisateurs en lecture seule (`user.read`) et sans acces au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". |
|
| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), sans acces aux utilisateurs ni au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". |
|
||||||
| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". |
|
| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". |
|
||||||
| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. |
|
| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. |
|
||||||
|
|
||||||
|
|
|
||||||
22
phpstan.neon
22
phpstan.neon
|
|
@ -1,22 +0,0 @@
|
||||||
# Analyse statique sans Composer : lancee via le .phar autonome
|
|
||||||
# (php phpstan.phar analyse --no-progress --error-format=raw).
|
|
||||||
# Aucune baseline, aucun vendor/ : colle au "from scratch" (PROJECT_CONTEXT.md decision 4).
|
|
||||||
parameters:
|
|
||||||
level: 6
|
|
||||||
paths:
|
|
||||||
- src
|
|
||||||
- tests
|
|
||||||
treatPhpDocTypesAsCertain: false
|
|
||||||
# Les classes de PHPUnit (TestCase, ...) vivent dans le .phar autonome, hors
|
|
||||||
# de src/. On les expose a PHPStan en scannant le phar telecharge par la CI.
|
|
||||||
# Si phpunit.phar est absent (analyse de src/ seul en local), la ligne est
|
|
||||||
# sans effet : on neutralise alors le bruit "classe inconnue" cote tests.
|
|
||||||
scanDirectories:
|
|
||||||
- phar://phpunit.phar
|
|
||||||
ignoreErrors:
|
|
||||||
# Tolere l'absence de phpunit.phar en local : les symboles PHPUnit ne
|
|
||||||
# sont alors pas resolus. En CI le phar est present, l'analyse est complete.
|
|
||||||
-
|
|
||||||
identifier: class.notFound
|
|
||||||
path: tests/*
|
|
||||||
reportUnmatched: false
|
|
||||||
26
phpunit.xml
26
phpunit.xml
|
|
@ -1,26 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
Configuration PHPUnit sans Composer : le bootstrap charge l'autoloader manuel
|
|
||||||
du Core (PSR-4 maison). Lance via le .phar autonome (php phpunit.phar -c phpunit.xml),
|
|
||||||
conformement a la stack lockee (PROJECT_CONTEXT.md section 6 : tests via .phar).
|
|
||||||
-->
|
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
|
|
||||||
bootstrap="tests/bootstrap.php"
|
|
||||||
colors="true"
|
|
||||||
failOnRisky="true"
|
|
||||||
failOnWarning="true"
|
|
||||||
beStrictAboutOutputDuringTests="true"
|
|
||||||
beStrictAboutTestsThatDoNotTestAnything="true"
|
|
||||||
cacheDirectory=".phpunit.cache">
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="unit">
|
|
||||||
<directory>tests/Unit</directory>
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
<source>
|
|
||||||
<include>
|
|
||||||
<directory>src</directory>
|
|
||||||
</include>
|
|
||||||
</source>
|
|
||||||
</phpunit>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use Throwable;
|
|
||||||
use App\Core\Controller;
|
|
||||||
use App\Core\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sonde de sante. GET /api/health.
|
|
||||||
*
|
|
||||||
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
|
|
||||||
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
|
|
||||||
*/
|
|
||||||
final class HealthController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
public function index(array $params = []): Response
|
|
||||||
{
|
|
||||||
$dbStatus = 'ok';
|
|
||||||
$categories = null;
|
|
||||||
$httpStatus = 200;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$row = $this->database->fetch('SELECT COUNT(*) AS total FROM category');
|
|
||||||
$categories = (int) ($row['total'] ?? 0);
|
|
||||||
} catch (Throwable) {
|
|
||||||
// Detail de l'erreur volontairement non expose (information disclosure) ;
|
|
||||||
// un statut degrade suffit a la sonde, les logs conteneur portent le reste.
|
|
||||||
$dbStatus = 'error';
|
|
||||||
$httpStatus = 503;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->json(
|
|
||||||
[
|
|
||||||
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
|
||||||
'app_env' => $this->config->appEnv(),
|
|
||||||
'php_version' => PHP_VERSION,
|
|
||||||
'db' => $dbStatus,
|
|
||||||
'categories' => $categories,
|
|
||||||
],
|
|
||||||
$httpStatus,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
use App\Core\Controller;
|
|
||||||
use App\Core\Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page d'accueil du back-office. GET /.
|
|
||||||
*
|
|
||||||
* Volontairement minimale en P2 : prouve que le rendu de vue MVC traverse
|
|
||||||
* controleur -> vue -> layout sans dependre de la BDD.
|
|
||||||
*/
|
|
||||||
final class HomeController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
public function index(array $params = []): Response
|
|
||||||
{
|
|
||||||
return $this->view('home', [
|
|
||||||
'title' => 'Wakdo back-office',
|
|
||||||
'appEnv' => $this->config->appEnv(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PSR-4 autoloader manuel, sans Composer (exigence "from scratch" Cr 4.c.3).
|
|
||||||
*
|
|
||||||
* Mappe le prefixe de namespace racine "App\" sur le dossier src/.
|
|
||||||
* Exemple : App\Core\Router -> {src}/Core/Router.php
|
|
||||||
*/
|
|
||||||
final class Autoloader
|
|
||||||
{
|
|
||||||
private const PREFIX = 'App\\';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enregistre l'autoloader aupres de la pile SPL.
|
|
||||||
*
|
|
||||||
* La racine src/ est calculee depuis l'emplacement de ce fichier
|
|
||||||
* (src/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/.
|
|
||||||
* Aucun chemin code en dur, donc portable host/conteneur.
|
|
||||||
*/
|
|
||||||
public static function register(): void
|
|
||||||
{
|
|
||||||
$root = dirname(__DIR__);
|
|
||||||
|
|
||||||
spl_autoload_register(static function (string $class) use ($root): void {
|
|
||||||
if (!str_starts_with($class, self::PREFIX)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$relative = substr($class, strlen(self::PREFIX));
|
|
||||||
$path = $root . DIRECTORY_SEPARATOR
|
|
||||||
. str_replace('\\', DIRECTORY_SEPARATOR, $relative)
|
|
||||||
. '.php';
|
|
||||||
|
|
||||||
if (is_file($path)) {
|
|
||||||
require $path;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Acces type a la configuration, lue depuis les variables d'environnement.
|
|
||||||
*
|
|
||||||
* Pas de parsing .env : en conteneur le .env n'est pas monte, les valeurs
|
|
||||||
* sont injectees par docker-compose / l'environnement (getenv).
|
|
||||||
*/
|
|
||||||
final class Config
|
|
||||||
{
|
|
||||||
public function get(string $key, ?string $default = null): ?string
|
|
||||||
{
|
|
||||||
$value = getenv($key);
|
|
||||||
|
|
||||||
// getenv renvoie false si absent ; une chaine vide est traitee comme absente
|
|
||||||
// car les variables d'env vides n'apportent pas d'information exploitable.
|
|
||||||
if ($value === false || $value === '') {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit une valeur obligatoire ; echoue tot si la config est incomplete
|
|
||||||
* plutot que de laisser une erreur survenir plus loin (fail-fast).
|
|
||||||
*/
|
|
||||||
public function required(string $key): string
|
|
||||||
{
|
|
||||||
$value = $this->get($key);
|
|
||||||
|
|
||||||
if ($value === null) {
|
|
||||||
throw new RuntimeException(sprintf('Missing required configuration: %s', $key));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function int(string $key, int $default = 0): int
|
|
||||||
{
|
|
||||||
$value = $this->get($key);
|
|
||||||
|
|
||||||
return $value === null ? $default : (int) $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interprete les conventions usuelles de booleen textuel d'environnement.
|
|
||||||
*/
|
|
||||||
public function bool(string $key, bool $default = false): bool
|
|
||||||
{
|
|
||||||
$value = $this->get($key);
|
|
||||||
|
|
||||||
if ($value === null) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function appEnv(): string
|
|
||||||
{
|
|
||||||
return $this->get('APP_ENV', 'production') ?? 'production';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isDebug(): bool
|
|
||||||
{
|
|
||||||
return $this->bool('APP_DEBUG', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function timezone(): string
|
|
||||||
{
|
|
||||||
return $this->get('APP_TIMEZONE', 'UTC') ?? 'UTC';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controleur de base. Toute la hierarchie de controleurs en herite
|
|
||||||
* (BaseController -> ProductController, etc., demonstration heritage Cr 4.c.1).
|
|
||||||
*
|
|
||||||
* Recoit ses dependances par constructeur : la requete courante, la config et
|
|
||||||
* l'acces BDD, injectes par le Router.
|
|
||||||
*/
|
|
||||||
abstract class Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected readonly Request $request,
|
|
||||||
protected readonly Config $config,
|
|
||||||
protected readonly Database $database,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string|int, mixed> $data
|
|
||||||
*/
|
|
||||||
protected function json(array $data, int $status = 200): Response
|
|
||||||
{
|
|
||||||
return (new Response())->json($data, $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rend une vue PHP sous src/Views/<name>.php avec ses donnees extraites.
|
|
||||||
*
|
|
||||||
* Le rendu est bufferise puis injecte dans le layout via la variable
|
|
||||||
* $content, ce qui permet aux vues de rester de simples fragments.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
protected function view(string $name, array $data = [], int $status = 200): Response
|
|
||||||
{
|
|
||||||
$content = $this->render($name, $data);
|
|
||||||
$html = $this->render('layout', $data + ['content' => $content]);
|
|
||||||
|
|
||||||
return (new Response())->html($html, $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*/
|
|
||||||
private function render(string $name, array $data): string
|
|
||||||
{
|
|
||||||
$file = dirname(__DIR__) . '/Views/' . $name . '.php';
|
|
||||||
|
|
||||||
if (!is_file($file)) {
|
|
||||||
throw new RuntimeException(sprintf('View not found: %s', $name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Les cles deviennent des variables locales a la vue ; le buffering
|
|
||||||
// capture le HTML produit sans l'emettre directement.
|
|
||||||
extract($data, EXTR_SKIP);
|
|
||||||
ob_start();
|
|
||||||
require $file;
|
|
||||||
|
|
||||||
return (string) ob_get_clean();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
use PDO;
|
|
||||||
use PDOStatement;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1).
|
|
||||||
*
|
|
||||||
* Connexion paresseuse : le PDO n'est ouvert qu'au premier acces afin que les
|
|
||||||
* routes sans BDD (ex : la home back-office) fonctionnent meme si la base est
|
|
||||||
* indisponible.
|
|
||||||
*/
|
|
||||||
final class Database
|
|
||||||
{
|
|
||||||
private ?PDO $pdo = null;
|
|
||||||
|
|
||||||
public function __construct(private readonly Config $config)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private function pdo(): PDO
|
|
||||||
{
|
|
||||||
if ($this->pdo === null) {
|
|
||||||
$dsn = sprintf(
|
|
||||||
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
|
||||||
$this->config->required('DB_HOST'),
|
|
||||||
$this->config->int('DB_PORT', 3306),
|
|
||||||
$this->config->required('DB_NAME'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->pdo = new PDO(
|
|
||||||
$dsn,
|
|
||||||
$this->config->required('DB_USER'),
|
|
||||||
$this->config->required('DB_PASSWORD'),
|
|
||||||
[
|
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
||||||
// Vraies requetes preparees cote serveur (pas d'emulation) :
|
|
||||||
// le SQL et les valeurs voyagent separement, fermant l'injection.
|
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->pdo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepare puis execute une requete avec ses parametres lies.
|
|
||||||
*
|
|
||||||
* @param array<string|int, mixed> $params
|
|
||||||
*/
|
|
||||||
public function query(string $sql, array $params = []): PDOStatement
|
|
||||||
{
|
|
||||||
$statement = $this->pdo()->prepare($sql);
|
|
||||||
$statement->execute($params);
|
|
||||||
|
|
||||||
return $statement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string|int, mixed> $params
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
public function fetch(string $sql, array $params = []): ?array
|
|
||||||
{
|
|
||||||
$row = $this->query($sql, $params)->fetch();
|
|
||||||
|
|
||||||
return $row === false ? null : $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string|int, mixed> $params
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function fetchAll(string $sql, array $params = []): array
|
|
||||||
{
|
|
||||||
return $this->query($sql, $params)->fetchAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute une ecriture et renvoie le nombre de lignes affectees.
|
|
||||||
*
|
|
||||||
* @param array<string|int, mixed> $params
|
|
||||||
*/
|
|
||||||
public function execute(string $sql, array $params = []): int
|
|
||||||
{
|
|
||||||
return $this->query($sql, $params)->rowCount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Representation immuable de la requete HTTP entrante.
|
|
||||||
*
|
|
||||||
* Construite depuis les super-globales par fromGlobals() ; le reste de
|
|
||||||
* l'application ne touche jamais $_SERVER / $_GET directement.
|
|
||||||
*/
|
|
||||||
final class Request
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $query
|
|
||||||
* @param array<string, string> $headers
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $method,
|
|
||||||
private readonly string $path,
|
|
||||||
private readonly array $query,
|
|
||||||
private readonly array $headers,
|
|
||||||
private readonly string $rawBody,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromGlobals(): self
|
|
||||||
{
|
|
||||||
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
|
||||||
|
|
||||||
// REQUEST_URI inclut la query string ; on isole le chemin seul.
|
|
||||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
|
||||||
$path = parse_url($uri, PHP_URL_PATH);
|
|
||||||
$path = is_string($path) ? $path : '/';
|
|
||||||
$path = self::normalizePath($path);
|
|
||||||
|
|
||||||
/** @var array<string, string> $query */
|
|
||||||
$query = $_GET;
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
$method,
|
|
||||||
$path,
|
|
||||||
$query,
|
|
||||||
self::extractHeaders(),
|
|
||||||
(string) file_get_contents('php://input'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Garde un slash de tete et retire le slash de fin (sauf racine) pour
|
|
||||||
* que "/api/health/" et "/api/health" matchent la meme route.
|
|
||||||
*/
|
|
||||||
private static function normalizePath(string $path): string
|
|
||||||
{
|
|
||||||
if ($path === '') {
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($path[0] !== '/') {
|
|
||||||
$path = '/' . $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($path !== '/' && str_ends_with($path, '/')) {
|
|
||||||
$path = rtrim($path, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function extractHeaders(): array
|
|
||||||
{
|
|
||||||
$headers = [];
|
|
||||||
|
|
||||||
foreach ($_SERVER as $key => $value) {
|
|
||||||
if (str_starts_with($key, 'HTTP_')) {
|
|
||||||
$name = str_replace('_', '-', substr($key, 5));
|
|
||||||
$headers[strtolower($name)] = (string) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content-Type / Content-Length ne sont pas prefixes HTTP_ par PHP.
|
|
||||||
if (isset($_SERVER['CONTENT_TYPE'])) {
|
|
||||||
$headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
|
|
||||||
}
|
|
||||||
if (isset($_SERVER['CONTENT_LENGTH'])) {
|
|
||||||
$headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function method(): string
|
|
||||||
{
|
|
||||||
return $this->method;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function path(): string
|
|
||||||
{
|
|
||||||
return $this->path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query(string $key, ?string $default = null): ?string
|
|
||||||
{
|
|
||||||
return $this->query[$key] ?? $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function allQuery(): array
|
|
||||||
{
|
|
||||||
return $this->query;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function header(string $name, ?string $default = null): ?string
|
|
||||||
{
|
|
||||||
return $this->headers[strtolower($name)] ?? $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rawBody(): string
|
|
||||||
{
|
|
||||||
return $this->rawBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode le corps JSON ; renvoie un tableau vide si le corps est vide ou
|
|
||||||
* invalide, pour laisser la validation metier decider (pas de fatale ici).
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function json(): array
|
|
||||||
{
|
|
||||||
if ($this->rawBody === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($this->rawBody, true);
|
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reponse HTTP accumulee puis emise par send().
|
|
||||||
*
|
|
||||||
* Permet de construire entierement la reponse avant tout echo, ce qui rend
|
|
||||||
* le front controller testable et evite les "headers already sent".
|
|
||||||
*/
|
|
||||||
final class Response
|
|
||||||
{
|
|
||||||
/** @var array<string, string> */
|
|
||||||
private array $headers = [];
|
|
||||||
|
|
||||||
private string $body = '';
|
|
||||||
|
|
||||||
public function __construct(private int $status = 200)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setStatus(int $status): self
|
|
||||||
{
|
|
||||||
$this->status = $status;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function status(): int
|
|
||||||
{
|
|
||||||
return $this->status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setHeader(string $name, string $value): self
|
|
||||||
{
|
|
||||||
$this->headers[$name] = $value;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBody(string $body): self
|
|
||||||
{
|
|
||||||
$this->body = $body;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $headers
|
|
||||||
*/
|
|
||||||
public static function make(string $body, int $status, array $headers): self
|
|
||||||
{
|
|
||||||
$response = new self($status);
|
|
||||||
$response->body = $body;
|
|
||||||
|
|
||||||
foreach ($headers as $name => $value) {
|
|
||||||
$response->setHeader($name, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string|int, mixed> $data
|
|
||||||
*/
|
|
||||||
public function json(array $data, int $status = 200): self
|
|
||||||
{
|
|
||||||
$this->status = $status;
|
|
||||||
$this->setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
||||||
$this->body = (string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function html(string $body, int $status = 200): self
|
|
||||||
{
|
|
||||||
$this->status = $status;
|
|
||||||
$this->setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
$this->body = $body;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function send(): void
|
|
||||||
{
|
|
||||||
http_response_code($this->status);
|
|
||||||
|
|
||||||
foreach ($this->headers as $name => $value) {
|
|
||||||
header($name . ': ' . $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
echo $this->body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Core;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routeur a base d'expressions regulieres compilees.
|
|
||||||
*
|
|
||||||
* Les patterns acceptent des segments dynamiques {param} compiles en groupes
|
|
||||||
* nommes. Le dispatch distingue 404 (aucun chemin ne correspond) de 405
|
|
||||||
* (le chemin correspond mais pas la methode).
|
|
||||||
*/
|
|
||||||
final class Router
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, array{method: string, regex: string, handler: array{0: class-string, 1: string}}>
|
|
||||||
*/
|
|
||||||
private array $routes = [];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly Config $config,
|
|
||||||
private readonly Database $database,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{0: class-string, 1: string} $handler [ControllerClass::class, 'action']
|
|
||||||
*/
|
|
||||||
public function add(string $method, string $pattern, array $handler): self
|
|
||||||
{
|
|
||||||
$this->routes[] = [
|
|
||||||
'method' => strtoupper($method),
|
|
||||||
'regex' => $this->compile($pattern),
|
|
||||||
'handler' => $handler,
|
|
||||||
];
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Traduit "/api/orders/{number}" en une regex ancree avec groupes nommes.
|
|
||||||
* Les segments litteraux sont echappes pour neutraliser tout metacaractere.
|
|
||||||
*/
|
|
||||||
private function compile(string $pattern): string
|
|
||||||
{
|
|
||||||
$regex = preg_replace_callback(
|
|
||||||
'/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
|
|
||||||
static fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
|
|
||||||
$pattern,
|
|
||||||
);
|
|
||||||
|
|
||||||
// preg_quote n'est pas applicable globalement (il echapperait les groupes
|
|
||||||
// generes) ; les patterns sont des litteraux de route controles, donc on
|
|
||||||
// se contente de figer les delimiteurs avec un delimiteur improbable.
|
|
||||||
return '#^' . $regex . '$#';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resout la requete : instancie le controleur et appelle l'action avec les
|
|
||||||
* parametres de route extraits, ou renvoie une reponse 404 / 405.
|
|
||||||
*/
|
|
||||||
public function dispatch(Request $request): Response
|
|
||||||
{
|
|
||||||
$pathMatched = false;
|
|
||||||
|
|
||||||
foreach ($this->routes as $route) {
|
|
||||||
if (preg_match($route['regex'], $request->path(), $matches) !== 1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pathMatched = true;
|
|
||||||
|
|
||||||
if ($route['method'] !== $request->method()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$params = array_filter(
|
|
||||||
$matches,
|
|
||||||
static fn (int|string $key): bool => is_string($key),
|
|
||||||
ARRAY_FILTER_USE_KEY,
|
|
||||||
);
|
|
||||||
|
|
||||||
[$controllerClass, $action] = $route['handler'];
|
|
||||||
|
|
||||||
/** @var Controller $controller */
|
|
||||||
$controller = new $controllerClass($request, $this->config, $this->database);
|
|
||||||
|
|
||||||
return $controller->$action($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pathMatched) {
|
|
||||||
return (new Response())->json(
|
|
||||||
['data' => null, 'error' => ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Method not allowed']],
|
|
||||||
405,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (new Response())->json(
|
|
||||||
['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Resource not found']],
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fragment de la page d'accueil back-office, injecte dans layout.php.
|
|
||||||
*
|
|
||||||
* @var string $appEnv
|
|
||||||
*/
|
|
||||||
|
|
||||||
$env = htmlspecialchars($appEnv ?? 'unknown', ENT_QUOTES, 'UTF-8');
|
|
||||||
?>
|
|
||||||
<main>
|
|
||||||
<h1>Wakdo back-office</h1>
|
|
||||||
<p>Le squelette back-end (P2) est en ligne.</p>
|
|
||||||
<p>
|
|
||||||
<small>
|
|
||||||
Coeur MVC from scratch : autoloader PSR-4 manuel, routeur, PDO prepared statements.
|
|
||||||
Environnement : <code><?= $env ?></code>.
|
|
||||||
</small>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<small>Sonde de sante : <code>GET /api/health</code></small>
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gabarit HTML5 du back-office. Recoit $title et $content depuis le controleur.
|
|
||||||
* Les variables sont fournies par Controller::view() via extract().
|
|
||||||
*
|
|
||||||
* @var string $title
|
|
||||||
* @var string $content
|
|
||||||
*/
|
|
||||||
|
|
||||||
$pageTitle = htmlspecialchars($title ?? 'Wakdo', ENT_QUOTES, 'UTF-8');
|
|
||||||
?><!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<!-- Back-office prive : jamais indexe par les moteurs de recherche. -->
|
|
||||||
<meta name="robots" content="noindex, nofollow">
|
|
||||||
<title><?= $pageTitle ?></title>
|
|
||||||
<style>
|
|
||||||
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a1a1a; line-height: 1.5; }
|
|
||||||
h1 { font-size: 1.5rem; }
|
|
||||||
small { color: #666; }
|
|
||||||
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<?= $content ?? '' ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,53 +1,34 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
// Stub pour debloquer le routage Apache + valider la chaine FastCGI vers PHP-FPM.
|
||||||
* Front controller du vhost admin (back-office + API sous /api).
|
// Sera remplace par le front controller MVC en phase P2 (src/Core/Router.php a venir).
|
||||||
*
|
|
||||||
* Apache reecrit toute requete non-fichier vers ce fichier (RewriteRule ^ index.php).
|
|
||||||
* Le REQUEST_URI arrive intact (pas de prefixe strippe), donc le routeur voit
|
|
||||||
* "/", "/api/health", etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
use App\Controllers\HealthController;
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
use App\Controllers\HomeController;
|
|
||||||
use App\Core\Autoloader;
|
|
||||||
use App\Core\Config;
|
|
||||||
use App\Core\Database;
|
|
||||||
use App\Core\Request;
|
|
||||||
use App\Core\Response;
|
|
||||||
use App\Core\Router;
|
|
||||||
|
|
||||||
// src/public/admin/index.php : __DIR__ = src/public/admin ; remonter de deux
|
|
||||||
// niveaux (admin -> public -> src) pour atteindre la racine src/.
|
|
||||||
require dirname(__DIR__, 2) . '/Core/Autoloader.php';
|
|
||||||
Autoloader::register();
|
|
||||||
|
|
||||||
// En-tetes de securite poses tot, valables sur toute reponse y compris une 500.
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
header('X-Robots-Tag: noindex, nofollow');
|
header('X-Robots-Tag: noindex, nofollow');
|
||||||
|
|
||||||
$config = new Config();
|
$phpVersion = htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8');
|
||||||
date_default_timezone_set($config->timezone());
|
$now = htmlspecialchars(date('Y-m-d H:i:s'), ENT_QUOTES, 'UTF-8');
|
||||||
|
?><!DOCTYPE html>
|
||||||
try {
|
<html lang="fr">
|
||||||
// Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(),
|
<head>
|
||||||
// donc la home back-office reste servie meme base indisponible.
|
<meta charset="UTF-8">
|
||||||
$database = new Database($config);
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
$router = new Router($config, $database);
|
<title>Wakdo - back-office</title>
|
||||||
$router->add('GET', '/', [HomeController::class, 'index']);
|
<style>
|
||||||
$router->add('GET', '/api/health', [HealthController::class, 'index']);
|
body { font-family: system-ui, sans-serif; margin: 2rem; color: #222; }
|
||||||
|
img { max-height: 80px; }
|
||||||
$response = $router->dispatch(Request::fromGlobals());
|
small { color: #666; }
|
||||||
$response->send();
|
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
|
||||||
} catch (Throwable $exception) {
|
</style>
|
||||||
// En debug on remonte le message pour iterer ; en prod, reponse generique
|
</head>
|
||||||
// pour ne rien divulguer de la pile interne (information disclosure).
|
<body>
|
||||||
$payload = $config->isDebug()
|
<h1>Wakdo - back-office</h1>
|
||||||
? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]]
|
<p>En construction.</p>
|
||||||
: ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']];
|
<p><small>Phase P1 - conception Merise en cours. Le back-office sera implemente en phases P2 a P4.</small></p>
|
||||||
|
<hr>
|
||||||
(new Response())->json($payload, 500)->send();
|
<p><small>Diagnostic FastCGI : PHP <code><?= $phpVersion ?></code> repond a <code><?= $now ?></code>.</small></p>
|
||||||
}
|
<p><small>TODO P2 : assets partages (logo, images produits) via Apache Alias entre les 2 vhosts.</small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use RuntimeException;
|
|
||||||
use App\Core\Config;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* La config lit getenv (pas de .env en conteneur). Les tests pilotent
|
|
||||||
* l'environnement via putenv et nettoient apres eux pour ne pas polluer les
|
|
||||||
* autres cas.
|
|
||||||
*/
|
|
||||||
final class ConfigTest extends TestCase
|
|
||||||
{
|
|
||||||
private Config $config;
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
private array $touchedKeys = [];
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->config = new Config();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
foreach ($this->touchedKeys as $key) {
|
|
||||||
putenv($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->touchedKeys = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function setEnv(string $key, string $value): void
|
|
||||||
{
|
|
||||||
$this->touchedKeys[] = $key;
|
|
||||||
putenv($key . '=' . $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGetReturnsValueWhenPresent(): void
|
|
||||||
{
|
|
||||||
$this->setEnv('WAKDO_TEST_NAME', 'borne');
|
|
||||||
|
|
||||||
self::assertSame('borne', $this->config->get('WAKDO_TEST_NAME'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGetReturnsDefaultWhenAbsent(): void
|
|
||||||
{
|
|
||||||
self::assertSame('fallback', $this->config->get('WAKDO_TEST_MISSING', 'fallback'));
|
|
||||||
self::assertNull($this->config->get('WAKDO_TEST_MISSING'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testGetTreatsEmptyStringAsAbsent(): void
|
|
||||||
{
|
|
||||||
// Une variable d'env vide n'apporte pas d'information : Config la traite
|
|
||||||
// comme absente et renvoie le defaut (contrat documente dans Config::get).
|
|
||||||
$this->setEnv('WAKDO_TEST_EMPTY', '');
|
|
||||||
|
|
||||||
self::assertSame('def', $this->config->get('WAKDO_TEST_EMPTY', 'def'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIntCastsValue(): void
|
|
||||||
{
|
|
||||||
$this->setEnv('WAKDO_TEST_PORT', '3307');
|
|
||||||
|
|
||||||
self::assertSame(3307, $this->config->int('WAKDO_TEST_PORT'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIntReturnsDefaultWhenAbsent(): void
|
|
||||||
{
|
|
||||||
self::assertSame(3306, $this->config->int('WAKDO_TEST_PORT_MISSING', 3306));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{0: string, 1: bool}>
|
|
||||||
*/
|
|
||||||
public static function truthyValuesProvider(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['1', true],
|
|
||||||
['true', true],
|
|
||||||
['TRUE', true],
|
|
||||||
['yes', true],
|
|
||||||
['on', true],
|
|
||||||
['0', false],
|
|
||||||
['false', false],
|
|
||||||
['no', false],
|
|
||||||
['off', false],
|
|
||||||
['anything-else', false],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
#[DataProvider('truthyValuesProvider')]
|
|
||||||
public function testBoolInterpretsCommonConventions(string $raw, bool $expected): void
|
|
||||||
{
|
|
||||||
$this->setEnv('WAKDO_TEST_FLAG', $raw);
|
|
||||||
|
|
||||||
self::assertSame($expected, $this->config->bool('WAKDO_TEST_FLAG'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testBoolReturnsDefaultWhenAbsent(): void
|
|
||||||
{
|
|
||||||
self::assertTrue($this->config->bool('WAKDO_TEST_FLAG_MISSING', true));
|
|
||||||
self::assertFalse($this->config->bool('WAKDO_TEST_FLAG_MISSING', false));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRequiredReturnsValueWhenPresent(): void
|
|
||||||
{
|
|
||||||
$this->setEnv('WAKDO_TEST_DB', 'wakdo');
|
|
||||||
|
|
||||||
self::assertSame('wakdo', $this->config->required('WAKDO_TEST_DB'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testRequiredThrowsWhenMissing(): void
|
|
||||||
{
|
|
||||||
$this->expectException(RuntimeException::class);
|
|
||||||
$this->expectExceptionMessage('Missing required configuration: WAKDO_TEST_REQUIRED_MISSING');
|
|
||||||
|
|
||||||
$this->config->required('WAKDO_TEST_REQUIRED_MISSING');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Unit;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use App\Core\Config;
|
|
||||||
use App\Core\Controller;
|
|
||||||
use App\Core\Database;
|
|
||||||
use App\Core\Request;
|
|
||||||
use App\Core\Response;
|
|
||||||
use App\Core\Router;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controleur sonde : capture les parametres de route recus pour prouver
|
|
||||||
* l'extraction du segment {id} par le Router. Etend le vrai Controller du Core
|
|
||||||
* pour traverser le meme chemin d'instanciation que la production.
|
|
||||||
*
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
final class RouteProbeController extends Controller
|
|
||||||
{
|
|
||||||
/** @var array<string, string> */
|
|
||||||
public static array $capturedParams = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
public function show(array $params = []): Response
|
|
||||||
{
|
|
||||||
self::$capturedParams = $params;
|
|
||||||
|
|
||||||
return (new Response())->json(['data' => $params], 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class RouterTest extends TestCase
|
|
||||||
{
|
|
||||||
private Config $config;
|
|
||||||
private Database $database;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->config = new Config();
|
|
||||||
// Le PDO est paresseux (ouvert au premier acces), donc construire la
|
|
||||||
// Database ne tente aucune connexion : aucune BDD requise pour ces tests.
|
|
||||||
$this->database = new Database($this->config);
|
|
||||||
RouteProbeController::$capturedParams = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fabrique une Request sans toucher aux super-globales : le constructeur
|
|
||||||
* de Request est public, on injecte directement methode et chemin.
|
|
||||||
*/
|
|
||||||
private function request(string $method, string $path): Request
|
|
||||||
{
|
|
||||||
return new Request($method, $path, [], [], '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function router(): Router
|
|
||||||
{
|
|
||||||
return new Router($this->config, $this->database);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMatchedRouteExtractsNamedParam(): void
|
|
||||||
{
|
|
||||||
$router = $this->router();
|
|
||||||
$router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']);
|
|
||||||
|
|
||||||
$response = $router->dispatch($this->request('GET', '/api/orders/42'));
|
|
||||||
|
|
||||||
self::assertSame(200, $response->status());
|
|
||||||
self::assertSame(['id' => '42'], RouteProbeController::$capturedParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMultipleParamsAreAllExtracted(): void
|
|
||||||
{
|
|
||||||
$router = $this->router();
|
|
||||||
$router->add('GET', '/api/menus/{menu}/options/{option}', [RouteProbeController::class, 'show']);
|
|
||||||
|
|
||||||
$response = $router->dispatch($this->request('GET', '/api/menus/7/options/maxi'));
|
|
||||||
|
|
||||||
self::assertSame(200, $response->status());
|
|
||||||
self::assertSame(['menu' => '7', 'option' => 'maxi'], RouteProbeController::$capturedParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUnknownPathReturns404(): void
|
|
||||||
{
|
|
||||||
$router = $this->router();
|
|
||||||
$router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']);
|
|
||||||
|
|
||||||
$response = $router->dispatch($this->request('GET', '/api/nope'));
|
|
||||||
|
|
||||||
self::assertSame(404, $response->status());
|
|
||||||
self::assertSame([], RouteProbeController::$capturedParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testKnownPathWrongMethodReturns405(): void
|
|
||||||
{
|
|
||||||
$router = $this->router();
|
|
||||||
// Seul GET est enregistre sur ce chemin ; un POST matche le chemin mais
|
|
||||||
// pas la methode, ce qui doit produire 405 et non 404.
|
|
||||||
$router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']);
|
|
||||||
|
|
||||||
$response = $router->dispatch($this->request('POST', '/api/orders/42'));
|
|
||||||
|
|
||||||
self::assertSame(405, $response->status());
|
|
||||||
self::assertSame([], RouteProbeController::$capturedParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testMethodMatchingIsCaseInsensitiveOnRegistration(): void
|
|
||||||
{
|
|
||||||
$router = $this->router();
|
|
||||||
// add() normalise la methode en majuscules ; une route "get" doit donc
|
|
||||||
// repondre a une requete GET.
|
|
||||||
$router->add('get', '/api/health', [RouteProbeController::class, 'show']);
|
|
||||||
|
|
||||||
$response = $router->dispatch($this->request('GET', '/api/health'));
|
|
||||||
|
|
||||||
self::assertSame(200, $response->status());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Amorce PHPUnit sans Composer : on charge l'autoloader manuel du Core puis on
|
|
||||||
* l'enregistre, exactement comme le fait le front controller en production
|
|
||||||
* (src/public/admin/index.php). Les tests resolvent ainsi App\... via PSR-4.
|
|
||||||
*/
|
|
||||||
|
|
||||||
require __DIR__ . '/../src/Core/Autoloader.php';
|
|
||||||
|
|
||||||
App\Core\Autoloader::register();
|
|
||||||
Loading…
Add table
Reference in a new issue