feat(testutil): add MockSlurm, MockMinIO, TestEnv and 37 integration tests

- mockminio: in-memory ObjectStorage with all 11 methods, thread-safe, SHA256 ETag, Range support
- mockslurm: httptest server with 11 Slurm REST API endpoints, job eviction from active to history queue
- testenv: one-line test environment factory (SQLite + MockSlurm + MockMinIO + all stores/services/handlers + httptest server)
- integration tests: 37 tests covering Jobs(5), Cluster(5), App(6), Upload(5), File(4), Folder(4), Task(4), E2E(1)
- no external dependencies, no existing files modified
This commit is contained in:
dailz
2026-04-16 13:23:27 +08:00
parent 73504f9fdb
commit b9b2f0d9b4
16 changed files with 4685 additions and 0 deletions

View File

@@ -0,0 +1,257 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"gcy_hpc_server/internal/testutil/testenv"
)
// appListData mirrors the list endpoint response data structure.
type appListData struct {
Applications []json.RawMessage `json:"applications"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// appCreatedData mirrors the create endpoint response data structure.
type appCreatedData struct {
ID int64 `json:"id"`
}
// appMessageData mirrors the update/delete endpoint response data structure.
type appMessageData struct {
Message string `json:"message"`
}
// appData mirrors the application model returned by GET.
type appData struct {
ID int64 `json:"id"`
Name string `json:"name"`
ScriptTemplate string `json:"script_template"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// appDoRequest is a small wrapper that marshals body and calls env.DoRequest.
func appDoRequest(env *testenv.TestEnv, method, path string, body interface{}) *http.Response {
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("appDoRequest marshal: %v", err))
}
r = bytes.NewReader(b)
}
return env.DoRequest(method, path, r)
}
// appDecodeAll decodes the response and also reads the HTTP status.
func appDecodeAll(env *testenv.TestEnv, resp *http.Response) (statusCode int, success bool, data json.RawMessage, err error) {
statusCode = resp.StatusCode
success, data, err = env.DecodeResponse(resp)
return
}
// appSeedApp creates an app via the service (bypasses HTTP) and returns its ID.
func appSeedApp(env *testenv.TestEnv, name string) int64 {
id, err := env.CreateApp(name, "#!/bin/bash\necho hello", json.RawMessage(`[]`))
if err != nil {
panic(fmt.Sprintf("appSeedApp: %v", err))
}
return id
}
// TestIntegration_App_List verifies GET /api/v1/applications returns an empty list initially.
func TestIntegration_App_List(t *testing.T) {
env := testenv.NewTestEnv(t)
resp := env.DoRequest(http.MethodGet, "/api/v1/applications", nil)
status, success, data, err := appDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var list appListData
if err := json.Unmarshal(data, &list); err != nil {
t.Fatalf("unmarshal list data: %v", err)
}
if list.Total != 0 {
t.Fatalf("expected total=0, got %d", list.Total)
}
if len(list.Applications) != 0 {
t.Fatalf("expected 0 applications, got %d", len(list.Applications))
}
}
// TestIntegration_App_Create verifies POST /api/v1/applications creates an application.
func TestIntegration_App_Create(t *testing.T) {
env := testenv.NewTestEnv(t)
body := map[string]interface{}{
"name": "test-app-create",
"script_template": "#!/bin/bash\necho hello",
"parameters": []interface{}{},
}
resp := appDoRequest(env, http.MethodPost, "/api/v1/applications", body)
status, success, data, err := appDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusCreated {
t.Fatalf("expected status 201, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var created appCreatedData
if err := json.Unmarshal(data, &created); err != nil {
t.Fatalf("unmarshal created data: %v", err)
}
if created.ID <= 0 {
t.Fatalf("expected positive id, got %d", created.ID)
}
}
// TestIntegration_App_Get verifies GET /api/v1/applications/:id returns the correct application.
func TestIntegration_App_Get(t *testing.T) {
env := testenv.NewTestEnv(t)
id := appSeedApp(env, "test-app-get")
path := fmt.Sprintf("/api/v1/applications/%d", id)
resp := env.DoRequest(http.MethodGet, path, nil)
status, success, data, err := appDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var app appData
if err := json.Unmarshal(data, &app); err != nil {
t.Fatalf("unmarshal app data: %v", err)
}
if app.ID != id {
t.Fatalf("expected id=%d, got %d", id, app.ID)
}
if app.Name != "test-app-get" {
t.Fatalf("expected name=test-app-get, got %s", app.Name)
}
}
// TestIntegration_App_Update verifies PUT /api/v1/applications/:id updates an application.
func TestIntegration_App_Update(t *testing.T) {
env := testenv.NewTestEnv(t)
id := appSeedApp(env, "test-app-update-before")
newName := "test-app-update-after"
body := map[string]interface{}{
"name": newName,
}
path := fmt.Sprintf("/api/v1/applications/%d", id)
resp := appDoRequest(env, http.MethodPut, path, body)
status, success, data, err := appDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var msg appMessageData
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal message data: %v", err)
}
if msg.Message != "application updated" {
t.Fatalf("expected message 'application updated', got %q", msg.Message)
}
getResp := env.DoRequest(http.MethodGet, path, nil)
_, _, getData, gErr := appDecodeAll(env, getResp)
if gErr != nil {
t.Fatalf("decode get response: %v", gErr)
}
var updated appData
if err := json.Unmarshal(getData, &updated); err != nil {
t.Fatalf("unmarshal updated app: %v", err)
}
if updated.Name != newName {
t.Fatalf("expected updated name=%q, got %q", newName, updated.Name)
}
}
// TestIntegration_App_Delete verifies DELETE /api/v1/applications/:id removes an application.
func TestIntegration_App_Delete(t *testing.T) {
env := testenv.NewTestEnv(t)
id := appSeedApp(env, "test-app-delete")
path := fmt.Sprintf("/api/v1/applications/%d", id)
resp := env.DoRequest(http.MethodDelete, path, nil)
status, success, data, err := appDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var msg appMessageData
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal message data: %v", err)
}
if msg.Message != "application deleted" {
t.Fatalf("expected message 'application deleted', got %q", msg.Message)
}
// Verify deletion returns 404.
getResp := env.DoRequest(http.MethodGet, path, nil)
getStatus, getSuccess, _, _ := appDecodeAll(env, getResp)
if getStatus != http.StatusNotFound {
t.Fatalf("expected status 404 after delete, got %d", getStatus)
}
if getSuccess {
t.Fatal("expected success=false after delete")
}
}
// TestIntegration_App_CreateValidation verifies POST /api/v1/applications with empty name returns error.
func TestIntegration_App_CreateValidation(t *testing.T) {
env := testenv.NewTestEnv(t)
body := map[string]interface{}{
"name": "",
"script_template": "#!/bin/bash\necho hello",
}
resp := appDoRequest(env, http.MethodPost, "/api/v1/applications", body)
status, success, _, err := appDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", status)
}
if success {
t.Fatal("expected success=false for validation error")
}
}

