test: add full-stack Playwright integration test infrastructure

Sprint 12 Phase 2: Real integration tests with seed DB
- R__seed_test_data.sql (Flyway repeatable, 7 members, strains, batches, docs, board, events)
- TestResetController (profile-gated per-test DB reset)
- docker-compose.test.yml (self-contained, tmpfs Postgres)
- Dockerfile.playwright (v1.60.0, pre-installed deps)
- 13 integration spec files, 70+ test cases (@smoke + @full)
- seed-constants.ts, selectors.ts, api-client.ts test helpers
This commit is contained in:
Patrick Plate
2026-06-18 14:43:16 +02:00
parent 6e25914074
commit 776149e7d3
25 changed files with 2127 additions and 39 deletions
@@ -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<Void> 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<String> 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<String> getApplicationTables(Connection conn) throws SQLException {
List<String> 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);
}
}
@@ -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
@@ -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;
@@ -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"]
+74
View File
@@ -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<void> {
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<void> {
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<any> {
return this.get("/api/v1/members")
}
async getDocuments(): Promise<any> {
return this.get("/api/v1/documents")
}
async getBatches(): Promise<any> {
return this.get("/api/v1/batches")
}
async getDistributions(): Promise<any> {
return this.get("/api/v1/distributions")
}
async getBoardPositions(): Promise<any> {
return this.get("/api/v1/board")
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (this.token) headers["Authorization"] = `Bearer ${this.token}`
return headers
}
private async get(path: string): Promise<any> {
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<any> {
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()
}
}
+29 -8
View File
@@ -1,6 +1,9 @@
import { expect, test as setup } from "@playwright/test"
import path from "path"
import fs from "fs" 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 * 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") const authFile = path.join(authDir, "admin.json")
setup("authenticate as admin", async ({ page, context }) => { 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 // Ensure .auth directory exists
if (!fs.existsSync(authDir)) { if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true }) 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 // Navigate to login page
await page.goto(`${baseURL}/login`) await page.goto(`${baseURL}/login`)
await page.waitForLoadState("domcontentloaded") await page.waitForLoadState("domcontentloaded")
// Fill credentials and submit // Fill credentials and submit
await page.fill('input[name="email"], input[type="email"]', "admin@test.de") await page.fill('input[name="email"], input[type="email"]', email)
await page.fill( await page.fill('input[name="password"], input[type="password"]', password)
'input[name="password"], input[type="password"]',
"test123"
)
await page.click('button[type="submit"]') await page.click('button[type="submit"]')
// Wait for successful redirect away from login // Wait for successful redirect away from login
@@ -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()
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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()
}
})
})
@@ -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||</i,
})
// Navigate forward
if (await nextBtn.isVisible()) {
await nextBtn.click()
await page.waitForTimeout(300)
// Page should still render without error
await expect(page.locator("body")).toBeVisible()
}
// Navigate backward twice (back to previous month)
if (await prevBtn.isVisible()) {
await prevBtn.click()
await page.waitForTimeout(300)
await prevBtn.click()
await page.waitForTimeout(300)
await expect(page.locator("body")).toBeVisible()
}
})
test("create event opens dialog with form fields", async ({ page }) => {
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()
}
}
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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()
})
})
@@ -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 })
})
})
@@ -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()`
+145
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
/**
* Centralized data-testid selectors for integration tests.
* Naming convention: <page>-<component>-<identifier>
*
* 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
+15 -4
View File
@@ -1,6 +1,7 @@
import { defineConfig } from "@playwright/test"
import path from "path" import path from "path"
import { defineConfig } from "@playwright/test"
const authFile = path.join(__dirname, "e2e", ".auth", "admin.json") const authFile = path.join(__dirname, "e2e", ".auth", "admin.json")
export default defineConfig({ export default defineConfig({
@@ -9,7 +10,7 @@ export default defineConfig({
retries: 0, retries: 0,
timeout: 90_000, timeout: 90_000,
use: { use: {
baseURL: "http://localhost:3000", baseURL: process.env.BASE_URL || "http://localhost:3000",
screenshot: "on", screenshot: "on",
trace: "on-first-retry", trace: "on-first-retry",
navigationTimeout: 60_000, navigationTimeout: 60_000,
@@ -22,8 +23,7 @@ export default defineConfig({
}, },
{ {
name: "authenticated", name: "authenticated",
testMatch: testMatch: /authenticated-admin|visual-regression|accessibility/,
/authenticated-admin|visual-regression|accessibility/,
dependencies: ["setup"], dependencies: ["setup"],
use: { use: {
storageState: authFile, storageState: authFile,
@@ -36,6 +36,17 @@ export default defineConfig({
/functional-flows|full-check|user-story-tests|system-test|staff-management|screenshot-tour|authenticated-tour/, /functional-flows|full-check|user-story-tests|system-test|staff-management|screenshot-tour|authenticated-tour/,
use: { browserName: "chromium" }, 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", outputDir: "./e2e/test-results",
}) })
+10
View File
@@ -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:
+60 -27
View File
@@ -1,46 +1,79 @@
# System test profile — runs full stack with seed data + Playwright # Integration test profile — full stack with Flyway seed + Playwright
# Usage: docker compose -f docker-compose.test.yml up --abort-on-container-exit # Usage: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
include:
- docker-compose.yml
services: services:
# Override db to include seed data
db: db:
volumes: image: postgres:16-alpine
- pgdata:/var/lib/postgresql/data container_name: cannamanage-test-db
- ./scripts/seed/init.sql:/docker-entrypoint-initdb.d/99-seed.sql:ro 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 backend:
seed: build:
image: curlimages/curl:latest context: .
container_name: cannamanage-seed 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: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
entrypoint: /bin/sh
command: ["-c", "/seed/seed.sh"]
volumes:
- ./scripts/seed:/seed:ro
# Playwright system tests
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.52.0-noble build:
container_name: cannamanage-playwright context: ./cannamanage-frontend
dockerfile: Dockerfile.playwright
container_name: cannamanage-test-playwright
working_dir: /app working_dir: /app
depends_on: depends_on:
seed:
condition: service_completed_successfully
frontend: frontend:
condition: service_started condition: service_started
backend:
condition: service_healthy
environment: environment:
BASE_URL: http://frontend:3000 BASE_URL: http://frontend:3000
API_URL: http://backend:8080
CI: "true" CI: "true"
# Volume mount allows test iteration without rebuild
# (Dockerfile pre-installs deps; mount overrides test files only)
volumes: volumes:
- ./cannamanage-frontend:/app - ./cannamanage-frontend/e2e:/app/e2e:ro
command: > command: >
sh -c " sh -c "
echo 'Waiting for frontend to be ready...' && echo 'Waiting for frontend...' &&
timeout 60 sh -c 'until wget -q -O /dev/null http://frontend:3000 2>/dev/null; do sleep 2; done' && timeout 90 sh -c 'until wget -q -O /dev/null http://frontend:3000 2>/dev/null; do sleep 2; done' &&
echo 'Frontend ready — running system tests...' && echo 'Frontend ready — running integration tests...' &&
npx playwright test e2e/system-test.spec.ts --reporter=list npx playwright test e2e/integration/ --reporter=html --grep @smoke
" "