From acc7f868910fcc1ba9896869453a5839f67a266f Mon Sep 17 00:00:00 2001 From: Chunsoo Park Date: Thu, 12 Feb 2026 22:12:48 +0900 Subject: [PATCH 1/4] Change deployment image and add environment variables Updated the deployment workflow to use the control_dev image and added environment variables for the container. --- .github/workflows/deploy.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index cb19a0e..8088ed0 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -21,8 +21,22 @@ jobs: - name: Deploy container locally run: | - IMAGE=${{ secrets.DOCKER_HUB_USERNAME }}/control_deploy:latest + IMAGE=${{ secrets.DOCKER_HUB_USERNAME }}/control_dev:latest docker stop CONTROL_DEPLOY || true docker rm CONTROL_DEPLOY || true - docker run -d --name CONTROL_DEPLOY -p 8081:8081 $IMAGE + docker run -d --name CONTROL_DEPLOY -p 8081:8081 --restart=always \ + --add-host host.docker.internal:host-gateway \ + -e CORES="${{ secrets.CORES }}" \ + -e DB_USER="${{ secrets.DB_USER }}" \ + -e DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ + -e DB_HOST="${{ secrets.DB_HOST }}" \ + -e DB_NAME="${{ secrets.DB_NAME }}" \ + -e GUAC_DB_USER="${{ secrets.GUAC_DB_USER }}" \ + -e GUAC_DB_PASSWORD="${{ secrets.GUAC_DB_PASSWORD }}" \ + -e GUAC_DB_HOST="${{ secrets.GUAC_DB_HOST }}" \ + -e GUAC_DB_NAME="${{ secrets.GUAC_DB_NAME }}" \ + -e REDIS_HOST="${{ secrets.REDIS_HOST }}" \ + -e CMS_HOST="${{ secrets.CMS_HOST }}" \ + -e GUAC_BASE_URL="${{ secrets.GUAC_BASE_URL }}" \ + $IMAGE docker system prune -af From a904eae1a24115fe568d0165c402088a22821e3d Mon Sep 17 00:00:00 2001 From: Chunsoo Park Date: Thu, 12 Feb 2026 22:23:12 +0900 Subject: [PATCH 2/4] Change CONTROL_DEPLOY port from 8081 to 8083 --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 8088ed0..f0203d2 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -24,7 +24,7 @@ jobs: IMAGE=${{ secrets.DOCKER_HUB_USERNAME }}/control_dev:latest docker stop CONTROL_DEPLOY || true docker rm CONTROL_DEPLOY || true - docker run -d --name CONTROL_DEPLOY -p 8081:8081 --restart=always \ + docker run -d --name CONTROL_DEPLOY -p 8083:8081 --restart=always \ --add-host host.docker.internal:host-gateway \ -e CORES="${{ secrets.CORES }}" \ -e DB_USER="${{ secrets.DB_USER }}" \ From 46fd80f30cc8c03efafdd5e87d5ef98289d73888 Mon Sep 17 00:00:00 2001 From: ga111o Date: Wed, 18 Mar 2026 22:19:18 +0900 Subject: [PATCH 3/4] Add API integration tests --- .github/workflows/api-integration-tests.yaml | 66 ++++ api/vm_integration_test.go | 391 +++++++++++++++++++ go.mod | 3 + go.sum | 7 + 4 files changed, 467 insertions(+) create mode 100644 .github/workflows/api-integration-tests.yaml create mode 100644 api/vm_integration_test.go diff --git a/.github/workflows/api-integration-tests.yaml b/.github/workflows/api-integration-tests.yaml new file mode 100644 index 0000000..d75692c --- /dev/null +++ b/.github/workflows/api-integration-tests.yaml @@ -0,0 +1,66 @@ +name: API Integration Tests (Simulation) + +on: + push: + branches: + - "**" + pull_request: + branches: + - "**" + +jobs: + api-integration-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + KWS_INTEGRATION_REQUIRED: "true" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run simulated API integration tests + run: | + set -euo pipefail + go test -json -count=1 ./api -run 'TestVM.*' | tee /tmp/test-report.jsonl + + - name: Fail if any tests were skipped + run: | + set -euo pipefail + python - <<'PY' + import json + from pathlib import Path + + p = Path("/tmp/test-report.jsonl") + skipped = [] + passed = [] + + for line in p.read_text().splitlines(): + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except json.JSONDecodeError: + continue + if e.get("Action") == "skip" and e.get("Test"): + skipped.append(e["Test"]) + if e.get("Action") == "pass" and e.get("Test"): + passed.append(e["Test"]) + + if not passed: + raise SystemExit("No tests were executed. Failing workflow.") + if skipped: + raise SystemExit("Skipped tests detected: " + ", ".join(sorted(set(skipped)))) + + print(f"Executed {len(set(passed))} tests with no skips.") + PY diff --git a/api/vm_integration_test.go b/api/vm_integration_test.go new file mode 100644 index 0000000..2a74593 --- /dev/null +++ b/api/vm_integration_test.go @@ -0,0 +1,391 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/alicebob/miniredis/v2" + reqmodel "github.com/easy-cloud-Knet/KWS_Control/request/model" + "github.com/easy-cloud-Knet/KWS_Control/service" + "github.com/easy-cloud-Knet/KWS_Control/structure" + "github.com/redis/go-redis/v9" +) + +func TestVMStartStatusShutdown_WithSimulatedCoreAndRedis(t *testing.T) { + t.Parallel() + + vmUUID := structure.UUID("a4b719aa-7f77-4b7f-8f45-e8470bd7e5d0") + + coreServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/BOOTVM": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"BootVM operation success"}`)) + return + case r.Method == http.MethodGet && r.URL.Path == "/getStatusUUID": + var req reqmodel.GetVMStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.UUID != vmUUID || req.DataType != reqmodel.CpuInfo { + http.Error(w, "unexpected payload", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"system_time":1.2,"idle_time":98.8,"usage_percent":15.5}`)) + return + case r.Method == http.MethodPost && r.URL.Path == "/forceShutDownUUID": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"Force shutdown success"}`)) + return + default: + http.NotFound(w, r) + } + })) + defer coreServer.Close() + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + db, sqlMock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + core := mustParseCoreFromServerURL(t, coreServer.URL) + core.VMInfoIdx = map[structure.UUID]*structure.VMInfo{ + vmUUID: { + UUID: vmUUID, + }, + } + + ctx := structure.ControlContext{ + DB: db, + Cores: []structure.Core{core}, + AliveVM: []*structure.VMInfo{ + {UUID: vmUUID}, + }, + } + + if err := service.StoreVMInfoToRedis(context.Background(), rdb, reqmodel.VMRedisInfo{ + UUID: vmUUID, + CPU: 2, + Memory: 2048, + Disk: 30, + IP: "10.0.0.11", + Status: reqmodel.VMStatusStartBegin, + Time: time.Now().Unix(), + }); err != nil { + t.Fatalf("failed to seed redis vm info: %v", err) + } + + srv := newAPIIntegrationServer(t, &ctx, rdb) + + expectVMCoreLookup(sqlMock, vmUUID, 0) + startResp := doJSONRequest(t, srv, http.MethodPost, "/vm/start", map[string]any{"uuid": vmUUID}) + if startResp.StatusCode != http.StatusOK { + t.Fatalf("unexpected /vm/start status: got=%d body=%s", startResp.StatusCode, startResp.BodyString) + } + + expectVMCoreLookup(sqlMock, vmUUID, 0) + statusResp := doJSONRequest(t, srv, http.MethodGet, "/vm/status", map[string]any{"uuid": vmUUID, "type": "cpu"}) + if statusResp.StatusCode != http.StatusOK { + t.Fatalf("unexpected /vm/status status: got=%d body=%s", statusResp.StatusCode, statusResp.BodyString) + } + if gotUsage, ok := statusResp.JSONBody["usage_percent"].(float64); !ok || gotUsage != 15.5 { + t.Fatalf("unexpected /vm/status response: %+v", statusResp.JSONBody) + } + + expectVMCoreLookup(sqlMock, vmUUID, 0) + shutdownResp := doJSONRequest(t, srv, http.MethodPost, "/vm/shutdown", map[string]any{"uuid": vmUUID}) + if shutdownResp.StatusCode != http.StatusOK { + t.Fatalf("unexpected /vm/shutdown status: got=%d body=%s", shutdownResp.StatusCode, shutdownResp.BodyString) + } + + stored, err := service.GetVMInfoFromRedis(context.Background(), rdb, vmUUID) + if err != nil { + t.Fatalf("failed to read redis value after shutdown: %v", err) + } + if stored.Status != reqmodel.VMStatusStopped { + t.Fatalf("unexpected redis status after shutdown: got=%s want=%s", stored.Status, reqmodel.VMStatusStopped) + } + if len(ctx.AliveVM) != 0 { + t.Fatalf("alive vm list should be empty after shutdown, got=%d", len(ctx.AliveVM)) + } + + if err := sqlMock.ExpectationsWereMet(); err != nil { + t.Fatalf("sqlmock expectations not met: %v", err) + } +} + +func TestVMConnect_WithSimulatedGuacamole(t *testing.T) { + t.Parallel() + + vmUUID := structure.UUID("499cb4fd-1d0d-442e-8945-9e39a2eb0d44") + guacPassword := "mock-guac-password" + + guacServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/tokens" { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + if r.Form.Get("username") != string(vmUUID) || r.Form.Get("password") != guacPassword { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"authToken":"mock-auth-token","username":"ok","dataSource":"mysql","availableDataSources":["mysql"]}`)) + })) + defer guacServer.Close() + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + db, sqlMock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + core := structure.Core{ + IP: "127.0.0.1", + Port: 9999, + VMInfoIdx: map[structure.UUID]*structure.VMInfo{ + vmUUID: { + UUID: vmUUID, + GuacPassword: guacPassword, + }, + }, + } + + ctx := structure.ControlContext{ + DB: db, + Config: structure.Config{GuacBaseURL: guacServer.URL}, + Cores: []structure.Core{core}, + } + srv := newAPIIntegrationServer(t, &ctx, rdb) + + expectVMCoreLookup(sqlMock, vmUUID, 0) + resp := doNoBodyRequest(t, srv, http.MethodGet, "/vm/connect?uuid="+string(vmUUID)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected /vm/connect status: got=%d body=%s", resp.StatusCode, resp.BodyString) + } + if got, ok := resp.JSONBody["authToken"].(string); !ok || got != "mock-auth-token" { + t.Fatalf("unexpected /vm/connect response: %+v", resp.JSONBody) + } + + if err := sqlMock.ExpectationsWereMet(); err != nil { + t.Fatalf("sqlmock expectations not met: %v", err) + } +} + +func TestVMRedisAndVMInfo_NormalizedAndReadable(t *testing.T) { + t.Parallel() + + vmUUID := structure.UUID("f63d5982-410d-4e56-9b32-f0f661997d62") + + mr := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if err := service.StoreVMInfoToRedis(context.Background(), rdb, reqmodel.VMRedisInfo{ + UUID: vmUUID, + CPU: 4, + Memory: 4096, + Disk: 60, + IP: "10.20.0.8", + Status: reqmodel.VMStatusPrepareBegin, + Time: 1700000000, + }); err != nil { + t.Fatalf("failed to seed redis vm info: %v", err) + } + + ctx := structure.ControlContext{ + DB: db, + Cores: []structure.Core{{}}, + } + srv := newAPIIntegrationServer(t, &ctx, rdb) + + redisResp := doJSONRequest(t, srv, http.MethodPost, "/vm/redis", map[string]any{ + "UUID": vmUUID, + "status": "unexpected-status-value", + }) + if redisResp.StatusCode != http.StatusOK { + t.Fatalf("unexpected /vm/redis status: got=%d body=%s", redisResp.StatusCode, redisResp.BodyString) + } + + vmInfoResp := doJSONRequest(t, srv, http.MethodGet, "/vm/info", map[string]any{"uuid": vmUUID}) + if vmInfoResp.StatusCode != http.StatusOK { + t.Fatalf("unexpected /vm/info status: got=%d body=%s", vmInfoResp.StatusCode, vmInfoResp.BodyString) + } + + if gotStatus, err := service.GetVMInfoFromRedis(context.Background(), rdb, vmUUID); err != nil { + t.Fatalf("failed to load vm from redis: %v", err) + } else if gotStatus.Status != reqmodel.VMStatusUnknown { + t.Fatalf("expected normalized redis status %q, got=%q", reqmodel.VMStatusUnknown, gotStatus.Status) + } + + if gotUUID, ok := vmInfoResp.JSONBody["uuid"].(string); !ok || gotUUID != string(vmUUID) { + t.Fatalf("unexpected /vm/info response: %+v", vmInfoResp.JSONBody) + } +} + +type testHTTPResponse struct { + StatusCode int + BodyString string + JSONBody map[string]any +} + +func newAPIIntegrationServer(t *testing.T, controlCtx *structure.ControlContext, rdb *redis.Client) *httptest.Server { + t.Helper() + + h := handlerContext{ + context: controlCtx, + rdb: rdb, + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /vm/start", h.startVm) + mux.HandleFunc("POST /vm/shutdown", h.shutdownVm) + mux.HandleFunc("GET /vm/status", h.vmStatus) + mux.HandleFunc("GET /vm/connect", h.vmConnect) + mux.HandleFunc("POST /vm/redis", h.redis) + mux.HandleFunc("GET /vm/info", h.vmInfo) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func doJSONRequest(t *testing.T, srv *httptest.Server, method, path string, payload map[string]any) testHTTPResponse { + t.Helper() + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + + req, err := http.NewRequest(method, srv.URL+path, bytes.NewReader(body)) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + out := testHTTPResponse{ + StatusCode: resp.StatusCode, + BodyString: string(respBytes), + } + + var obj map[string]any + if len(respBytes) > 0 && json.Unmarshal(respBytes, &obj) == nil { + out.JSONBody = obj + } + + return out +} + +func doNoBodyRequest(t *testing.T, srv *httptest.Server, method, path string) testHTTPResponse { + t.Helper() + req, err := http.NewRequest(method, srv.URL+path, nil) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + out := testHTTPResponse{ + StatusCode: resp.StatusCode, + BodyString: string(respBytes), + } + var obj map[string]any + if len(respBytes) > 0 && json.Unmarshal(respBytes, &obj) == nil { + out.JSONBody = obj + } + return out +} + +func expectVMCoreLookup(mock sqlmock.Sqlmock, uuid structure.UUID, coreIndex int) { + rows := sqlmock.NewRows([]string{"core"}).AddRow(coreIndex) + mock.ExpectBegin() + mock.ExpectQuery("SELECT core FROM inst_loc WHERE uuid = \\?").WithArgs(uuid).WillReturnRows(rows) + mock.ExpectCommit() +} + +func mustParseCoreFromServerURL(t *testing.T, serverURL string) structure.Core { + t.Helper() + + u, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("failed to parse test server url: %v", err) + } + host, portStr, err := splitHostPort(u.Host) + if err != nil { + t.Fatalf("failed to split host/port: %v", err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("invalid port in test server url: %v", err) + } + + return structure.Core{ + IP: host, + Port: uint16(port), + } +} + +func splitHostPort(hostport string) (string, string, error) { + u, err := url.Parse("http://" + hostport) + if err != nil { + return "", "", err + } + host := u.Hostname() + port := u.Port() + if host == "" || port == "" { + return "", "", fmt.Errorf("host or port missing in %q", hostport) + } + return host, port, nil +} diff --git a/go.mod b/go.mod index 76f4e21..a345ee1 100755 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.23.0 toolchain go1.23.4 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/alicebob/miniredis/v2 v2.37.0 github.com/go-sql-driver/mysql v1.9.2 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.11.0 @@ -18,5 +20,6 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index d7c55a1..03ee4b8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -15,6 +19,7 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= @@ -24,6 +29,8 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= From 26c545394e3ac55125e19ac17545fc7b52e8617f Mon Sep 17 00:00:00 2001 From: ga111o Date: Wed, 18 Mar 2026 22:29:19 +0900 Subject: [PATCH 4/4] Add integration tests for ControlContext --- .github/workflows/api-integration-tests.yaml | 2 +- structure/control_infra_sql_test.go | 78 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 structure/control_infra_sql_test.go diff --git a/.github/workflows/api-integration-tests.yaml b/.github/workflows/api-integration-tests.yaml index d75692c..7bbdb31 100644 --- a/.github/workflows/api-integration-tests.yaml +++ b/.github/workflows/api-integration-tests.yaml @@ -31,7 +31,7 @@ jobs: - name: Run simulated API integration tests run: | set -euo pipefail - go test -json -count=1 ./api -run 'TestVM.*' | tee /tmp/test-report.jsonl + go test -json -count=1 ./api ./structure -run 'TestVM.*|TestControlContext_.*' | tee /tmp/test-report.jsonl - name: Fail if any tests were skipped run: | diff --git a/structure/control_infra_sql_test.go b/structure/control_infra_sql_test.go new file mode 100644 index 0000000..d7bac68 --- /dev/null +++ b/structure/control_infra_sql_test.go @@ -0,0 +1,78 @@ +package structure + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestControlContext_AddInstance_ExecutesInsertQueries(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + ctx := &ControlContext{DB: db} + vm := &VMInfo{ + UUID: UUID("a7e8fa1b-3b4d-4e6f-80d4-6e3019c2f105"), + IP_VM: "10.0.0.31", + GuacPassword: "guac-pass", + Memory: 4096, + Cpu: 4, + Disk: 60, + } + coreIdx := 2 + + mock.ExpectBegin() + mock.ExpectExec(`INSERT INTO inst_info \(uuid, inst_ip, guac_pass, inst_mem, inst_vcpu, inst_disk\) VALUES \(\?, \?, \?, \?, \?, \?\)`). + WithArgs(string(vm.UUID), vm.IP_VM, vm.GuacPassword, vm.Memory, vm.Cpu, vm.Disk). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`INSERT INTO inst_loc \(uuid, core\) VALUES \(\?, \?\)`). + WithArgs(string(vm.UUID), coreIdx). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err := ctx.AddInstance(vm, coreIdx); err != nil { + t.Fatalf("AddInstance returned error: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations not met: %v", err) + } +} + +func TestControlContext_UpdateInstance_ExecutesUpdateQuery(t *testing.T) { + t.Parallel() + + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + ctx := &ControlContext{DB: db} + vm := &VMInfo{ + UUID: UUID("d5870f4c-d12b-467d-8e1f-68ad0f89d227"), + IP_VM: "10.0.0.41", + Memory: 8192, + Cpu: 8, + Disk: 120, + } + + mock.ExpectBegin() + mock.ExpectExec(`UPDATE inst_info SET inst_ip = \?, inst_mem = \?, inst_vcpu = \?, inst_disk = \? WHERE uuid = \?`). + WithArgs(vm.IP_VM, vm.Memory, vm.Cpu, vm.Disk, string(vm.UUID)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + if err := ctx.UpdateInstance(vm); err != nil { + t.Fatalf("UpdateInstance returned error: %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations not met: %v", err) + } +}