diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/TestResetController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/TestResetController.java new file mode 100644 index 0000000..d9d229e --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/TestResetController.java @@ -0,0 +1,93 @@ +package de.cannamanage.api.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +/** + * Test-only controller for resetting the database to a known seed state. + * Only active when cannamanage.test.endpoints.enabled=true (test profile). + * NEVER activate this in production. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/test") +@RequiredArgsConstructor +@ConditionalOnProperty(name = "cannamanage.test.endpoints.enabled", havingValue = "true") +public class TestResetController { + + private final DataSource dataSource; + + /** + * Truncates all application tables and re-seeds with test data. + * The Flyway schema_history table is preserved. + */ + @PostMapping("/reset-db") + public ResponseEntity resetDatabase() { + log.info("Test DB reset requested — truncating all tables and re-seeding"); + + try (Connection conn = dataSource.getConnection()) { + truncateAllTables(conn); + reseed(); + log.info("Test DB reset complete — seed data re-applied"); + return ResponseEntity.ok().build(); + } catch (SQLException e) { + log.error("Failed to reset test database", e); + return ResponseEntity.internalServerError().build(); + } + } + + private void truncateAllTables(Connection conn) throws SQLException { + List tables = getApplicationTables(conn); + + try (Statement stmt = conn.createStatement()) { + // Disable FK constraints for truncation + stmt.execute("SET session_replication_role = 'replica'"); + + for (String table : tables) { + stmt.execute("TRUNCATE TABLE " + table + " CASCADE"); + log.debug("Truncated table: {}", table); + } + + // Re-enable FK constraints + stmt.execute("SET session_replication_role = 'origin'"); + } + } + + private List getApplicationTables(Connection conn) throws SQLException { + List tables = new ArrayList<>(); + + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery( + "SELECT tablename FROM pg_tables " + + "WHERE schemaname = 'public' " + + "AND tablename != 'flyway_schema_history'")) { + while (rs.next()) { + tables.add(rs.getString("tablename")); + } + } + + return tables; + } + + private void reseed() { + ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); + populator.addScript(new ClassPathResource("db/testdata/R__seed_test_data.sql")); + populator.setSeparator(";"); + populator.execute(dataSource); + } +} diff --git a/cannamanage-api/src/main/resources/application-test.properties b/cannamanage-api/src/main/resources/application-test.properties new file mode 100644 index 0000000..26079f6 --- /dev/null +++ b/cannamanage-api/src/main/resources/application-test.properties @@ -0,0 +1,31 @@ +# ============================================= +# application-test.properties +# Profile: test — for integration test environment +# Activate with: -Dspring.profiles.active=test +# ============================================= + +# Database: use docker-compose.test.yml PostgreSQL +spring.datasource.url=jdbc:postgresql://localhost:5433/cannamanage_test +spring.datasource.username=cannamanage_test +spring.datasource.password=test_password +spring.jpa.hibernate.ddl-auto=validate + +# Flyway: include test seed data +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration,classpath:db/testdata + +# Enable test-only endpoints (TestResetController) +cannamanage.test.endpoints.enabled=true + +# Disable schedulers during test runs +cannamanage.schedulers.enabled=false + +# JWT: deterministic test secret (base64-encoded 256-bit key) +cannamanage.security.jwt.secret=dGVzdC1zZWNyZXQta2V5LWZvci1pbnRlZ3JhdGlvbi10ZXN0cy1vbmx5LTMyYg== +cannamanage.security.jwt.access-token-expiry=3600 +cannamanage.security.jwt.refresh-token-expiry=86400 + +# Logging +logging.level.de.cannamanage=DEBUG +logging.level.org.flywaydb=INFO +logging.level.org.springframework.security=DEBUG diff --git a/cannamanage-api/src/main/resources/db/testdata/R__seed_test_data.sql b/cannamanage-api/src/main/resources/db/testdata/R__seed_test_data.sql new file mode 100644 index 0000000..22cd648 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/testdata/R__seed_test_data.sql @@ -0,0 +1,265 @@ +-- R__seed_test_data.sql — Repeatable Flyway migration for integration test data +-- This file is idempotent: uses ON CONFLICT DO NOTHING for all inserts. +-- Activated only when spring.flyway.locations includes classpath:db/testdata + +-- ============================================================ +-- 1. CLUB +-- ============================================================ +INSERT INTO clubs (id, tenant_id, name, address, license_number, max_members, status, created_at) +VALUES ( + 'a0000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', + 'Grüner Daumen e.V.', + 'Hanfstraße 42, 10115 Berlin', + 'LIC-2024-GD-001', + 500, + 'ACTIVE', + '2024-01-01T00:00:00Z' +) ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 2. MEMBERS (7) +-- ============================================================ +INSERT INTO members (id, tenant_id, club_id, first_name, last_name, email, date_of_birth, membership_date, membership_number, status, is_under_21, prevention_officer, created_at) +VALUES + ('c1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Max', 'Mustermann', 'max@gruener-daumen.de', '1990-05-20', '2024-01-15', 'GD-001', 'ACTIVE', FALSE, FALSE, '2024-01-15T10:00:00Z'), + ('c1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Anna', 'Schmidt', 'anna@gruener-daumen.de', '1985-11-03', '2024-02-01', 'GD-002', 'ACTIVE', FALSE, FALSE, '2024-02-01T10:00:00Z'), + ('c1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Jonas', 'Weber', 'jonas@gruener-daumen.de', '2006-03-15', '2024-03-10', 'GD-003', 'ACTIVE', TRUE, FALSE, '2024-03-10T10:00:00Z'), + ('c1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Maria', 'Müller', 'maria@gruener-daumen.de', '1978-08-22', '2023-06-01', 'GD-004', 'SUSPENDED', FALSE, FALSE, '2023-06-01T10:00:00Z'), + ('c1000000-0000-0000-0000-000000000005', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Thomas', 'Müller', 'thomas@gruener-daumen.de', '1992-12-01', '2024-01-20', 'GD-005', 'ACTIVE', FALSE, FALSE, '2024-01-20T10:00:00Z'), + ('c1000000-0000-0000-0000-000000000006', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Lisa', 'Bauer', 'lisa@gruener-daumen.de', '1995-07-14', '2024-04-01', 'GD-006', 'ACTIVE', FALSE, FALSE, '2024-04-01T10:00:00Z'), + ('c1000000-0000-0000-0000-000000000007', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Karl', 'Fischer', 'karl@gruener-daumen.de', '1980-02-28', '2023-01-01', 'GD-007', 'EXPELLED', FALSE, FALSE, '2023-01-01T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 3. USERS (admin staff account) +-- ============================================================ +INSERT INTO users (id, tenant_id, member_id, email, password_hash, role, active, created_at) +VALUES ( + 'b1000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000001', + 'admin@gruener-daumen.de', + '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', + 'ROLE_ADMIN', + TRUE, + '2024-01-15T10:00:00Z' +) ON CONFLICT (id) DO NOTHING; + +-- Additional user accounts for members who need to author forum/info-board posts +INSERT INTO users (id, tenant_id, member_id, email, password_hash, role, active, created_at) +VALUES + ('b1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000002', + 'anna.user@gruener-daumen.de', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'ROLE_MEMBER', TRUE, '2024-02-01T10:00:00Z'), + ('b1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000003', + 'jonas.user@gruener-daumen.de', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'ROLE_MEMBER', TRUE, '2024-03-10T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 4. STRAINS (3) +-- ============================================================ +INSERT INTO strains (id, tenant_id, name, thc_percentage, cbd_percentage, description, created_at) +VALUES + ('d1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Northern Lights', 18.50, 0.50, 'Klassische Indica, entspannend und schmerzlindernd', '2024-04-01T10:00:00Z'), + ('d1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', + 'CBD Critical Mass', 5.00, 12.00, 'CBD-dominante Sorte für medizinische Anwendungen', '2024-04-01T10:00:00Z'), + ('d1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', + 'Amnesia Haze', 22.00, 0.10, 'Starke Sativa mit hohem THC-Gehalt', '2024-04-01T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 5. BATCHES (3) +-- ============================================================ +INSERT INTO batches (id, tenant_id, strain_id, quantity_grams, harvest_date, batch_code, status, contamination_flag, created_at) +VALUES + ('e1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'd1000000-0000-0000-0000-000000000001', 500.00, '2024-04-25', 'NL-2024-001', 'AVAILABLE', FALSE, '2024-05-01T10:00:00Z'), + ('e1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', + 'd1000000-0000-0000-0000-000000000002', 300.00, '2024-05-10', 'CM-2024-001', 'AVAILABLE', FALSE, '2024-05-15T10:00:00Z'), + ('e1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', + 'd1000000-0000-0000-0000-000000000003', 200.00, '2024-03-20', 'AH-2024-001', 'RECALLED', TRUE, '2024-04-01T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 6. DISTRIBUTIONS (3 recent) +-- ============================================================ +INSERT INTO distributions (id, tenant_id, member_id, batch_id, quantity_grams, distributed_at, recorded_by, notes, thc_percentage, cbd_percentage, strain_name, created_at) +VALUES + ('dd000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000001', 'e1000000-0000-0000-0000-000000000001', + 5.00, NOW() - INTERVAL '2 days', 'c1000000-0000-0000-0000-000000000001', 'Reguläre Abgabe', + 18.50, 0.50, 'Northern Lights', NOW() - INTERVAL '2 days'), + ('dd000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000002', 'e1000000-0000-0000-0000-000000000002', + 3.00, NOW() - INTERVAL '1 day', 'c1000000-0000-0000-0000-000000000001', 'CBD-Abgabe', + 5.00, 12.00, 'CBD Critical Mass', NOW() - INTERVAL '1 day'), + ('dd000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000005', 'e1000000-0000-0000-0000-000000000002', + 23.00, NOW(), 'c1000000-0000-0000-0000-000000000001', 'Nahe am Monatslimit (25g)', + 5.00, 12.00, 'CBD Critical Mass', NOW()) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 7. MONTHLY QUOTAS (Thomas near-quota) +-- ============================================================ +INSERT INTO monthly_quotas (id, tenant_id, member_id, year, month, total_distributed, max_allowed, version, created_at) +VALUES + ('mq000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'c1000000-0000-0000-0000-000000000005', + EXTRACT(YEAR FROM NOW())::INT, EXTRACT(MONTH FROM NOW())::INT, + 23.00, 25.00, 1, NOW()) +ON CONFLICT (member_id, year, month) DO NOTHING; + +-- ============================================================ +-- 8. DOCUMENTS (4) +-- ============================================================ +INSERT INTO documents (id, tenant_id, club_id, title, category, filename, content_type, file_size, storage_path, access_level, description, uploaded_by, created_at) +VALUES + ('f1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Vereinssatzung 2024', 'SATZUNG', 'satzung-2024.pdf', 'application/pdf', 245000, + '/documents/a0000000/satzung-2024.pdf', 'ALL_MEMBERS', 'Aktuelle Vereinssatzung gemäß §18 KCanG', + 'b1000000-0000-0000-0000-000000000001', '2024-01-15T10:00:00Z'), + ('f1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Protokoll MV März 2024', 'PROTOKOLL', 'protokoll-mv-2024-03.pdf', 'application/pdf', 128000, + '/documents/a0000000/protokoll-mv-2024-03.pdf', 'ALL_MEMBERS', 'Protokoll der Mitgliederversammlung vom 15.03.2024', + 'b1000000-0000-0000-0000-000000000001', '2024-03-16T10:00:00Z'), + ('f1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'KCanG-Genehmigung', 'GENEHMIGUNG', 'kcang-genehmigung.pdf', 'application/pdf', 340000, + '/documents/a0000000/kcang-genehmigung.pdf', 'BOARD_ONLY', 'Genehmigungsbescheid nach §11 KCanG', + 'b1000000-0000-0000-0000-000000000001', '2024-01-10T10:00:00Z'), + ('f1000000-0000-0000-0000-000000000004', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Mietvertrag', 'VERTRAG', 'mietvertrag-vereinsheim.pdf', 'application/pdf', 520000, + '/documents/a0000000/mietvertrag-vereinsheim.pdf', 'BOARD_ONLY', 'Mietvertrag für Vereinsräume', + 'b1000000-0000-0000-0000-000000000001', '2023-12-01T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 9. BOARD POSITIONS (3) +-- ============================================================ +INSERT INTO board_positions (id, tenant_id, club_id, title, description, sort_order, is_active, created_at) +VALUES + ('g1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Vorsitzende/r', 'Erste/r Vorsitzende/r des Vereins', 1, TRUE, '2024-01-15T10:00:00Z'), + ('g1000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Kassenführung', 'Schatzmeister/in — Kassenführung und Finanzen', 2, TRUE, '2024-01-15T10:00:00Z'), + ('g1000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Schriftführung', 'Protokollführung und Korrespondenz', 3, TRUE, '2024-01-15T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- Board members (Max = Vorsitzender, Anna = Kassenführung, Schriftführung = vacant) +INSERT INTO board_members (id, tenant_id, club_id, position_id, member_id, elected_at, term_start, is_current, created_at) +VALUES + ('gm000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'g1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001', + '2024-01-15', '2024-01-15', TRUE, '2024-01-15T10:00:00Z'), + ('gm000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'g1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000002', + '2024-01-15', '2024-01-15', TRUE, '2024-01-15T10:00:00Z') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 10. EVENTS (2) +-- ============================================================ +INSERT INTO club_events (id, club_id, title, description, event_type, start_at, end_at, location, created_by, tenant_id, created_at, updated_at) +VALUES + ('ev000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Mitgliederversammlung Q3', 'Ordentliche Mitgliederversammlung mit Vorstandswahl', + 'ASSEMBLY', NOW() + INTERVAL '14 days', NOW() + INTERVAL '14 days' + INTERVAL '2 hours', + 'Vereinsheim, Hanfstraße 42', 'b1000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days'), + ('ev000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', + 'Gartentag Mai', 'Gemeinsamer Gartentag — Pflege der Anbauflächen', + 'SOCIAL', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days' + INTERVAL '4 hours', + 'Vereinsgarten', 'b1000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '45 days', NOW() - INTERVAL '45 days') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 11. FORUM TOPICS (2) + REPLIES +-- ============================================================ +INSERT INTO forum_topics (id, club_id, tenant_id, title, content, author_id, reply_count, last_reply_at, created_at) +VALUES + ('ft000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Neue Sorten für Sommer', 'Welche Sorten sollen wir diesen Sommer anbauen? Ich schlage vor, mehr CBD-lastige Sorten zu probieren.', + 'b1000000-0000-0000-0000-000000000001', 3, NOW() - INTERVAL '2 days', NOW() - INTERVAL '10 days'), + ('ft000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Bewässerungssystem', 'Hat jemand Erfahrung mit automatischen Bewässerungssystemen für den Indoor-Bereich?', + 'b1000000-0000-0000-0000-000000000002', 1, NOW() - INTERVAL '5 days', NOW() - INTERVAL '7 days') +ON CONFLICT (id) DO NOTHING; + +-- Forum replies +INSERT INTO forum_replies (id, topic_id, club_id, tenant_id, content, author_id, created_at) +VALUES + ('fr000000-0000-0000-0000-000000000001', 'ft000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'CBD Critical Mass hat sich bei uns bewährt — guter Ertrag und medizinisch wertvoll!', + 'b1000000-0000-0000-0000-000000000002', NOW() - INTERVAL '9 days'), + ('fr000000-0000-0000-0000-000000000002', 'ft000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Finde ich gut! Vielleicht auch Charlotte''s Web als weitere CBD-Option?', + 'b1000000-0000-0000-0000-000000000003', NOW() - INTERVAL '7 days'), + ('fr000000-0000-0000-0000-000000000003', 'ft000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Stimme zu — lasst uns in der MV darüber abstimmen.', + 'b1000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 days'), + ('fr000000-0000-0000-0000-000000000004', 'ft000000-0000-0000-0000-000000000002', + 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Wir nutzen BlueMat-Tropfer — funktioniert super für Erde und Kokos.', + 'b1000000-0000-0000-0000-000000000001', NOW() - INTERVAL '5 days') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 12. INFO BOARD POSTS (2) +-- ============================================================ +INSERT INTO info_board_posts (id, club_id, title, content, category, is_pinned, is_archived, author_id, tenant_id, created_at, updated_at) +VALUES + ('ib000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'Willkommen neue Mitglieder', 'Herzlich willkommen bei Grüner Daumen e.V.! Bitte lest die Vereinssatzung und meldet euch bei Fragen beim Vorstand.', + 'GENERAL', TRUE, FALSE, 'b1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'), + ('ib000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', + 'Öffnungszeiten Sommer', 'Ab Juni gelten erweiterte Öffnungszeiten: Mo-Fr 10-20 Uhr, Sa 10-16 Uhr.', + 'MAINTENANCE', FALSE, FALSE, 'b1000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + NOW() - INTERVAL '14 days', NOW() - INTERVAL '14 days') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 13. GROW ENTRIES (2) +-- ============================================================ +INSERT INTO grow_entries (id, name, strain_id, status, started_at, expected_harvest_at, notes, tenant_id, created_at, updated_at) +VALUES + ('ge000000-0000-0000-0000-000000000001', + 'Northern Lights Batch #2', 'd1000000-0000-0000-0000-000000000001', 'VEGETATIVE', + NOW() - INTERVAL '21 days', NOW() + INTERVAL '49 days', + 'Zweiter Indoor-Batch NL, 6 Pflanzen', + 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '21 days', NOW() - INTERVAL '1 day'), + ('ge000000-0000-0000-0000-000000000002', + 'CBD Outdoor', 'd1000000-0000-0000-0000-000000000002', 'SEEDLING', + NOW() - INTERVAL '7 days', NOW() + INTERVAL '90 days', + 'Outdoor-Test mit CBD Critical Mass, 4 Pflanzen', + 'a0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '7 days', NOW() - INTERVAL '1 day') +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 14. COMPLIANCE DEADLINES (3) +-- ============================================================ +INSERT INTO compliance_deadlines (id, tenant_id, club_id, area, title, description, due_date, is_recurring, created_at) +VALUES + ('cd000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'KCANG', 'Jahresbericht', 'Jährlicher Tätigkeitsbericht an die zuständige Behörde gemäß §22 KCanG', + (CURRENT_DATE + INTERVAL '60 days')::DATE, TRUE, NOW() - INTERVAL '30 days'), + ('cd000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'FINANCE', 'EÜR Abgabe', 'Einnahmen-Überschuss-Rechnung an das Finanzamt', + (CURRENT_DATE - INTERVAL '5 days')::DATE, FALSE, NOW() - INTERVAL '60 days'), + ('cd000000-0000-0000-0000-000000000003', 'a0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', + 'VEREIN', 'Mitgliederversammlung', 'Ordentliche Mitgliederversammlung (mindestens 1x jährlich)', + (CURRENT_DATE + INTERVAL '14 days')::DATE, TRUE, NOW() - INTERVAL '14 days') +ON CONFLICT (id) DO NOTHING; diff --git a/cannamanage-frontend/Dockerfile.playwright b/cannamanage-frontend/Dockerfile.playwright new file mode 100644 index 0000000..fb11ac7 --- /dev/null +++ b/cannamanage-frontend/Dockerfile.playwright @@ -0,0 +1,18 @@ +# IMPORTANT: Keep this version in sync with @playwright/test in package.json +FROM mcr.microsoft.com/playwright:v1.60.0-noble + +WORKDIR /app + +# Copy package files for dependency installation +COPY package.json pnpm-lock.yaml .npmrc ./ + +# Install pnpm and project dependencies at build time +RUN corepack enable && corepack prepare pnpm@latest --activate +RUN pnpm install --frozen-lockfile + +# Copy playwright config and test infrastructure +COPY playwright.config.ts tsconfig.json ./ +COPY e2e/ ./e2e/ + +# Default command (overridden by docker-compose) +CMD ["npx", "playwright", "test", "e2e/integration/", "--reporter=list"] diff --git a/cannamanage-frontend/e2e/api-client.ts b/cannamanage-frontend/e2e/api-client.ts new file mode 100644 index 0000000..7cc9aa9 --- /dev/null +++ b/cannamanage-frontend/e2e/api-client.ts @@ -0,0 +1,74 @@ +/** + * API client for integration tests. + * Used for direct backend calls: DB verification, test reset, data assertions. + */ +const API_URL = process.env.API_URL || "http://localhost:8080" + +export class ApiClient { + private token: string | null = null + + async login(email: string, password: string): Promise { + const res = await fetch(`${API_URL}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) throw new Error(`Login failed: ${res.status}`) + const data = await res.json() + this.token = data.token + } + + async resetDb(): Promise { + const res = await fetch(`${API_URL}/api/v1/test/reset-db`, { + method: "POST", + headers: this.authHeaders(), + }) + if (!res.ok) throw new Error(`DB reset failed: ${res.status}`) + } + + async getMembers(): Promise { + return this.get("/api/v1/members") + } + + async getDocuments(): Promise { + return this.get("/api/v1/documents") + } + + async getBatches(): Promise { + return this.get("/api/v1/batches") + } + + async getDistributions(): Promise { + return this.get("/api/v1/distributions") + } + + async getBoardPositions(): Promise { + return this.get("/api/v1/board") + } + + private authHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + } + if (this.token) headers["Authorization"] = `Bearer ${this.token}` + return headers + } + + private async get(path: string): Promise { + const res = await fetch(`${API_URL}${path}`, { + headers: this.authHeaders(), + }) + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`) + return res.json() + } + + private async post(path: string, body?: unknown): Promise { + const res = await fetch(`${API_URL}${path}`, { + method: "POST", + headers: this.authHeaders(), + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) throw new Error(`POST ${path} failed: ${res.status}`) + return res.json() + } +} diff --git a/cannamanage-frontend/e2e/global-setup.ts b/cannamanage-frontend/e2e/global-setup.ts index 79c937c..c348446 100644 --- a/cannamanage-frontend/e2e/global-setup.ts +++ b/cannamanage-frontend/e2e/global-setup.ts @@ -1,6 +1,9 @@ -import { expect, test as setup } from "@playwright/test" -import path from "path" import fs from "fs" +import path from "path" + +import { expect, test as setup } from "@playwright/test" + +import { SEED } from "./seed-constants" /** * Global setup — authenticates as admin and saves the session state @@ -13,23 +16,41 @@ const authDir = path.join(__dirname, ".auth") const authFile = path.join(authDir, "admin.json") setup("authenticate as admin", async ({ page, context }) => { - const baseURL = "http://localhost:3000" + const baseURL = process.env.BASE_URL || "http://localhost:3000" + const apiUrl = process.env.API_URL || "http://localhost:8080" + + // Use seed credentials (from seed-constants), overridable via env vars + const email = process.env.TEST_ADMIN_EMAIL || SEED.admin.email + const password = process.env.TEST_ADMIN_PASSWORD || SEED.admin.password // Ensure .auth directory exists if (!fs.existsSync(authDir)) { fs.mkdirSync(authDir, { recursive: true }) } + // Wait for backend health (up to 60s) + let healthy = false + for (let i = 0; i < 30; i++) { + try { + const res = await fetch(`${apiUrl}/actuator/health`) + if (res.ok) { + healthy = true + break + } + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, 2000)) + } + if (!healthy) throw new Error("Backend health check failed after 60s") + // Navigate to login page await page.goto(`${baseURL}/login`) await page.waitForLoadState("domcontentloaded") // Fill credentials and submit - await page.fill('input[name="email"], input[type="email"]', "admin@test.de") - await page.fill( - 'input[name="password"], input[type="password"]', - "test123" - ) + await page.fill('input[name="email"], input[type="email"]', email) + await page.fill('input[name="password"], input[type="password"]', password) await page.click('button[type="submit"]') // Wait for successful redirect away from login diff --git a/cannamanage-frontend/e2e/integration/01-documents.spec.ts b/cannamanage-frontend/e2e/integration/01-documents.spec.ts new file mode 100644 index 0000000..7c439e3 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/01-documents.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" +import { SEL } from "../selectors" + +const apiClient = new ApiClient() + +test.describe("Documents Page @smoke", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("displays seed documents", async ({ page }) => { + await page.goto("/documents") + await expect(page.getByText(SEED.documents.satzung.title)).toBeVisible() + await expect(page.getByText(SEED.documents.protokoll.title)).toBeVisible() + await expect( + page.getByText(SEED.documents.genehmigung.title) + ).toBeVisible() + await expect( + page.getByText(SEED.documents.mietvertrag.title) + ).toBeVisible() + }) + + test("upload button opens dialog", async ({ page }) => { + await page.goto("/documents") + const uploadBtn = page.locator(SEL.documents.uploadButton) + await expect(uploadBtn).toBeVisible() + await uploadBtn.click() + await expect(page.locator(SEL.documents.uploadDialog)).toBeVisible() + await expect(page.locator(SEL.documents.titleInput)).toBeVisible() + await expect(page.locator(SEL.documents.categorySelect)).toBeVisible() + await expect(page.locator(SEL.documents.fileInput)).toBeVisible() + }) + + test("upload form submits successfully", async ({ page }) => { + // Requires backend + await page.goto("/documents") + await page.locator(SEL.documents.uploadButton).click() + await expect(page.locator(SEL.documents.uploadDialog)).toBeVisible() + + await page.locator(SEL.documents.titleInput).fill("Testdokument Upload") + await page.locator(SEL.documents.categorySelect).click() + await page.getByRole("option", { name: /satzung/i }).click() + + // Upload a test file + const fileInput = page.locator(SEL.documents.fileInput) + await fileInput.setInputFiles({ + name: "test.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("fake pdf content"), + }) + + const submitBtn = page.locator(SEL.documents.submitUpload) + await submitBtn.click() + + // Verify success toast + await expect(page.getByText(/erfolgreich|hochgeladen/i)).toBeVisible() + }) + + test("download button triggers download", async ({ page }) => { + await page.goto("/documents") + const downloadBtn = page.locator( + SEL.documents.downloadButton(SEED.documents.satzung.id) + ) + await expect(downloadBtn).toBeVisible() + + // Verify clicking download doesn't throw an error + const downloadPromise = page.waitForEvent("download") + await downloadBtn.click() + const download = await downloadPromise + expect(download.suggestedFilename()).toBeTruthy() + }) + + test("delete button shows confirmation and removes document", async ({ + page, + }) => { + // Requires backend + await page.goto("/documents") + const deleteBtn = page.locator( + SEL.documents.deleteButton(SEED.documents.mietvertrag.id) + ) + await expect(deleteBtn).toBeVisible() + await deleteBtn.click() + + // Confirmation dialog appears + await expect(page.locator(SEL.documents.deleteConfirm)).toBeVisible() + await page.locator(SEL.documents.deleteConfirm).click() + + // Document removed from list + await expect( + page.getByText(SEED.documents.mietvertrag.title) + ).not.toBeVisible() + }) + + test("category badges display correctly", async ({ page }) => { + await page.goto("/documents") + await expect( + page.locator( + SEL.documents.categoryBadge(SEED.documents.satzung.category) + ) + ).toBeVisible() + await expect( + page.locator( + SEL.documents.categoryBadge(SEED.documents.protokoll.category) + ) + ).toBeVisible() + await expect( + page.locator( + SEL.documents.categoryBadge(SEED.documents.genehmigung.category) + ) + ).toBeVisible() + await expect( + page.locator( + SEL.documents.categoryBadge(SEED.documents.mietvertrag.category) + ) + ).toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/02-board.spec.ts b/cannamanage-frontend/e2e/integration/02-board.spec.ts new file mode 100644 index 0000000..3e6389d --- /dev/null +++ b/cannamanage-frontend/e2e/integration/02-board.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" +import { SEL } from "../selectors" + +const apiClient = new ApiClient() + +test.describe("Board Page @smoke", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("displays seed board positions", async ({ page }) => { + await page.goto("/board") + await expect(page.getByText(SEED.board.vorsitz.title)).toBeVisible() + await expect(page.getByText(SEED.board.kasse.title)).toBeVisible() + await expect(page.getByText(SEED.board.schrift.title)).toBeVisible() + }) + + test("shows elected members on filled positions", async ({ page }) => { + await page.goto("/board") + await expect(page.getByText(SEED.board.vorsitz.elected)).toBeVisible() + await expect(page.getByText(SEED.board.kasse.elected)).toBeVisible() + }) + + test("shows vacant status for unfilled positions", async ({ page }) => { + await page.goto("/board") + const schriftCard = page.locator( + SEL.board.positionCard(SEED.board.schrift.id) + ) + await expect(schriftCard).toBeVisible() + await expect(schriftCard.getByText(/vakant|unbesetzt/i)).toBeVisible() + }) + + test("create position opens form and submits", async ({ page }) => { + // Requires backend + await page.goto("/board") + await page.locator(SEL.board.createPositionButton).click() + + // Fill form + await page.getByLabel(/titel|bezeichnung/i).fill("Beisitzer/in") + await page.getByRole("button", { name: /speichern|erstellen/i }).click() + + // Verify new position appears + await expect(page.getByText("Beisitzer/in")).toBeVisible() + }) + + test("elect member to vacant position", async ({ page }) => { + // Requires backend + await page.goto("/board") + + // Click elect on the vacant Schriftführung position + const schriftCard = page.locator( + SEL.board.positionCard(SEED.board.schrift.id) + ) + await schriftCard.locator(SEL.board.electMemberButton).click() + + // Select a member from dropdown/dialog + await page.getByRole("option", { name: /Lisa Bauer/i }).click() + await page.getByRole("button", { name: /speichern|wählen/i }).click() + + // Verify member is now shown + await expect(page.getByText(SEED.members.lisa.name)).toBeVisible() + }) + + test("remove member from position shows confirmation", async ({ page }) => { + // Requires backend + await page.goto("/board") + const removeBtn = page.locator( + SEL.board.removeButton(SEED.board.vorsitz.id) + ) + await removeBtn.click() + + // Confirmation dialog + await expect( + page.locator(SEL.common.alertDialogConfirm) + ).toBeVisible() + await page.locator(SEL.common.alertDialogConfirm).click() + + // Member name no longer visible on that position + const vorsitzCard = page.locator( + SEL.board.positionCard(SEED.board.vorsitz.id) + ) + await expect( + vorsitzCard.getByText(SEED.board.vorsitz.elected) + ).not.toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/03-members.spec.ts b/cannamanage-frontend/e2e/integration/03-members.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/cannamanage-frontend/e2e/integration/04-distributions.spec.ts b/cannamanage-frontend/e2e/integration/04-distributions.spec.ts new file mode 100644 index 0000000..075c723 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/04-distributions.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" +import { SEL } from "../selectors" + +const apiClient = new ApiClient() + +test.describe("Distributions Page @smoke", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("displays recent distributions from seed", async ({ page }) => { + await page.goto("/distributions") + // Verify distributions table/list is visible + await expect( + page.locator(SEL.distributions.table).or(page.getByRole("table")) + ).toBeVisible() + }) + + test("date filter works", async ({ page }) => { + await page.goto("/distributions") + + // Look for filter buttons/tabs for today/week/month/all + const todayFilter = page.getByRole("button", { name: /heute|today/i }) + const allFilter = page.getByRole("button", { name: /alle|all/i }) + + if (await todayFilter.isVisible()) { + await todayFilter.click() + // Page should update (no error) + await expect(page.locator("body")).toBeVisible() + } + + if (await allFilter.isVisible()) { + await allFilter.click() + await expect(page.locator("body")).toBeVisible() + } + }) + + test("new distribution button navigates to form", async ({ page }) => { + await page.goto("/distributions") + const newBtn = page + .locator(SEL.distributions.newButton) + .or(page.getByRole("link", { name: /neue ausgabe|new/i })) + await expect(newBtn).toBeVisible() + await newBtn.click() + await page.waitForURL(/\/distributions\/new/) + }) + + test("shows gram total display", async ({ page }) => { + await page.goto("/distributions") + // The page should show some kind of total/summary + await expect( + page.getByText(/gramm|gesamt|total/i).first() + ).toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/05-stock.spec.ts b/cannamanage-frontend/e2e/integration/05-stock.spec.ts new file mode 100644 index 0000000..fe24c3e --- /dev/null +++ b/cannamanage-frontend/e2e/integration/05-stock.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" +import { SEL } from "../selectors" + +const apiClient = new ApiClient() + +test.describe("Stock Page @smoke", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("displays seed batches", async ({ page }) => { + await page.goto("/stock") + await expect( + page.getByText(SEED.strains.northernLights.name) + ).toBeVisible() + await expect( + page.getByText(SEED.strains.cbdCriticalMass.name) + ).toBeVisible() + await expect(page.getByText(SEED.strains.amnesiaHaze.name)).toBeVisible() + await expect(page.getByText("500")).toBeVisible() + await expect(page.getByText("300")).toBeVisible() + await expect(page.getByText("200")).toBeVisible() + }) + + test("status filter works", async ({ page }) => { + await page.goto("/stock") + + // Filter: All — should show all 3 batches + const allFilter = page.getByRole("button", { name: /alle|all/i }) + if (await allFilter.isVisible()) { + await allFilter.click() + await expect( + page.getByText(SEED.strains.northernLights.name) + ).toBeVisible() + await expect( + page.getByText(SEED.strains.amnesiaHaze.name) + ).toBeVisible() + } + + // Filter: Available — should hide recalled batch + const availableFilter = page.getByRole("button", { + name: /verfügbar|available/i, + }) + if (await availableFilter.isVisible()) { + await availableFilter.click() + await expect( + page.getByText(SEED.strains.northernLights.name) + ).toBeVisible() + await expect( + page.getByText(SEED.strains.amnesiaHaze.name) + ).toBeHidden() + } + + // Filter: Recalled — should only show recalled batch + const recalledFilter = page.getByRole("button", { + name: /zurückgerufen|recalled/i, + }) + if (await recalledFilter.isVisible()) { + await recalledFilter.click() + await expect( + page.getByText(SEED.strains.amnesiaHaze.name) + ).toBeVisible() + await expect( + page.getByText(SEED.strains.northernLights.name) + ).toBeHidden() + } + }) + + test("new batch link navigates to /stock/new", async ({ page }) => { + await page.goto("/stock") + const addBtn = page + .locator(SEL.stock.addButton) + .or(page.getByRole("link", { name: /neue charge|new batch|hinzufügen/i })) + await expect(addBtn).toBeVisible() + await addBtn.click() + await page.waitForURL(/\/stock\/new/) + }) + + test("recall button opens AlertDialog confirmation", async ({ page }) => { + await page.goto("/stock") + const recallBtn = page.locator( + SEL.stock.recallButton(SEED.batches.northernLights.id) + ) + + if (await recallBtn.isVisible()) { + await recallBtn.click() + // AlertDialog should appear with confirm/cancel + await expect( + page + .locator(SEL.common.alertDialogConfirm) + .or(page.getByRole("alertdialog")) + ).toBeVisible() + } + }) + + test("recalled batch shows RECALLED badge", async ({ page }) => { + await page.goto("/stock") + // The Amnesia Haze batch is RECALLED + const recalledRow = page.locator( + SEL.stock.row(SEED.batches.amnesiaHaze.id) + ) + + if (await recalledRow.isVisible()) { + await expect( + recalledRow.getByText(/recalled|zurückgerufen/i) + ).toBeVisible() + } else { + // Fallback: look for the recalled badge near Amnesia Haze text + const amnesia = page.getByText(SEED.strains.amnesiaHaze.name) + await expect(amnesia).toBeVisible() + await expect( + page.getByText(/recalled|zurückgerufen/i).first() + ).toBeVisible() + } + }) +}) diff --git a/cannamanage-frontend/e2e/integration/06-calendar.spec.ts b/cannamanage-frontend/e2e/integration/06-calendar.spec.ts new file mode 100644 index 0000000..c2238e0 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/06-calendar.spec.ts @@ -0,0 +1,128 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Calendar Page @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("renders current month", async ({ page }) => { + await page.goto("/calendar") + + // Calendar should show current month name + const now = new Date() + const monthNames = [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", + ] + const currentMonth = monthNames[now.getMonth()] + const currentYear = now.getFullYear().toString() + + await expect( + page + .getByText(currentMonth, { exact: false }) + .or(page.getByText(currentYear)) + ).toBeVisible() + }) + + test("seed events are visible", async ({ page }) => { + await page.goto("/calendar") + + // There should be an upcoming assembly event (~14 days from now) + // and a past social event (~30 days ago) — look for event indicators + await expect( + page + .getByText(/versammlung|assembly/i) + .or(page.locator("[data-testid*='event']").first()) + ).toBeVisible() + }) + + test("month navigation works", async ({ page }) => { + await page.goto("/calendar") + + // Find prev/next month buttons + const nextBtn = page.getByRole("button", { name: /next|vor|nächst|›|>/i }) + const prevBtn = page.getByRole("button", { + name: /prev|zurück|vorig|‹| { + await page.goto("/calendar") + + const createBtn = page + .getByRole("button", { name: /erstellen|create|neues event|neu/i }) + .or(page.locator('[data-testid="calendar-create-event"]')) + + if (await createBtn.isVisible()) { + await createBtn.click() + // Dialog should have form fields for event creation + await expect( + page.getByRole("dialog").or(page.locator("[role='dialog']")) + ).toBeVisible() + // Expect title/name field + await expect( + page + .getByLabel(/titel|name|bezeichnung/i) + .or(page.locator("input[name*='title']")) + ).toBeVisible() + } + }) + + test("cancel event button shows confirmation", async ({ page }) => { + await page.goto("/calendar") + + // Click on an existing event to open detail + const eventEl = page.locator("[data-testid*='event']").first() + + if (await eventEl.isVisible()) { + await eventEl.click() + await page.waitForTimeout(300) + + // Look for cancel/delete button + const cancelBtn = page.getByRole("button", { + name: /absagen|löschen|cancel|delete/i, + }) + + if (await cancelBtn.isVisible()) { + await cancelBtn.click() + // Should show confirmation dialog + await expect( + page.getByRole("alertdialog").or(page.getByText(/bestätigen|sicher/i)) + ).toBeVisible() + } + } + }) +}) diff --git a/cannamanage-frontend/e2e/integration/07-forum.spec.ts b/cannamanage-frontend/e2e/integration/07-forum.spec.ts new file mode 100644 index 0000000..da272b1 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/07-forum.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Forum Page @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("lists seed topics", async ({ page }) => { + await page.goto("/forum") + await expect( + page.getByText("Neue Sorten für Sommer") + ).toBeVisible() + await expect(page.getByText("Bewässerungssystem")).toBeVisible() + }) + + test("topics show reply counts", async ({ page }) => { + await page.goto("/forum") + // Reply counts should be visible as numbers near topics + await expect( + page + .getByText(/antwort|repl/i) + .first() + .or(page.locator("[data-testid*='reply-count']").first()) + ).toBeVisible() + }) + + test("new topic button opens create form", async ({ page }) => { + await page.goto("/forum") + const newBtn = page + .getByRole("button", { name: /neues thema|new topic|erstellen/i }) + .or(page.locator('[data-testid="forum-new-topic"]')) + + await expect(newBtn).toBeVisible() + await newBtn.click() + + // Form should appear with title + content fields + await expect( + page + .getByRole("dialog") + .or(page.locator("form")) + .or(page.getByLabel(/titel|title/i)) + ).toBeVisible() + }) + + test("create topic submits and shows new topic", async ({ page }) => { + await page.goto("/forum") + + const newBtn = page + .getByRole("button", { name: /neues thema|new topic|erstellen/i }) + .or(page.locator('[data-testid="forum-new-topic"]')) + await newBtn.click() + + // Fill title + const titleInput = page + .getByLabel(/titel|title|thema/i) + .or(page.locator("input[name*='title']")) + await titleInput.fill("E2E Test Topic") + + // Fill content + const contentInput = page + .getByLabel(/inhalt|content|nachricht|text/i) + .or(page.locator("textarea")) + await contentInput.fill("This is an integration test topic body.") + + // Submit + const submitBtn = page.getByRole("button", { + name: /erstellen|submit|speichern|post/i, + }) + await submitBtn.click() + + // New topic should appear + await expect(page.getByText("E2E Test Topic")).toBeVisible({ + timeout: 5000, + }) + }) + + test("pin and lock buttons visible on topics", async ({ page }) => { + await page.goto("/forum") + + // Admin should see pin/lock action buttons + const pinBtn = page + .getByRole("button", { name: /pin|anheften/i }) + .first() + .or(page.locator("[data-testid*='pin']").first()) + const lockBtn = page + .getByRole("button", { name: /lock|sperren/i }) + .first() + .or(page.locator("[data-testid*='lock']").first()) + + // At least one should be visible for admin user + const pinVisible = await pinBtn.isVisible() + const lockVisible = await lockBtn.isVisible() + expect(pinVisible || lockVisible).toBeTruthy() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/08-info-board.spec.ts b/cannamanage-frontend/e2e/integration/08-info-board.spec.ts new file mode 100644 index 0000000..89f3b9b --- /dev/null +++ b/cannamanage-frontend/e2e/integration/08-info-board.spec.ts @@ -0,0 +1,122 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Info Board Page @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("lists seed posts with pinned post first", async ({ page }) => { + await page.goto("/info-board") + // Should have at least 2 posts visible + const posts = page.locator("[data-testid*='info-post']").or( + page.locator("article, [role='article']") + ) + + // Wait for content to load + await page.waitForTimeout(1000) + await expect(page.locator("body")).toBeVisible() + + // Verify posts are listed (look for post content or structure) + const postElements = page + .locator("[data-testid*='post']") + .or(page.locator("article")) + const count = await postElements.count() + expect(count).toBeGreaterThanOrEqual(1) + }) + + test("category filter dropdown works", async ({ page }) => { + await page.goto("/info-board") + + // Look for category filter + const filterSelect = page + .locator('[data-testid="info-board-category-filter"]') + .or(page.getByRole("combobox")) + .or(page.locator("select")) + + if (await filterSelect.first().isVisible()) { + await filterSelect.first().click() + await page.waitForTimeout(300) + // Options should appear + await expect(page.locator("body")).toBeVisible() + } + }) + + test("new post dialog opens and form submits", async ({ page }) => { + await page.goto("/info-board") + + const newBtn = page + .getByRole("button", { name: /neuer beitrag|new post|erstellen/i }) + .or(page.locator('[data-testid="info-board-new-post"]')) + + await expect(newBtn).toBeVisible() + await newBtn.click() + + // Dialog should open with form + await expect( + page.getByRole("dialog").or(page.locator("[role='dialog']")) + ).toBeVisible() + + // Fill form fields + const titleInput = page + .getByLabel(/titel|title/i) + .or(page.locator("input[name*='title']")) + if (await titleInput.isVisible()) { + await titleInput.fill("E2E Test Beitrag") + } + + const contentInput = page + .getByLabel(/inhalt|content|text/i) + .or(page.locator("textarea")) + if (await contentInput.isVisible()) { + await contentInput.fill("Test-Inhalt für Integration Test.") + } + + // Submit + const submitBtn = page.getByRole("button", { + name: /erstellen|speichern|submit|posten/i, + }) + if (await submitBtn.isVisible()) { + await submitBtn.click() + // Should succeed (toast or new post visible) + await page.waitForTimeout(1000) + await expect(page.locator("body")).toBeVisible() + } + }) + + test("pin indicator visible on pinned post", async ({ page }) => { + await page.goto("/info-board") + + // Look for pin icon/badge on the first (pinned) post + await expect( + page + .locator("[data-testid*='pinned']") + .first() + .or(page.locator("[aria-label*='pin']").first()) + .or(page.getByText(/📌|angepinnt|pinned/i).first()) + ).toBeVisible() + }) + + test("archive and delete buttons visible", async ({ page }) => { + await page.goto("/info-board") + + // Admin should see archive/delete actions + const archiveBtn = page + .getByRole("button", { name: /archiv/i }) + .first() + .or(page.locator("[data-testid*='archive']").first()) + const deleteBtn = page + .getByRole("button", { name: /löschen|delete/i }) + .first() + .or(page.locator("[data-testid*='delete']").first()) + + const archiveVisible = await archiveBtn.isVisible() + const deleteVisible = await deleteBtn.isVisible() + expect(archiveVisible || deleteVisible).toBeTruthy() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/09-grow.spec.ts b/cannamanage-frontend/e2e/integration/09-grow.spec.ts new file mode 100644 index 0000000..d5dbbd5 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/09-grow.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Grow Page @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("shows seed grow entries", async ({ page }) => { + await page.goto("/grow") + await expect( + page.getByText("Northern Lights Batch #2") + ).toBeVisible() + await expect(page.getByText("CBD Outdoor")).toBeVisible() + }) + + test("displays grow stages", async ({ page }) => { + await page.goto("/grow") + // Should show VEGETATIVE and SEEDLING stage indicators + await expect( + page + .getByText(/vegetativ|vegetative/i) + .first() + .or(page.locator("[data-testid*='stage-VEGETATIVE']").first()) + ).toBeVisible() + await expect( + page + .getByText(/sämling|seedling/i) + .first() + .or(page.locator("[data-testid*='stage-SEEDLING']").first()) + ).toBeVisible() + }) + + test("stage progress indicators shown", async ({ page }) => { + await page.goto("/grow") + // Look for progress bars or step indicators + const progressIndicators = page + .locator("[role='progressbar']") + .or(page.locator("[data-testid*='progress']")) + .or(page.locator("[data-testid*='stage-indicator']")) + + const count = await progressIndicators.count() + expect(count).toBeGreaterThanOrEqual(1) + }) + + test("new grow button links to correct path", async ({ page }) => { + await page.goto("/grow") + const newBtn = page + .getByRole("link", { name: /neuer grow|new grow|anlegen/i }) + .or(page.locator('[data-testid="grow-new-button"]')) + .or(page.getByRole("button", { name: /neuer grow|new grow|anlegen/i })) + + await expect(newBtn).toBeVisible() + await newBtn.click() + await page.waitForURL(/\/grow\/new/) + }) + + test("click on entry navigates to detail page", async ({ page }) => { + await page.goto("/grow") + + // Click on the first grow entry + const entry = page + .getByText("Northern Lights Batch #2") + .or(page.locator("[data-testid*='grow-entry']").first()) + await entry.click() + + // Should navigate to /grow/[id] + await page.waitForURL(/\/grow\/[a-zA-Z0-9-]+/) + await expect(page.locator("body")).toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/10-compliance.spec.ts b/cannamanage-frontend/e2e/integration/10-compliance.spec.ts new file mode 100644 index 0000000..a58a269 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/10-compliance.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Compliance Dashboard @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("compliance dashboard loads", async ({ page }) => { + await page.goto("/compliance") + // Page should load without error + await expect( + page + .getByText(/compliance|konformität/i) + .first() + .or(page.getByRole("heading").first()) + ).toBeVisible() + }) + + test("shows area status cards", async ({ page }) => { + await page.goto("/compliance") + // Should display compliance areas: KCANG, FINANCE, DSGVO, VEREIN + await expect(page.getByText(/kcang/i)).toBeVisible() + await expect(page.getByText(/finan/i).first()).toBeVisible() + await expect(page.getByText(/dsgvo|datenschutz/i).first()).toBeVisible() + await expect(page.getByText(/verein/i).first()).toBeVisible() + }) + + test("overdue deadlines highlighted", async ({ page }) => { + await page.goto("/compliance") + // EÜR Abgabe should be overdue and highlighted + await expect( + page.getByText(/EÜR/i).or(page.getByText(/überfällig|overdue/i).first()) + ).toBeVisible() + + // Overdue items should have visual distinction (red text, warning badge, etc.) + const overdueIndicator = page + .locator("[data-testid*='overdue']") + .or(page.locator(".text-destructive, .text-red, [class*='overdue']")) + .first() + + if (await overdueIndicator.isVisible()) { + await expect(overdueIndicator).toBeVisible() + } + }) + + test("upcoming deadlines show days remaining", async ({ page }) => { + await page.goto("/compliance") + // Should display upcoming deadlines with days remaining + await expect( + page + .getByText(/tag|day/i) + .first() + .or(page.locator("[data-testid*='deadline']").first()) + ).toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/11-finance.spec.ts b/cannamanage-frontend/e2e/integration/11-finance.spec.ts new file mode 100644 index 0000000..70cd977 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/11-finance.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Finance Page @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("finance page loads", async ({ page }) => { + await page.goto("/finance") + await expect( + page + .getByRole("heading", { name: /finan/i }) + .or(page.getByText(/finanzen|finance/i).first()) + ).toBeVisible() + }) + + test("sub-navigation links exist", async ({ page }) => { + await page.goto("/finance") + // Should have sub-nav links for: payments, kassenbuch, import, fee-schedules, reports + const links = [ + /zahlungen|payments/i, + /kassenbuch/i, + /import/i, + /beitragsordnung|fee/i, + /berichte|reports/i, + ] + + for (const linkPattern of links) { + const link = page + .getByRole("link", { name: linkPattern }) + .or(page.getByRole("tab", { name: linkPattern })) + .or(page.getByRole("button", { name: linkPattern })) + await expect(link.first()).toBeVisible() + } + }) + + test("payments sub-page loads", async ({ page }) => { + await page.goto("/finance/payments") + await expect(page.locator("body")).toBeVisible() + // Should not show an error page + await expect(page.getByText(/404|not found/i)).not.toBeVisible() + }) + + test("kassenbuch sub-page loads", async ({ page }) => { + await page.goto("/finance/kassenbuch") + await expect(page.locator("body")).toBeVisible() + await expect(page.getByText(/404|not found/i)).not.toBeVisible() + }) + + test("import sub-page loads", async ({ page }) => { + await page.goto("/finance/import") + await expect(page.locator("body")).toBeVisible() + await expect(page.getByText(/404|not found/i)).not.toBeVisible() + }) + + test("fee-schedules sub-page loads", async ({ page }) => { + await page.goto("/finance/fee-schedules") + await expect(page.locator("body")).toBeVisible() + await expect(page.getByText(/404|not found/i)).not.toBeVisible() + }) + + test("reports sub-page loads", async ({ page }) => { + await page.goto("/finance/reports") + await expect(page.locator("body")).toBeVisible() + await expect(page.getByText(/404|not found/i)).not.toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/12-audit-log.spec.ts b/cannamanage-frontend/e2e/integration/12-audit-log.spec.ts new file mode 100644 index 0000000..6514e7d --- /dev/null +++ b/cannamanage-frontend/e2e/integration/12-audit-log.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("Audit Log Page @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + test("audit log page loads", async ({ page }) => { + await page.goto("/audit-log") + await expect( + page + .getByRole("heading", { name: /audit|protokoll/i }) + .or(page.getByText(/audit/i).first()) + ).toBeVisible() + }) + + test("shows table or list structure", async ({ page }) => { + await page.goto("/audit-log") + // Should display audit entries in a table or list + const table = page + .getByRole("table") + .or(page.locator("[data-testid='audit-log-table']")) + .or(page.locator("[data-testid*='audit-entry']").first()) + + await expect(table.first()).toBeVisible() + }) + + test("has filter or search capability", async ({ page }) => { + await page.goto("/audit-log") + // Should have some kind of filter/search input + const filterInput = page + .getByRole("searchbox") + .or(page.getByPlaceholder(/such|filter|search/i)) + .or(page.locator('[data-testid="audit-log-filter"]')) + .or(page.locator("input[type='search']")) + .or(page.getByRole("combobox")) + + await expect(filterInput.first()).toBeVisible() + }) +}) diff --git a/cannamanage-frontend/e2e/integration/13-kcang-regulatory.spec.ts b/cannamanage-frontend/e2e/integration/13-kcang-regulatory.spec.ts new file mode 100644 index 0000000..fd8f4e1 --- /dev/null +++ b/cannamanage-frontend/e2e/integration/13-kcang-regulatory.spec.ts @@ -0,0 +1,295 @@ +import { expect, test } from "@playwright/test" + +import { ApiClient } from "../api-client" +import { SEED } from "../seed-constants" + +const apiClient = new ApiClient() + +test.describe("KCanG Regulatory Edge Cases @full", () => { + test.beforeEach(async () => { + await apiClient.login(SEED.admin.email, SEED.admin.password) + await apiClient.resetDb() + }) + + // Requires: backend quota enforcement + test("rejects adult distribution exceeding 25g/day", async ({ page }) => { + await page.goto("/distributions/new") + + // Select adult member (Max Mustermann) + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.max.name).click() + + // Select strain + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.northernLights.name).click() + + // Enter 26g (exceeds 25g daily limit) + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("26") + + // Submit + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should show rejection/error + await expect( + page.getByText(/überschr|exceeded|limit|abgelehnt|rejected/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("accepts adult distribution of exactly 25g", async ({ page }) => { + await page.goto("/distributions/new") + + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.max.name).click() + + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.northernLights.name).click() + + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("25") + + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should succeed + await expect( + page.getByText(/erfolg|success|gespeichert/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("rejects under-21 member with strain exceeding 10% THC", async ({ + page, + }) => { + await page.goto("/distributions/new") + + // Select under-21 member (Jonas Weber) + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.jonas.name).click() + + // Select Amnesia Haze (22% THC — exceeds 10% limit for under-21) + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.amnesiaHaze.name).click() + + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("5") + + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should show THC rejection + await expect( + page.getByText(/thc|überschr|exceeded|limit|abgelehnt|rejected/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("accepts under-21 member with strain within THC limit", async ({ + page, + }) => { + await page.goto("/distributions/new") + + // Select under-21 member (Jonas) + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.jonas.name).click() + + // Select CBD Critical Mass (5% THC — within 10% limit) + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.cbdCriticalMass.name).click() + + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("5") + + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should succeed + await expect( + page.getByText(/erfolg|success|gespeichert/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("rejects under-21 member exceeding 30g/month", async ({ page }) => { + // This test assumes Jonas has already received close to 30g this month + // The seed data should set up 31g attempted distribution + await page.goto("/distributions/new") + + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.jonas.name).click() + + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.cbdCriticalMass.name).click() + + // 31g exceeds the 30g/month limit for under-21 + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("31") + + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should show monthly quota rejection + await expect( + page.getByText(/überschr|exceeded|limit|monat|monthly|abgelehnt/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("accepts near-quota member within daily limit", async ({ page }) => { + // Thomas has 23g already this day — 2g more should be fine (25g total) + await page.goto("/distributions/new") + + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.thomas.name).click() + + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.northernLights.name).click() + + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("2") + + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should succeed (23g + 2g = 25g, exactly at limit) + await expect( + page.getByText(/erfolg|success|gespeichert/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("rejects near-quota member exceeding daily cumulative", async ({ + page, + }) => { + // Thomas has 23g already — 3g more would be 26g (exceeds 25g/day) + await page.goto("/distributions/new") + + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.thomas.name).click() + + const strainSelect = page + .getByLabel(/sorte|strain|charge|batch/i) + .or(page.locator("[data-testid='distribution-strain-select']")) + await strainSelect.click() + await page.getByText(SEED.strains.northernLights.name).click() + + const amountInput = page + .getByLabel(/menge|amount|gramm/i) + .or(page.locator("input[name*='amount']")) + await amountInput.fill("3") + + const submitBtn = page.getByRole("button", { + name: /ausgeben|submit|speichern/i, + }) + await submitBtn.click() + + // Should show daily cumulative rejection + await expect( + page.getByText(/überschr|exceeded|limit|abgelehnt|rejected/i) + ).toBeVisible({ timeout: 5000 }) + }) + + // Requires: backend quota enforcement + test("shows THC warning for under-21 members on distribution page", async ({ + page, + }) => { + await page.goto("/distributions/new") + + // Select under-21 member (Jonas) + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.jonas.name).click() + + // Should show THC% warning/info for under-21 + await expect( + page.getByText(/thc.*10|unter.*21|u21|jugendschutz/i).first() + ).toBeVisible({ timeout: 3000 }) + }) + + // Requires: backend quota enforcement + test("quota display shows correct remaining amount", async ({ page }) => { + await page.goto("/distributions/new") + + // Select Thomas (near-quota member, 23g already used today) + const memberSelect = page + .getByLabel(/mitglied|member/i) + .or(page.locator("[data-testid='distribution-member-select']")) + await memberSelect.click() + await page.getByText(SEED.members.thomas.name).click() + + // Should display remaining quota info + await expect( + page + .getByText(/verbleibend|remaining|rest|kontingent|quota/i) + .first() + .or(page.locator("[data-testid*='quota']").first()) + ).toBeVisible({ timeout: 3000 }) + }) +}) diff --git a/cannamanage-frontend/e2e/integration/README.md b/cannamanage-frontend/e2e/integration/README.md new file mode 100644 index 0000000..345b89d --- /dev/null +++ b/cannamanage-frontend/e2e/integration/README.md @@ -0,0 +1,22 @@ +# Integration Tests + +Full-stack integration tests that run against a real backend + database. + +## Running locally + +```bash +docker compose -f docker-compose.test.yml -f docker-compose.test.local.yml up --build +``` + +## Running in CI + +```bash +docker compose -f docker-compose.test.yml up --build --abort-on-container-exit +``` + +## Test Structure + +- Each spec file tests one page/feature +- Tests use `data-testid` selectors from `../selectors.ts` +- Expected values come from `../seed-constants.ts` +- DB is reset before each test via `ApiClient.resetDb()` diff --git a/cannamanage-frontend/e2e/seed-constants.ts b/cannamanage-frontend/e2e/seed-constants.ts new file mode 100644 index 0000000..6f950f6 --- /dev/null +++ b/cannamanage-frontend/e2e/seed-constants.ts @@ -0,0 +1,145 @@ +/** + * Deterministic seed data constants matching R__seed_test_data.sql. + * Single source of truth for all integration test assertions. + */ +export const SEED = { + club: { + id: "a0000000-0000-0000-0000-000000000001", + name: "Grüner Daumen e.V.", + }, + admin: { + id: "b1000000-0000-0000-0000-000000000001", + email: "admin@gruener-daumen.de", + password: "TestAdmin123!", + }, + members: { + max: { + id: "c1000000-0000-0000-0000-000000000001", + name: "Max Mustermann", + status: "ACTIVE", + }, + anna: { + id: "c1000000-0000-0000-0000-000000000002", + name: "Anna Schmidt", + status: "ACTIVE", + }, + jonas: { + id: "c1000000-0000-0000-0000-000000000003", + name: "Jonas Weber", + status: "ACTIVE", + isUnder21: true, + }, + maria: { + id: "c1000000-0000-0000-0000-000000000004", + name: "Maria Müller", + status: "SUSPENDED", + }, + thomas: { + id: "c1000000-0000-0000-0000-000000000005", + name: "Thomas Müller", + status: "ACTIVE", + nearQuota: true, + }, + lisa: { + id: "c1000000-0000-0000-0000-000000000006", + name: "Lisa Bauer", + status: "ACTIVE", + }, + karl: { + id: "c1000000-0000-0000-0000-000000000007", + name: "Karl Fischer", + status: "EXPELLED", + }, + }, + strains: { + northernLights: { + id: "d1000000-0000-0000-0000-000000000001", + name: "Northern Lights", + thc: 18.5, + cbd: 0.5, + }, + cbdCriticalMass: { + id: "d1000000-0000-0000-0000-000000000002", + name: "CBD Critical Mass", + thc: 5.0, + cbd: 12.0, + }, + amnesiaHaze: { + id: "d1000000-0000-0000-0000-000000000003", + name: "Amnesia Haze", + thc: 22.0, + cbd: 0.1, + }, + }, + batches: { + northernLights: { + id: "e1000000-0000-0000-0000-000000000001", + quantity: 500, + status: "AVAILABLE", + }, + cbdCriticalMass: { + id: "e1000000-0000-0000-0000-000000000002", + quantity: 300, + status: "AVAILABLE", + }, + amnesiaHaze: { + id: "e1000000-0000-0000-0000-000000000003", + quantity: 200, + status: "RECALLED", + }, + }, + documents: { + satzung: { + id: "f1000000-0000-0000-0000-000000000001", + title: "Vereinssatzung 2024", + category: "SATZUNG", + }, + protokoll: { + id: "f1000000-0000-0000-0000-000000000002", + title: "Protokoll MV März 2024", + category: "PROTOKOLL", + }, + genehmigung: { + id: "f1000000-0000-0000-0000-000000000003", + title: "KCanG-Genehmigung", + category: "GENEHMIGUNG", + }, + mietvertrag: { + id: "f1000000-0000-0000-0000-000000000004", + title: "Mietvertrag", + category: "VERTRAG", + }, + }, + board: { + vorsitz: { + id: "g1000000-0000-0000-0000-000000000001", + title: "Vorsitzende/r", + elected: "Max Mustermann", + }, + kasse: { + id: "g1000000-0000-0000-0000-000000000002", + title: "Kassenführung", + elected: "Anna Schmidt", + }, + schrift: { + id: "g1000000-0000-0000-0000-000000000003", + title: "Schriftführung", + vacant: true, + }, + }, + counts: { + totalMembers: 7, + activeMembers: 5, + documents: 4, + batches: 3, + availableBatches: 2, + boardPositions: 3, + vacantPositions: 1, + }, + kcang: { + adultDailyLimitGrams: 25, + adultMonthlyLimitGrams: 50, + under21MonthlyLimitGrams: 30, + under21MaxThcPercent: 10, + }, +} as const diff --git a/cannamanage-frontend/e2e/selectors.ts b/cannamanage-frontend/e2e/selectors.ts new file mode 100644 index 0000000..39b8221 --- /dev/null +++ b/cannamanage-frontend/e2e/selectors.ts @@ -0,0 +1,72 @@ +/** + * Centralized data-testid selectors for integration tests. + * Naming convention: -- + * + * Note: The actual data-testid attributes will be added incrementally + * to frontend components during Phase 2E as tests are written. + */ +export const SEL = { + // Sidebar / Navigation + nav: { + sidebar: '[data-testid="nav-sidebar"]', + members: '[data-testid="nav-link-members"]', + distributions: '[data-testid="nav-link-distributions"]', + stock: '[data-testid="nav-link-stock"]', + documents: '[data-testid="nav-link-documents"]', + board: '[data-testid="nav-link-board"]', + calendar: '[data-testid="nav-link-calendar"]', + forum: '[data-testid="nav-link-forum"]', + grow: '[data-testid="nav-link-grow"]', + compliance: '[data-testid="nav-link-compliance"]', + }, + // Members page + members: { + table: '[data-testid="members-table"]', + searchInput: '[data-testid="members-search-input"]', + addButton: '[data-testid="members-add-button"]', + row: (id: string) => `[data-testid="members-row-${id}"]`, + statusBadge: (id: string) => `[data-testid="members-status-${id}"]`, + }, + // Documents page + documents: { + uploadButton: '[data-testid="documents-upload-button"]', + uploadDialog: '[data-testid="documents-upload-dialog"]', + titleInput: '[data-testid="documents-title-input"]', + categorySelect: '[data-testid="documents-category-select"]', + fileInput: '[data-testid="documents-file-input"]', + submitUpload: '[data-testid="documents-submit-upload"]', + downloadButton: (id: string) => `[data-testid="documents-download-${id}"]`, + deleteButton: (id: string) => `[data-testid="documents-delete-${id}"]`, + deleteConfirm: '[data-testid="documents-delete-confirm"]', + categoryBadge: (category: string) => + `[data-testid="documents-category-${category}"]`, + row: (id: string) => `[data-testid="documents-row-${id}"]`, + }, + // Board page + board: { + createPositionButton: '[data-testid="board-create-position"]', + electMemberButton: '[data-testid="board-elect-member"]', + removeButton: (id: string) => `[data-testid="board-remove-${id}"]`, + positionCard: (id: string) => `[data-testid="board-position-${id}"]`, + }, + // Stock page + stock: { + addButton: '[data-testid="stock-add-button"]', + recallButton: (id: string) => `[data-testid="stock-recall-${id}"]`, + table: '[data-testid="stock-table"]', + row: (id: string) => `[data-testid="stock-row-${id}"]`, + }, + // Distributions page + distributions: { + newButton: '[data-testid="distributions-new-button"]', + table: '[data-testid="distributions-table"]', + row: (id: string) => `[data-testid="distributions-row-${id}"]`, + }, + // Common/shared + common: { + toast: '[data-testid="toast"]', + loadingSkeleton: '[data-testid="loading-skeleton"]', + alertDialogConfirm: '[data-testid="alert-dialog-confirm"]', + alertDialogCancel: '[data-testid="alert-dialog-cancel"]', + }, +} as const diff --git a/cannamanage-frontend/playwright.config.ts b/cannamanage-frontend/playwright.config.ts index e1646c0..4dbb3bf 100644 --- a/cannamanage-frontend/playwright.config.ts +++ b/cannamanage-frontend/playwright.config.ts @@ -1,6 +1,7 @@ -import { defineConfig } from "@playwright/test" import path from "path" +import { defineConfig } from "@playwright/test" + const authFile = path.join(__dirname, "e2e", ".auth", "admin.json") export default defineConfig({ @@ -9,7 +10,7 @@ export default defineConfig({ retries: 0, timeout: 90_000, use: { - baseURL: "http://localhost:3000", + baseURL: process.env.BASE_URL || "http://localhost:3000", screenshot: "on", trace: "on-first-retry", navigationTimeout: 60_000, @@ -22,8 +23,7 @@ export default defineConfig({ }, { name: "authenticated", - testMatch: - /authenticated-admin|visual-regression|accessibility/, + testMatch: /authenticated-admin|visual-regression|accessibility/, dependencies: ["setup"], use: { storageState: authFile, @@ -36,6 +36,17 @@ export default defineConfig({ /functional-flows|full-check|user-story-tests|system-test|staff-management|screenshot-tour|authenticated-tour/, use: { browserName: "chromium" }, }, + { + name: "integration", + testMatch: /integration\//, + dependencies: ["setup"], + use: { + storageState: authFile, + browserName: "chromium", + }, + timeout: 90_000, + expect: { timeout: 15_000 }, + }, ], outputDir: "./e2e/test-results", }) diff --git a/docker-compose.test.local.yml b/docker-compose.test.local.yml new file mode 100644 index 0000000..f57844d --- /dev/null +++ b/docker-compose.test.local.yml @@ -0,0 +1,10 @@ +# Local dev override — replaces tmpfs with named volume for macOS compatibility +# Usage: docker compose -f docker-compose.test.yml -f docker-compose.test.local.yml up --build +services: + db: + tmpfs: !reset [] + volumes: + - test-pgdata:/var/lib/postgresql/data + +volumes: + test-pgdata: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index bfa8fd5..26d4b83 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,46 +1,79 @@ -# System test profile — runs full stack with seed data + Playwright -# Usage: docker compose -f docker-compose.test.yml up --abort-on-container-exit -include: - - docker-compose.yml - +# Integration test profile — full stack with Flyway seed + Playwright +# Usage: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit services: - # Override db to include seed data db: - volumes: - - pgdata:/var/lib/postgresql/data - - ./scripts/seed/init.sql:/docker-entrypoint-initdb.d/99-seed.sql:ro + image: postgres:16-alpine + container_name: cannamanage-test-db + tmpfs: + - /var/lib/postgresql/data + environment: + POSTGRES_DB: cannamanage_test + POSTGRES_USER: cannamanage + POSTGRES_PASSWORD: cannamanage_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cannamanage -d cannamanage_test"] + interval: 3s + timeout: 2s + retries: 10 - # Seed container: waits for backend health, then validates readiness - seed: - image: curlimages/curl:latest - container_name: cannamanage-seed + backend: + build: + context: . + dockerfile: Dockerfile.backend + container_name: cannamanage-test-backend + environment: + SPRING_PROFILES_ACTIVE: test + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/cannamanage_test + SPRING_DATASOURCE_USERNAME: cannamanage + SPRING_DATASOURCE_PASSWORD: cannamanage_test + CANNAMANAGE_SECURITY_JWT_SECRET: dGVzdC1zZWNyZXQtZm9yLWludGVncmF0aW9uLXRlc3RzLW9ubHktMzJjaGFycw== + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/actuator/health"] + interval: 5s + timeout: 3s + retries: 15 + start_period: 30s + + frontend: + build: + context: ./cannamanage-frontend + dockerfile: Dockerfile + container_name: cannamanage-test-frontend + environment: + NEXTAUTH_URL: http://localhost:3000 + NEXTAUTH_SECRET: test-nextauth-secret-minimum-32-characters + BACKEND_URL: http://backend:8080 + AUTH_URL: http://localhost:3000 depends_on: backend: condition: service_healthy - entrypoint: /bin/sh - command: ["-c", "/seed/seed.sh"] - volumes: - - ./scripts/seed:/seed:ro - # Playwright system tests playwright: - image: mcr.microsoft.com/playwright:v1.52.0-noble - container_name: cannamanage-playwright + build: + context: ./cannamanage-frontend + dockerfile: Dockerfile.playwright + container_name: cannamanage-test-playwright working_dir: /app depends_on: - seed: - condition: service_completed_successfully frontend: condition: service_started + backend: + condition: service_healthy environment: BASE_URL: http://frontend:3000 + API_URL: http://backend:8080 CI: "true" + # Volume mount allows test iteration without rebuild + # (Dockerfile pre-installs deps; mount overrides test files only) volumes: - - ./cannamanage-frontend:/app + - ./cannamanage-frontend/e2e:/app/e2e:ro command: > sh -c " - echo 'Waiting for frontend to be ready...' && - timeout 60 sh -c 'until wget -q -O /dev/null http://frontend:3000 2>/dev/null; do sleep 2; done' && - echo 'Frontend ready — running system tests...' && - npx playwright test e2e/system-test.spec.ts --reporter=list + echo 'Waiting for frontend...' && + timeout 90 sh -c 'until wget -q -O /dev/null http://frontend:3000 2>/dev/null; do sleep 2; done' && + echo 'Frontend ready — running integration tests...' && + npx playwright test e2e/integration/ --reporter=html --grep @smoke "