A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
389 lines
9.0 KiB
Go
389 lines
9.0 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/breakpilot/consent-service/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestValidateAttendanceRecord tests attendance record validation
|
|
func TestValidateAttendanceRecord(t *testing.T) {
|
|
slotID := uuid.New()
|
|
|
|
tests := []struct {
|
|
name string
|
|
record models.AttendanceRecord
|
|
expectValid bool
|
|
}{
|
|
{
|
|
name: "valid present record",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.New(),
|
|
SlotID: slotID,
|
|
Date: time.Now(),
|
|
Status: models.AttendancePresent,
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "valid absent record",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.New(),
|
|
SlotID: slotID,
|
|
Date: time.Now(),
|
|
Status: models.AttendanceAbsent,
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "valid late record",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.New(),
|
|
SlotID: slotID,
|
|
Date: time.Now(),
|
|
Status: models.AttendanceLate,
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "missing student ID",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.Nil,
|
|
SlotID: slotID,
|
|
Date: time.Now(),
|
|
Status: models.AttendancePresent,
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid status",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.New(),
|
|
SlotID: slotID,
|
|
Date: time.Now(),
|
|
Status: "invalid_status",
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "future date",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.New(),
|
|
SlotID: slotID,
|
|
Date: time.Now().AddDate(0, 0, 7),
|
|
Status: models.AttendancePresent,
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "missing slot ID",
|
|
record: models.AttendanceRecord{
|
|
StudentID: uuid.New(),
|
|
SlotID: uuid.Nil,
|
|
Date: time.Now(),
|
|
Status: models.AttendancePresent,
|
|
RecordedBy: uuid.New(),
|
|
},
|
|
expectValid: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isValid := validateAttendanceRecord(tt.record)
|
|
if isValid != tt.expectValid {
|
|
t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// validateAttendanceRecord validates an attendance record
|
|
func validateAttendanceRecord(record models.AttendanceRecord) bool {
|
|
if record.StudentID == uuid.Nil {
|
|
return false
|
|
}
|
|
if record.SlotID == uuid.Nil {
|
|
return false
|
|
}
|
|
if record.RecordedBy == uuid.Nil {
|
|
return false
|
|
}
|
|
if record.Date.After(time.Now().AddDate(0, 0, 1)) {
|
|
return false
|
|
}
|
|
|
|
// Validate status
|
|
validStatuses := map[string]bool{
|
|
models.AttendancePresent: true,
|
|
models.AttendanceAbsent: true,
|
|
models.AttendanceAbsentExcused: true,
|
|
models.AttendanceAbsentUnexcused: true,
|
|
models.AttendanceLate: true,
|
|
models.AttendanceLateExcused: true,
|
|
models.AttendancePending: true,
|
|
}
|
|
|
|
if !validStatuses[record.Status] {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// TestValidateAbsenceReport tests absence report validation
|
|
func TestValidateAbsenceReport(t *testing.T) {
|
|
now := time.Now()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
reason := "Krankheit"
|
|
medicalReason := "Arzttermin"
|
|
|
|
tests := []struct {
|
|
name string
|
|
report models.AbsenceReport
|
|
expectValid bool
|
|
}{
|
|
{
|
|
name: "valid single day absence",
|
|
report: models.AbsenceReport{
|
|
StudentID: uuid.New(),
|
|
ReportedBy: uuid.New(),
|
|
StartDate: today,
|
|
EndDate: today,
|
|
Reason: &reason,
|
|
ReasonCategory: "illness",
|
|
Status: "reported",
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "valid multi-day absence",
|
|
report: models.AbsenceReport{
|
|
StudentID: uuid.New(),
|
|
ReportedBy: uuid.New(),
|
|
StartDate: today,
|
|
EndDate: today.AddDate(0, 0, 3),
|
|
Reason: &medicalReason,
|
|
ReasonCategory: "appointment",
|
|
Status: "reported",
|
|
},
|
|
expectValid: true,
|
|
},
|
|
{
|
|
name: "end before start",
|
|
report: models.AbsenceReport{
|
|
StudentID: uuid.New(),
|
|
ReportedBy: uuid.New(),
|
|
StartDate: today.AddDate(0, 0, 3),
|
|
EndDate: today,
|
|
Reason: &reason,
|
|
ReasonCategory: "illness",
|
|
Status: "reported",
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "missing reason category",
|
|
report: models.AbsenceReport{
|
|
StudentID: uuid.New(),
|
|
ReportedBy: uuid.New(),
|
|
StartDate: today,
|
|
EndDate: today,
|
|
Reason: &reason,
|
|
ReasonCategory: "",
|
|
Status: "reported",
|
|
},
|
|
expectValid: false,
|
|
},
|
|
{
|
|
name: "invalid reason category",
|
|
report: models.AbsenceReport{
|
|
StudentID: uuid.New(),
|
|
ReportedBy: uuid.New(),
|
|
StartDate: today,
|
|
EndDate: today,
|
|
Reason: &reason,
|
|
ReasonCategory: "invalid_type",
|
|
Status: "reported",
|
|
},
|
|
expectValid: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isValid := validateAbsenceReport(tt.report)
|
|
if isValid != tt.expectValid {
|
|
t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// validateAbsenceReport validates an absence report
|
|
func validateAbsenceReport(report models.AbsenceReport) bool {
|
|
if report.StudentID == uuid.Nil {
|
|
return false
|
|
}
|
|
if report.ReportedBy == uuid.Nil {
|
|
return false
|
|
}
|
|
if report.EndDate.Before(report.StartDate) {
|
|
return false
|
|
}
|
|
if report.ReasonCategory == "" {
|
|
return false
|
|
}
|
|
|
|
// Validate reason category
|
|
validCategories := map[string]bool{
|
|
"illness": true,
|
|
"appointment": true,
|
|
"family": true,
|
|
"other": true,
|
|
}
|
|
|
|
if !validCategories[report.ReasonCategory] {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// TestCalculateAttendanceStats tests attendance statistics calculation
|
|
func TestCalculateAttendanceStats(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
records []models.AttendanceRecord
|
|
expectedPresent int
|
|
expectedAbsent int
|
|
expectedLate int
|
|
}{
|
|
{
|
|
name: "all present",
|
|
records: []models.AttendanceRecord{
|
|
{Status: models.AttendancePresent},
|
|
{Status: models.AttendancePresent},
|
|
{Status: models.AttendancePresent},
|
|
},
|
|
expectedPresent: 3,
|
|
expectedAbsent: 0,
|
|
expectedLate: 0,
|
|
},
|
|
{
|
|
name: "mixed attendance",
|
|
records: []models.AttendanceRecord{
|
|
{Status: models.AttendancePresent},
|
|
{Status: models.AttendanceAbsent},
|
|
{Status: models.AttendanceLate},
|
|
{Status: models.AttendancePresent},
|
|
{Status: models.AttendanceAbsentExcused},
|
|
},
|
|
expectedPresent: 2,
|
|
expectedAbsent: 2,
|
|
expectedLate: 1,
|
|
},
|
|
{
|
|
name: "empty records",
|
|
records: []models.AttendanceRecord{},
|
|
expectedPresent: 0,
|
|
expectedAbsent: 0,
|
|
expectedLate: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
present, absent, late := calculateAttendanceStats(tt.records)
|
|
if present != tt.expectedPresent {
|
|
t.Errorf("expected present=%d, got present=%d", tt.expectedPresent, present)
|
|
}
|
|
if absent != tt.expectedAbsent {
|
|
t.Errorf("expected absent=%d, got absent=%d", tt.expectedAbsent, absent)
|
|
}
|
|
if late != tt.expectedLate {
|
|
t.Errorf("expected late=%d, got late=%d", tt.expectedLate, late)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// calculateAttendanceStats calculates attendance statistics
|
|
func calculateAttendanceStats(records []models.AttendanceRecord) (present, absent, late int) {
|
|
for _, r := range records {
|
|
switch r.Status {
|
|
case models.AttendancePresent:
|
|
present++
|
|
case models.AttendanceAbsent, models.AttendanceAbsentExcused, models.AttendanceAbsentUnexcused:
|
|
absent++
|
|
case models.AttendanceLate, models.AttendanceLateExcused:
|
|
late++
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// TestAttendanceRateCalculation tests attendance rate percentage calculation
|
|
func TestAttendanceRateCalculation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
present int
|
|
total int
|
|
expectedRate float64
|
|
}{
|
|
{
|
|
name: "100% attendance",
|
|
present: 26,
|
|
total: 26,
|
|
expectedRate: 100.0,
|
|
},
|
|
{
|
|
name: "92.3% attendance",
|
|
present: 24,
|
|
total: 26,
|
|
expectedRate: 92.31,
|
|
},
|
|
{
|
|
name: "0% attendance",
|
|
present: 0,
|
|
total: 26,
|
|
expectedRate: 0.0,
|
|
},
|
|
{
|
|
name: "empty class",
|
|
present: 0,
|
|
total: 0,
|
|
expectedRate: 0.0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rate := calculateAttendanceRate(tt.present, tt.total)
|
|
// Allow small floating point differences
|
|
if rate < tt.expectedRate-0.1 || rate > tt.expectedRate+0.1 {
|
|
t.Errorf("expected rate=%.2f, got rate=%.2f", tt.expectedRate, rate)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// calculateAttendanceRate calculates attendance rate as percentage
|
|
func calculateAttendanceRate(present, total int) float64 {
|
|
if total == 0 {
|
|
return 0.0
|
|
}
|
|
rate := float64(present) / float64(total) * 100
|
|
// Round to 2 decimal places
|
|
return float64(int(rate*100)) / 100
|
|
}
|