Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ concurrency:
jobs:
run-tests:
uses: ./.github/workflows/run_tests.yml
run-e2e-tests:
uses: ./.github/workflows/e2e_tests.yml
build-dev-container:
uses: ./.github/workflows/build_docker.yml
secrets: inherit
deploy-dev-container:
needs:
- run-tests
- run-e2e-tests
- build-dev-container
uses: ./.github/workflows/deploy_docker.yml
secrets: inherit
Expand Down
182 changes: 182 additions & 0 deletions .github/workflows/e2e_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
name: E2E Tests

on:
workflow_call:

jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30

services:
postgres:
image: postgres:18.2-alpine
env:
POSTGRES_USER: thesis-management-postgres
POSTGRES_PASSWORD: thesis-management-postgres
POSTGRES_DB: thesis-management
ports:
- 5144:5432
options: >-
--health-cmd "pg_isready -d thesis-management -U thesis-management-postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref || github.ref }}
fetch-depth: 1

# Start Keycloak manually (service containers don't support custom commands)
- name: Start Keycloak
run: |
docker run -d --name keycloak \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-p 8081:8080 \
quay.io/keycloak/keycloak:26.4 \
start-dev

- name: Wait for Keycloak to be ready
run: |
echo "Waiting for Keycloak..."
for i in $(seq 1 60); do
if curl -sf http://localhost:8081/realms/master > /dev/null 2>&1; then
echo "Keycloak is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "Keycloak failed to start"
docker logs keycloak
exit 1
fi
sleep 2
done

