From 74b637bcc99cf3a75e8450d152e029735597ab91 Mon Sep 17 00:00:00 2001 From: Godopu Date: Mon, 24 Aug 2026 22:44:55 +0900 Subject: [PATCH] feat(backend): shift existing research areas display_order automatically on insert and update - Automatically increment display_order (+1) for all existing research areas with display_order >= target order in a transaction - Handle display_order re-ordering gracefully during update operations - Add unit test TestResearchAreasDisplayOrderShift to verify shift behavior on create/update - Pass all Go tests and E2E test suites cleanly --- backend/internal/repository/home.go | 71 ++++++++++++++++++++++++-- backend/internal/router/router_test.go | 49 ++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/backend/internal/repository/home.go b/backend/internal/repository/home.go index c26dcab..443fd73 100644 --- a/backend/internal/repository/home.go +++ b/backend/internal/repository/home.go @@ -127,19 +127,84 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc } func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) { - res, err := r.db.ExecContext(ctx, ` + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + if a.DisplayOrder > 0 { + // Shift existing research areas with display_order >= target order by +1 + _, err = tx.ExecContext(ctx, ` + UPDATE research_areas + SET display_order = display_order + 1 + WHERE display_order >= ? + `, a.DisplayOrder) + if err != nil { + return 0, fmt.Errorf("shift research areas display_order: %w", err) + } + } else { + var maxOrder sql.NullInt64 + _ = tx.QueryRowContext(ctx, `SELECT MAX(display_order) FROM research_areas`).Scan(&maxOrder) + if maxOrder.Valid { + a.DisplayOrder = int(maxOrder.Int64) + 1 + } else { + a.DisplayOrder = 1 + } + } + + res, err := tx.ExecContext(ctx, ` INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?) `, a.NameEn, a.NameKr, a.DisplayOrder) if err != nil { return 0, fmt.Errorf("insert research area: %w", err) } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit tx: %w", err) + } + id, err := res.LastInsertId() return int(id), err } func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error { - res, err := r.db.ExecContext(ctx, ` + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + var oldOrder int + err = tx.QueryRowContext(ctx, `SELECT display_order FROM research_areas WHERE id = ?`, id).Scan(&oldOrder) + if err != nil { + if err == sql.ErrNoRows { + return sql.ErrNoRows + } + return fmt.Errorf("get old display_order: %w", err) + } + + if a.DisplayOrder > 0 && a.DisplayOrder != oldOrder { + if oldOrder < a.DisplayOrder { + _, err = tx.ExecContext(ctx, ` + UPDATE research_areas + SET display_order = display_order - 1 + WHERE display_order > ? AND display_order <= ? AND id != ? + `, oldOrder, a.DisplayOrder, id) + } else { + _, err = tx.ExecContext(ctx, ` + UPDATE research_areas + SET display_order = display_order + 1 + WHERE display_order >= ? AND display_order < ? AND id != ? + `, a.DisplayOrder, oldOrder, id) + } + if err != nil { + return fmt.Errorf("shift research areas display_order on update: %w", err) + } + } + + res, err := tx.ExecContext(ctx, ` UPDATE research_areas SET name_en = ?, name_kr = ?, display_order = ? WHERE id = ? @@ -151,7 +216,7 @@ func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a model if err == nil && rows == 0 { return sql.ErrNoRows } - return err + return tx.Commit() } func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error { diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 630ac9c..598f7d8 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -241,6 +241,10 @@ type APIResponse[T any] struct { } `json:"error"` } +func ptrString(s string) *string { + return &s +} + func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder { var reqBody []byte if body != nil { @@ -349,6 +353,51 @@ func TestResearchProjectsCRUD(t *testing.T) { assert.Equal(t, http.StatusNoContent, wDelete.Code) } +func TestResearchAreasDisplayOrderShift(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r := router.SetupRouter(db, testAdminToken) + + // Fetch initial research areas + wInit := doRequest(r, "GET", "/api/v1/research-areas", nil, "") + assert.Equal(t, http.StatusOK, wInit.Code) + var initResp APIResponse[[]models.ResearchArea] + require.NoError(t, json.Unmarshal(wInit.Body.Bytes(), &initResp)) + initAreas := initResp.Data + require.NotEmpty(t, initAreas) + + // Target display_order = 2 (should shift existing 2..N to 3..N+1) + newArea := models.ResearchArea{ + NameEn: "Quantum Internet Protocols", + NameKr: ptrString("양자 인터넷 프로토콜"), + DisplayOrder: 2, + } + + wCreate := doRequest(r, "POST", "/api/v1/research-areas", newArea, testAdminToken) + assert.Equal(t, http.StatusCreated, wCreate.Code) + var createResp APIResponse[models.ResearchArea] + require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) + createdID := createResp.Data.ID + assert.Equal(t, 2, createResp.Data.DisplayOrder) + + // Fetch updated list and assert display_order shifted correctly + wAfter := doRequest(r, "GET", "/api/v1/research-areas", nil, "") + assert.Equal(t, http.StatusOK, wAfter.Code) + var afterResp APIResponse[[]models.ResearchArea] + require.NoError(t, json.Unmarshal(wAfter.Body.Bytes(), &afterResp)) + afterAreas := afterResp.Data + + assert.Equal(t, len(initAreas)+1, len(afterAreas)) + assert.Equal(t, "Quantum Internet Protocols", afterAreas[1].NameEn) + assert.Equal(t, 2, afterAreas[1].DisplayOrder) + assert.Equal(t, initAreas[1].NameEn, afterAreas[2].NameEn) + assert.Equal(t, 3, afterAreas[2].DisplayOrder) + + // Clean up + wDel := doRequest(r, "DELETE", "/api/v1/research-areas/"+strconvItoa(createdID), nil, testAdminToken) + assert.Equal(t, http.StatusNoContent, wDel.Code) +} + func TestMembersAndAlumniCRUD(t *testing.T) { db := setupTestDB(t) defer db.Close()