View File

@@ -0,0 +1,186 @@
package main
import (
"encoding/json"
"net/http"
"testing"
"gcy_hpc_server/internal/testutil/testenv"
)
// clusterNodeData mirrors the NodeResponse DTO returned by the API.
type clusterNodeData struct {
Name string `json:"name"`
State []string `json:"state"`
CPUs int32 `json:"cpus"`
RealMemory int64 `json:"real_memory"`
}
// clusterPartitionData mirrors the PartitionResponse DTO returned by the API.
type clusterPartitionData struct {
Name string `json:"name"`
State []string `json:"state"`
TotalNodes int32 `json:"total_nodes,omitempty"`
TotalCPUs int32 `json:"total_cpus,omitempty"`
}
// clusterDiagStat mirrors a single entry from the diag statistics.
type clusterDiagStat struct {
Parts []struct {
Param string `json:"param"`
} `json:"parts,omitempty"`
}
// clusterDecodeAll decodes the response and returns status, success, and raw data.
func clusterDecodeAll(env *testenv.TestEnv, resp *http.Response) (statusCode int, success bool, data json.RawMessage, err error) {
statusCode = resp.StatusCode
success, data, err = env.DecodeResponse(resp)
return
}
// TestIntegration_Cluster_Nodes verifies GET /api/v1/nodes returns the 3 pre-loaded mock nodes.
func TestIntegration_Cluster_Nodes(t *testing.T) {
env := testenv.NewTestEnv(t)
resp := env.DoRequest(http.MethodGet, "/api/v1/nodes", nil)
status, success, data, err := clusterDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var nodes []clusterNodeData
if err := json.Unmarshal(data, &nodes); err != nil {
t.Fatalf("unmarshal nodes: %v", err)
}
if len(nodes) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(nodes))
}
names := make(map[string]bool, len(nodes))
for _, n := range nodes {
names[n.Name] = true
}
for _, expected := range []string{"node01", "node02", "node03"} {
if !names[expected] {
t.Errorf("missing expected node %q", expected)
}
}
}
// TestIntegration_Cluster_NodeByName verifies GET /api/v1/nodes/:name returns a single node.
func TestIntegration_Cluster_NodeByName(t *testing.T) {
env := testenv.NewTestEnv(t)
resp := env.DoRequest(http.MethodGet, "/api/v1/nodes/node01", nil)
status, success, data, err := clusterDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var node clusterNodeData
if err := json.Unmarshal(data, &node); err != nil {
t.Fatalf("unmarshal node: %v", err)
}
if node.Name != "node01" {
t.Fatalf("expected name=node01, got %q", node.Name)
}
}
// TestIntegration_Cluster_Partitions verifies GET /api/v1/partitions returns the 2 pre-loaded partitions.
func TestIntegration_Cluster_Partitions(t *testing.T) {
env := testenv.NewTestEnv(t)
resp := env.DoRequest(http.MethodGet, "/api/v1/partitions", nil)
status, success, data, err := clusterDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var partitions []clusterPartitionData
if err := json.Unmarshal(data, &partitions); err != nil {
t.Fatalf("unmarshal partitions: %v", err)
}
if len(partitions) != 2 {
t.Fatalf("expected 2 partitions, got %d", len(partitions))
}
names := make(map[string]bool, len(partitions))
for _, p := range partitions {
names[p.Name] = true
}
if !names["normal"] {
t.Error("missing expected partition \"normal\"")
}
if !names["gpu"] {
t.Error("missing expected partition \"gpu\"")
}
}
// TestIntegration_Cluster_PartitionByName verifies GET /api/v1/partitions/:name returns a single partition.
func TestIntegration_Cluster_PartitionByName(t *testing.T) {
env := testenv.NewTestEnv(t)
resp := env.DoRequest(http.MethodGet, "/api/v1/partitions/normal", nil)
status, success, data, err := clusterDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var part clusterPartitionData
if err := json.Unmarshal(data, &part); err != nil {
t.Fatalf("unmarshal partition: %v", err)
}
if part.Name != "normal" {
t.Fatalf("expected name=normal, got %q", part.Name)
}
}
// TestIntegration_Cluster_Diag verifies GET /api/v1/diag returns diagnostics data.
func TestIntegration_Cluster_Diag(t *testing.T) {
env := testenv.NewTestEnv(t)
resp := env.DoRequest(http.MethodGet, "/api/v1/diag", nil)
status, success, data, err := clusterDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
// Verify the response contains a "statistics" field (non-empty JSON object).
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal diag top-level: %v", err)
}
if _, ok := raw["statistics"]; !ok {
t.Fatal("diag response missing \"statistics\" field")
}
}

View File

