diff --git a/.gitignore b/.gitignore index 30fa495c..74809fab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ docs/* # Build artifacts /app /loader +/generate-test-data diff --git a/cmd/generate-test-data/README.md b/cmd/generate-test-data/README.md new file mode 100644 index 00000000..ded94fd5 --- /dev/null +++ b/cmd/generate-test-data/README.md @@ -0,0 +1,201 @@ +# Generate Test Data Tool + +A comprehensive CLI tool for generating test data in the Mini-Maxit database. + +## Features + +Generates realistic test data for all database entities: +- **Users** with different roles (admin, teacher, student) +- **Groups** with user memberships +- **Tasks** with test cases and file references +- **Contests** with tasks, participants, and registrations +- **Submissions** with results and test outcomes +- **Supporting tables**: AccessControl, ContestParticipant, ContestParticipantGroup, ContestRegistrationRequests, UserGroup, TestCase, File, LanguageConfig, SubmissionResult, TestResult + +## Prerequisites + +- PostgreSQL database running +- File storage service running (optional, can skip connectivity check) +- Go 1.23+ installed +- Environment variables configured in `.env` file at project root (automatically loaded) + - `JWT_SECRET_KEY` - Required for user password hashing (any string value works for testing) + +## Usage + +### Basic Usage + +```bash +# Generate default dataset (50 users, 10 groups, 15 tasks, 5 contests) +# Automatically loads .env from project root +# Requires JWT_SECRET_KEY environment variable +go run ./cmd/generate-test-data + +# Or set JWT_SECRET_KEY directly +JWT_SECRET_KEY=your_secret_key go run ./cmd/generate-test-data + +# View all available options +go run ./cmd/generate-test-data --help +``` + +**Default Password:** All generated users have the password `password123` + +### Common Examples + +```bash +# Large production-like dataset +go run ./cmd/generate-test-data --users 100 --groups 15 --tasks 30 --contests 10 \ + --registration-requests-per-contest 10 --collaborators-per-task 3 + +# Reproducible data for testing +go run ./cmd/generate-test-data --clear-existing --seed 42 --verbose + +# Preview what would be created +go run ./cmd/generate-test-data --dry-run --users 100 --contests 10 + +# Skip connectivity checks (for offline testing) +go run ./cmd/generate-test-data --skip-connectivity-check +``` + +## CLI Flags + +### User Generation +- `--users int` - Total number of users (default: 50) +- `--admin-count int` - Number of admin users (default: 2) +- `--teacher-count int` - Number of teacher users (default: 8) +- `--student-count int` - Number of student users (0 = remaining, default: 0) +- `--user-password string` - Default password for all users (default: "password123") + +### Group Generation +- `--groups int` - Number of groups to create (default: 10) +- `--users-per-group int` - Average number of users per group (default: 8) + +### Task Generation +- `--tasks int` - Number of tasks to create (default: 15) +- `--visible-tasks int` - Number of tasks visible to all (default: 10) +- `--tests-per-task int` - Number of test cases per task (default: 3) +- `--fixtures-dir string` - Path to fixtures directory (default: "./fixtures") +- `--create-fixtures` - Create sample fixture files if they don't exist (default: false) + +### Contest Generation +- `--contests int` - Number of contests to create (default: 5) +- `--tasks-per-contest int` - Number of tasks per contest (default: 5) +- `--participants-per-contest int` - Individual participants per contest (default: 10) +- `--group-participants-per-contest int` - Group participants per contest (default: 2) + +### Submission Generation +- `--submissions-per-task int` - Submissions per standalone task (default: 5) +- `--submissions-per-contest int` - Submissions per contest task (default: 3) + +### Supporting Data +- `--registration-requests-per-contest int` - Registration requests per contest (default: 3) +- `--collaborators-per-task int` - Collaborators per task via AccessControl (default: 2) +- `--collaborators-per-contest int` - Collaborators per contest via AccessControl (default: 2) + +### Database Configuration +- `--db-host string` - Database host (overrides DB_HOST env var) +- `--db-port int` - Database port (overrides DB_PORT env var) +- `--db-user string` - Database user (overrides DB_USER env var) +- `--db-password string` - Database password (overrides DB_PASSWORD env var) +- `--db-name string` - Database name (overrides DB_NAME env var) + +### File Storage Configuration +- `--file-storage-host string` - File storage host (overrides FILE_STORAGE_HOST env var) +- `--file-storage-port string` - File storage port (overrides FILE_STORAGE_PORT env var) + +### Utilities +- `--clear-existing` - Truncate all tables before generating new data (default: false) +- `--seed int` - Random seed for reproducible data (0 = time-based, default: 0) +- `--skip-connectivity-check` - Skip pre-flight connectivity checks (default: false) +- `--verbose` - Detailed progress output (default: false) +- `--dry-run` - Show what would be created without actually creating (default: false) + +## Pre-flight Checks + +Before generating data, the tool verifies: +1. **Database connectivity** - Tests PostgreSQL connection with `sqlDB.Ping()` +2. **File storage availability** - Tests file storage HTTP endpoint + +If connectivity fails, you'll see clear error messages: +``` +Error: Database connectivity check failed + Host: localhost:5432 + Error: connection refused + +Please ensure PostgreSQL is running and accessible. +``` + +Use `--skip-connectivity-check` to bypass these checks if needed. + +## Data Generation Order + +The tool generates data in dependency order: +1. LanguageConfig (C, C++, etc.) +2. Users (with bcrypt-hashed passwords) +3. Groups → UserGroup (many-to-many relationships) +4. Tasks → File (descriptions), TestCase → File (test I/O), AccessControl (permissions) +5. Contests → ContestTask, ContestParticipant, ContestParticipantGroup, ContestRegistrationRequests, AccessControl +6. Submissions → File (source), SubmissionResult, TestResult → File (stdout/stderr/diff) + +## Examples + +### Default Development Dataset +```bash +go run ./cmd/generate-test-data +``` +Creates: +- 50 users (2 admins, 8 teachers, 40 students) - password: `password123` +- 10 groups with ~8 members each +- 15 tasks (10 visible) with 3 test cases each +- 5 contests with 5 tasks and 10 participants each +- 5 submissions per task +- Supporting data (access control, registration requests, etc.) + +### Large Production-like Dataset +```bash +go run ./cmd/generate-test-data \ + --users 100 \ + --admin-count 5 \ + --teacher-count 15 \ + --groups 10 \ + --tasks 30 \ + --contests 10 \ + --submissions-per-task 10 \ + --registration-requests-per-contest 10 +``` + +### Reproducible CI/Testing Data +```bash +go run ./cmd/generate-test-data --clear-existing --seed 42 --verbose +``` +Always generates the same data with seed 42. + +### Preview Mode +```bash +go run ./cmd/generate-test-data --dry-run --users 100 --contests 10 +``` +Shows configuration without creating any data. + +## Troubleshooting + +### "Database connectivity check failed" +- Ensure PostgreSQL is running: `docker-compose up -d postgres` +- Check DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME environment variables +- Or use CLI flags: `--db-host localhost --db-port 5432 ...` + +### "File storage connectivity check failed" +- Ensure file-storage service is running: `docker-compose up -d file-storage` +- Check FILE_STORAGE_HOST and FILE_STORAGE_PORT environment variables +- Or skip the check: `--skip-connectivity-check` + +### "admin + teacher + student counts must equal total users" +- If you specify --student-count, ensure: admin-count + teacher-count + student-count = users +- Or set --student-count 0 to auto-calculate remaining + +## Development + +The tool is built with: +- **github.com/spf13/cobra** - CLI framework +- **github.com/brianvoe/gofakeit/v7** - Realistic fake data generation +- Existing backend repositories and services - Ensures data consistency with API + +All generated data follows the same constraints and validation rules as the API. diff --git a/cmd/generate-test-data/main.go b/cmd/generate-test-data/main.go new file mode 100644 index 00000000..c5731923 --- /dev/null +++ b/cmd/generate-test-data/main.go @@ -0,0 +1,1441 @@ +package main + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/brianvoe/gofakeit/v7" + "github.com/joho/godotenv" + "github.com/mini-maxit/backend/internal/config" + "github.com/mini-maxit/backend/internal/database" + "github.com/mini-maxit/backend/package/domain/models" + "github.com/mini-maxit/backend/package/domain/schemas" + "github.com/mini-maxit/backend/package/domain/types" + "github.com/mini-maxit/backend/package/filestorage" + "github.com/mini-maxit/backend/package/repository" + "github.com/mini-maxit/backend/package/service" + "github.com/mini-maxit/backend/package/utils" + "github.com/spf13/cobra" + "go.uber.org/zap" +) + +type Config struct { + // User generation + Users int + AdminCount int + TeacherCount int + StudentCount int + UserPassword string + + // Group generation + Groups int + UsersPerGroup int + + // Task generation + Tasks int + VisibleTasks int + TestsPerTask int + FixturesDir string + CreateFixtures bool + + // Contest generation + Contests int + TasksPerContest int + ParticipantsPerContest int + GroupParticipantsPerContest int + + // Submission generation + SubmissionsPerTask int + SubmissionsPerContest int + + // Supporting data + RegistrationRequestsPerContest int + CollaboratorsPerTask int + CollaboratorsPerContest int + + // Database + DBHost string + DBPort uint16 + DBUser string + DBPassword string + DBName string + + // File storage + FileStorageHost string + FileStoragePort string + + // Utilities + ClearExisting bool + Seed int64 + SkipConnectivityCheck bool + Verbose bool + DryRun bool +} + +type Generator struct { + config *Config + db database.Database + fileStorage filestorage.FileStorageService + logger *zap.SugaredLogger + random *rand.Rand + + // Services + authService service.AuthService + taskService service.TaskService + groupService service.GroupService + + // Repositories (for operations not covered by services) + userRepo repository.UserRepository + groupRepo repository.GroupRepository + taskRepo repository.TaskRepository + contestRepo repository.ContestRepository + submissionRepo repository.SubmissionRepository + fileRepo repository.File + langRepo repository.LanguageRepository + accessControlRepo repository.AccessControlRepository + testCaseRepo repository.TestCaseRepository + submissionResultRepo repository.SubmissionResultRepository + testResultRepo repository.TestRepository + + // Generated data tracking + users []*models.User + admins []*models.User + teachers []*models.User + students []*models.User + groups []*models.Group + tasks []*models.Task + contests []*models.Contest + languages []*models.LanguageConfig +} + +var ( + cfg = &Config{} + rootCmd = &cobra.Command{ + Use: "generate-test-data", + Short: "Generate test data for Mini-Maxit database", + Long: `Generate comprehensive test data including users, groups, tasks, contests, +submissions and all supporting tables with configurable parameters.`, + RunE: runGenerate, + } +) + +func init() { + // User generation flags + rootCmd.Flags().IntVar(&cfg.Users, "users", 50, "Total number of users to create") + rootCmd.Flags().IntVar(&cfg.AdminCount, "admin-count", 2, "Number of admin users") + rootCmd.Flags().IntVar(&cfg.TeacherCount, "teacher-count", 8, "Number of teacher users") + rootCmd.Flags().IntVar(&cfg.StudentCount, "student-count", 0, "Number of student users (0 = remaining)") + rootCmd.Flags().StringVar(&cfg.UserPassword, "user-password", "password123", "Default password for all users") + + // Group generation flags + rootCmd.Flags().IntVar(&cfg.Groups, "groups", 10, "Number of groups to create") + rootCmd.Flags().IntVar(&cfg.UsersPerGroup, "users-per-group", 8, "Average number of users per group") + + // Task generation flags + rootCmd.Flags().IntVar(&cfg.Tasks, "tasks", 15, "Number of tasks to create") + rootCmd.Flags().IntVar(&cfg.VisibleTasks, "visible-tasks", 10, "Number of visible tasks") + rootCmd.Flags().IntVar(&cfg.TestsPerTask, "tests-per-task", 3, "Number of test cases per task") + rootCmd.Flags().StringVar(&cfg.FixturesDir, "fixtures-dir", "./fixtures", "Path to fixtures directory") + rootCmd.Flags().BoolVar(&cfg.CreateFixtures, "create-fixtures", false, "Create sample fixture files if they don't exist") + + // Contest generation flags + rootCmd.Flags().IntVar(&cfg.Contests, "contests", 5, "Number of contests to create") + rootCmd.Flags().IntVar(&cfg.TasksPerContest, "tasks-per-contest", 5, "Number of tasks per contest") + rootCmd.Flags().IntVar(&cfg.ParticipantsPerContest, "participants-per-contest", 10, "Individual participants per contest") + rootCmd.Flags().IntVar(&cfg.GroupParticipantsPerContest, "group-participants-per-contest", 2, "Group participants per contest") + + // Submission generation flags + rootCmd.Flags().IntVar(&cfg.SubmissionsPerTask, "submissions-per-task", 5, "Number of submissions per standalone task") + rootCmd.Flags().IntVar(&cfg.SubmissionsPerContest, "submissions-per-contest", 3, "Number of submissions per contest task") + + // Supporting data flags + rootCmd.Flags().IntVar(&cfg.RegistrationRequestsPerContest, "registration-requests-per-contest", 3, "Registration requests per contest") + rootCmd.Flags().IntVar(&cfg.CollaboratorsPerTask, "collaborators-per-task", 2, "Collaborators per task (AccessControl)") + rootCmd.Flags().IntVar(&cfg.CollaboratorsPerContest, "collaborators-per-contest", 2, "Collaborators per contest (AccessControl)") + + // Database flags + rootCmd.Flags().StringVar(&cfg.DBHost, "db-host", "", "Database host (overrides DB_HOST)") + rootCmd.Flags().Uint16Var(&cfg.DBPort, "db-port", 0, "Database port (overrides DB_PORT)") + rootCmd.Flags().StringVar(&cfg.DBUser, "db-user", "", "Database user (overrides DB_USER)") + rootCmd.Flags().StringVar(&cfg.DBPassword, "db-password", "", "Database password (overrides DB_PASSWORD)") + rootCmd.Flags().StringVar(&cfg.DBName, "db-name", "", "Database name (overrides DB_NAME)") + + // File storage flags + rootCmd.Flags().StringVar(&cfg.FileStorageHost, "file-storage-host", "", "File storage host (overrides FILE_STORAGE_HOST)") + rootCmd.Flags().StringVar(&cfg.FileStoragePort, "file-storage-port", "", "File storage port (overrides FILE_STORAGE_PORT)") + + // Utility flags + rootCmd.Flags().BoolVar(&cfg.ClearExisting, "clear-existing", false, "Clear existing data before generating new") + rootCmd.Flags().Int64Var(&cfg.Seed, "seed", 0, "Random seed for reproducible data (0 = time-based)") + rootCmd.Flags().BoolVar(&cfg.SkipConnectivityCheck, "skip-connectivity-check", false, "Skip pre-flight connectivity checks") + rootCmd.Flags().BoolVar(&cfg.Verbose, "verbose", false, "Verbose output") + rootCmd.Flags().BoolVar(&cfg.DryRun, "dry-run", false, "Show what would be created without creating") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func runGenerate(cmd *cobra.Command, args []string) error { + logger := utils.NewNamedLogger("generate-test-data") + + // Load .env file from project root by default + if err := godotenv.Load(".env"); err != nil { + // Don't fail if .env doesn't exist, just log a debug message + logger.Debugf(".env file not found or could not be loaded: %v", err) + } + + // Initialize seed + if cfg.Seed == 0 { + cfg.Seed = time.Now().UnixNano() + } + gofakeit.Seed(cfg.Seed) + logger.Infof("Using seed: %d", cfg.Seed) + + // Validate configuration + if err := validateConfig(cfg); err != nil { + return fmt.Errorf("configuration validation failed: %w", err) + } + + if cfg.DryRun { + printDryRun(cfg, logger) + return nil + } + + // Load database configuration + dbConfig := loadDBConfig(cfg) + + // Create database connection + db, err := database.NewPostgresDB(dbConfig) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + // Build file storage URL + fileStorageURL := fmt.Sprintf("http://%s:%s", + getEnvOrDefault("FILE_STORAGE_HOST", cfg.FileStorageHost), + getEnvOrDefault("FILE_STORAGE_PORT", cfg.FileStoragePort)) + + // Create file storage service + fileStorage, err := filestorage.NewFileStorageService(fileStorageURL) + if err != nil { + return fmt.Errorf("failed to create file storage service: %w", err) + } + + // Perform connectivity checks + if !cfg.SkipConnectivityCheck { + logger.Info("Performing pre-flight connectivity checks...") + if err := checkConnectivity(db, fileStorageURL, logger); err != nil { + return err + } + logger.Info("✓ All connectivity checks passed") + } + + // Create generator + userRepo := repository.NewUserRepository() + groupRepo := repository.NewGroupRepository() + taskRepo := repository.NewTaskRepository() + contestRepo := repository.NewContestRepository() + submissionRepo := repository.NewSubmissionRepository() + fileRepo := repository.NewFileRepository() + testCaseRepo := repository.NewTestCaseRepository() + accessControlRepo := repository.NewAccessControlRepository() + + // Get JWT secret from environment + jwtSecretKey := os.Getenv("JWT_SECRET_KEY") + if jwtSecretKey == "" { + return fmt.Errorf("JWT_SECRET_KEY environment variable is required for user generation") + } + + // Initialize services with correct order and dependencies + jwtService := service.NewJWTService(userRepo, jwtSecretKey) + authService := service.NewAuthService(userRepo, jwtService) + + // AccessControlService needs: accessControlRepo, userRepo, taskRepo, contestRepo + accessControlService := service.NewAccessControlService( + accessControlRepo, + userRepo, + taskRepo, + contestRepo, + ) + + // TaskService needs: fileStorage, fileRepo, taskRepo, testCaseRepo, userRepo, groupRepo, submissionRepo, contestRepo, accessControlService + taskService := service.NewTaskService( + fileStorage, + fileRepo, + taskRepo, + testCaseRepo, + userRepo, + groupRepo, + submissionRepo, + contestRepo, + accessControlService, + ) + + // ContestService needs: contestRepo, userRepo, submissionRepo, taskRepo, accessControlService, taskService + contestService := service.NewContestService( + contestRepo, + userRepo, + submissionRepo, + taskRepo, + accessControlService, + taskService, + ) + + // UserService needs: userRepo, contestService + userService := service.NewUserService(userRepo, contestService) + + // GroupService needs: groupRepo, userRepo, userService + groupService := service.NewGroupService(groupRepo, userRepo, userService) + + gen := &Generator{ + config: cfg, + db: db, + fileStorage: fileStorage, + logger: logger, + random: rand.New(rand.NewSource(cfg.Seed)), + authService: authService, + taskService: taskService, + groupService: groupService, + userRepo: userRepo, + groupRepo: groupRepo, + taskRepo: taskRepo, + contestRepo: contestRepo, + submissionRepo: submissionRepo, + fileRepo: fileRepo, + langRepo: repository.NewLanguageRepository(), + accessControlRepo: accessControlRepo, + testCaseRepo: testCaseRepo, + submissionResultRepo: repository.NewSubmissionResultRepository(), + testResultRepo: repository.NewTestResultRepository(), + } + + // Clear existing data if requested + if cfg.ClearExisting { + logger.Info("Clearing existing data...") + if err := gen.clearExistingData(); err != nil { + return fmt.Errorf("failed to clear existing data: %w", err) + } + logger.Info("✓ Existing data cleared") + } + + // Generate data + logger.Info("Starting data generation...") + + if err := gen.generateLanguageConfigs(); err != nil { + return fmt.Errorf("failed to generate language configs: %w", err) + } + + if err := gen.generateUsers(); err != nil { + return fmt.Errorf("failed to generate users: %w", err) + } + + if err := gen.generateGroups(); err != nil { + return fmt.Errorf("failed to generate groups: %w", err) + } + + if err := gen.generateTasks(); err != nil { + return fmt.Errorf("failed to generate tasks: %w", err) + } + + if err := gen.generateContests(); err != nil { + return fmt.Errorf("failed to generate contests: %w", err) + } + + if err := gen.generateSubmissions(); err != nil { + return fmt.Errorf("failed to generate submissions: %w", err) + } + + // Print summary + gen.printSummary() + + logger.Info("✓ Data generation completed successfully!") + return nil +} + +func validateConfig(cfg *Config) error { + if cfg.Users < 1 { + return fmt.Errorf("users must be at least 1") + } + if cfg.AdminCount < 0 || cfg.TeacherCount < 0 || cfg.StudentCount < 0 { + return fmt.Errorf("user role counts cannot be negative") + } + if cfg.StudentCount == 0 { + cfg.StudentCount = cfg.Users - cfg.AdminCount - cfg.TeacherCount + } + if cfg.AdminCount+cfg.TeacherCount+cfg.StudentCount != cfg.Users { + return fmt.Errorf("admin + teacher + student counts must equal total users") + } + if cfg.VisibleTasks > cfg.Tasks { + return fmt.Errorf("visible-tasks cannot exceed tasks") + } + return nil +} + +func loadDBConfig(cfg *Config) *config.Config { + return &config.Config{ + DB: config.DBConfig{ + Host: getEnvOrDefault("DB_HOST", cfg.DBHost), + Port: getEnvOrDefaultUint16("DB_PORT", cfg.DBPort, 5432), + User: getEnvOrDefault("DB_USER", cfg.DBUser), + Password: getEnvOrDefault("DB_PASSWORD", cfg.DBPassword), + Name: getEnvOrDefault("DB_NAME", cfg.DBName), + }, + } +} + +func getEnvOrDefault(envKey, flagValue string) string { + if flagValue != "" { + return flagValue + } + if val := os.Getenv(envKey); val != "" { + return val + } + return "" +} + +func getEnvOrDefaultUint16(envKey string, flagValue uint16, defaultVal uint16) uint16 { + if flagValue != 0 { + return flagValue + } + if val := os.Getenv(envKey); val != "" { + // Parse the value + var parsed uint16 + if _, err := fmt.Sscanf(val, "%d", &parsed); err == nil && parsed != 0 { + return parsed + } + } + return defaultVal +} + +func checkConnectivity(db database.Database, fileStorageURL string, logger *zap.SugaredLogger) error { + // Check database connectivity + logger.Info("Checking database connectivity...") + sqlDB, err := db.DB().DB() + if err != nil { + return fmt.Errorf("failed to get database connection: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := sqlDB.PingContext(ctx); err != nil { + return fmt.Errorf("database connectivity check failed: %w\nPlease ensure PostgreSQL is running and accessible", err) + } + logger.Info(" ✓ Database connection successful") + + // Check file storage connectivity + logger.Info("Checking file storage connectivity...") + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(fileStorageURL + "/health") + if err != nil { + // Try without /health endpoint + resp, err = client.Get(fileStorageURL) + if err != nil { + return fmt.Errorf("file storage connectivity check failed: %w\nPlease ensure file-storage service is running at %s", err, fileStorageURL) + } + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("file storage returned error status %d\nPlease ensure file-storage service is running properly", resp.StatusCode) + } + logger.Info(" ✓ File storage connection successful") + + return nil +} + +func printDryRun(cfg *Config, logger *zap.SugaredLogger) { + logger.Info("DRY RUN - No data will be created") + logger.Info("Configuration:") + logger.Infof(" Users: %d (admins: %d, teachers: %d, students: %d)", + cfg.Users, cfg.AdminCount, cfg.TeacherCount, cfg.StudentCount) + logger.Infof(" Groups: %d (avg %d users per group)", cfg.Groups, cfg.UsersPerGroup) + logger.Infof(" Tasks: %d (%d visible, %d tests per task)", + cfg.Tasks, cfg.VisibleTasks, cfg.TestsPerTask) + logger.Infof(" Contests: %d (%d tasks, %d participants, %d group participants each)", + cfg.Contests, cfg.TasksPerContest, cfg.ParticipantsPerContest, cfg.GroupParticipantsPerContest) + logger.Infof(" Submissions: %d per task, %d per contest", + cfg.SubmissionsPerTask, cfg.SubmissionsPerContest) + logger.Infof(" Registration requests: %d per contest", cfg.RegistrationRequestsPerContest) + logger.Infof(" Collaborators: %d per task, %d per contest", + cfg.CollaboratorsPerTask, cfg.CollaboratorsPerContest) + logger.Infof(" Seed: %d", cfg.Seed) + logger.Infof(" Clear existing: %v", cfg.ClearExisting) +} + +func (g *Generator) clearExistingData() error { + // Delete in reverse dependency order + tables := []string{ + "test_results", + "submission_results", + "submissions", + "test_cases", + "contest_registration_requests", + "contest_participant_groups", + "contest_participants", + "contest_tasks", + "access_control", + "user_groups", + "contests", + "tasks", + "groups", + "files", + "language_configs", + "users", + } + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + for _, table := range tables { + fullTable := fmt.Sprintf("maxit.%s", table) + if err := tx.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", fullTable)).Error; err != nil { + g.logger.Warnf("Failed to truncate %s: %v (table may not exist)", table, err) + } else { + g.logger.Infof(" Truncated %s", table) + } + } + + return g.db.Commit() +} + +func (g *Generator) generateLanguageConfigs() error { + g.logger.Info("Generating language configurations...") + + // Check if languages already exist + existing, err := g.langRepo.GetAll(g.db) + if err == nil && len(existing) > 0 { + // Convert []models.LanguageConfig to []*models.LanguageConfig + for i := range existing { + g.languages = append(g.languages, &existing[i]) + } + g.logger.Infof(" Using %d existing language configs", len(existing)) + return nil + } + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + // Create basic language configs + langs := []struct { + Type string + Version string + Extension string + }{ + {"c", "99", ".c"}, + {"c", "11", ".c"}, + {"cpp", "11", ".cpp"}, + {"cpp", "14", ".cpp"}, + {"cpp", "17", ".cpp"}, + {"cpp", "20", ".cpp"}, + } + + for _, lang := range langs { + disabled := false + langModel := &models.LanguageConfig{ + Type: lang.Type, + Version: lang.Version, + FileExtension: lang.Extension, + IsDisabled: &disabled, + } + err := g.langRepo.Create(g.db, langModel) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create language %s-%s: %w", lang.Type, lang.Version, err) + } + g.languages = append(g.languages, langModel) + } + + if err := g.db.Commit(); err != nil { + return err + } + + g.logger.Infof(" ✓ Created %d language configs", len(g.languages)) + return nil +} + +func (g *Generator) generateUsers() error { + g.logger.Info("Generating users...") + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + // Generate admins + for i := 0; i < g.config.AdminCount; i++ { + userRequest := g.createUserRequest() + _, err := g.authService.Register(g.db, userRequest) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to register admin user: %w", err) + } + + // Get the created user and update role + user, err := g.userRepo.GetByEmail(g.db, userRequest.Email) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to get registered admin user: %w", err) + } + user.Role = types.UserRoleAdmin + err = g.userRepo.Edit(g.db, user) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to set admin role: %w", err) + } + + g.users = append(g.users, user) + g.admins = append(g.admins, user) + } + + // Generate teachers + for i := 0; i < g.config.TeacherCount; i++ { + userRequest := g.createUserRequest() + _, err := g.authService.Register(g.db, userRequest) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to register teacher user: %w", err) + } + + // Get the created user and update role + user, err := g.userRepo.GetByEmail(g.db, userRequest.Email) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to get registered teacher user: %w", err) + } + user.Role = types.UserRoleTeacher + err = g.userRepo.Edit(g.db, user) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to set teacher role: %w", err) + } + + g.users = append(g.users, user) + g.teachers = append(g.teachers, user) + } + + // Generate students + for i := 0; i < g.config.StudentCount; i++ { + userRequest := g.createUserRequest() + _, err := g.authService.Register(g.db, userRequest) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to register student user: %w", err) + } + + // Get the created user (students are default role from Register) + user, err := g.userRepo.GetByEmail(g.db, userRequest.Email) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to get registered student user: %w", err) + } + + g.users = append(g.users, user) + g.students = append(g.students, user) + } + + if err := g.db.Commit(); err != nil { + return err + } + + g.logger.Infof(" ✓ Created %d users (%d admins, %d teachers, %d students)", + len(g.users), len(g.admins), len(g.teachers), len(g.students)) + return nil +} + +func (g *Generator) createUserRequest() schemas.UserRegisterRequest { + return schemas.UserRegisterRequest{ + Name: gofakeit.FirstName(), + Surname: gofakeit.LastName(), + Email: gofakeit.Email(), + Username: gofakeit.Username(), + Password: g.config.UserPassword, + } +} + +func (g *Generator) generateGroups() error { + g.logger.Info("Generating groups...") + + if g.config.Groups == 0 { + g.logger.Info(" Skipping groups (count = 0)") + return nil + } + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + totalMemberships := 0 + + for i := 0; i < g.config.Groups; i++ { + // Pick a random teacher or admin as creator + var creator *models.User + if len(g.teachers) > 0 && (len(g.admins) == 0 || g.random.Intn(2) == 0) { + creator = g.teachers[g.random.Intn(len(g.teachers))] + } else if len(g.admins) > 0 { + creator = g.admins[g.random.Intn(len(g.admins))] + } else { + // Fallback to any user + creator = g.users[g.random.Intn(len(g.users))] + } + + group := &models.Group{ + Name: fmt.Sprintf("Group %s", gofakeit.Company()), + CreatedBy: creator.ID, + } + + id, err := g.groupRepo.Create(g.db, group) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create group: %w", err) + } + group.ID = id + g.groups = append(g.groups, group) + + // Add random users to the group + numMembers := g.config.UsersPerGroup + if numMembers > len(g.users) { + numMembers = len(g.users) + } + + // Shuffle and pick users + selectedUsers := make([]*models.User, len(g.users)) + copy(selectedUsers, g.users) + g.random.Shuffle(len(selectedUsers), func(i, j int) { + selectedUsers[i], selectedUsers[j] = selectedUsers[j], selectedUsers[i] + }) + + for j := 0; j < numMembers && j < len(selectedUsers); j++ { + if err := g.groupRepo.AddUser(g.db, group.ID, selectedUsers[j].ID); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to add user to group: %w", err) + } + totalMemberships++ + } + } + + if err := g.db.Commit(); err != nil { + return err + } + + g.logger.Infof(" ✓ Created %d groups with %d total memberships", len(g.groups), totalMemberships) + return nil +} + +func (g *Generator) generateTasks() error { + g.logger.Info("Generating tasks...") + + if g.config.Tasks == 0 { + g.logger.Info(" Skipping tasks (count = 0)") + return nil + } + + // Create fixtures if needed + if g.config.CreateFixtures { + if err := g.createFixtures(); err != nil { + return fmt.Errorf("failed to create fixtures: %w", err) + } + } + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + totalTestCases := 0 + totalCollaborators := 0 + + for i := 0; i < g.config.Tasks; i++ { + // Pick a random teacher or admin as creator + var creator *models.User + if len(g.teachers) > 0 && (len(g.admins) == 0 || g.random.Intn(2) == 0) { + creator = g.teachers[g.random.Intn(len(g.teachers))] + } else if len(g.admins) > 0 { + creator = g.admins[g.random.Intn(len(g.admins))] + } else { + creator = g.users[g.random.Intn(len(g.users))] + } + + isVisible := i < g.config.VisibleTasks + + // Create description file + descFile := &models.File{ + Filename: fmt.Sprintf("task-%d-description.txt", i+1), + Path: fmt.Sprintf("/tasks/task-%d/description.txt", i+1), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, descFile); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create description file: %w", err) + } + + task := &models.Task{ + Title: fmt.Sprintf("Task %d: %s", i+1, gofakeit.BuzzWord()), + DescriptionFileID: descFile.ID, + CreatedBy: creator.ID, + IsVisible: isVisible, + } + + taskID, err := g.taskRepo.Create(g.db, task) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create task: %w", err) + } + task.ID = taskID + g.tasks = append(g.tasks, task) + + // Create test cases + for j := 0; j < g.config.TestsPerTask; j++ { + inputFile := &models.File{ + Filename: fmt.Sprintf("input_%d.txt", j+1), + Path: fmt.Sprintf("/tasks/task-%d/input_%d.txt", i+1, j+1), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, inputFile); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create input file: %w", err) + } + + outputFile := &models.File{ + Filename: fmt.Sprintf("output_%d.txt", j+1), + Path: fmt.Sprintf("/tasks/task-%d/output_%d.txt", i+1, j+1), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, outputFile); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create output file: %w", err) + } + + testCase := &models.TestCase{ + TaskID: taskID, + InputFileID: inputFile.ID, + OutputFileID: outputFile.ID, + Order: j + 1, + TimeLimit: 1000, + MemoryLimit: 256 * 1024 * 1024, + } + + if err := g.testCaseRepo.Create(g.db, testCase); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create test case: %w", err) + } + totalTestCases++ + } + + // Add collaborators (AccessControl) + if g.config.CollaboratorsPerTask > 0 { + // Add owner permission for creator + if err := g.accessControlRepo.AddTaskCollaborator(g.db, taskID, creator.ID, types.PermissionOwner); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create owner access control: %w", err) + } + totalCollaborators++ + + // Add other collaborators + collabCount := g.config.CollaboratorsPerTask + if collabCount > len(g.teachers)+len(g.admins)-1 { + collabCount = len(g.teachers) + len(g.admins) - 1 + } + + potentialCollabs := append([]*models.User{}, g.teachers...) + potentialCollabs = append(potentialCollabs, g.admins...) + + // Remove creator from potential collaborators + filtered := make([]*models.User, 0) + for _, u := range potentialCollabs { + if u.ID != creator.ID { + filtered = append(filtered, u) + } + } + + g.random.Shuffle(len(filtered), func(i, j int) { + filtered[i], filtered[j] = filtered[j], filtered[i] + }) + + permissions := []types.Permission{types.PermissionEdit, types.PermissionManage} + for j := 0; j < collabCount && j < len(filtered); j++ { + perm := permissions[g.random.Intn(len(permissions))] + if err := g.accessControlRepo.AddTaskCollaborator(g.db, taskID, filtered[j].ID, perm); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create collaborator access control: %w", err) + } + totalCollaborators++ + } + } + } + + if err := g.db.Commit(); err != nil { + return err + } + + g.logger.Infof(" ✓ Created %d tasks with %d test cases and %d collaborators", + len(g.tasks), totalTestCases, totalCollaborators) + return nil +} + +func (g *Generator) createFixtures() error { + g.logger.Info(" Creating sample fixtures...") + + // Create fixtures directory if it doesn't exist + if err := os.MkdirAll(g.config.FixturesDir, 0755); err != nil { + return fmt.Errorf("failed to create fixtures directory: %w", err) + } + + // Create a sample task fixture + taskDir := filepath.Join(g.config.FixturesDir, "sample-task") + if err := os.MkdirAll(taskDir, 0755); err != nil { + return fmt.Errorf("failed to create task directory: %w", err) + } + + // Create description file + descPath := filepath.Join(taskDir, "description.pdf") + descContent := "Sample task description\n\nThis is a placeholder task." + if err := os.WriteFile(descPath, []byte(descContent), 0644); err != nil { + return fmt.Errorf("failed to create description file: %w", err) + } + + // Create test cases directory + inputDir := filepath.Join(taskDir, "input") + outputDir := filepath.Join(taskDir, "output") + if err := os.MkdirAll(inputDir, 0755); err != nil { + return fmt.Errorf("failed to create input directory: %w", err) + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Create sample test cases + for i := 1; i <= 3; i++ { + inputPath := filepath.Join(inputDir, fmt.Sprintf("input_%d.txt", i)) + outputPath := filepath.Join(outputDir, fmt.Sprintf("output_%d.txt", i)) + + inputContent := fmt.Sprintf("%d %d\n", i, i*2) + outputContent := fmt.Sprintf("%d\n", i*3) + + if err := os.WriteFile(inputPath, []byte(inputContent), 0644); err != nil { + return fmt.Errorf("failed to create input file: %w", err) + } + if err := os.WriteFile(outputPath, []byte(outputContent), 0644); err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + } + + g.logger.Info(" ✓ Sample fixtures created") + return nil +} + +func (g *Generator) generateContests() error { + g.logger.Info("Generating contests...") + + if g.config.Contests == 0 { + g.logger.Info(" Skipping contests (count = 0)") + return nil + } + + if len(g.tasks) == 0 { + g.logger.Warn(" No tasks available, skipping contests") + return nil + } + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + totalContestTasks := 0 + totalParticipants := 0 + totalGroupParticipants := 0 + totalRegistrationRequests := 0 + totalCollaborators := 0 + + for i := 0; i < g.config.Contests; i++ { + // Pick a random teacher or admin as creator + var creator *models.User + if len(g.teachers) > 0 && (len(g.admins) == 0 || g.random.Intn(2) == 0) { + creator = g.teachers[g.random.Intn(len(g.teachers))] + } else if len(g.admins) > 0 { + creator = g.admins[g.random.Intn(len(g.admins))] + } else { + creator = g.users[g.random.Intn(len(g.users))] + } + + startAt := time.Now().Add(time.Duration(g.random.Intn(30)) * 24 * time.Hour) + endAt := startAt.Add(time.Duration(1+g.random.Intn(7)) * 24 * time.Hour) + + contest := &models.Contest{ + Name: fmt.Sprintf("Contest %d: %s", i+1, gofakeit.BuzzWord()), + Description: gofakeit.Sentence(10), + CreatedBy: creator.ID, + StartAt: startAt, + EndAt: &endAt, + IsRegistrationOpen: g.random.Intn(2) == 0, + IsSubmissionOpen: g.random.Intn(2) == 0, + IsVisible: g.random.Intn(2) == 0, + } + + contestID, err := g.contestRepo.Create(g.db, contest) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create contest: %w", err) + } + contest.ID = contestID + g.contests = append(g.contests, contest) + + // Add tasks to contest + numTasks := g.config.TasksPerContest + if numTasks > len(g.tasks) { + numTasks = len(g.tasks) + } + + selectedTasks := make([]*models.Task, len(g.tasks)) + copy(selectedTasks, g.tasks) + g.random.Shuffle(len(selectedTasks), func(i, j int) { + selectedTasks[i], selectedTasks[j] = selectedTasks[j], selectedTasks[i] + }) + + for j := 0; j < numTasks; j++ { + taskStartAt := startAt.Add(time.Duration(j*24) * time.Hour) + taskEndAt := taskStartAt.Add(24 * time.Hour) + + contestTask := models.ContestTask{ + ContestID: contestID, + TaskID: selectedTasks[j].ID, + StartAt: taskStartAt, + EndAt: &taskEndAt, + IsSubmissionOpen: true, + } + + if err := g.contestRepo.AddTaskToContest(g.db, contestTask); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to add task to contest: %w", err) + } + totalContestTasks++ + } + + // Add individual participants + numParticipants := g.config.ParticipantsPerContest + if numParticipants > len(g.users) { + numParticipants = len(g.users) + } + + selectedUsers := make([]*models.User, len(g.users)) + copy(selectedUsers, g.users) + g.random.Shuffle(len(selectedUsers), func(i, j int) { + selectedUsers[i], selectedUsers[j] = selectedUsers[j], selectedUsers[i] + }) + + for j := 0; j < numParticipants; j++ { + if err := g.contestRepo.CreateContestParticipant(g.db, contestID, selectedUsers[j].ID); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to add participant to contest: %w", err) + } + totalParticipants++ + } + + // Add group participants + if len(g.groups) > 0 { + numGroupParticipants := g.config.GroupParticipantsPerContest + if numGroupParticipants > len(g.groups) { + numGroupParticipants = len(g.groups) + } + + selectedGroups := make([]*models.Group, len(g.groups)) + copy(selectedGroups, g.groups) + g.random.Shuffle(len(selectedGroups), func(i, j int) { + selectedGroups[i], selectedGroups[j] = selectedGroups[j], selectedGroups[i] + }) + + for j := 0; j < numGroupParticipants; j++ { + // Manually create ContestParticipantGroup entry + tx := g.db.GetInstance() + contestParticipantGroup := &models.ContestParticipantGroup{ + ContestID: contestID, + GroupID: selectedGroups[j].ID, + } + if err := tx.Create(contestParticipantGroup).Error; err != nil { + g.db.Rollback() + return fmt.Errorf("failed to add group participant to contest: %w", err) + } + totalGroupParticipants++ + } + } + + // Add registration requests + if g.config.RegistrationRequestsPerContest > 0 { + // Use users not yet participants + nonParticipants := make([]*models.User, 0) + for _, user := range g.users { + isParticipant := false + for j := 0; j < numParticipants && j < len(selectedUsers); j++ { + if selectedUsers[j].ID == user.ID { + isParticipant = true + break + } + } + if !isParticipant { + nonParticipants = append(nonParticipants, user) + } + } + + numRequests := g.config.RegistrationRequestsPerContest + if numRequests > len(nonParticipants) { + numRequests = len(nonParticipants) + } + + statuses := []types.RegistrationRequestStatus{ + types.RegistrationRequestStatusPending, + types.RegistrationRequestStatusApproved, + types.RegistrationRequestStatusRejected, + } + + for j := 0; j < numRequests; j++ { + status := statuses[g.random.Intn(len(statuses))] + // Manually create ContestRegistrationRequests entry + tx := g.db.GetInstance() + req := &models.ContestRegistrationRequests{ + ContestID: contestID, + UserID: nonParticipants[j].ID, + Status: status, + } + if err := tx.Create(req).Error; err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create registration request: %w", err) + } + totalRegistrationRequests++ + } + } + + // Add collaborators (AccessControl) + if g.config.CollaboratorsPerContest > 0 { + // Add owner permission for creator + if err := g.accessControlRepo.AddContestCollaborator(g.db, contestID, creator.ID, types.PermissionOwner); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create owner access control: %w", err) + } + totalCollaborators++ + + // Add other collaborators + collabCount := g.config.CollaboratorsPerContest + potentialCollabs := append([]*models.User{}, g.teachers...) + potentialCollabs = append(potentialCollabs, g.admins...) + + // Remove creator + filtered := make([]*models.User, 0) + for _, u := range potentialCollabs { + if u.ID != creator.ID { + filtered = append(filtered, u) + } + } + + if collabCount > len(filtered) { + collabCount = len(filtered) + } + + g.random.Shuffle(len(filtered), func(i, j int) { + filtered[i], filtered[j] = filtered[j], filtered[i] + }) + + permissions := []types.Permission{types.PermissionEdit, types.PermissionManage} + for j := 0; j < collabCount; j++ { + perm := permissions[g.random.Intn(len(permissions))] + if err := g.accessControlRepo.AddContestCollaborator(g.db, contestID, filtered[j].ID, perm); err != nil { + g.db.Rollback() + return fmt.Errorf("failed to create collaborator access control: %w", err) + } + totalCollaborators++ + } + } + } + + if err := g.db.Commit(); err != nil { + return err + } + + g.logger.Infof(" ✓ Created %d contests with %d tasks, %d participants, %d group participants, %d registration requests, %d collaborators", + len(g.contests), totalContestTasks, totalParticipants, totalGroupParticipants, totalRegistrationRequests, totalCollaborators) + return nil +} + +func (g *Generator) generateSubmissions() error { + g.logger.Info("Generating submissions...") + + if len(g.tasks) == 0 { + g.logger.Info(" Skipping submissions (no tasks)") + return nil + } + + if len(g.languages) == 0 { + g.logger.Warn(" No languages available, skipping submissions") + return nil + } + + tx, err := g.db.BeginTransaction() + if err != nil { + return err + } + defer func() { + if g.db.ShouldRollback() { + tx.Rollback() + } + }() + + totalSubmissions := 0 + totalResults := 0 + totalTestResults := 0 + + // Generate standalone task submissions + for _, task := range g.tasks { + for i := 0; i < g.config.SubmissionsPerTask; i++ { + // Pick random user + user := g.users[g.random.Intn(len(g.users))] + + if err := g.createSubmission(task.ID, user.ID, nil, i+1); err != nil { + g.db.Rollback() + return err + } + totalSubmissions++ + } + } + + // Generate contest submissions + for _, contest := range g.contests { + // Get contest tasks + contestTasks, err := g.contestRepo.GetContestTasksWithSettings(g.db, contest.ID) + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to get contest tasks: %w", err) + } + + // Get contest participants - manually query + tx := g.db.GetInstance() + var participants []models.User + err = tx.Table("maxit.users"). + Joins("JOIN maxit.contest_participants ON contest_participants.user_id = users.id"). + Where("contest_participants.contest_id = ?", contest.ID). + Find(&participants).Error + if err != nil { + g.db.Rollback() + return fmt.Errorf("failed to get contest participants: %w", err) + } + + if len(participants) == 0 { + continue + } + + for _, contestTask := range contestTasks { + for i := 0; i < g.config.SubmissionsPerContest; i++ { + // Pick random participant + participant := participants[g.random.Intn(len(participants))] + + if err := g.createSubmission(contestTask.TaskID, participant.ID, &contest.ID, i+1); err != nil { + g.db.Rollback() + return err + } + totalSubmissions++ + } + } + } + + // Count results (submissions create results automatically in this simplified version) + // In real scenario, this would be done separately + totalResults = totalSubmissions + totalTestResults = totalSubmissions * g.config.TestsPerTask + + if err := g.db.Commit(); err != nil { + return err + } + + g.logger.Infof(" ✓ Created %d submissions with %d results and %d test results", + totalSubmissions, totalResults, totalTestResults) + return nil +} + +func (g *Generator) createSubmission(taskID, userID int64, contestID *int64, order int) error { + // Create source file + lang := g.languages[g.random.Intn(len(g.languages))] + + sourceFile := &models.File{ + Filename: fmt.Sprintf("solution_%d%s", order, lang.FileExtension), + Path: fmt.Sprintf("/submissions/task-%d/user-%d/solution_%d%s", taskID, userID, order, lang.FileExtension), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, sourceFile); err != nil { + return fmt.Errorf("failed to create source file: %w", err) + } + + // Random submission status + statuses := []types.SubmissionStatus{ + types.SubmissionStatusReceived, + types.SubmissionStatusSentForEvaluation, + types.SubmissionStatusEvaluated, + } + status := statuses[g.random.Intn(len(statuses))] + + submission := &models.Submission{ + TaskID: taskID, + UserID: userID, + Order: order, + LanguageID: lang.ID, + FileID: sourceFile.ID, + Status: status, + ContestID: contestID, + SubmittedAt: time.Now().Add(-time.Duration(g.random.Intn(24*30)) * time.Hour), + } + + submissionID, err := g.submissionRepo.Create(g.db, submission) + if err != nil { + return fmt.Errorf("failed to create submission: %w", err) + } + + // Create submission result if evaluated + if status == types.SubmissionStatusEvaluated { + resultCodes := []types.SubmissionResultCode{ + types.SubmissionResultCodeSuccess, + types.SubmissionResultCodeTestFailed, + types.SubmissionResultCodeCompilationError, + types.SubmissionResultCodeInitializationError, + } + resultCode := resultCodes[g.random.Intn(len(resultCodes))] + + result := models.SubmissionResult{ + SubmissionID: submissionID, + Code: resultCode, + Message: fmt.Sprintf("Result: %s", resultCode), + } + + resultID, err := g.submissionResultRepo.Create(g.db, result) + if err != nil { + return fmt.Errorf("failed to create submission result: %w", err) + } + + // Create test results + testCases, err := g.testCaseRepo.GetByTask(g.db, taskID) + if err != nil { + return fmt.Errorf("failed to get test cases: %w", err) + } + + for _, testCase := range testCases { + passed := resultCode == types.SubmissionResultCodeSuccess || g.random.Intn(2) == 0 + + // Create stdout/stderr/diff files + stdoutFile := &models.File{ + Filename: "stdout.txt", + Path: fmt.Sprintf("/results/submission-%d/test-%d/stdout.txt", submissionID, testCase.ID), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, stdoutFile); err != nil { + return fmt.Errorf("failed to create stdout file: %w", err) + } + + stderrFile := &models.File{ + Filename: "stderr.txt", + Path: fmt.Sprintf("/results/submission-%d/test-%d/stderr.txt", submissionID, testCase.ID), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, stderrFile); err != nil { + return fmt.Errorf("failed to create stderr file: %w", err) + } + + diffFile := &models.File{ + Filename: "diff.txt", + Path: fmt.Sprintf("/results/submission-%d/test-%d/diff.txt", submissionID, testCase.ID), + Bucket: "maxit", + ServerType: "local", + } + if err := g.fileRepo.Create(g.db, diffFile); err != nil { + return fmt.Errorf("failed to create diff file: %w", err) + } + + statusCodes := []types.TestResultStatusCode{ + types.TestResultStatusCodeOK, + types.TestResultStatusCodeRuntimeError, + types.TestResultStatusCodeTimeLimit, + } + statusCode := types.TestResultStatusCodeOK + if !passed { + statusCode = statusCodes[1+g.random.Intn(len(statusCodes)-1)] + } + + testResult := &models.TestResult{ + SubmissionResultID: resultID, + TestCaseID: testCase.ID, + Passed: &passed, + ExecutionTime: float64(g.random.Intn(1000)) / 1000.0, + StatusCode: statusCode, + ErrorMessage: "", + StdoutFileID: stdoutFile.ID, + StderrFileID: stderrFile.ID, + DiffFileID: diffFile.ID, + } + + if err := g.testResultRepo.Create(g.db, testResult); err != nil { + return fmt.Errorf("failed to create test result: %w", err) + } + } + } + + return nil +} + +func (g *Generator) printSummary() { + g.logger.Info("") + g.logger.Info("=== Generation Summary ===") + g.logger.Infof("Seed: %d", g.config.Seed) + g.logger.Infof("Users: %d (admins: %d, teachers: %d, students: %d)", + len(g.users), len(g.admins), len(g.teachers), len(g.students)) + g.logger.Infof("Groups: %d", len(g.groups)) + g.logger.Infof("Tasks: %d", len(g.tasks)) + g.logger.Infof("Contests: %d", len(g.contests)) + g.logger.Infof("Languages: %d", len(g.languages)) + g.logger.Info("==========================") +} diff --git a/go.mod b/go.mod index 0a15e4d0..73dbe84e 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,13 @@ go 1.24.2 require ( ariga.io/atlas-provider-gorm v0.6.0 + github.com/brianvoe/gofakeit/v7 v7.12.1 github.com/go-playground/validator/v10 v10.28.0 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/mini-maxit/file-storage v0.3.3 github.com/rabbitmq/amqp091-go v1.10.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.5.2 go.uber.org/zap v1.27.0 @@ -52,6 +54,7 @@ require ( github.com/googleapis/go-gorm-spanner v1.8.6 // indirect github.com/googleapis/go-sql-spanner v1.17.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.2 // indirect @@ -61,6 +64,7 @@ require ( github.com/microsoft/go-mssqldb v1.7.2 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index 5ee92ef3..5aab374d 100644 --- a/go.sum +++ b/go.sum @@ -655,6 +655,8 @@ github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4x github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/brianvoe/gofakeit/v7 v7.12.1 h1:df1tiI4SL1dR5Ix4D/r6a3a+nXBJ/OBGU5jEKRBmmqg= +github.com/brianvoe/gofakeit/v7 v7.12.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= @@ -682,6 +684,7 @@ github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -892,6 +895,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -980,6 +985,7 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= @@ -988,6 +994,10 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -1055,6 +1065,7 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=