Рефакторинг тестов: интеграция с задачами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s

This commit is contained in:
poignatov
2026-01-13 18:22:02 +03:00
parent cfd9339e48
commit db3b2640a8
17 changed files with 1166 additions and 1278 deletions

View File

@@ -72,19 +72,15 @@ type TestProgressRequest struct {
}
type Config struct {
ID int `json:"id"`
Name string `json:"name"`
WordsCount int `json:"words_count"`
MaxCards *int `json:"max_cards,omitempty"`
TryMessage string `json:"try_message"`
ID int `json:"id"`
WordsCount int `json:"words_count"`
MaxCards *int `json:"max_cards,omitempty"`
}
type ConfigRequest struct {
Name string `json:"name"`
WordsCount int `json:"words_count"`
MaxCards *int `json:"max_cards,omitempty"`
TryMessage string `json:"try_message"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
WordsCount int `json:"words_count"`
MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
}
type Dictionary struct {
@@ -214,6 +210,7 @@ type Task struct {
RepetitionPeriod *string `json:"repetition_period,omitempty"`
RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"`
ConfigID *int `json:"config_id,omitempty"`
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"`
@@ -234,9 +231,13 @@ type Subtask struct {
}
type TaskDetail struct {
Task Task `json:"task"`
Rewards []Reward `json:"rewards"`
Subtasks []Subtask `json:"subtasks"`
Task Task `json:"task"`
Rewards []Reward `json:"rewards"`
Subtasks []Subtask `json:"subtasks"`
// Test-specific fields (only present if task has config_id)
WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
}
type RewardRequest struct {
@@ -262,6 +263,11 @@ type TaskRequest struct {
WishlistID *int `json:"wishlist_id,omitempty"`
Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
// Test-specific fields
IsTest bool `json:"is_test,omitempty"`
WordsCount *int `json:"words_count,omitempty"`
MaxCards *int `json:"max_cards,omitempty"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
}
type CompleteTaskRequest struct {
@@ -1671,47 +1677,9 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request)
return
}
// If config_id is provided, send webhook with try_message
if req.ConfigID != nil {
configID := *req.ConfigID
// Use mutex to prevent duplicate webhook sends
a.webhookMutex.Lock()
lastTime, exists := a.lastWebhookTime[configID]
now := time.Now()
// Only send webhook if it hasn't been sent in the last 5 seconds for this config
shouldSend := !exists || now.Sub(lastTime) > 5*time.Second
if shouldSend {
a.lastWebhookTime[configID] = now
}
a.webhookMutex.Unlock()
if !shouldSend {
log.Printf("Webhook skipped for config_id %d (sent recently)", configID)
} else {
var tryMessage sql.NullString
err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage)
if err == nil && tryMessage.Valid && tryMessage.String != "" {
// Process message directly (backend always runs together with frontend)
_, err := a.processMessage(tryMessage.String, &userID)
if err != nil {
log.Printf("Error processing message: %v", err)
// Remove from map on error so it can be retried
a.webhookMutex.Lock()
delete(a.lastWebhookTime, configID)
a.webhookMutex.Unlock()
} else {
log.Printf("Message processed successfully for config_id %d", configID)
}
} else if err != nil && err != sql.ErrNoRows {
log.Printf("Error fetching config: %v", err)
} else if err == nil && (!tryMessage.Valid || tryMessage.String == "") {
log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID)
}
}
}
// Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed.
// The config_id is kept in the request for potential future use, but we no longer send messages here
// to avoid duplicate messages (one from test completion, one from task completion).
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -1734,7 +1702,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
}
query := `
SELECT id, name, words_count, max_cards, try_message
SELECT id, words_count, max_cards
FROM configs
WHERE user_id = $1
ORDER BY id
@@ -1753,10 +1721,8 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
var maxCards sql.NullInt64
err := rows.Scan(
&config.ID,
&config.Name,
&config.WordsCount,
&maxCards,
&config.TryMessage,
)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
@@ -2076,7 +2042,7 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
// Get configs
configsQuery := `
SELECT id, name, words_count, max_cards, try_message
SELECT id, words_count, max_cards
FROM configs
WHERE user_id = $1
ORDER BY id
@@ -2095,10 +2061,8 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
var maxCards sql.NullInt64
err := configsRows.Scan(
&config.ID,
&config.Name,
&config.WordsCount,
&maxCards,
&config.TryMessage,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -2175,10 +2139,6 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
if req.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
return
}
if req.WordsCount <= 0 {
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
return
@@ -2193,10 +2153,10 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
var id int
err = tx.QueryRow(`
INSERT INTO configs (name, words_count, max_cards, try_message, user_id)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO configs (words_count, max_cards, user_id)
VALUES ($1, $2, $3)
RETURNING id
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id)
`, req.WordsCount, req.MaxCards, userID).Scan(&id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -2268,10 +2228,6 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
if req.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
return
}
if req.WordsCount <= 0 {
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
return
@@ -2286,9 +2242,9 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
result, err := tx.Exec(`
UPDATE configs
SET name = $1, words_count = $2, max_cards = $3, try_message = $4
WHERE id = $5
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID)
SET words_count = $1, max_cards = $2
WHERE id = $3
`, req.WordsCount, req.MaxCards, configID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -2840,6 +2796,12 @@ func (a *App) initAuthDB() error {
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Apply migration 022: Refactor configs to link with tasks
if err := a.applyMigration022(); err != nil {
log.Printf("Warning: Failed to apply migration 022: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Clean up expired refresh tokens (only those with expiration date set)
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
@@ -3109,6 +3071,51 @@ func (a *App) applyMigration021() error {
return nil
}
// applyMigration022 применяет миграцию 022_refactor_configs_to_tasks.sql
func (a *App) applyMigration022() error {
log.Printf("Applying migration 022: Refactor configs to link with tasks")
// Проверяем, существует ли уже поле config_id в tasks
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'tasks'
AND column_name = 'config_id'
)
`).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check config_id column existence: %w", err)
}
if exists {
log.Printf("Migration 022 already applied (config_id column exists), skipping")
return nil
}
// Читаем SQL из файла миграции
migrationPath := "migrations/022_refactor_configs_to_tasks.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
// Пробуем альтернативный путь (в Docker)
migrationPath = "/migrations/022_refactor_configs_to_tasks.sql"
}
migrationSQL, err := os.ReadFile(migrationPath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
}
// Выполняем миграцию
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
return fmt.Errorf("failed to execute migration 022: %w", err)
}
log.Printf("Migration 022 applied successfully")
return nil
}
func (a *App) initPlayLifeDB() error {
// Создаем таблицу projects
createProjectsTable := `
@@ -6734,6 +6741,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.repetition_date,
t.progression_base,
t.wishlist_id,
t.config_id,
COALESCE((
SELECT COUNT(*)
FROM tasks st
@@ -6778,6 +6786,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var repetitionDate sql.NullString
var progressionBase sql.NullFloat64
var wishlistID sql.NullInt64
var configID sql.NullInt64
var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray
@@ -6791,6 +6800,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&repetitionDate,
&progressionBase,
&wishlistID,
&configID,
&task.SubtasksCount,
&projectNames,
&subtaskProjectNames,
@@ -6822,6 +6832,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
wishlistIDInt := int(wishlistID.Int64)
task.WishlistID = &wishlistIDInt
}
if configID.Valid {
configIDInt := int(configID.Int64)
task.ConfigID = &configIDInt
}
// Объединяем проекты из основной задачи и подзадач
allProjects := make(map[string]bool)
@@ -6879,6 +6893,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var wishlistID sql.NullInt64
var configID sql.NullInt64
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string
@@ -6887,11 +6902,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
COALESCE(repetition_date, '') as repetition_date,
wishlist_id
wishlist_id,
config_id
FROM tasks
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
`, taskID, userID).Scan(
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID,
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID,
)
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
@@ -6944,6 +6960,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
wishlistIDInt := int(wishlistID.Int64)
task.WishlistID = &wishlistIDInt
}
if configID.Valid {
configIDInt := int(configID.Int64)
task.ConfigID = &configIDInt
}
// Получаем награды основной задачи
rewards := make([]Reward, 0)
@@ -7044,6 +7064,47 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
Subtasks: subtasks,
}
// Если задача - тест (есть config_id), загружаем данные конфигурации
if configID.Valid {
var wordsCount int
var maxCards sql.NullInt64
err := a.DB.QueryRow(`
SELECT words_count, max_cards
FROM configs
WHERE id = $1
`, configID.Int64).Scan(&wordsCount, &maxCards)
if err == nil {
response.WordsCount = &wordsCount
if maxCards.Valid {
maxCardsInt := int(maxCards.Int64)
response.MaxCards = &maxCardsInt
}
// Загружаем связанные словари
dictRows, err := a.DB.Query(`
SELECT dictionary_id
FROM config_dictionaries
WHERE config_id = $1
`, configID.Int64)
if err == nil {
defer dictRows.Close()
dictionaryIDs := make([]int, 0)
for dictRows.Next() {
var dictID int
if err := dictRows.Scan(&dictID); err == nil {
dictionaryIDs = append(dictionaryIDs, dictID)
}
}
if len(dictionaryIDs) > 0 {
response.DictionaryIDs = dictionaryIDs
}
}
} else {
log.Printf("Error loading config for task %d: %v", taskID, err)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
@@ -7365,6 +7426,66 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Если это тест, создаем конфигурацию
if req.IsTest {
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
if req.WordsCount == nil || *req.WordsCount < 1 {
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
return
}
if len(req.DictionaryIDs) == 0 {
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
return
}
// Создаем конфигурацию теста
var configID int
if req.MaxCards != nil {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count, max_cards)
VALUES ($1, $2, $3)
RETURNING id
`, userID, *req.WordsCount, *req.MaxCards).Scan(&configID)
} else {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count)
VALUES ($1, $2)
RETURNING id
`, userID, *req.WordsCount).Scan(&configID)
}
if err != nil {
log.Printf("Error creating config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
return
}
// Связываем конфигурацию со словарями
for _, dictID := range req.DictionaryIDs {
_, err = tx.Exec(`
INSERT INTO config_dictionaries (config_id, dictionary_id)
VALUES ($1, $2)
`, configID, dictID)
if err != nil {
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
return
}
}
// Обновляем задачу, привязывая config_id
_, err = tx.Exec(`
UPDATE tasks SET config_id = $1 WHERE id = $2
`, configID, taskID)
if err != nil {
log.Printf("Error linking config to task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Created test config %d for task %d", configID, taskID)
}
// Коммитим транзакцию
if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
@@ -7771,6 +7892,116 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Получаем текущий config_id задачи
var currentConfigID sql.NullInt64
err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(&currentConfigID)
if err != nil {
log.Printf("Error getting current config_id: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting task config: %v", err), http.StatusInternalServerError)
return
}
// Обработка конфигурации теста
if req.IsTest {
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
if req.WordsCount == nil || *req.WordsCount < 1 {
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
return
}
if len(req.DictionaryIDs) == 0 {
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
return
}
if currentConfigID.Valid {
// Обновляем существующую конфигурацию
if req.MaxCards != nil {
_, err = tx.Exec(`
UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3
`, *req.WordsCount, *req.MaxCards, currentConfigID.Int64)
} else {
_, err = tx.Exec(`
UPDATE configs SET words_count = $1, max_cards = NULL WHERE id = $2
`, *req.WordsCount, currentConfigID.Int64)
}
if err != nil {
log.Printf("Error updating config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating config: %v", err), http.StatusInternalServerError)
return
}
// Обновляем связи со словарями
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
if err != nil {
log.Printf("Error deleting config dictionaries: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating config dictionaries: %v", err), http.StatusInternalServerError)
return
}
for _, dictID := range req.DictionaryIDs {
_, err = tx.Exec(`
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
`, currentConfigID.Int64, dictID)
if err != nil {
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
return
}
}
} else {
// Создаем новую конфигурацию для существующей задачи
var newConfigID int
if req.MaxCards != nil {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id
`, userID, *req.WordsCount, *req.MaxCards).Scan(&newConfigID)
} else {
err = tx.QueryRow(`
INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id
`, userID, *req.WordsCount).Scan(&newConfigID)
}
if err != nil {
log.Printf("Error creating config: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
return
}
for _, dictID := range req.DictionaryIDs {
_, err = tx.Exec(`
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
`, newConfigID, dictID)
if err != nil {
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
return
}
}
_, err = tx.Exec("UPDATE tasks SET config_id = $1 WHERE id = $2", newConfigID, taskID)
if err != nil {
log.Printf("Error linking config to task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
return
}
}
} else if currentConfigID.Valid {
// Задача перестала быть тестом - удаляем конфигурацию
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
if err != nil {
log.Printf("Error deleting config dictionaries: %v", err)
}
_, err = tx.Exec("DELETE FROM configs WHERE id = $1", currentConfigID.Int64)
if err != nil {
log.Printf("Error deleting config: %v", err)
}
_, err = tx.Exec("UPDATE tasks SET config_id = NULL WHERE id = $1", taskID)
if err != nil {
log.Printf("Error unlinking config from task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error unlinking config from task: %v", err), http.StatusInternalServerError)
return
}
}
// Коммитим транзакцию
if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)