diff --git a/.gitignore b/.gitignore index aef1310..3987bcd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,8 @@ dist-ssr #py __pycache__/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +# Go tooling artifacts +backend/coverage +backend/service/service \ No newline at end of file diff --git a/backend/api/extended_handlers_test.go b/backend/api/extended_handlers_test.go new file mode 100644 index 0000000..237967a --- /dev/null +++ b/backend/api/extended_handlers_test.go @@ -0,0 +1,312 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "backend/models" + "backend/service" + "backend/testutil" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" +) + +type responseEnvelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func invokeJSONHandler(t *testing.T, handler func(*gin.Context), method, target string, body io.Reader, params gin.Params) (*httptest.ResponseRecorder, responseEnvelope) { + t.Helper() + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + req := httptest.NewRequest(method, target, body) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + c.Request = req + if params != nil { + c.Params = params + } + + handler(c) + + var resp responseEnvelope + require.NoErrorf(t, json.Unmarshal(w.Body.Bytes(), &resp), "unexpected response: %s", w.Body.String()) + return w, resp +} + +func decodeData[T any](t *testing.T, data json.RawMessage, out *T) { + t.Helper() + require.NoError(t, json.Unmarshal(data, out)) +} + +func TestTemplateHandlerCRUDAndRun(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + templateSvc := service.NewTemplateService(db, taskManager, nil) + handler := NewTemplateHandler(templateSvc, taskManager) + + _, resp := invokeJSONHandler(t, handler.ListTemplates, http.MethodGet, "/templates", nil, nil) + var templates []models.ParamTemplate + decodeData(t, resp.Data, &templates) + require.Len(t, templates, 0) + + createPayload := strings.NewReader(`{"name":"Analyse BTC","task_type":"analyse","config":{"pair":"BTCUSDT"}}`) + _, resp = invokeJSONHandler(t, handler.CreateTemplate, http.MethodPost, "/templates", createPayload, nil) + var created models.ParamTemplate + decodeData(t, resp.Data, &created) + require.NotZero(t, created.ID) + require.Equal(t, "Analyse BTC", created.Name) + + params := gin.Params{{Key: "id", Value: fmt.Sprintf("%d", created.ID)}} + updatePayload := strings.NewReader(`{"name":"Analyse ETH","task_type":"analyse","config":{"pair":"ETHUSDT"}}`) + _, resp = invokeJSONHandler(t, handler.UpdateTemplate, http.MethodPut, fmt.Sprintf("/templates/%d", created.ID), updatePayload, params) + var updated models.ParamTemplate + decodeData(t, resp.Data, &updated) + require.Equal(t, "Analyse ETH", updated.Name) + + runPayload := strings.NewReader(`{"trigger":"unit-test","overrides":{"overwrite":true}}`) + _, resp = invokeJSONHandler(t, handler.RunTemplate, http.MethodPost, fmt.Sprintf("/templates/%d/run", created.ID), runPayload, params) + var runResp TaskDetailResponse + decodeData(t, resp.Data, &runResp) + require.Equal(t, "analyse", runResp.Type) + require.NotEmpty(t, runResp.TaskID) + require.NotNil(t, runResp.Config["batch_id"]) + + _, resp = invokeJSONHandler(t, handler.DeleteTemplate, http.MethodDelete, fmt.Sprintf("/templates/%d", created.ID), nil, params) + require.Equal(t, http.StatusOK, resp.Code) + + _, resp = invokeJSONHandler(t, handler.ListTemplates, http.MethodGet, "/templates", nil, nil) + decodeData(t, resp.Data, &templates) + require.Len(t, templates, 0) +} + +func TestTemplateHandlerRunTemplateAllowsEmptyBody(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + templateSvc := service.NewTemplateService(db, taskManager, nil) + handler := NewTemplateHandler(templateSvc, taskManager) + + ctx := context.Background() + template, err := templateSvc.CreateTemplate(ctx, "Headless", "analyse", datatypes.JSONMap{"strategy": "baseline"}) + require.NoError(t, err) + + params := gin.Params{{Key: "id", Value: fmt.Sprintf("%d", template.ID)}} + emptyPayload := strings.NewReader("") + _, resp := invokeJSONHandler(t, handler.RunTemplate, http.MethodPost, fmt.Sprintf("/templates/%d/run", template.ID), emptyPayload, params) + + var detail TaskDetailResponse + decodeData(t, resp.Data, &detail) + require.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, "analyse", detail.Type) + require.NotEmpty(t, detail.TaskID) +} + +func TestBatchHandlerLifecycle(t *testing.T) { + db := testutil.NewInMemoryDB(t) + batchSvc := service.NewBatchService(db) + handler := NewBatchHandler(batchSvc) + + createPayload := strings.NewReader(`{"name":"Batch A","description":"first"}`) + _, resp := invokeJSONHandler(t, handler.CreateBatch, http.MethodPost, "/batches", createPayload, nil) + var batch models.Batch + decodeData(t, resp.Data, &batch) + require.NotZero(t, batch.ID) + require.Equal(t, "Batch A", batch.Name) + + _, resp = invokeJSONHandler(t, handler.ListBatches, http.MethodGet, "/batches", nil, nil) + var batches []models.Batch + decodeData(t, resp.Data, &batches) + require.Len(t, batches, 1) + + params := gin.Params{{Key: "id", Value: fmt.Sprintf("%d", batch.ID)}} + _, resp = invokeJSONHandler(t, handler.GetBatch, http.MethodGet, fmt.Sprintf("/batches/%d", batch.ID), nil, params) + var fetched models.Batch + decodeData(t, resp.Data, &fetched) + require.Equal(t, batch.ID, fetched.ID) + + updatePayload := strings.NewReader(`{"name":"Batch B","description":"updated","refreshed":true}`) + _, resp = invokeJSONHandler(t, handler.UpdateBatch, http.MethodPut, fmt.Sprintf("/batches/%d", batch.ID), updatePayload, params) + var updated models.Batch + decodeData(t, resp.Data, &updated) + require.Equal(t, "Batch B", updated.Name) + require.NotNil(t, updated.LastRefreshedAt) +} + +func TestTaskHandlerEndpoints(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + handler := NewTaskHandler(taskManager) + + ctx := context.Background() + task, err := taskManager.CreateTask(ctx, "collect_binance", "task-ext-1", "unit-test", service.EncodeConfig(map[string]interface{}{"chunk_size": 1000})) + require.NoError(t, err) + require.NoError(t, taskManager.AddTaskLog(ctx, task.ID, "INFO", "queued")) + + _, resp := invokeJSONHandler(t, handler.ListTasks, http.MethodGet, "/tasks?page=1&limit=5", nil, nil) + var listResp ListTasksResponse + decodeData(t, resp.Data, &listResp) + require.Len(t, listResp.Items, 1) + require.Equal(t, task.TaskID, listResp.Items[0].TaskID) + + params := gin.Params{{Key: "task_id", Value: task.TaskID}} + _, resp = invokeJSONHandler(t, handler.GetTask, http.MethodGet, fmt.Sprintf("/tasks/%s", task.TaskID), nil, params) + var detail TaskDetailResponse + decodeData(t, resp.Data, &detail) + require.Equal(t, task.TaskID, detail.TaskID) + + _, resp = invokeJSONHandler(t, handler.ListLogs, http.MethodGet, fmt.Sprintf("/tasks/%s/logs?limit=10", task.TaskID), nil, params) + var logs TaskLogResponse + decodeData(t, resp.Data, &logs) + require.Len(t, logs.Items, 1) + require.Equal(t, "queued", logs.Items[0].Message) + + cancelPayload := strings.NewReader(`{"reason":"no longer needed"}`) + _, resp = invokeJSONHandler(t, handler.CancelTask, http.MethodPost, fmt.Sprintf("/tasks/%s/cancel", task.TaskID), cancelPayload, params) + decodeData(t, resp.Data, &detail) + require.Equal(t, "CANCELLED", detail.Status) +} + +func TestTaskHandlerCancelTaskAllowsEmptyBody(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + handler := NewTaskHandler(taskManager) + + ctx := context.Background() + task, err := taskManager.CreateTask(ctx, "collect_binance", "cancel-empty", "unit-test", service.EncodeConfig(nil)) + require.NoError(t, err) + + params := gin.Params{{Key: "task_id", Value: task.TaskID}} + emptyPayload := strings.NewReader("") + _, resp := invokeJSONHandler(t, handler.CancelTask, http.MethodPost, fmt.Sprintf("/tasks/%s/cancel", task.TaskID), emptyPayload, params) + + var detail TaskDetailResponse + decodeData(t, resp.Data, &detail) + require.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, "CANCELLED", detail.Status) +} + +func TestExperimentHandlerLifecycle(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + templateSvc := service.NewTemplateService(db, taskManager, nil) + experimentSvc := service.NewExperimentService(db, templateSvc, taskManager) + handler := NewExperimentHandler(experimentSvc) + + ctx := context.Background() + batchSvc := service.NewBatchService(db) + batch, err := batchSvc.CreateBatch(ctx, "Batch-Exp", "experiment batch") + require.NoError(t, err) + + template, err := templateSvc.CreateTemplate(ctx, "Analyse", "analyse", datatypes.JSONMap{"strategy": "baseline"}) + require.NoError(t, err) + + createPayload := strings.NewReader(fmt.Sprintf(`{"batch_id":%d,"description":"smoke"}`, batch.ID)) + _, resp := invokeJSONHandler(t, handler.CreateExperiment, http.MethodPost, "/experiments", createPayload, nil) + var experiment models.Experiment + decodeData(t, resp.Data, &experiment) + require.Equal(t, batch.ID, experiment.BatchID) + + _, resp = invokeJSONHandler(t, handler.ListExperiments, http.MethodGet, fmt.Sprintf("/experiments?batch_id=%d", batch.ID), nil, nil) + var experiments []models.Experiment + decodeData(t, resp.Data, &experiments) + require.Len(t, experiments, 1) + + params := gin.Params{{Key: "id", Value: fmt.Sprintf("%d", experiment.ID)}} + _, resp = invokeJSONHandler(t, handler.GetExperiment, http.MethodGet, fmt.Sprintf("/experiments/%d", experiment.ID), nil, params) + var detail struct { + Experiment models.Experiment `json:"experiment"` + Runs []models.ExperimentRun `json:"runs"` + } + decodeData(t, resp.Data, &detail) + require.Equal(t, experiment.ID, detail.Experiment.ID) + require.Len(t, detail.Runs, 0) + + runPayload := strings.NewReader(fmt.Sprintf(`{"template_id":%d,"trigger":"manual","overrides":{"overwrite":true}}`, template.ID)) + _, resp = invokeJSONHandler(t, handler.RunTemplate, http.MethodPost, fmt.Sprintf("/experiments/%d/runs", experiment.ID), runPayload, params) + var runResp struct { + Run models.ExperimentRun `json:"run"` + Task models.Task `json:"task"` + } + decodeData(t, resp.Data, &runResp) + require.Equal(t, experiment.ID, runResp.Run.ExperimentID) + require.Equal(t, template.ID, runResp.Run.TemplateID) + require.Equal(t, "analyse", runResp.Task.Type) + + _, resp = invokeJSONHandler(t, handler.ListRuns, http.MethodGet, fmt.Sprintf("/experiments/%d/runs", experiment.ID), nil, params) + var runs []models.ExperimentRun + decodeData(t, resp.Data, &runs) + require.Len(t, runs, 1) +} + +func TestReportHandlerEndpoints(t *testing.T) { + db := testutil.NewInMemoryDB(t) + reportSvc := service.NewReportService(db) + handler := NewReportHandler(reportSvc) + ctx := context.Background() + + invalidPayload := strings.NewReader(`{"batch_id":0}`) + _, resp := invokeJSONHandler(t, handler.CreateReport, http.MethodPost, "/reports", invalidPayload, nil) + require.Equal(t, http.StatusBadRequest, resp.Code) + + validPayload := strings.NewReader(`{"batch_id":9,"format":"pdf"}`) + _, createResp := invokeJSONHandler(t, handler.CreateReport, http.MethodPost, "/reports", validPayload, nil) + require.Equal(t, http.StatusOK, createResp.Code) + + var createdReport models.Report + decodeData(t, createResp.Data, &createdReport) + require.NotZero(t, createdReport.ID) + + require.Eventually(t, func() bool { + var refreshed models.Report + if err := db.WithContext(ctx).First(&refreshed, createdReport.ID).Error; err != nil { + return false + } + return refreshed.Status == "SUCCESS" + }, 5*time.Second, 100*time.Millisecond) + + report, err := reportSvc.CreateReport(ctx, 7, 1, "pdf", "") + require.NoError(t, err) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "report.pdf") + require.NoError(t, os.WriteFile(filePath, []byte("pdf-body"), 0o644)) + require.NoError(t, db.Model(&models.Report{}).Where("id = ?", report.ID).Updates(map[string]interface{}{"status": "SUCCESS", "file_path": filePath}).Error) + + _, resp = invokeJSONHandler(t, handler.ListReports, http.MethodGet, fmt.Sprintf("/reports?batch_id=%d", report.BatchID), nil, nil) + var reports []models.Report + decodeData(t, resp.Data, &reports) + require.Len(t, reports, 1) + + downloadParams := gin.Params{{Key: "id", Value: fmt.Sprintf("%d", report.ID)}} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/reports/%d/download", report.ID), nil) + c.Request = req + c.Params = downloadParams + handler.DownloadReport(c) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) + require.Equal(t, []byte("pdf-body"), w.Body.Bytes()) + + _, resp = invokeJSONHandler(t, handler.DeleteReport, http.MethodDelete, fmt.Sprintf("/reports/%d", report.ID), nil, downloadParams) + require.Equal(t, http.StatusOK, resp.Code) + _, err = os.Stat(filePath) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) +} diff --git a/backend/api/handler_negative_test.go b/backend/api/handler_negative_test.go new file mode 100644 index 0000000..16d449d --- /dev/null +++ b/backend/api/handler_negative_test.go @@ -0,0 +1,456 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "backend/service" + "backend/testutil" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newTemplateHandlerForTest(t *testing.T) *TemplateHandler { + t.Helper() + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + templateSvc := service.NewTemplateService(db, taskManager, nil) + return NewTemplateHandler(templateSvc, taskManager) +} + +func newGinContext(method, path, payload string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(method, path, strings.NewReader(payload)) + if payload != "" { + req.Header.Set("Content-Type", "application/json") + } + c.Request = req + return c, recorder +} + +func closeSQLDB(t *testing.T, gdb *gorm.DB) { + t.Helper() + sqlDB, err := gdb.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) +} + +func TestTemplateHandlerCreateTemplateValidationError(t *testing.T) { + handler := newTemplateHandlerForTest(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/templates", strings.NewReader(`{"task_type":"analyse"}`)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + + handler.CreateTemplate(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestTemplateHandlerUpdateTemplateInvalidID(t *testing.T) { + handler := newTemplateHandlerForTest(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPut, "/templates/abc", strings.NewReader(`{"name":"n","task_type":"analyse"}`)) + req.Header.Set("Content-Type", "application/json") + c.Request = req + c.Params = gin.Params{{Key: "id", Value: "abc"}} + + handler.UpdateTemplate(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestTemplateHandlerRunTemplateBadJSON(t *testing.T) { + handler := newTemplateHandlerForTest(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/templates/1/run", strings.NewReader("not-json")) + req.Header.Set("Content-Type", "application/json") + c.Request = req + c.Params = gin.Params{{Key: "id", Value: "1"}} + + handler.RunTemplate(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestReportHandlerDownloadRequiresReadyStatus(t *testing.T) { + db := testutil.NewInMemoryDB(t) + reportSvc := service.NewReportService(db) + handler := NewReportHandler(reportSvc) + ctx := context.Background() + report, err := reportSvc.CreateReport(ctx, 7, 1, "pdf", "") + require.NoError(t, err) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodGet, "/reports/1/download", nil) + c.Request = req + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", report.ID)}} + + handler.DownloadReport(c) + + require.Equal(t, http.StatusBadRequest, w.Code) + + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = httptest.NewRequest(http.MethodGet, "/reports/bad/download", nil) + c2.Params = gin.Params{{Key: "id", Value: "bad"}} + handler.DownloadReport(c2) + require.Equal(t, http.StatusBadRequest, w2.Code) +} + +func TestTaskHandlerCancelTaskBadPayload(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + handler := NewTaskHandler(taskManager) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(http.MethodPost, "/tasks/task-1/cancel", strings.NewReader("{")) + req.Header.Set("Content-Type", "application/json") + c.Request = req + c.Params = gin.Params{{Key: "task_id", Value: "task-1"}} + + handler.CancelTask(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestBatchHandlerCreateBatchInvalidJSON(t *testing.T) { + handler := &BatchHandler{} + c, w := newGinContext(http.MethodPost, "/batches", "{") + handler.CreateBatch(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestBatchHandlerGetBatchInvalidID(t *testing.T) { + handler := &BatchHandler{} + c, w := newGinContext(http.MethodGet, "/batches/abc", "") + c.Params = gin.Params{{Key: "id", Value: "abc"}} + handler.GetBatch(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestBatchHandlerUpdateBatchInvalidPayload(t *testing.T) { + handler := &BatchHandler{} + c, w := newGinContext(http.MethodPut, "/batches/1", "not-json") + c.Params = gin.Params{{Key: "id", Value: "1"}} + handler.UpdateBatch(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandlerGetOpportunitiesRejectsInvalidOrder(t *testing.T) { + h := &Handler{} + c, w := newGinContext(http.MethodGet, "/opportunities?order=bad", "") + h.GetOpportunities(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestHandlerPriceComparisonValidation(t *testing.T) { + h := &Handler{} + ctxMissing, wMissing := newGinContext(http.MethodGet, "/price-comparison", "") + h.GetPriceComparisonData(ctxMissing) + require.Equal(t, http.StatusBadRequest, wMissing.Code) + + ctxInvalid, wInvalid := newGinContext(http.MethodGet, "/price-comparison?startTime=abc&endTime=1", "") + h.GetPriceComparisonData(ctxInvalid) + require.Equal(t, http.StatusBadRequest, wInvalid.Code) +} + +func TestReportHandlerListReportsInvalidBatchID(t *testing.T) { + h := &ReportHandler{} + c, w := newGinContext(http.MethodGet, "/reports?batch_id=bad", "") + h.ListReports(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestReportHandlerDeleteReportInvalidID(t *testing.T) { + h := &ReportHandler{} + c, w := newGinContext(http.MethodDelete, "/reports/bad", "") + c.Params = gin.Params{{Key: "id", Value: "bad"}} + h.DeleteReport(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestExperimentHandlerValidations(t *testing.T) { + h := &ExperimentHandler{} + ctxList, wList := newGinContext(http.MethodGet, "/experiments?batch_id=x", "") + h.ListExperiments(ctxList) + require.Equal(t, http.StatusBadRequest, wList.Code) + + ctxGet, wGet := newGinContext(http.MethodGet, "/experiments/abc", "") + ctxGet.Params = gin.Params{{Key: "id", Value: "abc"}} + h.GetExperiment(ctxGet) + require.Equal(t, http.StatusBadRequest, wGet.Code) + + ctxRuns, wRuns := newGinContext(http.MethodGet, "/experiments/def/runs", "") + ctxRuns.Params = gin.Params{{Key: "id", Value: "def"}} + h.ListRuns(ctxRuns) + require.Equal(t, http.StatusBadRequest, wRuns.Code) + + ctxRunTemplate, wRunTemplate := newGinContext(http.MethodPost, "/experiments/1/runs", "not-json") + ctxRunTemplate.Params = gin.Params{{Key: "id", Value: "1"}} + h.RunTemplate(ctxRunTemplate) + require.Equal(t, http.StatusBadRequest, wRunTemplate.Code) +} + +func TestTaskHandlerNotFoundBranches(t *testing.T) { + db := testutil.NewInMemoryDB(t) + manager := service.NewTaskManager(db) + h := NewTaskHandler(manager) + + ctxTask, wTask := newGinContext(http.MethodGet, "/tasks/missing", "") + ctxTask.Params = gin.Params{{Key: "task_id", Value: "missing"}} + h.GetTask(ctxTask) + require.Equal(t, http.StatusNotFound, wTask.Code) + + ctxLogs, wLogs := newGinContext(http.MethodGet, "/tasks/missing/logs", "") + ctxLogs.Params = gin.Params{{Key: "task_id", Value: "missing"}} + h.ListLogs(ctxLogs) + require.Equal(t, http.StatusNotFound, wLogs.Code) +} + +func TestParseUintParamHelpers(t *testing.T) { + ctxBad, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctxBad.Params = gin.Params{{Key: "id", Value: "abc"}} + _, err := parseUintParam(ctxBad, "id") + require.Error(t, err) + + ctxGood, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctxGood.Params = gin.Params{{Key: "id", Value: "42"}} + id, err := parseUintParam(ctxGood, "id") + require.NoError(t, err) + require.Equal(t, uint(42), id) +} + +func TestTemplateHandlerUpdateTemplateNotFound(t *testing.T) { + handler := newTemplateHandlerForTest(t) + c, w := newGinContext(http.MethodPut, "/templates/999", `{"name":"ghost","task_type":"analyse","config":{"pair":"ETHUSDT"}}`) + c.Params = gin.Params{{Key: "id", Value: "999"}} + + handler.UpdateTemplate(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestTemplateHandlerRunTemplateMissingTemplate(t *testing.T) { + handler := newTemplateHandlerForTest(t) + c, w := newGinContext(http.MethodPost, "/templates/999/run", `{"trigger":"manual","overrides":{"overwrite":true}}`) + c.Params = gin.Params{{Key: "id", Value: "999"}} + + handler.RunTemplate(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestBatchHandlerUpdateBatchNotFound(t *testing.T) { + db := testutil.NewInMemoryDB(t) + handler := NewBatchHandler(service.NewBatchService(db)) + c, w := newGinContext(http.MethodPut, "/batches/77", `{"name":"missing","description":"ghost","refreshed":true}`) + c.Params = gin.Params{{Key: "id", Value: "77"}} + + handler.UpdateBatch(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestReportHandlerDeleteReportNotFound(t *testing.T) { + db := testutil.NewInMemoryDB(t) + handler := NewReportHandler(service.NewReportService(db)) + c, w := newGinContext(http.MethodDelete, "/reports/33", "") + c.Params = gin.Params{{Key: "id", Value: "33"}} + + handler.DeleteReport(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestExperimentHandlerRunTemplateServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + templateSvc := service.NewTemplateService(db, taskManager, nil) + experimentSvc := service.NewExperimentService(db, templateSvc, taskManager) + handler := NewExperimentHandler(experimentSvc) + + c, w := newGinContext(http.MethodPost, "/experiments/555/runs", `{"template_id":1,"trigger":"manual"}`) + c.Params = gin.Params{{Key: "id", Value: "555"}} + + handler.RunTemplate(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestBatchHandlerListBatchesServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + handler := NewBatchHandler(service.NewBatchService(db)) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodGet, "/batches", "") + handler.ListBatches(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestBatchHandlerCreateBatchServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + handler := NewBatchHandler(service.NewBatchService(db)) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodPost, "/batches", `{"name":"batch","description":"desc"}`) + handler.CreateBatch(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestBatchHandlerUpdateBatchServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + handler := NewBatchHandler(service.NewBatchService(db)) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodPut, "/batches/1", `{"name":"batch","description":"desc"}`) + c.Params = gin.Params{{Key: "id", Value: "1"}} + handler.UpdateBatch(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestTemplateHandlerListTemplatesServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + handler := NewTemplateHandler(service.NewTemplateService(db, taskManager, nil), taskManager) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodGet, "/templates", "") + handler.ListTemplates(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestTemplateHandlerCreateTemplateServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + handler := NewTemplateHandler(service.NewTemplateService(db, taskManager, nil), taskManager) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodPost, "/templates", `{"name":"n","task_type":"analyse"}`) + handler.CreateTemplate(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestTemplateHandlerDeleteTemplateServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + handler := NewTemplateHandler(service.NewTemplateService(db, taskManager, nil), taskManager) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodDelete, "/templates/1", "") + c.Params = gin.Params{{Key: "id", Value: "1"}} + handler.DeleteTemplate(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestExperimentHandlerServiceErrors(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskManager := service.NewTaskManager(db) + templateSvc := service.NewTemplateService(db, taskManager, nil) + experimentSvc := service.NewExperimentService(db, templateSvc, taskManager) + handler := NewExperimentHandler(experimentSvc) + + _, err := experimentSvc.CreateExperiment(context.Background(), 1, "desc") + require.NoError(t, err) + + closeSQLDB(t, db) + + t.Run("ListExperiments", func(t *testing.T) { + c, w := newGinContext(http.MethodGet, "/experiments", "") + handler.ListExperiments(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("CreateExperiment", func(t *testing.T) { + c, w := newGinContext(http.MethodPost, "/experiments", `{"batch_id":1,"description":"d"}`) + handler.CreateExperiment(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("GetExperiment", func(t *testing.T) { + c, w := newGinContext(http.MethodGet, "/experiments/1", "") + c.Params = gin.Params{{Key: "id", Value: "1"}} + handler.GetExperiment(c) + require.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("ListRuns", func(t *testing.T) { + c, w := newGinContext(http.MethodGet, "/experiments/1/runs", "") + c.Params = gin.Params{{Key: "id", Value: "1"}} + handler.ListRuns(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("RunTemplate", func(t *testing.T) { + c, w := newGinContext(http.MethodPost, "/experiments/1/runs", `{"template_id":1}`) + c.Params = gin.Params{{Key: "id", Value: "1"}} + handler.RunTemplate(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) +} + +func TestTaskHandlerListTasksServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + h := NewTaskHandler(service.NewTaskManager(db)) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodGet, "/tasks", "") + h.ListTasks(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestTaskHandlerCancelTaskServiceError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + manager := service.NewTaskManager(db) + h := NewTaskHandler(manager) + _, err := manager.CreateTask(context.Background(), "collect_binance", "task-cancel", "manual", service.EncodeConfig(nil)) + require.NoError(t, err) + closeSQLDB(t, db) + + c, w := newGinContext(http.MethodPost, "/tasks/task-cancel/cancel", `{"reason":"stop"}`) + c.Params = gin.Params{{Key: "task_id", Value: "task-cancel"}} + h.CancelTask(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestReportHandlerServiceErrors(t *testing.T) { + db := testutil.NewInMemoryDB(t) + reportSvc := service.NewReportService(db) + handler := NewReportHandler(reportSvc) + + report, err := reportSvc.CreateReport(context.Background(), 1, 0, "pdf", "") + require.NoError(t, err) + + closeSQLDB(t, db) + + t.Run("ListReports", func(t *testing.T) { + c, w := newGinContext(http.MethodGet, "/reports", "") + handler.ListReports(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("CreateReport", func(t *testing.T) { + c, w := newGinContext(http.MethodPost, "/reports", `{"batch_id":1,"format":"pdf"}`) + handler.CreateReport(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) + + t.Run("DeleteReport", func(t *testing.T) { + c, w := newGinContext(http.MethodDelete, fmt.Sprintf("/reports/%d", report.ID), "") + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", report.ID)}} + handler.DeleteReport(c) + require.Equal(t, http.StatusInternalServerError, w.Code) + }) +} diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 49b24e0..cb6f144 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -97,3 +97,53 @@ func TestGetPriceComparisonDataHandlerReturnsSeries(t *testing.T) { require.Len(t, resp.Data["binance"], 1) require.Len(t, resp.Data["uniswap"], 1) } + +func TestGetOpportunitiesRejectsInvalidOrder(t *testing.T) { + h, _ := newTestHandler(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/opportunities?order=sideways", nil) + + h.GetOpportunities(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetPriceComparisonDataHandlerRejectsBadTimestamp(t *testing.T) { + h, _ := newTestHandler(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/price-comparison?startTime=abc&endTime=123", nil) + + h.GetPriceComparisonData(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGetOpportunitiesHandlesServiceError(t *testing.T) { + h, svc := newTestHandler(t) + sqlDB, err := svc.DB().DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/opportunities", nil) + + h.GetOpportunities(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestGetPriceComparisonHandlesServiceError(t *testing.T) { + h, svc := newTestHandler(t) + sqlDB, err := svc.DB().DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/price-comparison?startTime=1&endTime=2", nil) + + h.GetPriceComparisonData(c) + require.Equal(t, http.StatusInternalServerError, w.Code) +} diff --git a/backend/api/register_test.go b/backend/api/register_test.go new file mode 100644 index 0000000..064950e --- /dev/null +++ b/backend/api/register_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" +) + +func routeKey(method, path string) string { + return method + " " + path +} + +func assertRouteRegistered(t *testing.T, engine *gin.Engine, method, path string) { + t.Helper() + key := routeKey(method, path) + for _, r := range engine.Routes() { + if routeKey(r.Method, r.Path) == key { + return + } + } + t.Fatalf("route %s not found", key) +} + +func TestTemplateHandlerRegisterRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + group := router.Group("/api/v1") + handler := &TemplateHandler{} + + handler.Register(group) + + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/templates") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/templates") + assertRouteRegistered(t, router, http.MethodPut, "/api/v1/templates/:id") + assertRouteRegistered(t, router, http.MethodDelete, "/api/v1/templates/:id") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/templates/:id/run") +} + +func TestBatchHandlerRegisterRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + group := router.Group("/api/v1") + handler := &BatchHandler{} + + handler.Register(group) + + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/batches") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/batches") + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/batches/:id") + assertRouteRegistered(t, router, http.MethodPut, "/api/v1/batches/:id") +} + +func TestTaskHandlerRegisterRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + group := router.Group("/api/v1") + handler := &TaskHandler{} + + handler.Register(group) + + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/tasks") + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/tasks/:task_id") + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/tasks/:task_id/logs") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/tasks/:task_id/cancel") +} + +func TestReportHandlerRegisterRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + group := router.Group("/api/v1") + handler := &ReportHandler{} + + handler.Register(group) + + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/reports") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/reports") + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/reports/:id/download") + assertRouteRegistered(t, router, http.MethodDelete, "/api/v1/reports/:id") +} + +func TestExperimentHandlerRegisterRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + group := router.Group("/api/v1") + handler := &ExperimentHandler{} + + handler.Register(group) + + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/experiments") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/experiments") + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/experiments/:id") + assertRouteRegistered(t, router, http.MethodGet, "/api/v1/experiments/:id/runs") + assertRouteRegistered(t, router, http.MethodPost, "/api/v1/experiments/:id/runs") +} diff --git a/backend/config/viper_test.go b/backend/config/viper_test.go new file mode 100644 index 0000000..9d235c4 --- /dev/null +++ b/backend/config/viper_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestReadConfigFileLoadsValues(t *testing.T) { + viper.Reset() + cwd, err := os.Getwd() + require.NoError(t, err) + root := filepath.Dir(cwd) + + require.NoError(t, os.Chdir(root)) + t.Cleanup(func() { + _ = os.Chdir(cwd) + viper.Reset() + }) + + require.NoError(t, ReadConfigFile()) + require.Equal(t, "localhost", viper.GetString("db.host")) +} + +func TestReadConfigFileFailsWithoutConfigDir(t *testing.T) { + viper.Reset() + cwd, err := os.Getwd() + require.NoError(t, err) + tempDir := t.TempDir() + require.NoError(t, os.Chdir(tempDir)) + defer func() { + _ = os.Chdir(cwd) + viper.Reset() + }() + + err = ReadConfigFile() + require.Error(t, err) +} diff --git a/backend/db/database_test.go b/backend/db/database_test.go new file mode 100644 index 0000000..2d4ec59 --- /dev/null +++ b/backend/db/database_test.go @@ -0,0 +1,83 @@ +package db + +import ( + "fmt" + "net" + "path/filepath" + "testing" + + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func startEmbeddedPostgres(t *testing.T) (func(), int) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + + root := t.TempDir() + cfg := embeddedpostgres.DefaultConfig(). + Username("postgres"). + Password("postgres"). + Database("testdb"). + Port(uint32(port)). + Locale("C"). + RuntimePath(filepath.Join(root, "runtime")). + DataPath(filepath.Join(root, "data")). + BinariesPath(filepath.Join(root, "bin")) + + pg := embeddedpostgres.NewDatabase(cfg) + require.NoError(t, pg.Start()) + + return func() { _ = pg.Stop() }, port +} + +func resetDBState(t *testing.T) { + t.Helper() + if database != nil { + sqlDB, _ := database.DB() + if sqlDB != nil { + _ = sqlDB.Close() + } + database = nil + } + viper.Reset() +} + +func TestInitDBSuccess(t *testing.T) { + t.Cleanup(func() { resetDBState(t) }) + stop, port := startEmbeddedPostgres(t) + defer stop() + + viper.Set("db.host", "127.0.0.1") + viper.Set("db.port", fmt.Sprintf("%d", port)) + viper.Set("db.username", "postgres") + viper.Set("db.password", "postgres") + viper.Set("db.database", "testdb") + + require.NoError(t, InitDB()) + require.NotNil(t, GetDB()) +} + +func TestInitDBReturnsErrorWhenServerUnavailable(t *testing.T) { + t.Cleanup(func() { resetDBState(t) }) + viper.Set("db.host", "127.0.0.1") + viper.Set("db.port", "65530") + viper.Set("db.username", "postgres") + viper.Set("db.password", "postgres") + viper.Set("db.database", "missing") + + err := InitDB() + require.Error(t, err) +} + +func TestGetDBPanicsWhenNotInitialized(t *testing.T) { + t.Cleanup(func() { resetDBState(t) }) + database = nil + require.Panics(t, func() { + _ = GetDB() + }) +} diff --git a/backend/docs/docs_test.go b/backend/docs/docs_test.go new file mode 100644 index 0000000..f938e70 --- /dev/null +++ b/backend/docs/docs_test.go @@ -0,0 +1,27 @@ +package docs + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/swaggo/swag" +) + +func TestSwaggerInfoMetadata(t *testing.T) { + doc, err := swag.ReadDoc(SwaggerInfo.InstanceName()) + require.NoError(t, err) + require.Contains(t, doc, "\"swagger\": \"2.0\"") + require.Contains(t, doc, "\"/tasks\"") +} + +func TestSwaggerInfoCustomization(t *testing.T) { + original := SwaggerInfo.Title + SwaggerInfo.Title = "Coverage Title" + t.Cleanup(func() { + SwaggerInfo.Title = original + }) + + doc, err := swag.ReadDoc(SwaggerInfo.InstanceName()) + require.NoError(t, err) + require.Contains(t, doc, "Coverage Title") +} diff --git a/backend/docs/swagger_config.go b/backend/docs/swagger_config.go new file mode 100644 index 0000000..b74dac2 --- /dev/null +++ b/backend/docs/swagger_config.go @@ -0,0 +1,22 @@ +package docs + +// init enforces sensible default metadata for the generated swagger spec so tests +// can assert that documentation is wired up. +func init() { + if SwaggerInfo == nil { + return + } + + if SwaggerInfo.Title == "" { + SwaggerInfo.Title = "Etrade Arbitrage API" + } + if SwaggerInfo.Version == "" { + SwaggerInfo.Version = "1.0" + } + if SwaggerInfo.BasePath == "" { + SwaggerInfo.BasePath = "/" + } + if SwaggerInfo.Description == "" { + SwaggerInfo.Description = "Auto-generated API documentation for the Etrade arbitrage backend" + } +} diff --git a/backend/go.mod b/backend/go.mod index f83ebbe..c1ea925 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,9 +3,9 @@ module backend go 1.24.1 require ( + github.com/fergusstrange/embedded-postgres v1.33.0 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 - github.com/glebarez/sqlite v1.11.0 github.com/google/uuid v1.6.0 github.com/johnfercher/maroto v0.42.0 github.com/spf13/viper v1.21.0 @@ -13,8 +13,8 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 - google.golang.org/grpc v1.66.0 - google.golang.org/protobuf v1.36.10 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 gorm.io/datatypes v1.2.7 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.0 @@ -29,11 +29,9 @@ require ( github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-openapi/jsonpointer v0.22.3 // indirect github.com/go-openapi/jsonreference v0.21.3 // indirect github.com/go-openapi/spec v0.22.1 // indirect @@ -61,6 +59,7 @@ require ( github.com/jung-kurt/gofpdf v1.16.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -68,7 +67,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -78,21 +76,18 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/mysql v1.5.6 // indirect - modernc.org/libc v1.22.5 // indirect - modernc.org/mathutil v1.5.0 // indirect - modernc.org/memory v1.5.0 // indirect - modernc.org/sqlite v1.23.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index c0f0f5f..ede4850 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -16,8 +16,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fergusstrange/embedded-postgres v1.33.0 h1:ka8vmRpm4IDsES7NPXQ/NThAp1fc/f+crcXYjCW7wK0= +github.com/fergusstrange/embedded-postgres v1.33.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -32,10 +32,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= @@ -84,11 +84,11 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -119,10 +119,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -144,9 +144,6 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE= github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= -github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= @@ -191,7 +188,23 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -214,8 +227,8 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -232,8 +245,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -242,12 +255,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= -google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -260,18 +275,8 @@ gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= -modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= -modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= -modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= -modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= -modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= -modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= -modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/backend/grpcserver/server_test.go b/backend/grpcserver/server_test.go new file mode 100644 index 0000000..2f571f2 --- /dev/null +++ b/backend/grpcserver/server_test.go @@ -0,0 +1,126 @@ +package grpcserver + +import ( + "context" + "encoding/json" + "testing" + + "backend/models" + "backend/pkg/taskpb" + "backend/service" + "backend/testutil" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestServerRPCsPersistTasks(t *testing.T) { + db := testutil.NewInMemoryDB(t) + taskMgr := service.NewTaskManager(db) + srv := New(taskMgr, "grpc-trigger") + ctx := context.Background() + + t.Run("CollectBinanceGeneratesID", func(t *testing.T) { + resp, err := srv.CollectBinance(ctx, &taskpb.CollectBinanceRequest{ + ImportPercentage: 75, + ChunkSize: 0, + }) + require.NoError(t, err) + require.NotEmpty(t, resp.TaskId) + + var task models.Task + require.NoError(t, db.Where("task_id = ?", resp.TaskId).First(&task).Error) + require.Equal(t, "collect_binance", task.Type) + require.Equal(t, "grpc-trigger", task.Trigger) + require.Equal(t, 75, jsonNumberAsInt(task.ConfigJSON["import_percentage"])) + }) + + t.Run("CollectUniswapUsesProvidedID", func(t *testing.T) { + resp, err := srv.CollectUniswap(ctx, &taskpb.CollectUniswapRequest{ + TaskId: "fixed-id", + PoolAddress: "0xpool", + StartTs: 1, + EndTs: 2, + }) + require.NoError(t, err) + require.Equal(t, "fixed-id", resp.TaskId) + + var task models.Task + require.NoError(t, db.Where("task_id = ?", resp.TaskId).First(&task).Error) + require.Equal(t, "collect_uniswap", task.Type) + require.Equal(t, "0xpool", task.ConfigJSON["pool_address"]) + }) + + t.Run("ProcessPricesPersistsOverrides", func(t *testing.T) { + resp, err := srv.ProcessPrices(ctx, &taskpb.ProcessPricesRequest{ + TaskId: "prices", + StartDate: 10, + EndDate: 20, + AggregationInterval: "1h", + Overwrite: true, + DbOverrides: map[string]string{"dsn": "postgres://"}, + }) + require.NoError(t, err) + require.Equal(t, "prices", resp.TaskId) + + var task models.Task + require.NoError(t, db.Where("task_id = ?", resp.TaskId).First(&task).Error) + overrides, ok := task.ConfigJSON["db_overrides"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, "postgres://", overrides["dsn"]) + }) + + t.Run("AnalyseCarriesStrategy", func(t *testing.T) { + strategy := map[string]string{"key": "value"} + payload, err := json.Marshal(strategy) + require.NoError(t, err) + + resp, err := srv.Analyse(ctx, &taskpb.AnalyseRequest{ + TaskId: "analyse", + BatchId: 42, + Overwrite: true, + StrategyJson: string(payload), + }) + require.NoError(t, err) + require.Equal(t, "analyse", resp.TaskId) + + var task models.Task + require.NoError(t, db.Where("task_id = ?", resp.TaskId).First(&task).Error) + require.Equal(t, string(payload), task.ConfigJSON["strategy_json"]) + }) +} + +func jsonNumberAsInt(v interface{}) int { + switch val := v.(type) { + case float64: + return int(val) + case float32: + return int(val) + case int: + return val + case int64: + return int(val) + case json.Number: + if i, err := val.Int64(); err == nil { + return int(i) + } + } + return 0 +} + +func TestCreateTaskReturnsInternalOnManagerError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + srv := New(service.NewTaskManager(db), "grpc-trigger") + _, err := srv.createTask(context.Background(), "", "", map[string]interface{}{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.Internal, st.Code()) +} + +func TestRunReturnsListenError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + err := Run(Config{Port: "bad-port"}, service.NewTaskManager(db)) + require.Error(t, err) +} diff --git a/backend/models/opportunity.go b/backend/models/opportunity.go index b1f8a82..31628a8 100644 --- a/backend/models/opportunity.go +++ b/backend/models/opportunity.go @@ -1,6 +1,10 @@ package models -import "gorm.io/datatypes" +import ( + "time" + + "gorm.io/datatypes" +) // ArbitrageOpportunity 对应于 arbitrage_opportunities 表 type ArbitrageOpportunity struct { @@ -13,6 +17,8 @@ type ArbitrageOpportunity struct { ProfitUSDT float64 `json:"profit_usdt" gorm:"not null"` DetailsJSON datatypes.JSONMap `json:"details,omitempty" gorm:"type:jsonb" swaggertype:"object"` RiskMetricsJSON datatypes.JSONMap `json:"risk_metrics,omitempty" gorm:"type:jsonb" swaggertype:"object"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } // PaginationData 定义分页响应的结构 diff --git a/backend/pkg/taskpb/taskpb_test.go b/backend/pkg/taskpb/taskpb_test.go new file mode 100644 index 0000000..f51f9f5 --- /dev/null +++ b/backend/pkg/taskpb/taskpb_test.go @@ -0,0 +1,659 @@ +package taskpb + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +type fakeConn struct { + lastMethod string + lastOpts []grpc.CallOption + errors map[string]error +} + +func (f *fakeConn) Invoke(ctx context.Context, method string, args interface{}, reply interface{}, opts ...grpc.CallOption) error { + f.lastMethod = method + f.lastOpts = append([]grpc.CallOption(nil), opts...) + if err, ok := f.errors[method]; ok { + return err + } + return nil +} + +func (f *fakeConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, errors.New("not implemented") +} + +func TestTaskServiceClientInvokesMethods(t *testing.T) { + conn := &fakeConn{} + client := NewTaskServiceClient(conn) + + _, err := client.CollectBinance(context.Background(), &CollectBinanceRequest{}) + require.NoError(t, err) + require.Equal(t, TaskService_CollectBinance_FullMethodName, conn.lastMethod) + + _, err = client.CollectUniswap(context.Background(), &CollectUniswapRequest{}) + require.NoError(t, err) + require.Equal(t, TaskService_CollectUniswap_FullMethodName, conn.lastMethod) + + _, err = client.ProcessPrices(context.Background(), &ProcessPricesRequest{}) + require.NoError(t, err) + require.Equal(t, TaskService_ProcessPrices_FullMethodName, conn.lastMethod) + + _, err = client.Analyse(context.Background(), &AnalyseRequest{}) + require.NoError(t, err) + require.Equal(t, TaskService_Analyse_FullMethodName, conn.lastMethod) + + _, err = client.CollectBinanceByDate(context.Background(), &CollectBinanceByDateRequest{}) + require.NoError(t, err) + require.Equal(t, TaskService_CollectBinanceByDate_FullMethodName, conn.lastMethod) +} + +func TestTaskServiceClientPropagatesErrorsAndOptions(t *testing.T) { + boom := errors.New("invoke failure") + conn := &fakeConn{errors: map[string]error{ + TaskService_ProcessPrices_FullMethodName: boom, + }} + client := NewTaskServiceClient(conn) + opts := []grpc.CallOption{grpc.WaitForReady(true)} + resp, err := client.ProcessPrices(context.Background(), &ProcessPricesRequest{TaskId: "pp"}, opts...) + require.Nil(t, resp) + require.ErrorIs(t, err, boom) + require.Equal(t, TaskService_ProcessPrices_FullMethodName, conn.lastMethod) + require.Len(t, conn.lastOpts, len(opts)+1) +} + +func TestTaskServiceClientErrorBranches(t *testing.T) { + testCases := []struct { + name string + method string + call func(TaskServiceClient) error + }{ + { + name: "CollectBinance", + method: TaskService_CollectBinance_FullMethodName, + call: func(client TaskServiceClient) error { + _, err := client.CollectBinance(context.Background(), &CollectBinanceRequest{TaskId: "cb"}) + return err + }, + }, + { + name: "CollectBinanceByDate", + method: TaskService_CollectBinanceByDate_FullMethodName, + call: func(client TaskServiceClient) error { + _, err := client.CollectBinanceByDate(context.Background(), &CollectBinanceByDateRequest{TaskId: "cbd"}) + return err + }, + }, + { + name: "CollectUniswap", + method: TaskService_CollectUniswap_FullMethodName, + call: func(client TaskServiceClient) error { + _, err := client.CollectUniswap(context.Background(), &CollectUniswapRequest{TaskId: "cu"}) + return err + }, + }, + { + name: "Analyse", + method: TaskService_Analyse_FullMethodName, + call: func(client TaskServiceClient) error { + _, err := client.Analyse(context.Background(), &AnalyseRequest{TaskId: "an"}) + return err + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + expect := errors.New("call failed: " + tc.name) + conn := &fakeConn{errors: map[string]error{tc.method: expect}} + client := NewTaskServiceClient(conn) + err := tc.call(client) + require.ErrorIs(t, err, expect) + require.Equal(t, tc.method, conn.lastMethod) + }) + } +} + +type noopServer struct { + UnimplementedTaskServiceServer +} + +func (n *noopServer) CollectBinance(ctx context.Context, req *CollectBinanceRequest) (*TaskResponse, error) { + return &TaskResponse{TaskId: req.GetTaskId()}, nil +} + +func TestRegisterTaskServiceServer(t *testing.T) { + srv := grpc.NewServer() + RegisterTaskServiceServer(srv, &noopServer{}) + srv.Stop() +} + +func TestUnimplementedTaskServiceServerReturnsCodes(t *testing.T) { + server := &UnimplementedTaskServiceServer{} + ctx := context.Background() + checks := []struct { + name string + run func() error + }{ + {"CollectBinance", func() error { _, err := server.CollectBinance(ctx, &CollectBinanceRequest{}); return err }}, + {"CollectBinanceByDate", func() error { _, err := server.CollectBinanceByDate(ctx, &CollectBinanceByDateRequest{}); return err }}, + {"CollectUniswap", func() error { _, err := server.CollectUniswap(ctx, &CollectUniswapRequest{}); return err }}, + {"ProcessPrices", func() error { _, err := server.ProcessPrices(ctx, &ProcessPricesRequest{}); return err }}, + {"Analyse", func() error { _, err := server.Analyse(ctx, &AnalyseRequest{}); return err }}, + } + + for _, tc := range checks { + t.Run(tc.name, func(t *testing.T) { + err := tc.run() + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.Unimplemented, st.Code()) + }) + } +} + +type spyTaskServer struct { + UnimplementedTaskServiceServer + lastMethod string + lastReq interface{} +} + +func (s *spyTaskServer) CollectBinance(ctx context.Context, req *CollectBinanceRequest) (*TaskResponse, error) { + s.lastMethod = "CollectBinance" + s.lastReq = req + return &TaskResponse{TaskId: req.GetTaskId()}, nil +} + +func (s *spyTaskServer) CollectBinanceByDate(ctx context.Context, req *CollectBinanceByDateRequest) (*TaskResponse, error) { + s.lastMethod = "CollectBinanceByDate" + s.lastReq = req + return &TaskResponse{TaskId: req.GetTaskId()}, nil +} + +func (s *spyTaskServer) CollectUniswap(ctx context.Context, req *CollectUniswapRequest) (*TaskResponse, error) { + s.lastMethod = "CollectUniswap" + s.lastReq = req + return &TaskResponse{TaskId: req.GetTaskId()}, nil +} + +func (s *spyTaskServer) ProcessPrices(ctx context.Context, req *ProcessPricesRequest) (*TaskResponse, error) { + s.lastMethod = "ProcessPrices" + s.lastReq = req + return &TaskResponse{TaskId: req.GetTaskId()}, nil +} + +func (s *spyTaskServer) Analyse(ctx context.Context, req *AnalyseRequest) (*TaskResponse, error) { + s.lastMethod = "Analyse" + s.lastReq = req + return &TaskResponse{TaskId: req.GetTaskId()}, nil +} + +func TestCollectBinanceHandlerInvokesServer(t *testing.T) { + rec := &spyTaskServer{} + dec := func(v interface{}) error { + req := v.(*CollectBinanceRequest) + req.TaskId = "decoded" + return nil + } + called := false + interceptor := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + called = true + require.Equal(t, TaskService_CollectBinance_FullMethodName, info.FullMethod) + return handler(ctx, req) + } + + resp, err := _TaskService_CollectBinance_Handler(rec, context.Background(), dec, interceptor) + require.NoError(t, err) + require.True(t, called) + require.NotNil(t, rec.lastReq) + require.Equal(t, "decoded", rec.lastReq.(*CollectBinanceRequest).GetTaskId()) + msg, ok := resp.(*TaskResponse) + require.True(t, ok) + require.Equal(t, "decoded", msg.TaskId) +} + +func TestTaskServiceHandlersWithoutInterceptor(t *testing.T) { + srv := &spyTaskServer{} + ctx := context.Background() + testCases := []struct { + name string + handler func(interface{}, context.Context, func(interface{}) error, grpc.UnaryServerInterceptor) (interface{}, error) + prep func(interface{}) error + wantMethod string + }{ + {"CollectBinance", _TaskService_CollectBinance_Handler, func(v interface{}) error { + req := v.(*CollectBinanceRequest) + req.TaskId = "cb" + return nil + }, "CollectBinance"}, + {"CollectBinanceByDate", _TaskService_CollectBinanceByDate_Handler, func(v interface{}) error { + req := v.(*CollectBinanceByDateRequest) + req.TaskId = "cbd" + return nil + }, "CollectBinanceByDate"}, + {"CollectUniswap", _TaskService_CollectUniswap_Handler, func(v interface{}) error { + req := v.(*CollectUniswapRequest) + req.TaskId = "cu" + return nil + }, "CollectUniswap"}, + {"ProcessPrices", _TaskService_ProcessPrices_Handler, func(v interface{}) error { + req := v.(*ProcessPricesRequest) + req.TaskId = "pp" + return nil + }, "ProcessPrices"}, + {"Analyse", _TaskService_Analyse_Handler, func(v interface{}) error { + req := v.(*AnalyseRequest) + req.TaskId = "an" + return nil + }, "Analyse"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + srv.lastMethod = "" + srv.lastReq = nil + resp, err := tc.handler(srv, ctx, tc.prep, nil) + require.NoError(t, err) + require.Equal(t, tc.wantMethod, srv.lastMethod) + require.NotNil(t, srv.lastReq) + msg, ok := resp.(*TaskResponse) + require.True(t, ok) + require.Equal(t, srv.lastReq.(interface{ GetTaskId() string }).GetTaskId(), msg.TaskId) + }) + } +} + +func TestTaskServiceHandlersWithInterceptor(t *testing.T) { + srv := &spyTaskServer{} + ctx := context.Background() + testCases := []struct { + name string + handler func(interface{}, context.Context, func(interface{}) error, grpc.UnaryServerInterceptor) (interface{}, error) + prep func(interface{}) error + fullMethod string + }{ + { + name: "CollectBinanceByDate", + handler: _TaskService_CollectBinanceByDate_Handler, + prep: func(v interface{}) error { + v.(*CollectBinanceByDateRequest).TaskId = "cbd" + return nil + }, + fullMethod: TaskService_CollectBinanceByDate_FullMethodName, + }, + { + name: "CollectUniswap", + handler: _TaskService_CollectUniswap_Handler, + prep: func(v interface{}) error { + v.(*CollectUniswapRequest).TaskId = "cu" + return nil + }, + fullMethod: TaskService_CollectUniswap_FullMethodName, + }, + { + name: "ProcessPrices", + handler: _TaskService_ProcessPrices_Handler, + prep: func(v interface{}) error { + v.(*ProcessPricesRequest).TaskId = "pp" + return nil + }, + fullMethod: TaskService_ProcessPrices_FullMethodName, + }, + { + name: "Analyse", + handler: _TaskService_Analyse_Handler, + prep: func(v interface{}) error { + v.(*AnalyseRequest).TaskId = "an" + return nil + }, + fullMethod: TaskService_Analyse_FullMethodName, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + called := false + srv.lastMethod = "" + srv.lastReq = nil + resp, err := tc.handler(srv, ctx, tc.prep, func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + called = true + require.Equal(t, tc.fullMethod, info.FullMethod) + require.Equal(t, srv, info.Server) + return handler(ctx, req) + }) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, tc.name, srv.lastMethod) + require.NotNil(t, resp) + }) + } +} + +func TestTaskServiceHandlerDecoderError(t *testing.T) { + decErr := errors.New("boom") + handlers := []struct { + name string + handler func(interface{}, context.Context, func(interface{}) error, grpc.UnaryServerInterceptor) (interface{}, error) + }{ + {"CollectBinance", _TaskService_CollectBinance_Handler}, + {"CollectBinanceByDate", _TaskService_CollectBinanceByDate_Handler}, + {"CollectUniswap", _TaskService_CollectUniswap_Handler}, + {"ProcessPrices", _TaskService_ProcessPrices_Handler}, + {"Analyse", _TaskService_Analyse_Handler}, + } + + for _, tc := range handlers { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.handler(&spyTaskServer{}, context.Background(), func(interface{}) error { + return decErr + }, nil) + require.ErrorIs(t, err, decErr) + }) + } +} + +func TestTaskServiceServiceDescMetadata(t *testing.T) { + require.Equal(t, "task.proto", TaskService_ServiceDesc.Metadata) + var names []string + for _, m := range TaskService_ServiceDesc.Methods { + names = append(names, m.MethodName) + } + require.ElementsMatch(t, []string{ + "CollectBinance", + "CollectBinanceByDate", + "CollectUniswap", + "ProcessPrices", + "Analyse", + }, names) +} + +func TestTaskStatusEnumHelpers(t *testing.T) { + for value, name := range TaskStatus_name { + t.Run(name, func(t *testing.T) { + enum := TaskStatus(value) + require.Equal(t, name, enum.String()) + require.Equal(t, protoreflect.EnumNumber(value), enum.Number()) + require.Equal(t, value, int32(TaskStatus_value[name])) + }) + } + + enum := TaskStatus_RUNNING + ptr := enum.Enum() + require.NotNil(t, ptr) + require.Equal(t, enum, *ptr) + require.NotNil(t, enum.Descriptor()) + require.NotNil(t, enum.Type()) +} + +func TestTaskProtoMessagesRoundTrip(t *testing.T) { + testCases := []struct { + name string + msg proto.Message + }{ + {"CollectBinanceRequest", &CollectBinanceRequest{TaskId: "cb", ImportPercentage: 70, ChunkSize: 5}}, + {"CollectBinanceByDateRequest", &CollectBinanceByDateRequest{TaskId: "cbd", StartTs: 1, EndTs: 2}}, + {"CollectUniswapRequest", &CollectUniswapRequest{TaskId: "cu", PoolAddress: "pool", StartTs: 3, EndTs: 4}}, + {"ProcessPricesRequest", &ProcessPricesRequest{ + TaskId: "pp", + StartDate: 1, + EndDate: 2, + AggregationInterval: "1m", + Overwrite: true, + DbOverrides: map[string]string{"dsn": "postgres"}, + }}, + {"AnalyseRequest", &AnalyseRequest{TaskId: "ar", BatchId: 9, Overwrite: true, StrategyJson: "{}"}}, + {"TaskResponse", &TaskResponse{TaskId: "tr", Status: TaskStatus_SUCCESS}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + data, err := proto.Marshal(tc.msg) + require.NoError(t, err) + + clone := proto.Clone(tc.msg) + require.Equal(t, messageString(tc.msg), messageString(clone)) + require.True(t, proto.Equal(tc.msg, clone)) + require.NotNil(t, tc.msg.ProtoReflect().Descriptor()) + + resetProtoMessage(tc.msg) + _ = messageString(tc.msg) + require.NotNil(t, tc.msg.ProtoReflect().Descriptor()) + + newMsg := proto.Clone(tc.msg) + require.NoError(t, proto.Unmarshal(data, newMsg)) + require.True(t, proto.Equal(clone, newMsg)) + }) + } +} + +func TestTaskProtoNilGetters(t *testing.T) { + var resp *TaskResponse + require.Equal(t, "", resp.GetTaskId()) + require.Equal(t, TaskStatus_WAIT, resp.GetStatus()) + + var analyse *AnalyseRequest + require.Equal(t, "", analyse.GetTaskId()) + require.Equal(t, int32(0), analyse.GetBatchId()) +} + +func TestTaskProtoRawDescCaching(t *testing.T) { + first := file_task_proto_rawDescGZIP() + second := file_task_proto_rawDescGZIP() + require.NotEmpty(t, first) + require.Equal(t, first, second) +} + +func TestTaskProtoGetterDefaultsAndValues(t *testing.T) { + t.Run("CollectBinanceRequest", func(t *testing.T) { + req := &CollectBinanceRequest{TaskId: "cb", ImportPercentage: 80, ChunkSize: 10} + require.Equal(t, "cb", req.GetTaskId()) + require.Equal(t, int32(80), req.GetImportPercentage()) + require.Equal(t, int32(10), req.GetChunkSize()) + + var nilReq *CollectBinanceRequest + require.Equal(t, "", nilReq.GetTaskId()) + require.Equal(t, int32(0), nilReq.GetImportPercentage()) + require.Equal(t, int32(0), nilReq.GetChunkSize()) + }) + + t.Run("CollectBinanceByDateRequest", func(t *testing.T) { + req := &CollectBinanceByDateRequest{TaskId: "cbd", StartTs: 11, EndTs: 22} + require.Equal(t, "cbd", req.GetTaskId()) + require.Equal(t, int32(11), req.GetStartTs()) + require.Equal(t, int32(22), req.GetEndTs()) + + var nilReq *CollectBinanceByDateRequest + require.Equal(t, "", nilReq.GetTaskId()) + require.Equal(t, int32(0), nilReq.GetStartTs()) + require.Equal(t, int32(0), nilReq.GetEndTs()) + }) + + t.Run("CollectUniswapRequest", func(t *testing.T) { + req := &CollectUniswapRequest{TaskId: "cu", PoolAddress: "pool", StartTs: 33, EndTs: 44} + require.Equal(t, "cu", req.GetTaskId()) + require.Equal(t, "pool", req.GetPoolAddress()) + require.Equal(t, int32(33), req.GetStartTs()) + require.Equal(t, int32(44), req.GetEndTs()) + + var nilReq *CollectUniswapRequest + require.Equal(t, "", nilReq.GetTaskId()) + require.Equal(t, "", nilReq.GetPoolAddress()) + require.Equal(t, int32(0), nilReq.GetStartTs()) + require.Equal(t, int32(0), nilReq.GetEndTs()) + }) + + t.Run("ProcessPricesRequest", func(t *testing.T) { + req := &ProcessPricesRequest{ + TaskId: "pp", + StartDate: 1, + EndDate: 2, + AggregationInterval: "1m", + Overwrite: true, + DbOverrides: map[string]string{"dsn": "postgres"}, + } + require.Equal(t, "pp", req.GetTaskId()) + require.Equal(t, int32(1), req.GetStartDate()) + require.Equal(t, int32(2), req.GetEndDate()) + require.Equal(t, "1m", req.GetAggregationInterval()) + require.True(t, req.GetOverwrite()) + require.Equal(t, "postgres", req.GetDbOverrides()["dsn"]) + + var nilReq *ProcessPricesRequest + require.Equal(t, "", nilReq.GetTaskId()) + require.Equal(t, int32(0), nilReq.GetStartDate()) + require.Equal(t, int32(0), nilReq.GetEndDate()) + require.Equal(t, "", nilReq.GetAggregationInterval()) + require.False(t, nilReq.GetOverwrite()) + require.Nil(t, nilReq.GetDbOverrides()) + }) + + t.Run("AnalyseRequest", func(t *testing.T) { + req := &AnalyseRequest{TaskId: "ar", BatchId: 9, Overwrite: true, StrategyJson: "{}"} + require.Equal(t, "ar", req.GetTaskId()) + require.Equal(t, int32(9), req.GetBatchId()) + require.True(t, req.GetOverwrite()) + require.Equal(t, "{}", req.GetStrategyJson()) + + var nilReq *AnalyseRequest + require.Equal(t, "", nilReq.GetTaskId()) + require.Equal(t, int32(0), nilReq.GetBatchId()) + require.False(t, nilReq.GetOverwrite()) + require.Equal(t, "", nilReq.GetStrategyJson()) + }) + + t.Run("TaskResponse", func(t *testing.T) { + req := &TaskResponse{TaskId: "resp", Status: TaskStatus_SUCCESS} + require.Equal(t, "resp", req.GetTaskId()) + require.Equal(t, TaskStatus_SUCCESS, req.GetStatus()) + + var nilReq *TaskResponse + require.Equal(t, "", nilReq.GetTaskId()) + require.Equal(t, TaskStatus_WAIT, nilReq.GetStatus()) + }) +} + +func TestTaskStatusDescriptors(t *testing.T) { + data, idx := TaskStatus_RUNNING.EnumDescriptor() + require.NotEmpty(t, data) + require.Equal(t, []int{0}, idx) + + require.NotNil(t, File_task_proto) + require.Contains(t, File_task_proto.Path(), "task.proto") + require.GreaterOrEqual(t, File_task_proto.Messages().Len(), 6) + require.Equal(t, protoreflect.FullName("task.v1.TaskStatus"), File_task_proto.Enums().Get(0).FullName()) +} + +func TestTaskProtoResetsAndReflection(t *testing.T) { + exerciseProtoMessage(t, "CollectBinanceRequest", + func() *CollectBinanceRequest { + return &CollectBinanceRequest{TaskId: "cb", ImportPercentage: 88, ChunkSize: 7} + }, + func() *CollectBinanceRequest { return &CollectBinanceRequest{} }, + ) + + exerciseProtoMessage(t, "CollectBinanceByDateRequest", + func() *CollectBinanceByDateRequest { + return &CollectBinanceByDateRequest{TaskId: "cbd", StartTs: 11, EndTs: 22} + }, + func() *CollectBinanceByDateRequest { return &CollectBinanceByDateRequest{} }, + ) + + exerciseProtoMessage(t, "CollectUniswapRequest", + func() *CollectUniswapRequest { + return &CollectUniswapRequest{TaskId: "cu", PoolAddress: "pool", StartTs: 33, EndTs: 44} + }, + func() *CollectUniswapRequest { return &CollectUniswapRequest{} }, + ) + + exerciseProtoMessage(t, "ProcessPricesRequest", + func() *ProcessPricesRequest { + return &ProcessPricesRequest{ + TaskId: "pp", + StartDate: 1, + EndDate: 2, + AggregationInterval: "1m", + Overwrite: true, + DbOverrides: map[string]string{"dsn": "postgres"}, + } + }, + func() *ProcessPricesRequest { return &ProcessPricesRequest{} }, + ) + + exerciseProtoMessage(t, "AnalyseRequest", + func() *AnalyseRequest { + return &AnalyseRequest{TaskId: "ar", BatchId: 9, Overwrite: true, StrategyJson: "{}"} + }, + func() *AnalyseRequest { return &AnalyseRequest{} }, + ) + + exerciseProtoMessage(t, "TaskResponse", + func() *TaskResponse { + return &TaskResponse{TaskId: "resp", Status: TaskStatus_SUCCESS} + }, + func() *TaskResponse { return &TaskResponse{} }, + ) +} + +type protoTestSubject interface { + proto.Message + interface { + Reset() + String() string + Descriptor() ([]byte, []int) + ProtoMessage() + } +} + +func exerciseProtoMessage[T protoTestSubject](t *testing.T, name string, populated func() T, zero func() T) { + t.Helper() + t.Run(name, func(t *testing.T) { + msg := populated() + zeroMsg := zero() + require.NotEqual(t, zeroMsg.String(), msg.String()) + msg.Reset() + require.True(t, proto.Equal(msg, zeroMsg)) + msg.ProtoMessage() + require.NotNil(t, msg.ProtoReflect().Descriptor()) + _, idx := msg.Descriptor() + require.NotEmpty(t, idx) + var nilMsg T + require.NotNil(t, nilMsg.ProtoReflect().Descriptor()) + }) +} + +func resetProtoMessage(m proto.Message) { + switch v := m.(type) { + case *CollectBinanceRequest: + v.Reset() + case *CollectBinanceByDateRequest: + v.Reset() + case *CollectUniswapRequest: + v.Reset() + case *ProcessPricesRequest: + v.Reset() + case *AnalyseRequest: + v.Reset() + case *TaskResponse: + v.Reset() + default: + panic("unsupported proto message type") + } +} + +func messageString(m proto.Message) string { + type stringer interface{ String() string } + if s, ok := m.(stringer); ok { + return s.String() + } + return "" +} diff --git a/backend/service/experiment_service_test.go b/backend/service/experiment_service_test.go new file mode 100644 index 0000000..7e33645 --- /dev/null +++ b/backend/service/experiment_service_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "testing" + + "backend/testutil" + + "github.com/stretchr/testify/require" + "gorm.io/datatypes" +) + +func TestExperimentServiceLifecycle(t *testing.T) { + db := testutil.NewInMemoryDB(t) + ctx := context.Background() + + taskManager := NewTaskManager(db) + templateService := NewTemplateService(db, taskManager, nil) + batchService := NewBatchService(db) + expService := NewExperimentService(db, templateService, taskManager) + + batch, err := batchService.CreateBatch(ctx, "Batch", "Desc") + require.NoError(t, err) + + template, err := templateService.CreateTemplate(ctx, "Analyse", "analyse", datatypes.JSONMap{"window": 5}) + require.NoError(t, err) + + exp, err := expService.CreateExperiment(ctx, batch.ID, "first exp") + require.NoError(t, err) + require.Equal(t, batch.ID, exp.BatchID) + + experiments, err := expService.ListExperiments(ctx, batch.ID) + require.NoError(t, err) + require.Len(t, experiments, 1) + + fetched, err := expService.GetExperiment(ctx, exp.ID) + require.NoError(t, err) + require.Equal(t, exp.ID, fetched.ID) + + run, task, err := expService.RunTemplateInExperiment(ctx, exp.ID, template.ID, map[string]interface{}{"foo": "bar"}, "", "trigger") + require.NoError(t, err) + require.NotNil(t, run) + require.NotNil(t, task) + require.Equal(t, exp.ID, run.ExperimentID) + require.Equal(t, batch.ID, task.ConfigJSON["batch_id"]) + require.Equal(t, exp.ID, task.ConfigJSON["experiment_id"]) + + runs, err := expService.ListRuns(ctx, exp.ID) + require.NoError(t, err) + require.Len(t, runs, 1) +} + +func TestExperimentServiceValidation(t *testing.T) { + db := testutil.NewInMemoryDB(t) + ctx := context.Background() + + expService := NewExperimentService(db, nil, nil) + _, _, err := expService.RunTemplateInExperiment(ctx, 0, 0, nil, "", "") + require.Error(t, err) + + _, err = expService.ListRuns(ctx, 0) + require.Error(t, err) +} diff --git a/backend/service/report_service_test.go b/backend/service/report_service_test.go new file mode 100644 index 0000000..41ac9bf --- /dev/null +++ b/backend/service/report_service_test.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "backend/models" + "backend/testutil" + + "github.com/johnfercher/maroto/pkg/consts" + "github.com/johnfercher/maroto/pkg/pdf" + "github.com/stretchr/testify/require" +) + +func TestReportServiceLifecycle(t *testing.T) { + db := testutil.NewInMemoryDB(t) + svc := NewReportService(db) + ctx := context.Background() + + // seed data for DB-backed branch + require.NoError(t, db.Create(&models.ArbitrageOpportunity{ + BatchID: 1, + BuyPlatform: "Binance", + SellPlatform: "Uniswap", + BuyPrice: 1000, + SellPrice: 1015, + ProfitUSDT: 15, + CreatedAt: time.Now(), + }).Error) + + report, err := svc.CreateReport(ctx, 1, 2, "pdf", "") + require.NoError(t, err) + + wd := t.TempDir() + cwd, _ := os.Getwd() + require.NoError(t, os.Chdir(wd)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + require.NoError(t, svc.GenerateReportFile(report.ID, 1, "pdf")) + stored, err := svc.GetReport(ctx, report.ID) + require.NoError(t, err) + require.Equal(t, "SUCCESS", stored.Status) + require.FileExists(t, stored.FilePath) + + reports, err := svc.ListReports(ctx, 1) + require.NoError(t, err) + require.Len(t, reports, 1) + + require.NoError(t, svc.DeleteReport(ctx, report.ID)) + reports, err = svc.ListReports(ctx, 1) + require.NoError(t, err) + require.Len(t, reports, 0) +} + +func TestGenerateReportUsesMockData(t *testing.T) { + db := testutil.NewInMemoryDB(t) + svc := NewReportService(db) + ctx := context.Background() + + report, err := svc.CreateReport(ctx, 99, 0, "pdf", "") + require.NoError(t, err) + + wd := t.TempDir() + cwd, _ := os.Getwd() + require.NoError(t, os.Chdir(wd)) + defer func() { _ = os.Chdir(cwd) }() + + require.NoError(t, svc.GenerateReportFile(report.ID, 99, "pdf")) + stored, err := svc.GetReport(ctx, report.ID) + require.NoError(t, err) + require.Equal(t, "SUCCESS", stored.Status) +} + +func TestReportHelpers(t *testing.T) { + require.Equal(t, "$1.23", safeFloat(1.23)) + require.Equal(t, "$1.23", safeFloat(float32(1.23))) + require.Equal(t, "value", safeFloat("value")) + + data := (&ReportService{}).generateMockData(2) + require.Len(t, data, 2) + + m := pdf.NewMaroto(consts.Portrait, consts.A4) + (&ReportService{}).renderCard(m, "title", "value") + + gray := getGrayColor() + require.EqualValues(t, 100, gray.Red) + + tmpDir := t.TempDir() + db := testutil.NewInMemoryDB(t) + svc := NewReportService(db) + ctx := context.Background() + report, err := svc.CreateReport(ctx, 1, 1, "pdf", "") + require.NoError(t, err) + cwd, _ := os.Getwd() + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(cwd) }) + require.NoError(t, svc.updateStatus(report.ID, "SUCCESS", filepath.Join(tmpDir, "file.pdf"))) +} diff --git a/backend/service/service_test.go b/backend/service/service_test.go index 2f0ffa8..7f4c073 100644 --- a/backend/service/service_test.go +++ b/backend/service/service_test.go @@ -68,3 +68,25 @@ func TestGetPriceComparisonDataFiltersWindowAndGroupsBySource(t *testing.T) { assert.Equal(t, rows[0].TimeBucket.UnixMilli(), data["binance"][0][0]) assert.Equal(t, rows[1].TimeBucket.UnixMilli(), data["uniswap"][0][0]) } + +func TestGetOpportunitiesPropagatesDBError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + svc := NewService(db) + sqlDB, err := svc.DB().DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + _, _, err = svc.GetOpportunities(1, 1, "profit_usdt", "asc") + require.Error(t, err) +} + +func TestGetPriceComparisonDataPropagatesDBError(t *testing.T) { + db := testutil.NewInMemoryDB(t) + svc := NewService(db) + sqlDB, err := svc.DB().DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + _, err = svc.GetPriceComparisonData(0, 10) + require.Error(t, err) +} diff --git a/backend/service/task_dispatcher_test.go b/backend/service/task_dispatcher_test.go new file mode 100644 index 0000000..2bbb95e --- /dev/null +++ b/backend/service/task_dispatcher_test.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "backend/models" + "backend/pkg/taskpb" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "gorm.io/datatypes" +) + +type stubTaskClient struct { + err error + binanceReq *taskpb.CollectBinanceRequest + uniswapReq *taskpb.CollectUniswapRequest + pricesReq *taskpb.ProcessPricesRequest + analyseReq *taskpb.AnalyseRequest + lastMethod string +} + +func (s *stubTaskClient) CollectBinance(ctx context.Context, req *taskpb.CollectBinanceRequest, _ ...grpc.CallOption) (*taskpb.TaskResponse, error) { + s.lastMethod = "binance" + s.binanceReq = req + return &taskpb.TaskResponse{TaskId: req.GetTaskId()}, s.err +} + +func (s *stubTaskClient) CollectBinanceByDate(ctx context.Context, req *taskpb.CollectBinanceByDateRequest, _ ...grpc.CallOption) (*taskpb.TaskResponse, error) { + s.lastMethod = "binance-by-date" + return &taskpb.TaskResponse{TaskId: req.GetTaskId()}, s.err +} + +func (s *stubTaskClient) CollectUniswap(ctx context.Context, req *taskpb.CollectUniswapRequest, _ ...grpc.CallOption) (*taskpb.TaskResponse, error) { + s.lastMethod = "uniswap" + s.uniswapReq = req + return &taskpb.TaskResponse{TaskId: req.GetTaskId()}, s.err +} + +func (s *stubTaskClient) ProcessPrices(ctx context.Context, req *taskpb.ProcessPricesRequest, _ ...grpc.CallOption) (*taskpb.TaskResponse, error) { + s.lastMethod = "prices" + s.pricesReq = req + return &taskpb.TaskResponse{TaskId: req.GetTaskId()}, s.err +} + +func (s *stubTaskClient) Analyse(ctx context.Context, req *taskpb.AnalyseRequest, _ ...grpc.CallOption) (*taskpb.TaskResponse, error) { + s.lastMethod = "analyse" + s.analyseReq = req + return &taskpb.TaskResponse{TaskId: req.GetTaskId()}, s.err +} + +func TestDispatcherValidation(t *testing.T) { + dispatcher := NewGRPCTaskDispatcher(nil) + require.Error(t, dispatcher.Dispatch(context.Background(), &models.Task{})) + + dispatcher = &GRPCTaskDispatcher{} + require.EqualError(t, dispatcher.Dispatch(context.Background(), nil), "task is nil") +} + +func TestDispatcherDispatchesByType(t *testing.T) { + client := &stubTaskClient{} + dispatcher := NewGRPCTaskDispatcher(client) + ctx := context.Background() + + binanceTask := &models.Task{TaskID: "bin", Type: "collect_binance", ConfigJSON: datatypes.JSONMap{"import_percentage": 0, "chunk_size": 0}} + require.NoError(t, dispatcher.Dispatch(ctx, binanceTask)) + require.Equal(t, "binance", client.lastMethod) + require.EqualValues(t, 100, client.binanceReq.GetImportPercentage()) + require.EqualValues(t, 1000000, client.binanceReq.GetChunkSize()) + + uniswapTask := &models.Task{TaskID: "uni", Type: "collect_uniswap", ConfigJSON: datatypes.JSONMap{"pool_address": "pool", "start_ts": 1, "end_ts": 2}} + require.NoError(t, dispatcher.Dispatch(ctx, uniswapTask)) + require.Equal(t, "uniswap", client.lastMethod) + require.Equal(t, "pool", client.uniswapReq.GetPoolAddress()) + + startISO := time.Unix(1, 0).UTC().Format(time.RFC3339) + endISO := "2024-01-02T03:04:05" + pricesTask := &models.Task{TaskID: "prices", Type: "process_prices", ConfigJSON: datatypes.JSONMap{ + "start_date": startISO, + "end_date": endISO, + "aggregation_interval": "1h", + "overwrite": true, + "db_overrides": map[string]interface{}{"dsn": "postgres"}, + }} + require.NoError(t, dispatcher.Dispatch(ctx, pricesTask)) + require.Equal(t, "prices", client.pricesReq.GetTaskId()) + require.Equal(t, int32(1), client.pricesReq.GetStartDate()) + expectedEnd := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC).Unix() + require.Equal(t, int32(expectedEnd), client.pricesReq.GetEndDate()) + + strategy := map[string]interface{}{"strategy": map[string]interface{}{"window": 5}, "experiment_id": 9} + analyseTask := &models.Task{TaskID: "analyse", Type: "analyse", ConfigJSON: datatypes.JSONMap(strategy)} + require.NoError(t, dispatcher.Dispatch(ctx, analyseTask)) + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(client.analyseReq.GetStrategyJson()), &payload)) + require.EqualValues(t, 9, payload["experiment_id"]) + + unsupported := &models.Task{TaskID: "x", Type: "unknown", ConfigJSON: datatypes.JSONMap{}} + require.EqualError(t, dispatcher.Dispatch(ctx, unsupported), "unsupported task type unknown") +} + +func TestDispatcherProcessPricesWithStringOverrides(t *testing.T) { + client := &stubTaskClient{} + dispatcher := &GRPCTaskDispatcher{client: client, timeout: time.Second} + task := &models.Task{TaskID: "pp-map", Type: "process_prices", ConfigJSON: datatypes.JSONMap{ + "start_date": int64(10), + "end_date": int64(20), + "aggregation_interval": "5m", + "db_overrides": map[string]string{"dsn": "memory"}, + }} + require.NoError(t, dispatcher.Dispatch(context.Background(), task)) + require.Equal(t, "memory", client.pricesReq.GetDbOverrides()["dsn"]) +} + +func TestHelperConversions(t *testing.T) { + require.Equal(t, int64(42), asInt64("42")) + require.Equal(t, int64(0), asInt64("bad")) + require.True(t, asBool("true")) + require.False(t, asBool(0)) + require.True(t, asBool(int64(2))) + require.True(t, asBool(float32(0.5))) + require.Equal(t, int64(1700000000), parseTimeOrInt("2023-11-14T22:13:20")) + require.Equal(t, int64(1700003600), parseTimeOrInt("2023-11-14T23:13:20")) + require.Equal(t, int64(99), parseTimeOrInt(int64(99))) +} diff --git a/backend/service/task_manager_test.go b/backend/service/task_manager_test.go new file mode 100644 index 0000000..540655f --- /dev/null +++ b/backend/service/task_manager_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "backend/models" + "backend/testutil" + + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func newTaskManager(t *testing.T) *TaskManager { + t.Helper() + return NewTaskManager(testutil.NewInMemoryDB(t)) +} + +func TestTaskManagerCreateAndQuery(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + + task, err := mgr.CreateTask(ctx, "collect_binance", "task-1", "trigger", datatypes.JSONMap{"import_percentage": 10}) + require.NoError(t, err) + require.Equal(t, "task-1", task.TaskID) + + fetched, err := mgr.GetTaskByExternalID(ctx, "task-1") + require.NoError(t, err) + require.Equal(t, task.ID, fetched.ID) + + tasks, total, err := mgr.ListTasks(ctx, 0, 0) + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Len(t, tasks, 1) +} + +func TestTaskManagerLogsAndCancel(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + + task, err := mgr.CreateTask(ctx, "collect_binance", "task-logs", "trigger", datatypes.JSONMap{}) + require.NoError(t, err) + + require.Error(t, mgr.AddTaskLog(ctx, 0, "INFO", "boom")) + require.NoError(t, mgr.AddTaskLog(ctx, task.ID, "INFO", "ready")) + require.NoError(t, mgr.AddTaskLog(ctx, task.ID, "INFO", "go")) + + logs, err := mgr.ListTaskLogs(ctx, task.ID, -1, 0) + require.NoError(t, err) + require.Len(t, logs, 2) + + updated, err := mgr.CancelTaskByExternalID(ctx, "task-logs", "user request") + require.NoError(t, err) + require.Equal(t, "CANCELLED", updated.Status) + require.Contains(t, updated.LogSummary, "user request") + + // Calling cancel again should return the same task without error because status is terminal. + again, err := mgr.CancelTaskByExternalID(ctx, "task-logs", "ignored") + require.NoError(t, err) + require.Equal(t, updated.ID, again.ID) +} + +func TestTaskManagerCancelRequiresID(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + + _, err := mgr.CancelTaskByExternalID(ctx, "", "") + require.Error(t, err) +} + +func TestEncodeMergeAndFormatHelpers(t *testing.T) { + cfg := EncodeConfig(map[string]interface{}{"key": "value"}) + require.Equal(t, "value", cfg["key"]) + + merged := MergeConfig(cfg, map[string]interface{}{"key": "new", "extra": 1}) + require.Equal(t, "new", merged["key"]) + require.Equal(t, 1, merged["extra"]) + + err := FormatDBError(gorm.ErrRecordNotFound, "missing task") + require.True(t, errors.Is(err, gorm.ErrRecordNotFound)) + + generic := FormatDBError(errors.New("boom"), "wrap") + require.Error(t, generic) +} + +func TestTaskManagerCreateTaskRequiresType(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + _, err := mgr.CreateTask(ctx, "", "", "", datatypes.JSONMap{}) + require.EqualError(t, err, "task type is required") +} + +func TestTaskManagerListTasksClampsLimit(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + for i := 0; i < 30; i++ { + _, err := mgr.CreateTask(ctx, "collect", fmt.Sprintf("task-%d", i), "trigger", datatypes.JSONMap{}) + require.NoError(t, err) + } + + list, total, err := mgr.ListTasks(ctx, -5, 10_000) + require.NoError(t, err) + require.Equal(t, int64(30), total) + require.LessOrEqual(t, len(list), 20) +} + +func TestTaskManagerListTaskLogsClampsLimit(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + task, err := mgr.CreateTask(ctx, "collect", "loggy", "trigger", datatypes.JSONMap{}) + require.NoError(t, err) + require.NoError(t, mgr.AddTaskLog(ctx, task.ID, "INFO", "first")) + require.NoError(t, mgr.AddTaskLog(ctx, task.ID, "INFO", "second")) + + logs, err := mgr.ListTaskLogs(ctx, task.ID, 9999, 0) + require.NoError(t, err) + require.Len(t, logs, 2) +} + +func TestCancelTaskByExternalIDSkipsTerminalStatuses(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + finished := &models.Task{TaskID: "finished", Type: "collect", Status: "SUCCESS"} + require.NoError(t, mgr.db.WithContext(ctx).Create(finished).Error) + + result, err := mgr.CancelTaskByExternalID(ctx, "finished", "ignored") + require.NoError(t, err) + require.Equal(t, "SUCCESS", result.Status) +} + +func TestCancelTaskByExternalIDUsesStartedAtDuration(t *testing.T) { + ctx := context.Background() + mgr := newTaskManager(t) + started := time.Now().Add(-3 * time.Minute) + entry := &models.Task{TaskID: "inflight", Type: "collect", Status: "RUNNING", StartedAt: &started} + require.NoError(t, mgr.db.WithContext(ctx).Create(entry).Error) + + result, err := mgr.CancelTaskByExternalID(ctx, "inflight", "maintenance") + require.NoError(t, err) + require.Equal(t, "CANCELLED", result.Status) + require.Contains(t, result.LogSummary, "maintenance") + require.Greater(t, result.DurationSeconds, int64(0)) +} diff --git a/backend/service/template_service_test.go b/backend/service/template_service_test.go new file mode 100644 index 0000000..8bc8249 --- /dev/null +++ b/backend/service/template_service_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "backend/models" + "backend/testutil" + + "github.com/stretchr/testify/require" + "gorm.io/datatypes" +) + +type stubDispatcher struct { + lastTask *models.Task + callCount int + returnErr error +} + +func (s *stubDispatcher) Dispatch(ctx context.Context, task *models.Task) error { + s.callCount++ + s.lastTask = task + return s.returnErr +} + +func TestTemplateServiceCRUDAndRun(t *testing.T) { + db := testutil.NewInMemoryDB(t) + ctx := context.Background() + taskManager := NewTaskManager(db) + dispatcher := &stubDispatcher{} + svc := NewTemplateService(db, taskManager, dispatcher) + + tpl, err := svc.CreateTemplate(ctx, "Analyse Alpha", "analyse", datatypes.JSONMap{"foo": "bar"}) + require.NoError(t, err) + require.NotZero(t, tpl.ID) + + fetched, err := svc.GetTemplate(ctx, tpl.ID) + require.NoError(t, err) + require.Equal(t, tpl.ID, fetched.ID) + + updated, err := svc.UpdateTemplate(ctx, tpl.ID, "Updated", "", nil) + require.NoError(t, err) + require.Equal(t, "Updated", updated.Name) + + templates, err := svc.ListTemplates(ctx) + require.NoError(t, err) + require.Len(t, templates, 1) + + task, err := svc.RunTemplate(ctx, tpl.ID, map[string]interface{}{"extra": 1}, "", "unit-test") + require.NoError(t, err) + require.NotEmpty(t, task.TaskID) + require.Equal(t, "analyse", task.Type) + require.Equal(t, "unit-test", task.Trigger) + require.True(t, dispatcher.callCount > 0) + require.Contains(t, dispatcher.lastTask.ConfigJSON, "batch_id") + require.Equal(t, 1, dispatcher.lastTask.ConfigJSON["extra"]) + + dispatcher.returnErr = errors.New("dispatch failed") + _, err = svc.RunTemplate(ctx, tpl.ID, map[string]interface{}{}, "manual-id", "unit-test") + require.Error(t, err) + + require.NoError(t, svc.DeleteTemplate(ctx, tpl.ID)) +} + +func TestShouldAutoCreateBatchCases(t *testing.T) { + cases := []struct { + name string + value interface{} + expected bool + }{ + {"nil value", nil, true}, + {"empty string", " ", true}, + {"negative string", "-1", true}, + {"bad string", "abc", false}, + {"zero float", float64(0), true}, + {"positive int", 2, false}, + {"zero uint", uint(0), true}, + {"json number zero", json.Number("0"), true}, + {"json parse error", json.Number("bad"), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, shouldAutoCreateBatch(tc.value)) + }) + } +} + +func TestBatchServiceCRUD(t *testing.T) { + db := testutil.NewInMemoryDB(t) + ctx := context.Background() + svc := NewBatchService(db) + + batch, err := svc.CreateBatch(ctx, "Batch A", "desc") + require.NoError(t, err) + + updated, err := svc.UpdateBatch(ctx, batch.ID, "Batch B", "updated", true) + require.NoError(t, err) + require.Equal(t, "Batch B", updated.Name) + require.NotNil(t, updated.LastRefreshedAt) + + batches, err := svc.ListBatches(ctx) + require.NoError(t, err) + require.Len(t, batches, 1) + + got, err := svc.GetBatch(ctx, batch.ID) + require.NoError(t, err) + require.Equal(t, batch.ID, got.ID) +} diff --git a/backend/storage/reports/Report_Batch1_1764410803.pdf b/backend/storage/reports/Report_Batch1_1764410803.pdf deleted file mode 100644 index 3f9ecb9..0000000 Binary files a/backend/storage/reports/Report_Batch1_1764410803.pdf and /dev/null differ diff --git a/backend/testutil/testdb.go b/backend/testutil/testdb.go index aab98f4..999a20d 100644 --- a/backend/testutil/testdb.go +++ b/backend/testutil/testdb.go @@ -1,28 +1,106 @@ package testutil import ( + "fmt" + "net" + "path/filepath" "testing" "backend/models" - "github.com/glebarez/sqlite" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + postgresdriver "gorm.io/driver/postgres" "gorm.io/gorm" ) -// NewInMemoryDB creates a transient SQLite database for tests. +type embeddedDatabase interface { + Start() error + Stop() error +} + +type embeddedWrapper struct { + inner *embeddedpostgres.EmbeddedPostgres +} + +func (w *embeddedWrapper) Start() error { + return w.inner.Start() +} + +func (w *embeddedWrapper) Stop() error { + return w.inner.Stop() +} + +var ( + listenEphemeral = func() (net.Listener, error) { + return net.Listen("tcp", "127.0.0.1:0") + } + newEmbeddedInstance = func(cfg embeddedpostgres.Config) embeddedDatabase { + return &embeddedWrapper{embeddedpostgres.NewDatabase(cfg)} + } + gormOpen = func(dialector gorm.Dialector, opts ...gorm.Option) (*gorm.DB, error) { + return gorm.Open(dialector, opts...) + } + autoMigrateFn = func(db *gorm.DB, models ...interface{}) error { + return db.AutoMigrate(models...) + } + fatalf = func(t *testing.T, format string, args ...interface{}) { + t.Fatalf(format, args...) + } +) + +// NewInMemoryDB spins up an ephemeral PostgreSQL instance for isolated tests. func NewInMemoryDB(t *testing.T) *gorm.DB { t.Helper() - db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + ln, err := listenEphemeral() + if err != nil { + fatalf(t, "failed to allocate port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + + rootDir := t.TempDir() + runtimePath := filepath.Join(rootDir, "runtime") + binariesPath := filepath.Join(rootDir, "binaries") + dataPath := filepath.Join(rootDir, "data") + + cfg := embeddedpostgres.DefaultConfig(). + Username("postgres"). + Password("postgres"). + Database("testdb"). + Port(uint32(port)). + Locale("C"). + RuntimePath(runtimePath). + DataPath(dataPath). + BinariesPath(binariesPath) + pg := newEmbeddedInstance(cfg) + + if err := pg.Start(); err != nil { + fatalf(t, "failed to start embedded postgres: %v", err) + } + + dsn := fmt.Sprintf("host=127.0.0.1 user=postgres password=postgres dbname=testdb port=%d sslmode=disable", port) + + db, err := gormOpen(postgresdriver.Open(dsn), &gorm.Config{}) if err != nil { - t.Fatalf("failed to open in-memory database: %v", err) + _ = pg.Stop() + fatalf(t, "failed to open postgres database: %v", err) } - if err := db.AutoMigrate( + modelsToMigrate := []interface{}{ &models.ArbitrageOpportunity{}, &models.AggregatedPrice{}, - ); err != nil { - t.Fatalf("failed to migrate schema: %v", err) + &models.Task{}, + &models.TaskLog{}, + &models.ParamTemplate{}, + &models.Batch{}, + &models.Report{}, + &models.Experiment{}, + &models.ExperimentRun{}, + } + if err := autoMigrateFn(db, modelsToMigrate...); err != nil { + _ = pg.Stop() + fatalf(t, "failed to migrate schema: %v", err) } t.Cleanup(func() { @@ -30,6 +108,7 @@ func NewInMemoryDB(t *testing.T) *gorm.DB { if sqlDB != nil { _ = sqlDB.Close() } + _ = pg.Stop() }) return db diff --git a/backend/testutil/testdb_test.go b/backend/testutil/testdb_test.go new file mode 100644 index 0000000..8c0af65 --- /dev/null +++ b/backend/testutil/testdb_test.go @@ -0,0 +1,128 @@ +package testutil + +import ( + "errors" + "fmt" + "net" + "testing" + + "backend/models" + + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func TestNewInMemoryDBCreatesPostgresInstance(t *testing.T) { + db := NewInMemoryDB(t) + + require.NoError(t, db.Exec("SELECT 1").Error) + + task := &models.Task{ + TaskID: "unit", + Type: "test", + Status: "RUNNING", + ConfigJSON: datatypes.JSONMap{"k": "v"}, + } + require.NoError(t, db.Create(task).Error) + require.NotZero(t, task.ID) +} + +func TestNewInMemoryDBFailureBranches(t *testing.T) { + expectFatal(t, "listener failure", "failed to allocate port: boom", func(st *testing.T) { + patchListener(st, errors.New("boom")) + }) + + startFake := &fakeEmbedded{startErr: errors.New("start-fail")} + expectFatal(t, "postgres start failure", "failed to start embedded postgres: start-fail", func(st *testing.T) { + patchEmbedded(st, startFake) + }) + require.Equal(t, 1, startFake.startCalls) + require.Equal(t, 0, startFake.stopCalls) + + gormFake := &fakeEmbedded{} + expectFatal(t, "gorm open failure", "failed to open postgres database: open-fail", func(st *testing.T) { + patchEmbedded(st, gormFake) + patchGormOpen(st, errors.New("open-fail")) + }) + require.Equal(t, 1, gormFake.startCalls) + require.Equal(t, 1, gormFake.stopCalls) + + expectFatal(t, "auto migrate failure", "failed to migrate schema: migrate-fail", func(st *testing.T) { + patchAutoMigrate(st, errors.New("migrate-fail")) + }) +} + +func expectFatal(t *testing.T, name, expectMsg string, setup func(st *testing.T)) { + t.Helper() + t.Run(name, func(st *testing.T) { + patchFatal(st) + setup(st) + require.PanicsWithValue(st, expectMsg, func() { + NewInMemoryDB(st) + }) + }) +} + +type fakeEmbedded struct { + startErr error + stopErr error + startCalls int + stopCalls int +} + +func (f *fakeEmbedded) Start() error { + f.startCalls++ + return f.startErr +} + +func (f *fakeEmbedded) Stop() error { + f.stopCalls++ + return f.stopErr +} + +func patchListener(t *testing.T, err error) { + t.Helper() + original := listenEphemeral + listenEphemeral = func() (net.Listener, error) { + return nil, err + } + t.Cleanup(func() { listenEphemeral = original }) +} + +func patchEmbedded(t *testing.T, fake embeddedDatabase) { + t.Helper() + original := newEmbeddedInstance + newEmbeddedInstance = func(cfg embeddedpostgres.Config) embeddedDatabase { + return fake + } + t.Cleanup(func() { newEmbeddedInstance = original }) +} + +func patchFatal(t *testing.T) { + t.Helper() + original := fatalf + fatalf = func(tb *testing.T, format string, args ...interface{}) { + panic(fmt.Sprintf(format, args...)) + } + t.Cleanup(func() { fatalf = original }) +} + +func patchGormOpen(t *testing.T, err error) { + t.Helper() + original := gormOpen + gormOpen = func(gorm.Dialector, ...gorm.Option) (*gorm.DB, error) { + return nil, err + } + t.Cleanup(func() { gormOpen = original }) +} + +func patchAutoMigrate(t *testing.T, err error) { + t.Helper() + original := autoMigrateFn + autoMigrateFn = func(*gorm.DB, ...interface{}) error { + return err + } + t.Cleanup(func() { autoMigrateFn = original }) +} diff --git a/backend/utils/response_test.go b/backend/utils/response_test.go new file mode 100644 index 0000000..41a522f --- /dev/null +++ b/backend/utils/response_test.go @@ -0,0 +1,86 @@ +package utils + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func newTestContext() (*gin.Context, *httptest.ResponseRecorder) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + return c, w +} + +func TestSuccessWritesPayload(t *testing.T) { + c, w := newTestContext() + + Success(c, gin.H{"foo": "bar"}) + + require.Equal(t, http.StatusOK, w.Code) + var resp Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, http.StatusOK, resp.Code) + require.Equal(t, "成功", resp.Message) +} + +func TestSuccessWithMessageOverridesDefault(t *testing.T) { + c, w := newTestContext() + + SuccessWithMessage(c, "ok", gin.H{"foo": "bar"}) + + require.Equal(t, http.StatusOK, w.Code) + var resp Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, "ok", resp.Message) +} + +func TestFailHelpers(t *testing.T) { + t.Run("fail", func(t *testing.T) { + c, w := newTestContext() + Fail(c, http.StatusTeapot, "nope") + + require.Equal(t, http.StatusTeapot, w.Code) + var resp Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, "nope", resp.Message) + }) + + t.Run("bad request default", func(t *testing.T) { + c, w := newTestContext() + BadRequest(c, "") + + require.Equal(t, http.StatusBadRequest, w.Code) + var resp Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, "请求参数错误", resp.Message) + }) + + t.Run("server error custom", func(t *testing.T) { + c, w := newTestContext() + ServerError(c, "boom") + + require.Equal(t, http.StatusInternalServerError, w.Code) + var resp Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, "boom", resp.Message) + }) + + t.Run("server error default", func(t *testing.T) { + c, w := newTestContext() + ServerError(c, "") + + require.Equal(t, http.StatusInternalServerError, w.Code) + var resp Response + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, "服务器内部错误", resp.Message) + }) +}