diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000..f8889c5 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'cypress'; +import { config } from 'dotenv'; + +config({ path: '.env.local' }); + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:3000', + specPattern: 'tests/cypress/**/*.cy.ts', + supportFile: 'tests/cypress/support/e2e.ts', + videosFolder: 'tests/cypress/videos', + screenshotsFolder: 'tests/cypress/screenshots', + viewportWidth: 1280, + viewportHeight: 800, + video: true, + screenshotOnRunFailure: true, + defaultCommandTimeout: 10000, + pageLoadTimeout: 30000, + experimentalModifyObstructiveThirdPartyCode: false, + }, + env: { + TEST_EMAIL: process.env.TEST_EMAIL, + TEST_PASSWORD: process.env.TEST_PASSWORD, + }, +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0f1b8c8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test'; +import { config } from 'dotenv'; + +config({ path: '.env.local' }); + +export default defineConfig({ + testDir: './tests/playwright', + outputDir: './tests/test-results', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { open: 'never', outputFolder: 'tests/playwright-report' }], ['line']], + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + locale: 'nl-NL', + timezoneId: 'Europe/Amsterdam', + }, + + projects: [ + // Auth setup - runs first, creates storageState + { + name: 'setup', + testMatch: /auth\.setup\.ts/, + }, + + // All E2E tests - depend on setup, start authenticated + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..b155afd --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,382 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "mini-epd-prototype" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..260213f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,256 @@ +# Testhandleiding - Mini EPD Prototype + +Deze handleiding is bedoeld voor iedereen die de EPD-applicatie wil testen — ook zonder technische achtergrond. Je kunt kiezen tussen twee testtools: **Playwright** en **Cypress**. Beide doen hetzelfde — een browser automatisch bedienen alsof er een echte gebruiker achter zit — maar de interface is net iets anders. + +--- + +## Mappenstructuur + +``` +tests/ + playwright/ # Playwright tests + .auth/ # Opgeslagen inlogsessie (automatisch aangemaakt) + fixtures/ + ai-mocks.ts # Gesimuleerde AI-antwoorden + auth.setup.ts # Eenmalige inlog setup + login.spec.ts + agenda/agenda.spec.ts + cortex/ + command-center.spec.ts + dagnotitie-flow.spec.ts + verpleegrapportage/overzicht.spec.ts + + cypress/ # Cypress tests (zelfde flows, andere syntax) + support/ + commands.ts # cy.login(), cy.mockDagnotitieChat() + e2e.ts + login.cy.ts + agenda/agenda.cy.ts + cortex/ + command-center.cy.ts + dagnotitie-flow.cy.ts + verpleegrapportage/overzicht.cy.ts + screenshots/ # Schermafbeeldingen bij fouten (automatisch) + videos/ # Video-opnames per testrun (automatisch) + + test-results/ # Playwright: details bij falende tests (automatisch) + playwright-report/ # Playwright: HTML rapport na afloop (automatisch) + + README.md # Dit bestand +``` + +--- + +## Voorbereiding + +Zorg dat je twee dingen hebt gestart voordat je de tests draait: + +**1. De applicatie zelf** + +Open een terminal en typ: +```bash +pnpm dev +``` +Wacht tot je ziet: `✓ Ready on http://localhost:3000`. Laat dit venster open staan. + +**2. De testtool naar keuze** — zie hieronder. + +--- + +## Playwright gebruiken + +Playwright is geïnstalleerd en geconfigureerd in `playwright.config.ts`. + +### Starten + +```bash +pnpm test:e2e:ui # visuele UI (aanbevolen voor demo en testers) +pnpm test:e2e # alle tests in de terminal +pnpm test:e2e:debug # stap-voor-stap debuggen +pnpm test:e2e:report # HTML rapport na afloop bekijken +``` + +### De Playwright UI gebruiken + +``` +┌─────────────────────────┬──────────────────────────────┐ +│ Testlijst (links) │ Browser preview (rechts) │ +│ │ │ +│ ▶ auth.setup │ Hier zie je de app │ +│ ▶ login │ meelopen terwijl de test │ +│ ▶ agenda │ wordt uitgevoerd │ +│ ▶ cortex │ │ +│ ▶ verpleegrapportage │ │ +└─────────────────────────┴──────────────────────────────┘ +``` + +**Stap 1 — Filter instellen** +Zorg dat bovenaan "chromium" of "All" geselecteerd is, niet "setup". Anders zie je maar één test. + +**Stap 2 — Inloggen (eenmalig per sessie)** +Klik op ▶ naast `auth.setup.ts`. Dit logt automatisch in en slaat de sessie op. Daarna starten alle andere tests al ingelogd. + +**Stap 3 — Test uitvoeren** +Klik op een individuele testnaam (niet de groepsnaam). De browser aan de rechterkant springt tot leven. + +**Wat betekenen de kleuren?** +- **Grijs** — test is nog niet gedraaid +- **Groen** ✓ — test geslaagd +- **Rood** ✗ — test gefaald, zie details onderaan + +--- + +## Cypress gebruiken + +Cypress is de tool die jullie testers al kennen. Geconfigureerd in `cypress.config.ts`. + +### Starten + +```bash +pnpm test:cypress:open # visuele UI (aanbevolen voor testers) +pnpm test:cypress:run # alle tests headless in de terminal +``` + +### De Cypress UI gebruiken + +Na `pnpm test:cypress:open` opent de Cypress app. Kies **E2E Testing** en daarna **Chrome** als browser. + +``` +┌──────────────────────────────────────────────────────┐ +│ Specs (testbestanden) │ +│ │ +│ login.cy.ts │ +│ agenda / agenda.cy.ts │ +│ cortex / command-center.cy.ts │ +│ cortex / dagnotitie-flow.cy.ts │ +│ verpleegrapportage / overzicht.cy.ts │ +└──────────────────────────────────────────────────────┘ +``` + +Klik op een bestand om alle tests erin te draaien. De browser opent en je ziet links de stappen en rechts de live applicatie. + +**Inloggen gaat automatisch** via `cy.login()` — je hoeft niets extra te doen. De sessie wordt gecached zodat het maar één keer per testrun hoeft. + +--- + +## Vergelijking Playwright vs Cypress + +Beide tools testen exact dezelfde flows. De syntax verschilt iets: + +| Actie | Playwright | Cypress | +|---|---|---| +| Navigeer naar pagina | `page.goto('/login')` | `cy.visit('/login')` | +| Vind element | `page.locator('#email')` | `cy.get('#email')` | +| Typ tekst | `page.fill('#email', '...')` | `cy.get('#email').type('...')` | +| Klik | `page.click('button')` | `cy.contains('button').click()` | +| Controleer zichtbaar | `expect(...).toBeVisible()` | `.should('be.visible')` | +| Mock API | `page.route('**/api/...')` | `cy.intercept('GET', '/api/...')` | +| Wacht op redirect | `page.waitForURL(/\/epd\//)` | `cy.url().should('match', /\/epd\//)` | + +--- + +## Overzicht van alle tests + +### login — Inlogpagina (5 tests) + +| Test | Wat wordt gecontroleerd | +|---|---| +| Toont loginpagina correct | Email, wachtwoord en inlogknop zijn zichtbaar | +| Foutmelding bij verkeerde gegevens | Foutmelding verschijnt bij foute combinatie | +| Demo login knop werkt | ⚡ knop logt direct in en stuurt door naar het EPD | +| Redirect na succesvol inloggen | Na inloggen kom je op `/epd/` terecht | +| Beveiligde route stuurt door | Probeer `/epd/dashboard` zonder sessie → je gaat naar login | + +--- + +### agenda — Agenda en afspraken (8 tests) + +Zoekresultaten zijn gesimuleerd met **Robbert Robot** zodat de tests niet afhankelijk zijn van live patiëntdata. + +**Basiscontroles** + +| Test | Wat wordt gecontroleerd | +|---|---| +| Laadt de agendapagina | Pagina opent correct met "Agenda" titel en "Nieuwe Afspraak" knop | +| Navigatieknoppen zichtbaar | "Vandaag", "Dag" en andere knoppen aanwezig | + +**Afspraak aanmaken** + +| Test | Wat wordt gecontroleerd | +|---|---| +| Modal opent | Klik op "Nieuwe Afspraak" → formulier verschijnt | +| Verplichte velden aanwezig | Patiëntzoekveld, datum, tijd en "Afspraak maken" knop zichtbaar | +| Zoeken toont resultaat | Type "Robbert" → "Robbert Robot" verschijnt in de lijst | +| Patiënt selecteren | Klik op naam → bevestigingskaart verschijnt in formulier | +| Knop uitgeschakeld zonder patiënt | "Afspraak maken" is niet klikbaar zonder geselecteerde patiënt | +| Sluiten werkt | Klik op "Sluiten" → modal verdwijnt | + +--- + +### cortex/command-center — Cortex interface (5 tests) + +Puur UI — geen echte AI-calls. + +| Test | Wat wordt gecontroleerd | +|---|---| +| Interface laadt | Invoerveld en voorbeeldtekst zichtbaar | +| Tekst invoeren werkt | Je kunt iets typen in het veld | +| ⌘K opent het veld | Sneltoets focust automatisch op het invoerveld | +| Escape wist de invoer | Escape-toets leegt het veld | +| Voorbeeldcommandos zichtbaar | Hints zoals "notitie jan" en "overdracht" staan in beeld | + +--- + +### cortex/dagnotitie-flow — Chat flows (5 tests) + +AI-antwoorden zijn gesimuleerd — tests werken altijd hetzelfde en hebben geen Anthropic API nodig. + +| Test | Scenario | +|---|---| +| Dagnotitie | Typ "notitie jan de vries voelt zich goed" → AI antwoordt "Genoteerd." | +| Invoer leeg na verzenden | Na het sturen is het invoerveld weer leeg | +| Zoeken | Typ "zoek marie" → AI antwoordt "Even kijken." | +| Onbekende opdracht | AI vraagt om verduidelijking als de intentie onduidelijk is | +| Meerdere berichten | Eerder gestuurde berichten blijven zichtbaar in de chat | + +--- + +### verpleegrapportage — Rapportagepagina's (3 tests) + +| Test | Wat wordt gecontroleerd | +|---|---| +| Rapportage pagina laadt | `/epd/verpleegrapportage` opent zonder fout of redirect naar login | +| Inhoud zichtbaar | Er is een patiëntenlijst of een lege-staat melding | +| Overdracht pagina laadt | `/epd/verpleegrapportage/overdracht` opent correct | + +--- + +## Als een test faalt + +**Playwright** schrijft bij een falende test het volgende weg in `tests/test-results/`: + +| Bestand | Inhoud | +|---|---| +| `screenshot.png` | Foto van de browser op het moment van de fout | +| `video.webm` | Video van de volledige testrun | +| `error-context.md` | Tekstbeschrijving van wat er misging | + +Bekijk het volledige HTML-rapport (in `tests/playwright-report/`): +```bash +pnpm test:e2e:report +``` + +**Cypress** schrijft bij een falende test naar `tests/cypress/screenshots/` en `tests/cypress/videos/`. In de visuele UI zie je de fout direct in beeld met een tijdlijn van alle uitgevoerde stappen. + +--- + +## Veelvoorkomende problemen + +| Probleem | Oplossing | +|---|---| +| Alle tests falen meteen | `pnpm dev` draait niet — start de applicatie eerst | +| Auth setup faalt | Supabase is gepauzeerd — herstart het project op supabase.com | +| Alleen de auth test zichtbaar (Playwright) | Filter staat op "setup" — zet hem op "chromium" of "All" | +| Browser preview blijft leeg (Playwright) | Klik op een individuele test (niet de groepsnaam) en druk op ▶ | +| Cypress vraagt om browser te kiezen | Kies "Chrome" in het openingsscherm | +| Tests werken maar app doet het niet | Tests controleren de interface, niet alle logica — meld bevindingen apart | diff --git a/tests/cypress/agenda/agenda.cy.ts b/tests/cypress/agenda/agenda.cy.ts new file mode 100644 index 0000000..1305de9 --- /dev/null +++ b/tests/cypress/agenda/agenda.cy.ts @@ -0,0 +1,92 @@ +const MOCK_PATIENT = { + id: 'test-patient-uuid', + name_family: 'Robot', + name_given: ['Robbert'], + birth_date: '1990-01-01', + identifier_client_number: '99001', +}; + +const FHIR_RESPONSE = { + entry: [{ + resource: { + id: MOCK_PATIENT.id, + name: [{ family: MOCK_PATIENT.name_family, given: MOCK_PATIENT.name_given }], + birthDate: MOCK_PATIENT.birth_date, + identifier: [{ system: 'http://example.com/client/999.7.6', value: MOCK_PATIENT.identifier_client_number }], + }, + }], +}; + +describe('Agenda', () => { + beforeEach(() => { + cy.login(); + cy.visit('/epd/agenda'); + }); + + it('laadt de agendapagina', () => { + cy.contains('h1', 'Agenda').should('be.visible'); + cy.contains('Nieuwe Afspraak').should('be.visible'); + }); + + it('toont navigatieknoppen en weergave-switcher', () => { + cy.contains('Vandaag').should('be.visible'); + cy.contains('Dag').should('be.visible'); + }); +}); + +describe('Afspraak maken', () => { + beforeEach(() => { + cy.intercept('GET', '/api/fhir/Patient**', FHIR_RESPONSE).as('patientSearch'); + cy.login(); + cy.visit('/epd/agenda'); + }); + + it('opent de "Nieuwe Afspraak" modal', () => { + cy.contains('Nieuwe Afspraak').click(); + cy.get('[role="dialog"]').should('be.visible'); + cy.get('[role="dialog"]').contains('Nieuwe Afspraak'); + }); + + it('modal toont alle verplichte velden', () => { + cy.contains('Nieuwe Afspraak').click(); + cy.get('[role="dialog"]').within(() => { + cy.get('input[placeholder*="Zoek op naam"]').should('be.visible'); + cy.get('input[type="date"]').should('be.visible'); + cy.get('input[type="time"]').first().should('be.visible'); + cy.contains('button', 'Afspraak maken').should('be.visible'); + }); + }); + + it('zoeken naar patiënt toont resultaat', () => { + cy.contains('Nieuwe Afspraak').click(); + cy.get('[role="dialog"]').within(() => { + cy.get('input[placeholder*="Zoek op naam"]').type('Robbert'); + }); + cy.wait('@patientSearch'); + cy.contains('Robbert Robot').should('be.visible'); + }); + + it('selecteren van patiënt vult het veld in', () => { + cy.contains('Nieuwe Afspraak').click(); + cy.get('[role="dialog"]').within(() => { + cy.get('input[placeholder*="Zoek op naam"]').type('Robbert'); + }); + cy.wait('@patientSearch'); + cy.contains('Robbert Robot').click(); + cy.get('.bg-teal-50').should('be.visible'); + }); + + it('afspraak maken knop is uitgeschakeld zonder patiënt', () => { + cy.contains('Nieuwe Afspraak').click(); + cy.get('[role="dialog"]').within(() => { + cy.contains('button', 'Afspraak maken').should('be.disabled'); + }); + }); + + it('sluiten sluit de modal', () => { + cy.contains('Nieuwe Afspraak').click(); + cy.get('[role="dialog"]').should('be.visible'); + cy.get('[role="dialog"]').contains('button', 'Sluiten').click(); + cy.get('[role="dialog"]').should('not.exist'); + }); +}); diff --git a/tests/cypress/cortex/command-center.cy.ts b/tests/cypress/cortex/command-center.cy.ts new file mode 100644 index 0000000..e8b3093 --- /dev/null +++ b/tests/cypress/cortex/command-center.cy.ts @@ -0,0 +1,35 @@ +describe('Cortex Command Center - UI', () => { + beforeEach(() => { + cy.login(); + cy.visit('/epd/cortex'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.visible'); + }); + + it('laadt de command center interface', () => { + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.visible'); + cy.contains('Welkom bij Cortex Assistent').first().should('be.visible'); + }); + + it('chat input accepteert tekst', () => { + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('test invoer'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('have.value', 'test invoer'); + }); + + it('keyboard shortcut ⌘K focust de input', () => { + cy.get('body').click(); + cy.get('body').type('{meta}k'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.focused'); + }); + + it('escape wist de input', () => { + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('iets typen'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('{esc}'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('have.value', ''); + }); + + it('toont voorbeeldcommandos in de empty state', () => { + cy.contains('Dagnotitie maken').first().should('be.visible'); + cy.contains('Patiënt zoeken').first().should('be.visible'); + cy.contains('Overdracht maken').first().should('be.visible'); + }); +}); diff --git a/tests/cypress/cortex/dagnotitie-flow.cy.ts b/tests/cypress/cortex/dagnotitie-flow.cy.ts new file mode 100644 index 0000000..4dcad21 --- /dev/null +++ b/tests/cypress/cortex/dagnotitie-flow.cy.ts @@ -0,0 +1,44 @@ +describe('Cortex - Demo flows', () => { + beforeEach(() => { + cy.login(); + cy.visit('/epd/cortex'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('be.visible'); + }); + + it('dagnotitie flow: bericht → AI response → artifact verschijnt', () => { + cy.mockDagnotitieChat('Jan de Vries'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie jan de vries voelt zich goed vandaag{enter}'); + cy.contains('notitie jan de vries voelt zich goed vandaag').first().should('be.visible'); + cy.contains('Genoteerd.').first().should('be.visible'); + }); + + it('dagnotitie flow: input is leeg na verzenden', () => { + cy.mockDagnotitieChat(); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie jan medicatie gegeven{enter}'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').should('have.value', ''); + }); + + it('zoeken flow: bericht → zoek-artifact verschijnt', () => { + cy.mockZoekenChat('Marie'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('zoek marie{enter}'); + cy.contains('Even kijken.').first().should('be.visible'); + }); + + it('onbekende intent: AI vraagt om verduidelijking', () => { + cy.mockOnbekendChat(); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('xyz{enter}'); + cy.contains('snap niet helemaal wat je bedoelt').first().should('be.visible'); + }); + + it('meerdere berichten: chat history groeit', () => { + cy.mockDagnotitieChat(); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie jan medicatie gegeven{enter}'); + cy.contains('Genoteerd.').first().should('be.visible'); + + cy.mockDagnotitieChat('Marie'); + cy.get('textarea[placeholder="Typ of spreek wat je wilt doen..."]:first').type('notitie marie slaapt slecht{enter}'); + + cy.contains('notitie jan medicatie gegeven').first().should('be.visible'); + cy.contains('notitie marie slaapt slecht').first().should('be.visible'); + }); +}); diff --git a/tests/cypress/login.cy.ts b/tests/cypress/login.cy.ts new file mode 100644 index 0000000..ebfd99e --- /dev/null +++ b/tests/cypress/login.cy.ts @@ -0,0 +1,36 @@ +describe('Login flow', () => { + beforeEach(() => { + cy.visit('/login'); + }); + + it('toont de loginpagina correct', () => { + cy.get('h2').should('contain', 'Welkom terug'); + cy.get('#email').should('be.visible'); + cy.get('#password').should('be.visible'); + cy.get('button[type="submit"]').should('contain', 'Inloggen'); + }); + + it('toont foutmelding bij verkeerde inloggegevens', () => { + cy.get('#email').type('verkeerd@email.nl'); + cy.get('#password').type('foutWachtwoord123'); + cy.get('button[type="submit"]').click(); + cy.contains('onjuist').should('be.visible'); + }); + + it('demo login knop is zichtbaar en werkt', () => { + cy.contains('Demo Account Proberen').should('be.visible').click(); + cy.url().should('match', /\/epd\//); + }); + + it('redirect naar EPD na succesvol inloggen', () => { + cy.get('#email').type(Cypress.env('TEST_EMAIL') ?? 'demo@mini-ecd.demo'); + cy.get('#password').type(Cypress.env('TEST_PASSWORD') ?? 'Demo2024!'); + cy.get('button[type="submit"]').click(); + cy.url().should('match', /\/epd\//); + }); + + it('beveiligde route stuurt door naar login', () => { + cy.visit('/epd/dashboard'); + cy.url().should('include', '/login'); + }); +}); diff --git a/tests/cypress/support/commands.ts b/tests/cypress/support/commands.ts new file mode 100644 index 0000000..fbaf93f --- /dev/null +++ b/tests/cypress/support/commands.ts @@ -0,0 +1,77 @@ +/// + +const TEST_EMAIL = Cypress.env('TEST_EMAIL') ?? 'demo@mini-ecd.demo'; +const TEST_PASSWORD = Cypress.env('TEST_PASSWORD') ?? 'Demo2024!'; + +// Login command met session caching — logt maar één keer in per testrun +Cypress.Commands.add('login', () => { + cy.session('epd-user', () => { + cy.visit('/login'); + cy.get('h2').should('contain', 'Welkom terug'); + cy.get('#email').type(TEST_EMAIL); + cy.get('#password').type(TEST_PASSWORD); + cy.get('button[type="submit"]').click(); + cy.url().should('match', /\/epd\//); + }); +}); + +// AI chat mock — onderschept de Cortex chat API +Cypress.Commands.add('mockDagnotitieChat', (patientName = 'Jan de Vries') => { + const sseBody = [ + `data: ${JSON.stringify({ type: 'content', text: `Genoteerd.\n\n\`\`\`json\n${JSON.stringify({ + type: 'action', + intent: 'dagnotitie', + entities: { patientName, category: 'observatie', content: 'Patiënt voelt zich goed vandaag.' }, + confidence: 0.95, + artifact: { type: 'dagnotitie', prefill: { patientName, category: 'observatie', content: 'Patiënt voelt zich goed vandaag.' } }, + }, null, 2)}\n\`\`\`` })}\n\n`, + `data: ${JSON.stringify({ type: 'done' })}\n\n`, + ].join(''); + + cy.intercept('POST', '/api/cortex/chat', { + statusCode: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: sseBody, + }).as('cortexChat'); +}); + +Cypress.Commands.add('mockZoekenChat', (query = 'Jan') => { + const sseBody = [ + `data: ${JSON.stringify({ type: 'content', text: `Even kijken.\n\n\`\`\`json\n${JSON.stringify({ + type: 'action', intent: 'zoeken', + entities: { query }, confidence: 0.97, + artifact: { type: 'zoeken', prefill: { query } }, + }, null, 2)}\n\`\`\`` })}\n\n`, + `data: ${JSON.stringify({ type: 'done' })}\n\n`, + ].join(''); + + cy.intercept('POST', '/api/cortex/chat', { + statusCode: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: sseBody, + }).as('cortexChat'); +}); + +Cypress.Commands.add('mockOnbekendChat', () => { + const sseBody = [ + `data: ${JSON.stringify({ type: 'content', text: 'Hmm, snap niet helemaal wat je bedoelt. Kun je het anders zeggen?' })}\n\n`, + `data: ${JSON.stringify({ type: 'done' })}\n\n`, + ].join(''); + + cy.intercept('POST', '/api/cortex/chat', { + statusCode: 200, + headers: { 'Content-Type': 'text/event-stream' }, + body: sseBody, + }).as('cortexChat'); +}); + +declare global { + namespace Cypress { + interface Chainable { + login(): Chainable; + mockDagnotitieChat(patientName?: string): Chainable; + mockZoekenChat(query?: string): Chainable; + mockOnbekendChat(): Chainable; + } + } +} diff --git a/tests/cypress/support/e2e.ts b/tests/cypress/support/e2e.ts new file mode 100644 index 0000000..1221b17 --- /dev/null +++ b/tests/cypress/support/e2e.ts @@ -0,0 +1 @@ +import './commands'; diff --git a/tests/cypress/verpleegrapportage/overzicht.cy.ts b/tests/cypress/verpleegrapportage/overzicht.cy.ts new file mode 100644 index 0000000..ec13616 --- /dev/null +++ b/tests/cypress/verpleegrapportage/overzicht.cy.ts @@ -0,0 +1,31 @@ +describe('Verpleegrapportage', () => { + beforeEach(() => { + cy.login(); + }); + + it('laadt de rapportage pagina', () => { + cy.visit('/epd/verpleegrapportage'); + cy.url().should('not.include', '/login'); + cy.get('body').should('be.visible'); + }); + + it('toont lege staat of patiëntenlijst', () => { + cy.visit('/epd/verpleegrapportage'); + cy.get('body').then(($body) => { + const hasContent = + $body.find('[class*="rapportage"]').length > 0 || + $body.find('[class*="patient"]').length > 0 || + $body.text().includes('Geen patiënten'); + expect(hasContent).to.be.true; + }); + }); +}); + +describe('Overdracht pagina', () => { + it('laadt de overdracht pagina', () => { + cy.login(); + cy.visit('/epd/verpleegrapportage/overdracht'); + cy.url().should('not.include', '/login'); + cy.get('body').should('be.visible'); + }); +}); diff --git a/tests/playwright/agenda/agenda.spec.ts b/tests/playwright/agenda/agenda.spec.ts new file mode 100644 index 0000000..1207819 --- /dev/null +++ b/tests/playwright/agenda/agenda.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +const MOCK_PATIENT = { + id: 'test-patient-uuid', + name_family: 'Robot', + name_given: ['Robbert'], + birth_date: '1990-01-01', + identifier_client_number: '99001', +}; + +const FHIR_PATIENT_RESPONSE = { + entry: [ + { + resource: { + id: MOCK_PATIENT.id, + name: [{ family: MOCK_PATIENT.name_family, given: MOCK_PATIENT.name_given }], + birthDate: MOCK_PATIENT.birth_date, + identifier: [ + { system: 'http://example.com/client/999.7.6', value: MOCK_PATIENT.identifier_client_number }, + ], + }, + }, + ], +}; + +test.describe('Agenda', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/epd/agenda'); + await page.waitForLoadState('networkidle'); + }); + + test('laadt de agendapagina', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Agenda' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i })).toBeVisible(); + }); + + test('toont navigatieknoppen en weergave-switcher', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Vandaag' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Dag' })).toBeVisible(); + }); +}); + +test.describe('Afspraak maken', () => { + test.beforeEach(async ({ page }) => { + // Mock patient search + await page.route('**/api/fhir/Patient**', async (route) => { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FHIR_PATIENT_RESPONSE) }); + }); + + // Mock afspraak aanmaken (voorkom echte database write) + await page.route('**/epd/agenda**', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) }); + } else { + await route.continue(); + } + }); + + await page.goto('/epd/agenda'); + await page.waitForLoadState('networkidle'); + }); + + test('opent de "Nieuwe Afspraak" modal', async ({ page }) => { + await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Nieuwe Afspraak' })).toBeVisible(); + }); + + test('modal toont alle verplichte velden', async ({ page }) => { + await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog.getByPlaceholder(/Zoek op naam/i)).toBeVisible(); + await expect(dialog.locator('input[type="date"]')).toBeVisible(); + await expect(dialog.locator('input[type="time"]').first()).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Afspraak maken' })).toBeVisible(); + }); + + test('zoeken naar patiënt toont resultaat', async ({ page }) => { + await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click(); + + const dialog = page.getByRole('dialog'); + await dialog.getByPlaceholder(/Zoek op naam/i).fill('Robbert'); + + // Dropdown met patiënt verschijnt + await expect(dialog.getByText('Robbert Robot')).toBeVisible({ timeout: 5_000 }); + }); + + test('selecteren van patiënt vult het veld in', async ({ page }) => { + await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click(); + + const dialog = page.getByRole('dialog'); + await dialog.getByPlaceholder(/Zoek op naam/i).fill('Robbert'); + await expect(dialog.getByText('Robbert Robot')).toBeVisible({ timeout: 5_000 }); + await dialog.getByText('Robbert Robot').click(); + + // Patiënt bevestigingskaart zichtbaar + await expect(dialog.locator('.bg-teal-50')).toBeVisible(); + }); + + test('afspraak maken knop is uitgeschakeld zonder patiënt', async ({ page }) => { + await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click(); + + const dialog = page.getByRole('dialog'); + const submitButton = dialog.getByRole('button', { name: 'Afspraak maken' }); + + await expect(submitButton).toBeDisabled(); + }); + + test('sluiten sluit de modal', async ({ page }) => { + await page.getByRole('button', { name: /Nieuwe Afspraak|Nieuw/i }).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('button', { name: 'Sluiten' }).click(); + await expect(dialog).not.toBeVisible(); + }); +}); diff --git a/tests/playwright/auth.setup.ts b/tests/playwright/auth.setup.ts new file mode 100644 index 0000000..89ed1c0 --- /dev/null +++ b/tests/playwright/auth.setup.ts @@ -0,0 +1,49 @@ +import { test as setup } from '@playwright/test'; +import path from 'path'; + +const authFile = path.join(process.cwd(), 'tests/playwright/.auth/user.json'); + +const DEMO_EMAIL = process.env.TEST_EMAIL ?? 'demo@mini-ecd.demo'; +const DEMO_PASSWORD = process.env.TEST_PASSWORD ?? 'Demo2024!'; +const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; +const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + +setup('authenticate as demo user', async ({ page, request }) => { + // Authenticate via Supabase REST API - faster and more reliable than UI form + const response = await request.post(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, { + headers: { + apikey: SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + }, + data: { email: DEMO_EMAIL, password: DEMO_PASSWORD }, + }); + + if (!response.ok()) { + throw new Error(`Supabase auth failed: ${response.status()} ${await response.text()}`); + } + + const tokenData = await response.json(); + + // Build Supabase auth cookie (format: base64-) + const projectRef = SUPABASE_URL.replace('https://', '').split('.')[0]; + const cookieName = `sb-${projectRef}-auth-token`; + const cookieValue = `base64-${Buffer.from(JSON.stringify(tokenData)).toString('base64')}`; + + await page.context().addCookies([ + { + name: cookieName, + value: cookieValue, + domain: 'localhost', + path: '/', + httpOnly: false, + secure: false, + sameSite: 'Lax', + }, + ]); + + // Verify the session works by navigating to a protected route + await page.goto('/epd/cortex'); + await page.waitForURL(/\/epd\//, { timeout: 15_000 }); + + await page.context().storageState({ path: authFile }); +}); diff --git a/tests/playwright/cortex/command-center.spec.ts b/tests/playwright/cortex/command-center.spec.ts new file mode 100644 index 0000000..66f5dee --- /dev/null +++ b/tests/playwright/cortex/command-center.spec.ts @@ -0,0 +1,39 @@ +import { test, expect, type Page } from '@playwright/test'; + +// De pagina heeft twee textareas (desktop + mobiel layout). .first() pakt de zichtbare desktop-versie. +const input = (page: Page) => page.getByPlaceholder('Typ of spreek wat je wilt doen...').first(); + +test.describe('Cortex Command Center - UI', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/epd/cortex'); + await expect(input(page)).toBeVisible({ timeout: 10_000 }); + }); + + test('laadt de command center interface', async ({ page }) => { + await expect(input(page)).toBeVisible(); + await expect(page.locator('text=Welkom bij Cortex Assistent').first()).toBeVisible(); + }); + + test('chat input accepteert tekst', async ({ page }) => { + await input(page).fill('test invoer'); + await expect(input(page)).toHaveValue('test invoer'); + }); + + test('keyboard shortcut ⌘K focust de input', async ({ page }) => { + await page.click('body'); + await page.keyboard.press('Meta+k'); + await expect(input(page)).toBeFocused(); + }); + + test('escape wist de input', async ({ page }) => { + await input(page).fill('iets typen'); + await input(page).press('Escape'); + await expect(input(page)).toHaveValue(''); + }); + + test('toont voorbeeldcommandos in de empty state', async ({ page }) => { + await expect(page.locator('text=Dagnotitie maken').first()).toBeVisible(); + await expect(page.locator('text=Patiënt zoeken').first()).toBeVisible(); + await expect(page.locator('text=Overdracht maken').first()).toBeVisible(); + }); +}); diff --git a/tests/playwright/cortex/dagnotitie-flow.spec.ts b/tests/playwright/cortex/dagnotitie-flow.spec.ts new file mode 100644 index 0000000..89bb131 --- /dev/null +++ b/tests/playwright/cortex/dagnotitie-flow.spec.ts @@ -0,0 +1,63 @@ +import { test, expect, type Page } from '@playwright/test'; +import { mockDagnotitieChat, mockZoekenChat, mockOverdrachtChat, mockOnbekendChat } from '../fixtures/ai-mocks'; + +// De pagina heeft twee textareas (desktop + mobiel layout). .first() pakt de zichtbare desktop-versie. +const input = (page: Page) => page.getByPlaceholder('Typ of spreek wat je wilt doen...').first(); + +test.describe('Cortex - Demo flows', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/epd/cortex'); + await expect(input(page)).toBeVisible({ timeout: 10_000 }); + }); + + test('dagnotitie flow: bericht → AI response → artifact verschijnt', async ({ page }) => { + await mockDagnotitieChat(page, 'Jan de Vries'); + await input(page).fill('notitie jan de vries voelt zich goed vandaag'); + await input(page).press('Enter'); + await expect(page.locator('text=notitie jan de vries voelt zich goed vandaag').first()).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('text=Genoteerd.').first()).toBeVisible({ timeout: 10_000 }); + }); + + test('dagnotitie flow: input is leeg na verzenden', async ({ page }) => { + await mockDagnotitieChat(page); + await input(page).fill('notitie jan medicatie gegeven'); + await input(page).press('Enter'); + await expect(input(page)).toHaveValue('', { timeout: 3_000 }); + }); + + test('zoeken flow: bericht → zoek-artifact verschijnt', async ({ page }) => { + await mockZoekenChat(page, 'Marie'); + await input(page).fill('zoek marie'); + await input(page).press('Enter'); + await expect(page.locator('text=zoek marie').first()).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('text=Even kijken.').first()).toBeVisible({ timeout: 10_000 }); + }); + + test('overdracht flow: bericht → overdracht-artifact', async ({ page }) => { + await mockOverdrachtChat(page); + await input(page).fill('overdracht'); + await input(page).press('Enter'); + await expect(page.locator('text=overdracht').first()).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('text=Overdracht klaar.').first()).toBeVisible({ timeout: 10_000 }); + }); + + test('onbekende intent: AI vraagt om verduidelijking', async ({ page }) => { + await mockOnbekendChat(page); + await input(page).fill('xyz'); + await input(page).press('Enter'); + await expect(page.locator('text=snap niet helemaal wat je bedoelt').first()).toBeVisible({ timeout: 10_000 }); + }); + + test('meerdere berichten: chat history groeit', async ({ page }) => { + await mockDagnotitieChat(page); + await input(page).fill('notitie jan medicatie gegeven'); + await input(page).press('Enter'); + await expect(page.locator('text=Genoteerd.').first()).toBeVisible({ timeout: 10_000 }); + + await mockDagnotitieChat(page, 'Marie'); + await input(page).fill('notitie marie slaapt slecht'); + await input(page).press('Enter'); + await expect(page.locator('text=notitie jan medicatie gegeven').first()).toBeVisible(); + await expect(page.locator('text=notitie marie slaapt slecht').first()).toBeVisible(); + }); +}); diff --git a/tests/playwright/fixtures/ai-mocks.ts b/tests/playwright/fixtures/ai-mocks.ts new file mode 100644 index 0000000..978d3a0 --- /dev/null +++ b/tests/playwright/fixtures/ai-mocks.ts @@ -0,0 +1,110 @@ +import type { Page } from '@playwright/test'; + +/** + * Builds a mock SSE response body for the Cortex chat endpoint. + * The chat route streams: { type: 'content', text } chunks, then { type: 'done' }. + */ +function buildSSEBody(text: string, actionJson?: object): string { + const encoder = new TextEncoder(); + const parts: string[] = []; + + // Stream text in one chunk for simplicity + const fullText = actionJson + ? `${text}\n\n\`\`\`json\n${JSON.stringify(actionJson, null, 2)}\n\`\`\`` + : text; + + parts.push(`data: ${JSON.stringify({ type: 'content', text: fullText })}\n\n`); + parts.push(`data: ${JSON.stringify({ type: 'done' })}\n\n`); + + return parts.join(''); +} + +/** Mock de Cortex chat endpoint voor een dagnotitie intent. */ +export async function mockDagnotitieChat(page: Page, patientName = 'Jan de Vries') { + await page.route('**/api/cortex/chat', async (route) => { + const action = { + type: 'action', + intent: 'dagnotitie', + entities: { + patientName, + category: 'observatie', + content: 'Patiënt voelt zich goed vandaag.', + }, + confidence: 0.95, + artifact: { + type: 'dagnotitie', + prefill: { + patientName, + category: 'observatie', + content: 'Patiënt voelt zich goed vandaag.', + }, + }, + }; + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: buildSSEBody('Genoteerd.', action), + }); + }); +} + +/** Mock de Cortex chat endpoint voor een zoeken intent. */ +export async function mockZoekenChat(page: Page, query = 'Jan') { + await page.route('**/api/cortex/chat', async (route) => { + const action = { + type: 'action', + intent: 'zoeken', + entities: { query }, + confidence: 0.97, + artifact: { + type: 'zoeken', + prefill: { query }, + }, + }; + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: buildSSEBody('Even kijken.', action), + }); + }); +} + +/** Mock de Cortex chat endpoint voor een overdracht intent. */ +export async function mockOverdrachtChat(page: Page) { + await page.route('**/api/cortex/chat', async (route) => { + const action = { + type: 'action', + intent: 'overdracht', + entities: {}, + confidence: 0.98, + artifact: { + type: 'overdracht', + prefill: {}, + }, + }; + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: buildSSEBody('Overdracht klaar.', action), + }); + }); +} + +/** Mock de Cortex chat endpoint voor een onduidelijke intent (geen artifact). */ +export async function mockOnbekendChat(page: Page) { + await page.route('**/api/cortex/chat', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: buildSSEBody('Hmm, snap niet helemaal wat je bedoelt. Kun je het anders zeggen?'), + }); + }); +} + +/** Verwijder alle actieve route mocks op de chat endpoint. */ +export async function clearChatMocks(page: Page) { + await page.unroute('**/api/cortex/chat'); +} diff --git a/tests/playwright/login.spec.ts b/tests/playwright/login.spec.ts new file mode 100644 index 0000000..08ed036 --- /dev/null +++ b/tests/playwright/login.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +// These tests run WITHOUT storageState (they test the login UI itself) +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('Login flow', () => { + test('toont de loginpagina correct', async ({ page }) => { + await page.goto('/login'); + + await expect(page.locator('h2')).toContainText('Welkom terug'); + await expect(page.locator('#email')).toBeVisible(); + await expect(page.locator('#password')).toBeVisible(); + await expect(page.locator('button[type="submit"]')).toBeVisible(); + }); + + test('toont foutmelding bij verkeerde inloggegevens', async ({ page }) => { + await page.goto('/login'); + + await page.fill('#email', 'verkeerd@email.nl'); + await page.fill('#password', 'foutWachtwoord123'); + await page.click('button[type="submit"]'); + + // Wait for error message + await expect(page.locator('text=onjuist')).toBeVisible({ timeout: 8_000 }); + }); + + test('demo login knop is zichtbaar en werkt', async ({ page }) => { + await page.goto('/login'); + + const demoButton = page.getByRole('button', { name: /Demo Account Proberen/i }); + await expect(demoButton).toBeVisible(); + + await demoButton.click(); + + // Should redirect to EPD after demo login + await page.waitForURL(/\/epd\//, { timeout: 10_000 }); + }); + + test('redirect naar EPD na succesvol inloggen', async ({ page }) => { + await page.goto('/login'); + + await page.fill('#email', 'demo@mini-ecd.demo'); + await page.fill('#password', 'Demo2024!'); + await page.click('button[type="submit"]'); + + await page.waitForURL(/\/epd\//, { timeout: 10_000 }); + expect(page.url()).toMatch(/\/epd\//); + }); + + test('beveiligde route stuurt door naar login', async ({ page }) => { + await page.goto('/epd/dashboard'); + + await page.waitForURL('/login**', { timeout: 5_000 }); + expect(page.url()).toContain('/login'); + }); +}); diff --git a/tests/playwright/patients/rapportage.spec.ts b/tests/playwright/patients/rapportage.spec.ts new file mode 100644 index 0000000..0a0bde5 --- /dev/null +++ b/tests/playwright/patients/rapportage.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from '@playwright/test'; + +const PATIENT_NAAM = 'Robbert Robot'; +const AUTH_STATE = 'tests/playwright/.auth/user.json'; + +const RAPPORTAGES = [ + { + type: 'Voortgang', + inhoud: + 'Cliënt heeft vandaag goed geslapen en voelt zich uitgerust. Medicatie correct ingenomen zonder bijwerkingen. Stemming is stabiel en positief gedurende de gehele dag.', + }, + { + type: 'Contact', + inhoud: + 'Telefonisch contact gehad met de familie van de cliënt. Positieve terugkoppeling over het afgelopen weekend. Familie ervaart momenteel weinig zorgen over de thuissituatie.', + }, + { + type: 'Vrije notitie', + inhoud: + 'Cliënt heeft interesse getoond in deelname aan de dagactiviteitengroep. Dit voorstel wordt volgende week besproken tijdens het multidisciplinair overleg met het behandelteam.', + }, +]; + +test.describe(`Rapportages schrijven - ${PATIENT_NAAM}`, () => { + let rapportageUrl: string; + + test.beforeAll(async ({ browser }) => { + // Gebruik een aparte context om het patiënt-ID op te zoeken + const context = await browser.newContext({ storageState: AUTH_STATE }); + const page = await context.newPage(); + + await page.goto('/epd/patients'); + await page.waitForLoadState('networkidle'); + + // Zoek Robbert Robot via de zoekbalk + await page.getByPlaceholder('Zoek op naam of BSN...').fill('Robbert'); + await expect(page.getByText(PATIENT_NAAM).first()).toBeVisible({ timeout: 8_000 }); + await page.getByText(PATIENT_NAAM).first().click(); + await page.waitForURL(/\/epd\/patients\/.+/); + + const patientId = page.url().match(/\/epd\/patients\/([^/?#]+)/)?.[1]; + if (!patientId) throw new Error(`Patiënt "${PATIENT_NAAM}" niet gevonden in de patiëntenlijst`); + + rapportageUrl = `/epd/patients/${patientId}/rapportage`; + await context.close(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(rapportageUrl); + await page.waitForLoadState('networkidle'); + + // Wacht tot de TipTap editor volledig geladen is + await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 }); + + // Verwijder eventuele localStorage drafts voor een schone start + await page.evaluate(() => { + Object.keys(localStorage) + .filter((k) => k.startsWith('rapportage-draft-')) + .forEach((k) => localStorage.removeItem(k)); + }); + }); + + RAPPORTAGES.forEach((rapportage, i) => { + test(`rapportage ${i + 1}: ${rapportage.type}`, async ({ page }) => { + // Selecteer het rapportage type via de QuickActions (exact=true voorkomt match met tijdlijn items) + await page.getByRole('button', { name: rapportage.type, exact: true }).click(); + + // Klik op de editor en typ de inhoud + const editor = page.locator('.ProseMirror'); + await editor.click(); + await page.keyboard.type(rapportage.inhoud); + + // Verifieer dat de tekst meetelt in de karakterteller + await expect(page.locator('text=/\\d+ \\/ 5000 karakters/')).toBeVisible(); + + // Opslaan knop moet actief zijn (inhoud > 20 karakters) + const opslaanKnop = page.getByRole('button', { name: 'Opslaan' }); + await expect(opslaanKnop).toBeEnabled(); + await opslaanKnop.click(); + + // Verifieer de succesmelding + await expect(page.getByText('Rapportage opgeslagen').first()).toBeVisible({ timeout: 10_000 }); + + // Na opslaan is de editor leeg → Opslaan knop is weer uitgeschakeld + await expect(opslaanKnop).toBeDisabled({ timeout: 5_000 }); + }); + }); + + test('alle drie rapportages staan in de tijdlijn', async ({ page }) => { + // Schrijf de drie rapportages in volgorde + for (const rapportage of RAPPORTAGES) { + await page.getByRole('button', { name: rapportage.type, exact: true }).click(); + await page.locator('.ProseMirror').click(); + await page.keyboard.type(rapportage.inhoud); + await page.getByRole('button', { name: 'Opslaan', exact: true }).click(); + await expect(page.getByText('Rapportage opgeslagen').first()).toBeVisible({ timeout: 10_000 }); + // exact: true onderscheidt "Opslaan" (klaar) van "Opslaan…" (bezig) — wacht tot opslaan echt klaar is + await expect(page.getByRole('button', { name: 'Opslaan', exact: true })).toBeDisabled({ timeout: 10_000 }); + } + + // Controleer dat de tijdlijn de rapportages toont + // TimelineCard rendert als
— anders dan QuickAction