- name: Import Keycloak realm
run: |
# Get admin token
TOKEN=$(curl -sf -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin&grant_type=password&client_id=admin-cli" | jq -r '.access_token')

# Import realm
curl -sf -X POST "http://localhost:8081/admin/realms" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @keycloak/thesis-management-realm.json

echo "Realm imported successfully"

# Set up Java for server
- name: Set up JDK 25
uses: actions/setup-java@v5
with:
java-version: '25'
distribution: 'zulu'
cache: 'gradle'

- name: Grant execute permission for gradlew
run: chmod +x ./server/gradlew

# Start server in background with dev profile
- name: Start server
working-directory: ./server
run: ./gradlew bootRun --args='--spring.profiles.active=dev' &
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5144/thesis-management
SPRING_DATASOURCE_USERNAME: thesis-management-postgres
SPRING_DATASOURCE_PASSWORD: thesis-management-postgres
KEYCLOAK_HOST: http://localhost:8081
KEYCLOAK_REALM_NAME: thesis-management
KEYCLOAK_CLIENT_ID: thesis-management-app
KEYCLOAK_SERVICE_CLIENT_ID: thesis-management-service-client
KEYCLOAK_SERVICE_CLIENT_SECRET: ""
CLIENT_HOST: http://localhost:3000

# Set up Node.js for client
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: client/package-lock.json

- name: Install client dependencies
working-directory: ./client
run: npm ci

- name: Install Playwright browsers
working-directory: ./client
run: npx playwright install --with-deps chromium

# Start client dev server in background
- name: Start client dev server
working-directory: ./client
run: npx webpack serve --env NODE_ENV=development &
env:
SERVER_HOST: http://localhost:8080
KEYCLOAK_HOST: http://localhost:8081
KEYCLOAK_REALM_NAME: thesis-management
KEYCLOAK_CLIENT_ID: thesis-management-app

# Wait for both services to be ready
- name: Wait for server to be ready
run: |
echo "Waiting for Spring Boot server..."
for i in $(seq 1 120); do
if curl -sf http://localhost:8080/api/actuator/health > /dev/null 2>&1; then
echo "Server is ready"
break
fi
if [ "$i" -eq 120 ]; then
echo "Server failed to start"
exit 1
fi
sleep 2
done

- name: Wait for client to be ready
run: |
echo "Waiting for client dev server..."
for i in $(seq 1 60); do
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
echo "Client is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo "Client failed to start"
exit 1
fi
sleep 2
done

# Run E2E tests
- name: Run Playwright tests
working-directory: ./client
run: npx playwright test
env:
CI: "1"
CLIENT_URL: http://localhost:3000
KEYCLOAK_HOST: http://localhost:8081
KEYCLOAK_REALM_NAME: thesis-management
KEYCLOAK_CLIENT_ID: thesis-management-app

- name: Upload Playwright report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: client/playwright-report/
retention-days: 14

- name: Upload test results
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-results
path: client/test-results/
retention-days: 14
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ postfix-config
node_modules/

# misc
.DS_Store
.DS_Store

# E2E test runner
.e2e-pids/
.e2e-server.log
.e2e-client.log
37 changes: 9 additions & 28 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,32 @@ This file provides guidance for Claude Code when working with this repository.
### Server (Spring Boot + Gradle)
- **Run server**: `cd server && ./gradlew bootRun`
- **Run tests**: `cd server && ./gradlew test`
- **Run tests with coverage**: `cd server && ./gradlew test jacocoTestReport`
- **Format code**: `cd server && ./gradlew spotlessApply`
- **Check formatting**: `cd server && ./gradlew spotlessCheck`

### Client (React + Webpack)
- **Install dependencies**: `cd client && npm install`
- **Run dev server**: `cd client && npm run dev`
- **Build**: `cd client && npm run build`
- **Lint**: `cd client && npx eslint src/`
- **Type check**: `cd client && npx tsc --noEmit` (ignore mantine-datatable type errors — pre-existing)
- **Type check**: `cd client && npx tsc --noEmit` (ignore mantine-datatable type errors)

### E2E Tests (Playwright)
- **Run locally**: `./execute-e2e-local.sh` (starts all services automatically)
- **Run only tests**: `cd client && npm run e2e` (when services already running)
- **Interactive UI**: `./execute-e2e-local.sh --ui`

## Architecture

- **Server**: Spring Boot 3, Java 25, PostgreSQL, Keycloak for auth, Liquibase for migrations
- **Client**: React 19, TypeScript, Mantine UI, Webpack
- **Deployment**: Docker multi-platform images (amd64/arm64), deployed via GitHub Actions to VMs using docker-compose
- **Deployment**: Docker multi-platform images (amd64/arm64), deployed via GitHub Actions

## Key Conventions

### Server: DTO Serialization (`@JsonInclude(NON_EMPTY)`)

All DTOs use `@JsonInclude(JsonInclude.Include.NON_EMPTY)`. This is an intentional API contract:
- `null` values, empty strings (`""`), and empty collections (`[]`) are **omitted** from JSON responses
- This applies to **all** DTOs (detail and overview), not just list endpoints
- The client **must** handle missing fields gracefully — this is by design, not a bug

### Server: JPA Fetch Types

Prefer `FetchType.LAZY` for `@OneToMany` and `@ManyToMany` relationships. The application uses `spring.jpa.open-in-view=true`, so lazy loading works throughout the request lifecycle including in controllers.
### DTO Serialization (`@JsonInclude(NON_EMPTY)`)

### Client: Handling API Responses

Since the server omits empty/null fields from JSON:
- TypeScript interfaces mark omittable fields as optional (`?`)
- Always use fallback defaults: `?? ''` for strings, `?? []` for arrays
- Use optional chaining (`?.`) for nested access on optional fields
- Never assume an array or string field is present in the response
All DTOs use `@JsonInclude(JsonInclude.Include.NON_EMPTY)`. `null`, empty strings, and empty collections are omitted from JSON. The client must handle missing fields with `?? ''`, `?? []`, and `?.`.

### Role Terminology

The backend/Keycloak uses `supervisor` and `advisor` roles. In the UI these are displayed as "Examiner" and "Supervisor" respectively.

## CI/CD

- `dev.yml`: Triggers on PRs to develop/main and pushes to develop/main. Has concurrency control per PR.
- `prod.yml`: Triggers on pushes to main only. Has concurrency control (no cancellation).
- `build_docker.yml`: Separate jobs for server and client builds (not a matrix) to avoid output race conditions.
- `deploy_docker.yml`: Deploys to VMs via SSH. Uses environment protection rules requiring approval.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Group heads have the Group Admin role for their group by default (this cannot be
1. [Production Setup](docs/PRODUCTION.md)
2. [Configuration](docs/CONFIGURATION.md)
3. [Customizing E-Mails](docs/MAILS.md)
4. [Development Setup](docs/DEVELOPMENT.md)
4. [Development Setup](docs/DEVELOPMENT.md) (includes [E2E Tests](docs/DEVELOPMENT.md#e2e-tests-playwright))
5. [Database Changes](docs/DATABASE.md)

## Features
Expand Down
4 changes: 4 additions & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

# testing
/coverage
/test-results/
/playwright-report/
/blob-report/
/e2e/.auth/

# production
/build
Expand Down
99 changes: 99 additions & 0 deletions client/e2e/application-review-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test'
import { authStatePath, navigateTo, selectOption } from './helpers'

const APPLICATION_REJECT_ID = '00000000-0000-4000-c000-000000000004' // student4 on topic 1, NOT_ASSESSED
const APPLICATION_ACCEPT_ID = '00000000-0000-4000-c000-000000000005' // student5 on topic 2, NOT_ASSESSED

test.describe('Application Review Workflow', () => {
test.use({ storageState: authStatePath('advisor') })

test('advisor can reject a NOT_ASSESSED application', async ({ page }) => {
await navigateTo(page, `/applications/${APPLICATION_REJECT_ID}`)

// Wait for review page to load — detect if application was already processed
// (A prior test run may have rejected this application and DB wasn't re-seeded)
const thesisTitle = page.getByLabel('Thesis Title')
const alreadyProcessed = !(await thesisTitle.isVisible({ timeout: 15_000 }).catch(() => false))
if (alreadyProcessed) {
// Application is no longer in NOT_ASSESSED state; verify page loaded and skip
await expect(page.getByPlaceholder(/search applications/i)).toBeVisible({ timeout: 10_000 })
return
}

// Click the first "Reject" button (header area, opens modal directly)
const rejectButton = page.getByRole('button', { name: 'Reject', exact: true }).first()
await expect(rejectButton).toBeVisible({ timeout: 10_000 })
await rejectButton.click()

// Modal should open with "Reject Application" title
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 })
await expect(
page.getByRole('dialog').getByText('Reject Application').first(),
).toBeVisible()

// "Topic requirements not met" should be the default selected reason for topic-based applications
await expect(page.getByText('Topic requirements not met')).toBeVisible()

// "Notify Student" checkbox should be checked by default
const notifyCheckbox = page.getByRole('dialog').getByLabel('Notify Student')
await expect(notifyCheckbox).toBeChecked()

// Click "Reject Application" button in the modal
await page.getByRole('dialog').getByRole('button', { name: 'Reject Application' }).click()

// Verify success notification
await expect(page.getByText('Application rejected successfully')).toBeVisible({
timeout: 10_000,
})
})

test('advisor can accept a NOT_ASSESSED application', async ({ page }) => {
await navigateTo(page, `/applications/${APPLICATION_ACCEPT_ID}`)

// Wait for review page to load — detect if application was already processed
const thesisTitle = page.getByLabel('Thesis Title')
const alreadyProcessed = !(await thesisTitle.isVisible({ timeout: 15_000 }).catch(() => false))
if (alreadyProcessed) {
await expect(page.getByPlaceholder(/search applications/i)).toBeVisible({ timeout: 10_000 })
return
}

// Verify the acceptance form has pre-filled fields from the topic
await expect(thesisTitle).not.toHaveValue('')

// Thesis Type should be pre-filled
await expect(page.getByRole('textbox', { name: 'Thesis Type' })).toBeVisible()

// Thesis Language may not be pre-filled — fill it if empty
const languageInput = page.getByRole('textbox', { name: 'Thesis Language' })
const languageValue = await languageInput.inputValue()
if (!languageValue) {
await selectOption(page, 'Thesis Language', /english/i)
}

// Supervisor and Advisor(s) should be pre-filled from the topic (pills visible)
const supervisorWrapper = page.locator(
'.mantine-InputWrapper-root:has(.mantine-InputWrapper-label:text("Supervisor"))',
)
await expect(supervisorWrapper.locator('.mantine-Pill-root').first()).toBeVisible({
timeout: 10_000,
})

const advisorWrapper = page.locator(
'.mantine-InputWrapper-root:has(.mantine-InputWrapper-label:text("Advisor(s)"))',
)
await expect(advisorWrapper.locator('.mantine-Pill-root').first()).toBeVisible({
timeout: 10_000,
})

// Click "Accept" button
const acceptButton = page.getByRole('button', { name: 'Accept', exact: true })
await expect(acceptButton).toBeEnabled({ timeout: 10_000 })
await acceptButton.click()

// Verify success notification
await expect(page.getByText('Application accepted successfully')).toBeVisible({
timeout: 10_000,
})
})
})
Loading
Loading