@@ -0,0 +1,202 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"gcy_hpc_server/internal/testutil/testenv"
)
// e2eResponse mirrors the unified API response structure.
type e2eResponse struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// e2eTaskCreatedData mirrors the POST /api/v1/tasks response data.
type e2eTaskCreatedData struct {
ID int64 `json:"id"`
}
// e2eTaskItem mirrors a single task in the list response.
type e2eTaskItem struct {
ID int64 `json:"id"`
TaskName string `json:"task_name"`
Status string `json:"status"`
WorkDir string `json:"work_dir"`
ErrorMessage string `json:"error_message"`
}
// e2eTaskListData mirrors the list endpoint response data.
type e2eTaskListData struct {
Items []e2eTaskItem `json:"items"`
Total int64 `json:"total"`
}
// e2eSendRequest sends an HTTP request via the test env and returns the response.
func e2eSendRequest(env *testenv.TestEnv, method, path string, body string) *http.Response {
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
return env.DoRequest(method, path, r)
}
// e2eParseResponse decodes an HTTP response into e2eResponse.
func e2eParseResponse(resp *http.Response) (int, e2eResponse) {
b, err := io.ReadAll(resp.Body)
if err != nil {
panic(fmt.Sprintf("e2eParseResponse read: %v", err))
}
resp.Body.Close()
var result e2eResponse
if err := json.Unmarshal(b, &result); err != nil {
panic(fmt.Sprintf("e2eParseResponse unmarshal: %v (body: %s)", err, string(b)))
}
return resp.StatusCode, result
}
// TestIntegration_E2E_CompleteWorkflow verifies the full lifecycle:
// create app → upload file → submit task → queued → running → completed.
func TestIntegration_E2E_CompleteWorkflow(t *testing.T) {
t.Log("========== E2E 全链路测试开始 ==========")
t.Log("")
env := testenv.NewTestEnv(t)
t.Log("✓ 测试环境创建完成 (SQLite + MockSlurm + MockMinIO + Router + Poller)")
t.Log("")
// Step 1: Create Application with script template and parameters.
t.Log("【步骤 1】创建应用")
appID, err := env.CreateApp("e2e-app", "#!/bin/bash\necho {{.np}}",
json.RawMessage(`[{"name":"np","type":"string","default":"1"}]`))
if err != nil {
t.Fatalf("step 1 create app: %v", err)
}
t.Logf(" → 应用创建成功, appID=%d, 脚本模板='#!/bin/bash echo {{.np}}', 参数=[np]", appID)
t.Log("")
// Step 2: Upload input file.
t.Log("【步骤 2】上传输入文件")
fileID, _ := env.UploadTestData("input.txt", []byte("test input data"))
t.Logf(" → 文件上传成功, fileID=%d, 内容='test input data' (存入 MockMinIO + SQLite)", fileID)
t.Log("")
// Step 3: Submit Task via API.
t.Log("【步骤 3】通过 HTTP API 提交任务")
body := fmt.Sprintf(
`{"app_id": %d, "task_name": "e2e-task", "values": {"np": "4"}, "file_ids": [%d]}`,
appID, fileID,
)
t.Logf(" → POST /api/v1/tasks body=%s", body)
resp := e2eSendRequest(env, http.MethodPost, "/api/v1/tasks", body)
status, result := e2eParseResponse(resp)
if status != http.StatusCreated {
t.Fatalf("step 3 submit task: status=%d, success=%v, error=%q", status, result.Success, result.Error)
}
var created e2eTaskCreatedData
if err := json.Unmarshal(result.Data, &created); err != nil {
t.Fatalf("step 3 parse task id: %v", err)
}
taskID := created.ID
if taskID <= 0 {
t.Fatalf("step 3: expected positive task id, got %d", taskID)
}
t.Logf(" → HTTP 201 Created, taskID=%d", taskID)
t.Log("")
// Step 4: Wait for queued status.
t.Log("【步骤 4】等待 TaskProcessor 异步提交到 MockSlurm")
t.Log(" → 后台流程: submitted → preparing → downloading → ready → queued")
if err := env.WaitForTaskStatus(taskID, "queued", 5*time.Second); err != nil {
taskStatus, _ := e2eFetchTaskStatus(env, taskID)
t.Fatalf("step 4 wait for queued: %v (current status via API: %q)", err, taskStatus)
}
t.Logf(" → 任务状态变为 'queued' (TaskProcessor 已提交到 Slurm)")
t.Log("")
// Step 5: Get slurmJobID.
t.Log("【步骤 5】查询数据库获取 Slurm Job ID")
slurmJobID, err := env.GetTaskSlurmJobID(taskID)
if err != nil {
t.Fatalf("step 5 get slurm job id: %v", err)
}
t.Logf(" → slurmJobID=%d (MockSlurm 中的作业号)", slurmJobID)
t.Log("")
// Step 6: Transition to RUNNING.
t.Log("【步骤 6】模拟 Slurm: 作业开始运行")
t.Logf(" → MockSlurm.SetJobState(%d, 'RUNNING')", slurmJobID)
env.MockSlurm.SetJobState(slurmJobID, "RUNNING")
t.Logf(" → MakeTaskStale(%d) — 绕过 30s 等待,让 poller 立即刷新", taskID)
if err := env.MakeTaskStale(taskID); err != nil {
t.Fatalf("step 6 make task stale: %v", err)
}
if err := env.WaitForTaskStatus(taskID, "running", 5*time.Second); err != nil {
taskStatus, _ := e2eFetchTaskStatus(env, taskID)
t.Fatalf("step 6 wait for running: %v (current status via API: %q)", err, taskStatus)
}
t.Logf(" → 任务状态变为 'running'")
t.Log("")
// Step 7: Transition to COMPLETED — job evicted from activeJobs to historyJobs.
t.Log("【步骤 7】模拟 Slurm: 作业运行完成")
t.Logf(" → MockSlurm.SetJobState(%d, 'COMPLETED') — 作业从 activeJobs 淘汰到 historyJobs", slurmJobID)
env.MockSlurm.SetJobState(slurmJobID, "COMPLETED")
t.Log(" → MakeTaskStale + WaitForTaskStatus...")
if err := env.MakeTaskStale(taskID); err != nil {
t.Fatalf("step 7 make task stale: %v", err)
}
if err := env.WaitForTaskStatus(taskID, "completed", 5*time.Second); err != nil {
taskStatus, _ := e2eFetchTaskStatus(env, taskID)
t.Fatalf("step 7 wait for completed: %v (current status via API: %q)", err, taskStatus)
}
t.Logf(" → 任务状态变为 'completed' (通过 SlurmDB 历史回退路径获取)")
t.Log("")
// Step 8: Verify final state via GET /api/v1/tasks.
t.Log("【步骤 8】通过 HTTP API 验证最终状态")
finalStatus, finalItem := e2eFetchTaskStatus(env, taskID)
if finalStatus != "completed" {
t.Fatalf("step 8: expected status completed, got %q (error: %q)", finalStatus, finalItem.ErrorMessage)
}
t.Logf(" → GET /api/v1/tasks 返回 status='completed'")
t.Logf(" → task_name='%s', work_dir='%s'", finalItem.TaskName, finalItem.WorkDir)
t.Logf(" → MockSlurm activeJobs=%d, historyJobs=%d",
len(env.MockSlurm.GetAllActiveJobs()), len(env.MockSlurm.GetAllHistoryJobs()))
t.Log("")
// Step 9: Verify WorkDir exists and contains the input file.
t.Log("【步骤 9】验证工作目录")
if finalItem.WorkDir == "" {
t.Fatal("step 9: expected non-empty work_dir")
}
t.Logf(" → work_dir='%s' (非空TaskProcessor 已创建)", finalItem.WorkDir)
t.Log("")
t.Log("========== E2E 全链路测试通过 ✓ ==========")
}
// e2eFetchTaskStatus fetches a single task's status from the list API.
func e2eFetchTaskStatus(env *testenv.TestEnv, taskID int64) (string, e2eTaskItem) {
resp := e2eSendRequest(env, http.MethodGet, "/api/v1/tasks", "")
_, result := e2eParseResponse(resp)
var list e2eTaskListData
if err := json.Unmarshal(result.Data, &list); err != nil {
return "", e2eTaskItem{}
}
for _, item := range list.Items {
if item.ID == taskID {
return item.Status, item
}
}
return "", e2eTaskItem{}
}

