package countryregion import ( "context" "database/sql" "encoding/json" "errors" "testing" "github.com/DATA-DOG/go-sqlmock" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/roomclient" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" ) func TestRenameCountryCodeRejectsInvalidCode(t *testing.T) { db, mock, closeDB := newCountryRenameMockDB(t) defer closeDB() store := &fakeCountryRenameJobStore{} service := NewService(nil, db, store, nil) _, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("P1")) if !errors.Is(err, errCountryInvalid) { t.Fatalf("expected invalid country error, got %v", err) } assertCountryRenameExpectations(t, mock) if len(store.created) != 0 { t.Fatalf("invalid request should not create job, got %d", len(store.created)) } } func TestRenameCountryCodeRejectsDuplicateCode(t *testing.T) { db, mock, closeDB := newCountryRenameMockDB(t) defer closeDB() store := &fakeCountryRenameJobStore{} service := NewService(nil, db, store, nil) mock.ExpectBegin() expectCountryRenameLockedCountry(mock, "PH") expectCountryRenameDuplicateCode(mock, "PHL", 88) mock.ExpectRollback() _, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("PHL")) if !errors.Is(err, errCountryConflict) { t.Fatalf("expected duplicate country error, got %v", err) } assertCountryRenameExpectations(t, mock) if len(store.created) != 0 { t.Fatalf("duplicate country code should not create job, got %d", len(store.created)) } } func TestRenameCountryCodeRejectsActiveJobConflict(t *testing.T) { db, mock, closeDB := newCountryRenameMockDB(t) defer closeDB() payload := countryCodeRenameJobPayload{ AppCode: "lalu", CountryID: 12, OldCountryCode: "PH", NewCountryCode: "PHL", } body, _ := json.Marshal(payload) store := &fakeCountryRenameJobStore{active: []model.AdminJob{{PayloadJSON: string(body)}}} service := NewService(nil, db, store, nil) mock.ExpectBegin() expectCountryRenameLockedCountry(mock, "PH") expectCountryRenameAvailableCode(mock, "PHL") mock.ExpectRollback() _, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("PHL")) if !errors.Is(err, errCountryRenameJobConflict) { t.Fatalf("expected active job conflict, got %v", err) } assertCountryRenameExpectations(t, mock) if len(store.created) != 0 { t.Fatalf("active conflict should not create job, got %d", len(store.created)) } } func TestRenameCountryCodeCreatesPendingJob(t *testing.T) { db, mock, closeDB := newCountryRenameMockDB(t) defer closeDB() store := &fakeCountryRenameJobStore{} service := NewService(nil, db, store, nil) mock.ExpectBegin() expectCountryRenameLockedCountry(mock, "PH") expectCountryRenameAvailableCode(mock, "PHL") mock.ExpectCommit() resp, err := service.RenameCountryCode(appctx.WithContext(context.Background(), "lalu"), 7, "admin", 12, "req-1", validRenameCountryCodeRequest("phl")) if err != nil { t.Fatalf("rename country code failed: %v", err) } assertCountryRenameExpectations(t, mock) if resp.JobID != 101 || resp.Status != model.JobStatusPending || resp.OldCountryCode != "PH" || resp.NewCountryCode != "PHL" { t.Fatalf("unexpected response: %+v", resp) } if len(store.created) != 1 { t.Fatalf("expected one created job, got %d", len(store.created)) } var payload countryCodeRenameJobPayload if err := json.Unmarshal([]byte(store.created[0].PayloadJSON), &payload); err != nil { t.Fatalf("decode payload: %v", err) } if payload.CountryID != 12 || payload.OldCountryCode != "PH" || payload.NewCountryCode != "PHL" || payload.ActorID != 7 { t.Fatalf("unexpected payload: %+v", payload) } } func TestHandleCountryCodeRenameJobUpdatesCurrentStateAndReturnsCounts(t *testing.T) { db, mock, closeDB := newCountryRenameMockDB(t) defer closeDB() store := &fakeCountryRenameJobStore{ adminCounts: repository.CountryCodeAdminReferenceCounts{Banners: 2, SplashScreens: 3}, } room := &fakeCountryRenameRoomClient{ result: roomclient.RenameOwnerCountryCodeResult{RoomListEntries: 4, RoomSnapshots: 5}, } service := NewService(nil, db, store, room) mock.ExpectBegin() expectCountryRenameLockedCountry(mock, "PH") expectCountryRenameAvailableCode(mock, "PHL") mock.ExpectExec("UPDATE countries").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("UPDATE region_countries").WillReturnResult(sqlmock.NewResult(0, 6)) mock.ExpectExec("UPDATE users").WillReturnResult(sqlmock.NewResult(0, 7)) mock.ExpectExec("UPDATE user_region_rebuild_tasks").WillReturnResult(sqlmock.NewResult(0, 8)) mock.ExpectCommit() payload := countryCodeRenameJobPayload{ AppCode: "lalu", CountryID: 12, OldCountryCode: "PH", NewCountryCode: "PHL", CountryName: "Philippines", CountryDisplayName: "菲律宾", ActorID: 7, RequestID: "req-1", } body, _ := json.Marshal(payload) resultJSON, err := service.HandleCountryCodeRenameJob(context.Background(), &model.AdminJob{ID: 101, PayloadJSON: string(body)}) if err != nil { t.Fatalf("handle job failed: %v", err) } assertCountryRenameExpectations(t, mock) var result countryCodeRenameResult if err := json.Unmarshal([]byte(resultJSON), &result); err != nil { t.Fatalf("decode result: %v", err) } if result.Users != 7 || result.RegionMappings != 6 || result.RebuildTasks != 8 || result.Banners != 2 || result.SplashScreens != 3 || result.RoomListEntries != 4 || result.RoomSnapshots != 5 { t.Fatalf("unexpected result counts: %+v", result) } if store.renamedAppCode != "lalu" || store.renamedOld != "PH" || store.renamedNew != "PHL" { t.Fatalf("admin references not renamed with expected codes: %+v", store) } if room.req.OldCountryCode != "PH" || room.req.NewCountryCode != "PHL" || room.req.AdminID != 7 { t.Fatalf("room rename request mismatch: %+v", room.req) } } func TestHandleCountryCodeRenameJobIsIdempotentAfterUserDBAlreadyRenamed(t *testing.T) { db, mock, closeDB := newCountryRenameMockDB(t) defer closeDB() store := &fakeCountryRenameJobStore{ adminCounts: repository.CountryCodeAdminReferenceCounts{Banners: 1, SplashScreens: 1}, } room := &fakeCountryRenameRoomClient{ result: roomclient.RenameOwnerCountryCodeResult{RoomListEntries: 1, RoomSnapshots: 1}, } service := NewService(nil, db, store, room) mock.ExpectBegin() expectCountryRenameLockedCountry(mock, "PHL") expectCountryRenameAvailableCode(mock, "PHL") mock.ExpectExec("UPDATE countries").WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("UPDATE region_countries").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec("UPDATE users").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec("UPDATE user_region_rebuild_tasks").WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectCommit() payload := countryCodeRenameJobPayload{ AppCode: "lalu", CountryID: 12, OldCountryCode: "PH", NewCountryCode: "PHL", CountryName: "Philippines", CountryDisplayName: "菲律宾", ActorID: 7, RequestID: "req-1", } body, _ := json.Marshal(payload) resultJSON, err := service.HandleCountryCodeRenameJob(context.Background(), &model.AdminJob{ID: 101, PayloadJSON: string(body)}) if err != nil { t.Fatalf("handle retry job failed: %v", err) } assertCountryRenameExpectations(t, mock) var result countryCodeRenameResult if err := json.Unmarshal([]byte(resultJSON), &result); err != nil { t.Fatalf("decode result: %v", err) } if result.Users != 0 || result.RegionMappings != 0 || result.RebuildTasks != 0 || result.Banners != 1 || result.SplashScreens != 1 || result.RoomListEntries != 1 || result.RoomSnapshots != 1 { t.Fatalf("unexpected idempotent result counts: %+v", result) } } func validRenameCountryCodeRequest(countryCode string) renameCountryCodeRequest { return renameCountryCodeRequest{ CountryName: "Philippines", CountryCode: countryCode, CountryDisplayName: "菲律宾", ISOAlpha3: "PHL", ISONumeric: "608", PhoneCountryCode: "+63", Flag: "PH", SortOrder: 1770, } } func newCountryRenameMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock, func()) { t.Helper() db, mock, err := sqlmock.New() if err != nil { t.Fatalf("create sqlmock: %v", err) } return db, mock, func() { _ = db.Close() } } func expectCountryRenameLockedCountry(mock sqlmock.Sqlmock, countryCode string) { mock.ExpectQuery(`(?s)SELECT country_id, country_code.*FROM countries.*FOR UPDATE`). WithArgs("lalu", int64(12)). WillReturnRows(sqlmock.NewRows([]string{"country_id", "country_code"}).AddRow(12, countryCode)) } func expectCountryRenameDuplicateCode(mock sqlmock.Sqlmock, countryCode string, existingID int64) { mock.ExpectQuery(`(?s)SELECT country_id.*FROM countries.*country_id <>`). WithArgs("lalu", countryCode, int64(12)). WillReturnRows(sqlmock.NewRows([]string{"country_id"}).AddRow(existingID)) } func expectCountryRenameAvailableCode(mock sqlmock.Sqlmock, countryCode string) { mock.ExpectQuery(`(?s)SELECT country_id.*FROM countries.*country_id <>`). WithArgs("lalu", countryCode, int64(12)). WillReturnError(sql.ErrNoRows) } func assertCountryRenameExpectations(t *testing.T, mock sqlmock.Sqlmock) { t.Helper() if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } type fakeCountryRenameJobStore struct { active []model.AdminJob adminCounts repository.CountryCodeAdminReferenceCounts created []model.AdminJob renamedAppCode string renamedOld string renamedNew string } func (s *fakeCountryRenameJobStore) CreateJob(job *model.AdminJob) error { job.ID = 101 job.Status = model.JobStatusPending s.created = append(s.created, *job) return nil } func (s *fakeCountryRenameJobStore) ListActiveJobsByType(string) ([]model.AdminJob, error) { return s.active, nil } func (s *fakeCountryRenameJobStore) RenameCountryCodeAdminReferences(appCode string, oldCountryCode string, newCountryCode string, _ int64) (repository.CountryCodeAdminReferenceCounts, error) { s.renamedAppCode = appCode s.renamedOld = oldCountryCode s.renamedNew = newCountryCode return s.adminCounts, nil } type fakeCountryRenameRoomClient struct { req roomclient.RenameOwnerCountryCodeRequest result roomclient.RenameOwnerCountryCodeResult } func (c *fakeCountryRenameRoomClient) RenameOwnerCountryCode(_ context.Context, req roomclient.RenameOwnerCountryCodeRequest) (roomclient.RenameOwnerCountryCodeResult, error) { c.req = req return c.result, nil }