## Major Achievements ✅ ### Story 1.14: 前端事件画廊页面 - Gallery Page Implementation - ✅ Protected /gallery route with authentication redirect - ✅ Infinite scroll with React Query + Intersection Observer - ✅ Responsive event cards with thumbnail, date, location - ✅ Loading states, empty states, error handling - ✅ Dark theme UI consistent with design system ### Full-Stack Integration Testing Framework - ✅ Docker-based test environment (PostgreSQL + LocalStack) - ✅ E2E tests with Playwright (authentication, gallery workflows) - ✅ API integration tests covering complete user journeys - ✅ Automated test data generation and cleanup - ✅ Performance and concurrency testing ### Technical Stack Validation - ✅ Next.js 15 + React Query + TypeScript frontend - ✅ NestJS + TypeORM + PostgreSQL backend - ✅ AWS S3/SQS integration (LocalStack for testing) - ✅ JWT authentication with secure token management - ✅ Complete data pipeline: Edge → Backend → Processing → Gallery ## Files Added/Modified ### Frontend Implementation - src/app/gallery/page.tsx - Main gallery page with auth protection - src/services/events.ts - API client for events with pagination - src/hooks/use-events.ts - React Query hooks for infinite scroll - src/components/gallery/ - Modular UI components (EventCard, GalleryGrid, States) - src/contexts/query-provider.tsx - React Query configuration ### Testing Infrastructure - docker-compose.test.yml - Complete test environment setup - test-setup.sh - One-command test environment initialization - test-data/seed-test-data.js - Automated test data generation - e2e/gallery.spec.ts - Comprehensive E2E gallery tests - test/integration.e2e-spec.ts - Full-stack workflow validation - TESTING.md - Complete testing guide and documentation ### Project Configuration - package.json (root) - Monorepo scripts and workspace management - playwright.config.ts - E2E testing configuration - .env.test - Test environment variables - README.md - Project documentation ## Test Results 📊 - ✅ Unit Tests: 10/10 passing (Frontend components) - ✅ Integration Tests: Full workflow validation - ✅ E2E Tests: Complete user journey coverage - ✅ Lint: No warnings or errors - ✅ Build: Production ready (11.7kB gallery page) ## Milestone: Epic 1 "First Light" Achieved 🚀 The complete data flow is now validated: 1. User Authentication ✅ 2. Device Registration ✅ 3. Event Upload Pipeline ✅ 4. Background Processing ✅ 5. Gallery Display ✅ This establishes the foundation for all future development. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
316 lines
8.9 KiB
Go
316 lines
8.9 KiB
Go
package validation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"meteor-compute-service/internal/models"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Validator interface defines the contract for event validation
|
|
type Validator interface {
|
|
Validate(ctx context.Context, rawEvent *models.RawEvent) (*models.ValidationResult, error)
|
|
}
|
|
|
|
// MVPValidator implements a basic pass-through validation for MVP
|
|
// This will be replaced with more sophisticated algorithms in Epic 3
|
|
type MVPValidator struct {
|
|
algorithmName string
|
|
version string
|
|
}
|
|
|
|
// NewMVPValidator creates a new MVP validator instance
|
|
func NewMVPValidator() *MVPValidator {
|
|
return &MVPValidator{
|
|
algorithmName: "mvp_pass_through",
|
|
version: "1.0.0",
|
|
}
|
|
}
|
|
|
|
// Validate performs basic validation on a raw event
|
|
// For MVP, this is a simple pass-through that marks all events as valid
|
|
func (v *MVPValidator) Validate(ctx context.Context, rawEvent *models.RawEvent) (*models.ValidationResult, error) {
|
|
// Basic validation details that will be stored
|
|
details := ValidationDetails{
|
|
Algorithm: v.algorithmName,
|
|
Version: v.version,
|
|
ValidationSteps: []ValidationStep{},
|
|
Metadata: make(map[string]interface{}),
|
|
}
|
|
|
|
// Step 1: Basic data completeness check
|
|
step1 := v.validateDataCompleteness(rawEvent)
|
|
details.ValidationSteps = append(details.ValidationSteps, step1)
|
|
|
|
// Step 2: Event type validation
|
|
step2 := v.validateEventType(rawEvent)
|
|
details.ValidationSteps = append(details.ValidationSteps, step2)
|
|
|
|
// Step 3: File validation
|
|
step3 := v.validateFile(rawEvent)
|
|
details.ValidationSteps = append(details.ValidationSteps, step3)
|
|
|
|
// Step 4: Metadata validation
|
|
step4 := v.validateMetadata(rawEvent)
|
|
details.ValidationSteps = append(details.ValidationSteps, step4)
|
|
|
|
// For MVP, calculate a simple score based on completed validation steps
|
|
totalSteps := len(details.ValidationSteps)
|
|
passedSteps := 0
|
|
for _, step := range details.ValidationSteps {
|
|
if step.Passed {
|
|
passedSteps++
|
|
}
|
|
}
|
|
|
|
score := float64(passedSteps) / float64(totalSteps)
|
|
isValid := score >= 0.8 // 80% threshold for MVP
|
|
|
|
// Add summary to metadata
|
|
details.Metadata["total_steps"] = totalSteps
|
|
details.Metadata["passed_steps"] = passedSteps
|
|
details.Metadata["score"] = score
|
|
details.Metadata["threshold"] = 0.8
|
|
|
|
// Serialize details to JSON
|
|
detailsJSON, err := json.Marshal(details)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal validation details: %w", err)
|
|
}
|
|
|
|
return &models.ValidationResult{
|
|
IsValid: isValid,
|
|
Score: score,
|
|
Algorithm: v.algorithmName,
|
|
Details: detailsJSON,
|
|
ProcessedAt: time.Now().UTC(),
|
|
Reason: v.generateReason(isValid, passedSteps, totalSteps),
|
|
}, nil
|
|
}
|
|
|
|
// ValidationDetails represents the detailed validation information
|
|
type ValidationDetails struct {
|
|
Algorithm string `json:"algorithm"`
|
|
Version string `json:"version"`
|
|
ValidationSteps []ValidationStep `json:"validation_steps"`
|
|
Metadata map[string]interface{} `json:"metadata"`
|
|
}
|
|
|
|
// ValidationStep represents a single validation step
|
|
type ValidationStep struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Passed bool `json:"passed"`
|
|
Details map[string]interface{} `json:"details,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// validateDataCompleteness checks if required fields are present
|
|
func (v *MVPValidator) validateDataCompleteness(rawEvent *models.RawEvent) ValidationStep {
|
|
step := ValidationStep{
|
|
Name: "data_completeness",
|
|
Description: "Checks if required fields are present and valid",
|
|
Details: make(map[string]interface{}),
|
|
}
|
|
|
|
issues := []string{}
|
|
|
|
// Check required UUID fields
|
|
if rawEvent.ID == (uuid.UUID{}) {
|
|
issues = append(issues, "missing_id")
|
|
}
|
|
if rawEvent.DeviceID == (uuid.UUID{}) {
|
|
issues = append(issues, "missing_device_id")
|
|
}
|
|
if rawEvent.UserProfileID == (uuid.UUID{}) {
|
|
issues = append(issues, "missing_user_profile_id")
|
|
}
|
|
|
|
// Check required string fields
|
|
if rawEvent.FilePath == "" {
|
|
issues = append(issues, "missing_file_path")
|
|
}
|
|
if rawEvent.EventType == "" {
|
|
issues = append(issues, "missing_event_type")
|
|
}
|
|
|
|
// Check timestamp
|
|
if rawEvent.EventTimestamp.IsZero() {
|
|
issues = append(issues, "missing_event_timestamp")
|
|
}
|
|
|
|
step.Details["issues"] = issues
|
|
step.Details["issues_count"] = len(issues)
|
|
step.Passed = len(issues) == 0
|
|
|
|
if len(issues) > 0 {
|
|
step.Error = fmt.Sprintf("Found %d data completeness issues", len(issues))
|
|
}
|
|
|
|
return step
|
|
}
|
|
|
|
// validateEventType checks if the event type is supported
|
|
func (v *MVPValidator) validateEventType(rawEvent *models.RawEvent) ValidationStep {
|
|
step := ValidationStep{
|
|
Name: "event_type_validation",
|
|
Description: "Validates that the event type is supported",
|
|
Details: make(map[string]interface{}),
|
|
}
|
|
|
|
supportedTypes := []string{
|
|
models.EventTypeMotion,
|
|
models.EventTypeAlert,
|
|
models.EventTypeMeteor,
|
|
}
|
|
|
|
step.Details["event_type"] = rawEvent.EventType
|
|
step.Details["supported_types"] = supportedTypes
|
|
|
|
// Check if event type is supported
|
|
isSupported := false
|
|
for _, supportedType := range supportedTypes {
|
|
if rawEvent.EventType == supportedType {
|
|
isSupported = true
|
|
break
|
|
}
|
|
}
|
|
|
|
step.Passed = isSupported
|
|
step.Details["is_supported"] = isSupported
|
|
|
|
if !isSupported {
|
|
step.Error = fmt.Sprintf("Unsupported event type: %s", rawEvent.EventType)
|
|
}
|
|
|
|
return step
|
|
}
|
|
|
|
// validateFile checks basic file information
|
|
func (v *MVPValidator) validateFile(rawEvent *models.RawEvent) ValidationStep {
|
|
step := ValidationStep{
|
|
Name: "file_validation",
|
|
Description: "Validates file information and properties",
|
|
Details: make(map[string]interface{}),
|
|
}
|
|
|
|
issues := []string{}
|
|
|
|
// Check file path format (basic validation)
|
|
if len(rawEvent.FilePath) < 3 {
|
|
issues = append(issues, "file_path_too_short")
|
|
}
|
|
|
|
// Check file size if provided
|
|
if rawEvent.FileSize != nil {
|
|
step.Details["file_size"] = *rawEvent.FileSize
|
|
if *rawEvent.FileSize <= 0 {
|
|
issues = append(issues, "invalid_file_size")
|
|
}
|
|
// Check for reasonable file size limits (e.g., not more than 100MB for video files)
|
|
if *rawEvent.FileSize > 100*1024*1024 {
|
|
issues = append(issues, "file_size_too_large")
|
|
}
|
|
}
|
|
|
|
// Check file type if provided
|
|
if rawEvent.FileType != nil {
|
|
step.Details["file_type"] = *rawEvent.FileType
|
|
// Basic MIME type validation for common formats
|
|
supportedMimeTypes := []string{
|
|
"video/mp4",
|
|
"video/quicktime",
|
|
"video/x-msvideo",
|
|
"image/jpeg",
|
|
"image/png",
|
|
"application/gzip",
|
|
"application/x-tar",
|
|
}
|
|
|
|
isSupportedMime := false
|
|
for _, mimeType := range supportedMimeTypes {
|
|
if *rawEvent.FileType == mimeType {
|
|
isSupportedMime = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isSupportedMime {
|
|
issues = append(issues, "unsupported_file_type")
|
|
}
|
|
step.Details["supported_mime_types"] = supportedMimeTypes
|
|
}
|
|
|
|
step.Details["issues"] = issues
|
|
step.Details["issues_count"] = len(issues)
|
|
step.Passed = len(issues) == 0
|
|
|
|
if len(issues) > 0 {
|
|
step.Error = fmt.Sprintf("Found %d file validation issues", len(issues))
|
|
}
|
|
|
|
return step
|
|
}
|
|
|
|
// validateMetadata performs basic metadata validation
|
|
func (v *MVPValidator) validateMetadata(rawEvent *models.RawEvent) ValidationStep {
|
|
step := ValidationStep{
|
|
Name: "metadata_validation",
|
|
Description: "Validates event metadata structure and content",
|
|
Details: make(map[string]interface{}),
|
|
}
|
|
|
|
issues := []string{}
|
|
|
|
// Check if metadata is valid JSON
|
|
if rawEvent.Metadata != nil {
|
|
var metadata map[string]interface{}
|
|
if err := json.Unmarshal(rawEvent.Metadata, &metadata); err != nil {
|
|
issues = append(issues, "invalid_json_metadata")
|
|
step.Details["json_error"] = err.Error()
|
|
} else {
|
|
step.Details["metadata_keys"] = getKeys(metadata)
|
|
step.Details["metadata_size"] = len(rawEvent.Metadata)
|
|
|
|
// Check for reasonable metadata size (not more than 10KB)
|
|
if len(rawEvent.Metadata) > 10*1024 {
|
|
issues = append(issues, "metadata_too_large")
|
|
}
|
|
}
|
|
} else {
|
|
// Metadata is optional, so this is not an error
|
|
step.Details["metadata_present"] = false
|
|
}
|
|
|
|
step.Details["issues"] = issues
|
|
step.Details["issues_count"] = len(issues)
|
|
step.Passed = len(issues) == 0
|
|
|
|
if len(issues) > 0 {
|
|
step.Error = fmt.Sprintf("Found %d metadata validation issues", len(issues))
|
|
}
|
|
|
|
return step
|
|
}
|
|
|
|
// generateReason creates a human-readable reason for the validation result
|
|
func (v *MVPValidator) generateReason(isValid bool, passedSteps, totalSteps int) string {
|
|
if isValid {
|
|
return fmt.Sprintf("Event passed validation with %d/%d steps completed successfully", passedSteps, totalSteps)
|
|
}
|
|
return fmt.Sprintf("Event failed validation with only %d/%d steps completed successfully (required: 80%%)", passedSteps, totalSteps)
|
|
}
|
|
|
|
// getKeys extracts keys from a map
|
|
func getKeys(m map[string]interface{}) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|