View File

@@ -0,0 +1,170 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/testutil/testenv"
)
// fileAPIResp mirrors server.APIResponse for file integration tests.
type fileAPIResp struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// fileDecode parses an HTTP response body into fileAPIResp.
func fileDecode(t *testing.T, body io.Reader) fileAPIResp {
t.Helper()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("fileDecode: read body: %v", err)
}
var r fileAPIResp
if err := json.Unmarshal(data, &r); err != nil {
t.Fatalf("fileDecode: unmarshal: %v (body: %s)", err, string(data))
}
return r
}
func TestIntegration_File_List(t *testing.T) {
env := testenv.NewTestEnv(t)
// Upload a file so the list is non-empty.
env.UploadTestData("list_test.txt", []byte("hello list"))
resp := env.DoRequest("GET", "/api/v1/files", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
r := fileDecode(t, resp.Body)
if !r.Success {
t.Fatalf("response not success: %s", r.Error)
}
var listResp model.ListFilesResponse
if err := json.Unmarshal(r.Data, &listResp); err != nil {
t.Fatalf("unmarshal list response: %v", err)
}
if len(listResp.Files) == 0 {
t.Fatal("expected at least 1 file in list, got 0")
}
found := false
for _, f := range listResp.Files {
if f.Name == "list_test.txt" {
found = true
break
}
}
if !found {
t.Fatal("expected to find list_test.txt in file list")
}
if listResp.Total < 1 {
t.Fatalf("expected total >= 1, got %d", listResp.Total)
}
if listResp.Page < 1 {
t.Fatalf("expected page >= 1, got %d", listResp.Page)
}
}
func TestIntegration_File_Get(t *testing.T) {
env := testenv.NewTestEnv(t)
fileID, _ := env.UploadTestData("get_test.txt", []byte("hello get"))
resp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/%d", fileID), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
r := fileDecode(t, resp.Body)
if !r.Success {
t.Fatalf("response not success: %s", r.Error)
}
var fileResp model.FileResponse
if err := json.Unmarshal(r.Data, &fileResp); err != nil {
t.Fatalf("unmarshal file response: %v", err)
}
if fileResp.ID != fileID {
t.Fatalf("expected file ID %d, got %d", fileID, fileResp.ID)
}
if fileResp.Name != "get_test.txt" {
t.Fatalf("expected name get_test.txt, got %s", fileResp.Name)
}
if fileResp.Size != int64(len("hello get")) {
t.Fatalf("expected size %d, got %d", len("hello get"), fileResp.Size)
}
}
func TestIntegration_File_Download(t *testing.T) {
env := testenv.NewTestEnv(t)
content := []byte("hello world")
fileID, _ := env.UploadTestData("download_test.txt", content)
resp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/%d/download", fileID), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read download body: %v", err)
}
if string(body) != string(content) {
t.Fatalf("downloaded content mismatch: got %q, want %q", string(body), string(content))
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
t.Fatal("expected Content-Type header to be set")
}
}
func TestIntegration_File_Delete(t *testing.T) {
env := testenv.NewTestEnv(t)
fileID, _ := env.UploadTestData("delete_test.txt", []byte("hello delete"))
// Delete the file.
resp := env.DoRequest("DELETE", fmt.Sprintf("/api/v1/files/%d", fileID), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
r := fileDecode(t, resp.Body)
if !r.Success {
t.Fatalf("delete response not success: %s", r.Error)
}
// Verify the file is gone — GET should return 500 (internal error) or 404.
getResp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/%d", fileID), nil)
defer getResp.Body.Close()
if getResp.StatusCode == http.StatusOK {
gr := fileDecode(t, getResp.Body)
if gr.Success {
t.Fatal("expected file to be deleted, but GET still returns success")
}
}
}

View File

@@ -0,0 +1,193 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
"gcy_hpc_server/internal/testutil/testenv"
)
// folderData mirrors the FolderResponse DTO returned by the API.
type folderData struct {
ID int64 `json:"id"`
Name string `json:"name"`
ParentID *int64 `json:"parent_id,omitempty"`
Path string `json:"path"`
FileCount int64 `json:"file_count"`
SubFolderCount int64 `json:"subfolder_count"`
}
// folderMessageData mirrors the delete endpoint response data structure.
type folderMessageData struct {
Message string `json:"message"`
}
// folderDoRequest marshals body and calls env.DoRequest.
func folderDoRequest(env *testenv.TestEnv, method, path string, body interface{}) *http.Response {
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
panic(fmt.Sprintf("folderDoRequest marshal: %v", err))
}
r = bytes.NewReader(b)
}
return env.DoRequest(method, path, r)
}
// folderDecodeAll decodes the response and returns status, success, and raw data.
func folderDecodeAll(env *testenv.TestEnv, resp *http.Response) (statusCode int, success bool, data json.RawMessage, err error) {
statusCode = resp.StatusCode
success, data, err = env.DecodeResponse(resp)
return
}
// folderSeed creates a folder via HTTP and returns its ID.
func folderSeed(env *testenv.TestEnv, name string) int64 {
body := map[string]interface{}{"name": name}
resp := folderDoRequest(env, http.MethodPost, "/api/v1/files/folders", body)
status, success, data, err := folderDecodeAll(env, resp)
if err != nil {
panic(fmt.Sprintf("folderSeed decode: %v", err))
}
if status != http.StatusCreated {
panic(fmt.Sprintf("folderSeed: expected 201, got %d", status))
}
if !success {
panic("folderSeed: expected success=true")
}
var f folderData
if err := json.Unmarshal(data, &f); err != nil {
panic(fmt.Sprintf("folderSeed unmarshal: %v", err))
}
return f.ID
}
// TestIntegration_Folder_Create verifies POST /api/v1/files/folders creates a folder.
func TestIntegration_Folder_Create(t *testing.T) {
env := testenv.NewTestEnv(t)
body := map[string]interface{}{"name": "test-folder-create"}
resp := folderDoRequest(env, http.MethodPost, "/api/v1/files/folders", body)
status, success, data, err := folderDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusCreated {
t.Fatalf("expected status 201, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var created folderData
if err := json.Unmarshal(data, &created); err != nil {
t.Fatalf("unmarshal created data: %v", err)
}
if created.ID <= 0 {
t.Fatalf("expected positive id, got %d", created.ID)
}
if created.Name != "test-folder-create" {
t.Fatalf("expected name=test-folder-create, got %s", created.Name)
}
}
// TestIntegration_Folder_List verifies GET /api/v1/files/folders returns a list.
func TestIntegration_Folder_List(t *testing.T) {
env := testenv.NewTestEnv(t)
// Seed two folders.
folderSeed(env, "list-folder-1")
folderSeed(env, "list-folder-2")
resp := env.DoRequest(http.MethodGet, "/api/v1/files/folders", nil)
status, success, data, err := folderDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var folders []folderData
if err := json.Unmarshal(data, &folders); err != nil {
t.Fatalf("unmarshal list data: %v", err)
}
if len(folders) < 2 {
t.Fatalf("expected at least 2 folders, got %d", len(folders))
}
}
// TestIntegration_Folder_Get verifies GET /api/v1/files/folders/:id returns folder details.
func TestIntegration_Folder_Get(t *testing.T) {
env := testenv.NewTestEnv(t)
id := folderSeed(env, "test-folder-get")
path := fmt.Sprintf("/api/v1/files/folders/%d", id)
resp := env.DoRequest(http.MethodGet, path, nil)
status, success, data, err := folderDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var f folderData
if err := json.Unmarshal(data, &f); err != nil {
t.Fatalf("unmarshal folder data: %v", err)
}
if f.ID != id {
t.Fatalf("expected id=%d, got %d", id, f.ID)
}
if f.Name != "test-folder-get" {
t.Fatalf("expected name=test-folder-get, got %s", f.Name)
}
}
// TestIntegration_Folder_Delete verifies DELETE /api/v1/files/folders/:id removes a folder.
func TestIntegration_Folder_Delete(t *testing.T) {
env := testenv.NewTestEnv(t)
id := folderSeed(env, "test-folder-delete")
path := fmt.Sprintf("/api/v1/files/folders/%d", id)
resp := env.DoRequest(http.MethodDelete, path, nil)
status, success, data, err := folderDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var msg folderMessageData
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal message data: %v", err)
}
if msg.Message != "folder deleted" {
t.Fatalf("expected message 'folder deleted', got %q", msg.Message)
}
// Verify it's gone via GET → 404.
getResp := env.DoRequest(http.MethodGet, path, nil)
getStatus, getSuccess, _, _ := folderDecodeAll(env, getResp)
if getStatus != http.StatusNotFound {
t.Fatalf("expected status 404 after delete, got %d", getStatus)
}
if getSuccess {
t.Fatal("expected success=false after delete")
}
}

View File

@@ -0,0 +1,222 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing"
"gcy_hpc_server/internal/testutil/testenv"
)
// jobItemData mirrors the JobResponse DTO for a single job.
type jobItemData struct {
JobID int32 `json:"job_id"`
Name string `json:"name"`
State []string `json:"job_state"`
Partition string `json:"partition"`
}
// jobListData mirrors the paginated JobListResponse DTO.
type jobListData struct {
Jobs []jobItemData `json:"jobs"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// jobCancelData mirrors the cancel response message.
type jobCancelData struct {
Message string `json:"message"`
}
// jobDecodeAll decodes the response and returns status, success, and raw data.
func jobDecodeAll(env *testenv.TestEnv, resp *http.Response) (statusCode int, success bool, data json.RawMessage, err error) {
statusCode = resp.StatusCode
success, data, err = env.DecodeResponse(resp)
return
}
// jobSubmitBody builds a JSON body for job submit requests.
func jobSubmitBody(script string) *bytes.Reader {
body, _ := json.Marshal(map[string]string{"script": script})
return bytes.NewReader(body)
}
// jobSubmitViaAPI submits a job and returns the job ID. Fatals on failure.
func jobSubmitViaAPI(t *testing.T, env *testenv.TestEnv, script string) int32 {
t.Helper()
resp := env.DoRequest(http.MethodPost, "/api/v1/jobs/submit", jobSubmitBody(script))
status, success, data, err := jobDecodeAll(env, resp)
if err != nil {
t.Fatalf("submit job decode: %v", err)
}
if status != http.StatusCreated {
t.Fatalf("expected status 201, got %d", status)
}
if !success {
t.Fatal("expected success=true on submit")
}
var job jobItemData
if err := json.Unmarshal(data, &job); err != nil {
t.Fatalf("unmarshal submitted job: %v", err)
}
return job.JobID
}
// TestIntegration_Jobs_Submit verifies POST /api/v1/jobs/submit creates a new job.
func TestIntegration_Jobs_Submit(t *testing.T) {
env := testenv.NewTestEnv(t)
script := "#!/bin/bash\necho hello"
resp := env.DoRequest(http.MethodPost, "/api/v1/jobs/submit", jobSubmitBody(script))
status, success, data, err := jobDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusCreated {
t.Fatalf("expected status 201, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var job jobItemData
if err := json.Unmarshal(data, &job); err != nil {
t.Fatalf("unmarshal job: %v", err)
}
if job.JobID <= 0 {
t.Fatalf("expected positive job_id, got %d", job.JobID)
}
}
// TestIntegration_Jobs_List verifies GET /api/v1/jobs returns a paginated job list.
func TestIntegration_Jobs_List(t *testing.T) {
env := testenv.NewTestEnv(t)
// Submit a job so the list is not empty.
jobSubmitViaAPI(t, env, "#!/bin/bash\necho list-test")
resp := env.DoRequest(http.MethodGet, "/api/v1/jobs", nil)
status, success, data, err := jobDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var list jobListData
if err := json.Unmarshal(data, &list); err != nil {
t.Fatalf("unmarshal job list: %v", err)
}
if list.Total < 1 {
t.Fatalf("expected at least 1 job, got total=%d", list.Total)
}
if list.Page != 1 {
t.Fatalf("expected page=1, got %d", list.Page)
}
}
// TestIntegration_Jobs_Get verifies GET /api/v1/jobs/:id returns a single job.
func TestIntegration_Jobs_Get(t *testing.T) {
env := testenv.NewTestEnv(t)
jobID := jobSubmitViaAPI(t, env, "#!/bin/bash\necho get-test")
path := fmt.Sprintf("/api/v1/jobs/%d", jobID)
resp := env.DoRequest(http.MethodGet, path, nil)
status, success, data, err := jobDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var job jobItemData
if err := json.Unmarshal(data, &job); err != nil {
t.Fatalf("unmarshal job: %v", err)
}
if job.JobID != jobID {
t.Fatalf("expected job_id=%d, got %d", jobID, job.JobID)
}
}
// TestIntegration_Jobs_Cancel verifies DELETE /api/v1/jobs/:id cancels a job.
func TestIntegration_Jobs_Cancel(t *testing.T) {
env := testenv.NewTestEnv(t)
jobID := jobSubmitViaAPI(t, env, "#!/bin/bash\necho cancel-test")
path := fmt.Sprintf("/api/v1/jobs/%d", jobID)
resp := env.DoRequest(http.MethodDelete, path, nil)
status, success, data, err := jobDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var msg jobCancelData
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal cancel response: %v", err)
}
if msg.Message == "" {
t.Fatal("expected non-empty cancel message")
}
}
// TestIntegration_Jobs_History verifies GET /api/v1/jobs/history returns historical jobs.
func TestIntegration_Jobs_History(t *testing.T) {
env := testenv.NewTestEnv(t)
// Submit and cancel a job so it moves from active to history queue.
jobID := jobSubmitViaAPI(t, env, "#!/bin/bash\necho history-test")
path := fmt.Sprintf("/api/v1/jobs/%d", jobID)
env.DoRequest(http.MethodDelete, path, nil)
resp := env.DoRequest(http.MethodGet, "/api/v1/jobs/history", nil)
status, success, data, err := jobDecodeAll(env, resp)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if status != http.StatusOK {
t.Fatalf("expected status 200, got %d", status)
}
if !success {
t.Fatal("expected success=true")
}
var list jobListData
if err := json.Unmarshal(data, &list); err != nil {
t.Fatalf("unmarshal history: %v", err)
}
if list.Total < 1 {
t.Fatalf("expected at least 1 history job, got total=%d", list.Total)
}
// Verify the cancelled job appears in history.
found := false
for _, j := range list.Jobs {
if j.JobID == jobID {
found = true
break
}
}
if !found {
t.Fatalf("cancelled job %d not found in history", jobID)
}
}

View File

@@ -0,0 +1,261 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"gcy_hpc_server/internal/testutil/testenv"
)
// taskAPIResponse decodes the unified API response envelope.
type taskAPIResponse struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// taskCreateData is the data payload from a successful task creation.
type taskCreateData struct {
ID int64 `json:"id"`
}
// taskListData is the data payload from listing tasks.
type taskListData struct {
Items []taskListItem `json:"items"`
Total int64 `json:"total"`
}
type taskListItem struct {
ID int64 `json:"id"`
TaskName string `json:"task_name"`
AppID int64 `json:"app_id"`
Status string `json:"status"`
SlurmJobID *int32 `json:"slurm_job_id"`
}
// taskSendReq sends an HTTP request via the test env and returns the response.
func taskSendReq(t *testing.T, env *testenv.TestEnv, method, path string, body string) *http.Response {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
resp := env.DoRequest(method, path, r)
return resp
}
// taskParseResp decodes the response body into a taskAPIResponse.
func taskParseResp(t *testing.T, resp *http.Response) taskAPIResponse {
t.Helper()
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
t.Fatalf("read response body: %v", err)
}
var result taskAPIResponse
if err := json.Unmarshal(b, &result); err != nil {
t.Fatalf("unmarshal response: %v (body: %s)", err, string(b))
}
return result
}
// taskCreateViaAPI creates a task via the HTTP API and returns the task ID.
func taskCreateViaAPI(t *testing.T, env *testenv.TestEnv, appID int64, taskName string) int64 {
t.Helper()
body := fmt.Sprintf(`{"app_id":%d,"task_name":"%s","values":{},"file_ids":[]}`, appID, taskName)
resp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", body)
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(b))
}
parsed := taskParseResp(t, resp)
if !parsed.Success {
t.Fatalf("expected success=true, got error: %s", parsed.Error)
}
var data taskCreateData
if err := json.Unmarshal(parsed.Data, &data); err != nil {
t.Fatalf("unmarshal create data: %v", err)
}
if data.ID == 0 {
t.Fatal("expected non-zero task ID")
}
return data.ID
}
// ---------- Tests ----------
func TestIntegration_Task_Create(t *testing.T) {
env := testenv.NewTestEnv(t)
// Create application
appID, err := env.CreateApp("task-create-app", "#!/bin/bash\necho hello", nil)
if err != nil {
t.Fatalf("create app: %v", err)
}
// Create task via API
taskID := taskCreateViaAPI(t, env, appID, "test-task-create")
// Verify the task ID is positive
if taskID <= 0 {
t.Fatalf("expected positive task ID, got %d", taskID)
}
// Wait briefly for async processing, then verify task exists in DB via list
time.Sleep(200 * time.Millisecond)
resp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
defer resp.Body.Close()
parsed := taskParseResp(t, resp)
var listData taskListData
if err := json.Unmarshal(parsed.Data, &listData); err != nil {
t.Fatalf("unmarshal list data: %v", err)
}
found := false
for _, item := range listData.Items {
if item.ID == taskID {
found = true
if item.TaskName != "test-task-create" {
t.Errorf("expected task_name=test-task-create, got %s", item.TaskName)
}
if item.AppID != appID {
t.Errorf("expected app_id=%d, got %d", appID, item.AppID)
}
break
}
}
if !found {
t.Fatalf("task %d not found in list", taskID)
}
}
func TestIntegration_Task_List(t *testing.T) {
env := testenv.NewTestEnv(t)
// Create application
appID, err := env.CreateApp("task-list-app", "#!/bin/bash\necho hello", nil)
if err != nil {
t.Fatalf("create app: %v", err)
}
// Create 3 tasks
taskCreateViaAPI(t, env, appID, "list-task-1")
taskCreateViaAPI(t, env, appID, "list-task-2")
taskCreateViaAPI(t, env, appID, "list-task-3")
// Allow async processing
time.Sleep(200 * time.Millisecond)
// List tasks
resp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(b))
}
parsed := taskParseResp(t, resp)
if !parsed.Success {
t.Fatalf("expected success, got error: %s", parsed.Error)
}
var listData taskListData
if err := json.Unmarshal(parsed.Data, &listData); err != nil {
t.Fatalf("unmarshal list data: %v", err)
}
if listData.Total < 3 {
t.Fatalf("expected at least 3 tasks, got %d", listData.Total)
}
// Verify each created task has required fields
for _, item := range listData.Items {
if item.ID == 0 {
t.Error("expected non-zero ID")
}
if item.Status == "" {
t.Error("expected non-empty status")
}
if item.AppID == 0 {
t.Error("expected non-zero app_id")
}
}
}
func TestIntegration_Task_PollerLifecycle(t *testing.T) {
env := testenv.NewTestEnv(t)
// 1. Create application
appID, err := env.CreateApp("poller-lifecycle-app", "#!/bin/bash\necho hello", nil)
if err != nil {
t.Fatalf("create app: %v", err)
}
// 2. Submit task via API
taskID := taskCreateViaAPI(t, env, appID, "poller-lifecycle-task")
// 3. Wait for queued — TaskProcessor submits to MockSlurm asynchronously.
// Intermediate states (submitted→preparing→downloading→ready→queued) are
// non-deterministic; only assert the final "queued" state.
if err := env.WaitForTaskStatus(taskID, "queued", 5*time.Second); err != nil {
t.Fatalf("wait for queued: %v", err)
}
// 4. Get slurm job ID from DB (not returned by API)
slurmJobID, err := env.GetTaskSlurmJobID(taskID)
if err != nil {
t.Fatalf("get slurm job id: %v", err)
}
// 5. Transition: queued → running
// ORDER IS CRITICAL: SetJobState BEFORE MakeTaskStale
env.MockSlurm.SetJobState(slurmJobID, "RUNNING")
if err := env.MakeTaskStale(taskID); err != nil {
t.Fatalf("make task stale (running): %v", err)
}
if err := env.WaitForTaskStatus(taskID, "running", 5*time.Second); err != nil {
t.Fatalf("wait for running: %v", err)
}
// 6. Transition: running → completed
// ORDER IS CRITICAL: SetJobState BEFORE MakeTaskStale
env.MockSlurm.SetJobState(slurmJobID, "COMPLETED")
if err := env.MakeTaskStale(taskID); err != nil {
t.Fatalf("make task stale (completed): %v", err)
}
if err := env.WaitForTaskStatus(taskID, "completed", 5*time.Second); err != nil {
t.Fatalf("wait for completed: %v", err)
}
}
func TestIntegration_Task_Validation(t *testing.T) {
env := testenv.NewTestEnv(t)
// Missing required app_id
resp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", `{"task_name":"no-app-id"}`)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing app_id, got %d", resp.StatusCode)
}
parsed := taskParseResp(t, resp)
if parsed.Success {
t.Fatal("expected success=false for validation error")
}
if parsed.Error == "" {
t.Error("expected non-empty error message")
}
}

View File

@@ -0,0 +1,279 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"testing"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/testutil/testenv"
)
// uploadResponse mirrors server.APIResponse for upload integration tests.
type uploadAPIResp struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// uploadDecode parses an HTTP response body into uploadAPIResp.
func uploadDecode(t *testing.T, body io.Reader) uploadAPIResp {
t.Helper()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("uploadDecode: read body: %v", err)
}
var r uploadAPIResp
if err := json.Unmarshal(data, &r); err != nil {
t.Fatalf("uploadDecode: unmarshal: %v (body: %s)", err, string(data))
}
return r
}
// uploadInitSession calls InitUpload and returns the created session.
// Uses the real HTTP server from testenv.
func uploadInitSession(t *testing.T, env *testenv.TestEnv, fileName string, fileSize int64, sha256Hash string) model.UploadSessionResponse {
t.Helper()
reqBody := model.InitUploadRequest{
FileName: fileName,
FileSize: fileSize,
SHA256: sha256Hash,
}
body, _ := json.Marshal(reqBody)
resp := env.DoRequest("POST", "/api/v1/files/uploads", bytes.NewReader(body))
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("uploadInitSession: expected 201, got %d", resp.StatusCode)
}
r := uploadDecode(t, resp.Body)
if !r.Success {
t.Fatalf("uploadInitSession: response not success: %s", r.Error)
}
var session model.UploadSessionResponse
if err := json.Unmarshal(r.Data, &session); err != nil {
t.Fatalf("uploadInitSession: unmarshal session: %v", err)
}
return session
}
// uploadSendChunk sends a single chunk via multipart form data.
// Uses raw HTTP client to set the correct multipart content type.
func uploadSendChunk(t *testing.T, env *testenv.TestEnv, sessionID int64, chunkIndex int, chunkData []byte) {
t.Helper()
url := fmt.Sprintf("%s/api/v1/files/uploads/%d/chunks/%d", env.URL(), sessionID, chunkIndex)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("chunk", "chunk.bin")
if err != nil {
t.Fatalf("uploadSendChunk: create form file: %v", err)
}
part.Write(chunkData)
writer.Close()
req, err := http.NewRequest("PUT", url, &buf)
if err != nil {
t.Fatalf("uploadSendChunk: new request: %v", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("uploadSendChunk: do request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("uploadSendChunk: expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
}
func TestIntegration_Upload_Init(t *testing.T) {
env := testenv.NewTestEnv(t)
fileData := []byte("integration test upload init content")
h := sha256.Sum256(fileData)
sha256Hash := hex.EncodeToString(h[:])
session := uploadInitSession(t, env, "init_test.txt", int64(len(fileData)), sha256Hash)
if session.ID <= 0 {
t.Fatalf("expected positive session ID, got %d", session.ID)
}
if session.FileName != "init_test.txt" {
t.Fatalf("expected file_name init_test.txt, got %s", session.FileName)
}
if session.Status != "pending" {
t.Fatalf("expected status pending, got %s", session.Status)
}
if session.TotalChunks != 1 {
t.Fatalf("expected 1 chunk for small file, got %d", session.TotalChunks)
}
if session.FileSize != int64(len(fileData)) {
t.Fatalf("expected file_size %d, got %d", len(fileData), session.FileSize)
}
if session.SHA256 != sha256Hash {
t.Fatalf("expected sha256 %s, got %s", sha256Hash, session.SHA256)
}
}
func TestIntegration_Upload_Status(t *testing.T) {
env := testenv.NewTestEnv(t)
fileData := []byte("integration test status content")
h := sha256.Sum256(fileData)
sha256Hash := hex.EncodeToString(h[:])
session := uploadInitSession(t, env, "status_test.txt", int64(len(fileData)), sha256Hash)
resp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
r := uploadDecode(t, resp.Body)
if !r.Success {
t.Fatalf("response not success: %s", r.Error)
}
var status model.UploadSessionResponse
if err := json.Unmarshal(r.Data, &status); err != nil {
t.Fatalf("unmarshal status: %v", err)
}
if status.ID != session.ID {
t.Fatalf("expected session ID %d, got %d", session.ID, status.ID)
}
if status.Status != "pending" {
t.Fatalf("expected status pending, got %s", status.Status)
}
if status.FileName != "status_test.txt" {
t.Fatalf("expected file_name status_test.txt, got %s", status.FileName)
}
}
func TestIntegration_Upload_Chunk(t *testing.T) {
env := testenv.NewTestEnv(t)
fileData := []byte("integration test chunk upload data")
h := sha256.Sum256(fileData)
sha256Hash := hex.EncodeToString(h[:])
session := uploadInitSession(t, env, "chunk_test.txt", int64(len(fileData)), sha256Hash)
uploadSendChunk(t, env, session.ID, 0, fileData)
// Verify chunk appears in uploaded_chunks via status endpoint
resp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
defer resp.Body.Close()
r := uploadDecode(t, resp.Body)
var status model.UploadSessionResponse
if err := json.Unmarshal(r.Data, &status); err != nil {
t.Fatalf("unmarshal status after chunk: %v", err)
}
if len(status.UploadedChunks) != 1 {
t.Fatalf("expected 1 uploaded chunk, got %d", len(status.UploadedChunks))
}
if status.UploadedChunks[0] != 0 {
t.Fatalf("expected uploaded chunk index 0, got %d", status.UploadedChunks[0])
}
}
func TestIntegration_Upload_Complete(t *testing.T) {
env := testenv.NewTestEnv(t)
fileData := []byte("integration test complete upload data")
h := sha256.Sum256(fileData)
sha256Hash := hex.EncodeToString(h[:])
session := uploadInitSession(t, env, "complete_test.txt", int64(len(fileData)), sha256Hash)
// Upload all chunks
for i := 0; i < session.TotalChunks; i++ {
uploadSendChunk(t, env, session.ID, i, fileData)
}
// Complete upload
resp := env.DoRequest("POST", fmt.Sprintf("/api/v1/files/uploads/%d/complete", session.ID), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
}
r := uploadDecode(t, resp.Body)
if !r.Success {
t.Fatalf("complete response not success: %s", r.Error)
}
var fileResp model.FileResponse
if err := json.Unmarshal(r.Data, &fileResp); err != nil {
t.Fatalf("unmarshal file response: %v", err)
}
if fileResp.ID <= 0 {
t.Fatalf("expected positive file ID, got %d", fileResp.ID)
}
if fileResp.Name != "complete_test.txt" {
t.Fatalf("expected name complete_test.txt, got %s", fileResp.Name)
}
if fileResp.Size != int64(len(fileData)) {
t.Fatalf("expected size %d, got %d", len(fileData), fileResp.Size)
}
if fileResp.SHA256 != sha256Hash {
t.Fatalf("expected sha256 %s, got %s", sha256Hash, fileResp.SHA256)
}
}
func TestIntegration_Upload_Cancel(t *testing.T) {
env := testenv.NewTestEnv(t)
fileData := []byte("integration test cancel upload data")
h := sha256.Sum256(fileData)
sha256Hash := hex.EncodeToString(h[:])
session := uploadInitSession(t, env, "cancel_test.txt", int64(len(fileData)), sha256Hash)
// Cancel the upload
resp := env.DoRequest("DELETE", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
r := uploadDecode(t, resp.Body)
if !r.Success {
t.Fatalf("cancel response not success: %s", r.Error)
}
// Verify session is no longer in pending state by checking status
statusResp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
defer statusResp.Body.Close()
sr := uploadDecode(t, statusResp.Body)
if sr.Success {
var status model.UploadSessionResponse
if err := json.Unmarshal(sr.Data, &status); err == nil {
if status.Status == "pending" {
t.Fatal("expected status to not be pending after cancel")
}
}
}
}