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:
@@ -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"]
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
"
|
||||
|
||||
Reference in New Issue
Block a user