package service import ( "context" "errors" "fmt" "testing" "time" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "oci-portal/internal/crypto" "oci-portal/internal/model" "oci-portal/internal/oci" ) type invalidatingClient struct { *fakeClient invalidated []string } type blockingClient struct { *fakeClient started chan struct{} release chan struct{} } func (c *blockingClient) ValidateKey(context.Context, oci.Credentials) (oci.TenancyInfo, error) { c.started <- struct{}{} <-c.release return oci.TenancyInfo{Name: "target"}, nil } func (c *invalidatingClient) InvalidateTenancy(id string) { c.invalidated = append(c.invalidated, id) } func newTenantDeleteEnv(t *testing.T, client oci.Client) (*OciConfigService, *TaskService, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if err != nil { t.Fatalf("open database: %v", err) } sqlDB, err := db.DB() if err != nil { t.Fatalf("database handle: %v", err) } sqlDB.SetMaxOpenConns(1) migrateTenantDeleteModels(t, db) cipher, err := crypto.NewCipher("test-data-key") if err != nil { t.Fatalf("new cipher: %v", err) } configs := NewOciConfigService(db, cipher, client) tasks := NewTaskService(db, configs, nil, nil) configs.SetTenantCleanupDeps(tasks, nil) return configs, tasks, db } func migrateTenantDeleteModels(t *testing.T, db *gorm.DB) { t.Helper() err := db.AutoMigrate( &model.OciConfig{}, &model.Task{}, &model.TaskLog{}, &model.Setting{}, &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.LogEvent{}, &model.AlertRule{}, &model.AlertRuleHit{}, &model.AiChannel{}, &model.AiModelCache{}, &model.AiCallLog{}, &model.AiContentLog{}, &model.Proxy{}, &model.AiKey{}, &model.SystemLog{}, ) if err != nil { t.Fatalf("auto migrate: %v", err) } } func TestPlanTenantTask(t *testing.T) { for _, tt := range tenantTaskCases() { t.Run(tt.name, func(t *testing.T) { action, ok, err := planTenantTask(tt.task, 1) if (err != nil) != tt.wantErr { t.Fatalf("error = %v, wantErr %v", err, tt.wantErr) } if ok != tt.wantOK || action.deleteTask != tt.wantDelete { t.Errorf("result = (ok=%v, delete=%v), want (%v, %v)", ok, action.deleteTask, tt.wantOK, tt.wantDelete) } if action.payload != tt.wantPayload { t.Errorf("payload = %q, want %q", action.payload, tt.wantPayload) } }) } } type tenantTaskCase struct { name string task model.Task wantOK bool wantDelete bool wantPayload string wantErr bool } func tenantTaskCases() []tenantTaskCase { return []tenantTaskCase{ {name: "抢机命中", task: taskOf(model.TaskTypeSnatch, `{"ociConfigId":1}`), wantOK: true, wantDelete: true}, {name: "抢机未命中", task: taskOf(model.TaskTypeSnatch, `{"ociConfigId":2}`)}, {name: "测活全局", task: taskOf(model.TaskTypeHealthCheck, `{"ociConfigIds":[]}`), wantOK: true, wantPayload: `{"ociConfigIds":[]}`}, {name: "测活单租户", task: taskOf(model.TaskTypeHealthCheck, `{"ociConfigIds":[1]}`), wantOK: true, wantDelete: true}, {name: "测活多租户", task: taskOf(model.TaskTypeHealthCheck, `{"ociConfigIds":[1,2]}`), wantOK: true, wantPayload: `{"ociConfigIds":[2]}`}, {name: "成本去重命中", task: taskOf(model.TaskTypeCost, `{"ociConfigIds":[1,1,2]}`), wantOK: true, wantPayload: `{"ociConfigIds":[2]}`}, {name: "成本未命中", task: taskOf(model.TaskTypeCost, `{"ociConfigIds":[2]}`)}, {name: "非法 JSON", task: taskOf(model.TaskTypeCost, `{`), wantErr: true}, } } func taskOf(taskType, payload string) model.Task { return model.Task{ID: 10, Type: taskType, Payload: payload} } func TestDeleteTenantCleansRelatedRows(t *testing.T) { client := &invalidatingClient{fakeClient: &fakeClient{}} configs, _, db := newTenantDeleteEnv(t, client) target, other := seedDeleteTenants(t, db) seedTenantSnapshots(t, db, target.ID, other.ID) seedTenantEvents(t, db, target.ID, other.ID) seedTenantAI(t, db, target.ID, other.ID) seedRetainedGlobals(t, db) if err := configs.Delete(context.Background(), target.ID); err != nil { t.Fatalf("delete tenant: %v", err) } assertTenantRowsGone(t, db, target.ID) assertOtherTenantRowsRemain(t, db, other.ID) assertRetainedGlobals(t, db) if len(client.invalidated) != 1 || client.invalidated[0] != target.TenancyOCID { t.Errorf("invalidated = %v, want [%s]", client.invalidated, target.TenancyOCID) } } func seedDeleteTenants(t *testing.T, db *gorm.DB) (model.OciConfig, model.OciConfig) { t.Helper() target := model.OciConfig{Alias: "target", TenancyOCID: "ocid1.tenancy.target"} other := model.OciConfig{Alias: "other", TenancyOCID: "ocid1.tenancy.other"} mustCreate(t, db, &target) mustCreate(t, db, &other) return target, other } func seedTenantSnapshots(t *testing.T, db *gorm.DB, target, other uint) { t.Helper() for _, id := range []uint{target, other} { mustCreate(t, db, &model.CheckSnapshot{OciConfigID: id}) mustCreate(t, db, &model.CostSnapshot{OciConfigID: id, Day: "2026-07-10"}) mustCreate(t, db, &model.RegionCache{OciConfigID: id, Key: "PHX"}) mustCreate(t, db, &model.CompartmentCache{OciConfigID: id, OCID: fmt.Sprintf("comp-%d", id)}) mustCreate(t, db, &model.Setting{Key: secretKey(id), Value: fmt.Sprintf("secret-%d", id)}) } } func seedTenantEvents(t *testing.T, db *gorm.DB, target, other uint) { t.Helper() targetEvent := model.LogEvent{OciConfigID: target, MessageID: "target-event"} otherEvent := model.LogEvent{OciConfigID: other, MessageID: "other-event"} targetRule := model.AlertRule{Name: "target-rule", OciConfigID: target} globalRule := model.AlertRule{Name: "global-rule", OciConfigID: 0} for _, value := range []any{&targetEvent, &otherEvent, &targetRule, &globalRule} { mustCreate(t, db, value) } hits := []model.AlertRuleHit{ {RuleID: targetRule.ID, LogEventID: otherEvent.ID}, {RuleID: globalRule.ID, LogEventID: targetEvent.ID}, {RuleID: globalRule.ID, LogEventID: otherEvent.ID}, } for i := range hits { mustCreate(t, db, &hits[i]) } } func seedTenantAI(t *testing.T, db *gorm.DB, target, other uint) { t.Helper() for _, id := range []uint{target, other} { channel := model.AiChannel{Name: fmt.Sprintf("channel-%d", id), OciConfigID: id, Region: "us-phoenix-1"} mustCreate(t, db, &channel) mustCreate(t, db, &model.AiModelCache{ChannelID: channel.ID, ModelOcid: fmt.Sprintf("model-%d", id)}) call := model.AiCallLog{ChannelID: channel.ID, ChannelName: channel.Name} mustCreate(t, db, &call) mustCreate(t, db, &model.AiContentLog{CallLogID: call.ID, RequestBody: "sensitive"}) } } func seedRetainedGlobals(t *testing.T, db *gorm.DB) { t.Helper() mustCreate(t, db, &model.Proxy{Name: "shared", Type: "http"}) mustCreate(t, db, &model.AiKey{Name: "global-key", KeyHash: "hash", Tail: "hash"}) mustCreate(t, db, &model.SystemLog{Method: "DELETE", Path: "/api/v1/oci-configs/1"}) mustCreate(t, db, &model.Setting{Key: "notify_channels", Value: "[]"}) } func assertTenantRowsGone(t *testing.T, db *gorm.DB, id uint) { t.Helper() rows := []any{ &model.OciConfig{}, &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.LogEvent{}, &model.AlertRule{}, &model.AiChannel{}, } for _, value := range rows { column := "oci_config_id" if _, ok := value.(*model.OciConfig); ok { column = "id" } assertCount(t, db, value, column+" = ?", []any{id}, 0) } assertCount(t, db, &model.Setting{}, "key = ?", []any{secretKey(id)}, 0) assertCount(t, db, &model.AlertRuleHit{}, "", nil, 1) assertCount(t, db, &model.AiModelCache{}, "", nil, 1) assertCount(t, db, &model.AiCallLog{}, "", nil, 1) assertCount(t, db, &model.AiContentLog{}, "", nil, 1) } func assertOtherTenantRowsRemain(t *testing.T, db *gorm.DB, id uint) { t.Helper() for _, value := range []any{ &model.OciConfig{}, &model.CheckSnapshot{}, &model.CostSnapshot{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.LogEvent{}, &model.AiChannel{}, } { column := "oci_config_id" if _, ok := value.(*model.OciConfig); ok { column = "id" } assertCount(t, db, value, column+" = ?", []any{id}, 1) } assertCount(t, db, &model.Setting{}, "key = ?", []any{secretKey(id)}, 1) assertRemainingIndirectRows(t, db, id) } func assertRemainingIndirectRows(t *testing.T, db *gorm.DB, otherID uint) { t.Helper() var channel model.AiChannel if err := db.Where("oci_config_id = ?", otherID).First(&channel).Error; err != nil { t.Fatalf("load other AI channel: %v", err) } assertCount(t, db, &model.AiModelCache{}, "channel_id = ?", []any{channel.ID}, 1) assertCount(t, db, &model.AiCallLog{}, "channel_id = ?", []any{channel.ID}, 1) var call model.AiCallLog if err := db.Where("channel_id = ?", channel.ID).First(&call).Error; err != nil { t.Fatalf("load other AI call: %v", err) } assertCount(t, db, &model.AiContentLog{}, "call_log_id = ?", []any{call.ID}, 1) assertRemainingAlertHit(t, db, otherID) } func assertRemainingAlertHit(t *testing.T, db *gorm.DB, otherID uint) { t.Helper() var hit model.AlertRuleHit if err := db.First(&hit).Error; err != nil { t.Fatalf("load remaining alert hit: %v", err) } var rule model.AlertRule var event model.LogEvent if err := db.First(&rule, hit.RuleID).Error; err != nil { t.Fatalf("load remaining rule: %v", err) } if err := db.First(&event, hit.LogEventID).Error; err != nil { t.Fatalf("load remaining event: %v", err) } if rule.OciConfigID != 0 || event.OciConfigID != otherID { t.Errorf("remaining hit = rule cfg %d/event cfg %d, want global/other", rule.OciConfigID, event.OciConfigID) } } func assertRetainedGlobals(t *testing.T, db *gorm.DB) { t.Helper() assertCount(t, db, &model.AlertRule{}, "oci_config_id = 0", nil, 1) assertCount(t, db, &model.Proxy{}, "", nil, 1) assertCount(t, db, &model.AiKey{}, "", nil, 1) assertCount(t, db, &model.SystemLog{}, "", nil, 1) assertCount(t, db, &model.Setting{}, "key = ?", []any{"notify_channels"}, 1) } func TestDeleteTenantRewritesTasksAndCron(t *testing.T) { configs, tasks, db := newTenantDeleteEnv(t, &fakeClient{}) target, other := seedDeleteTenants(t, db) created := seedTenantTasks(t, tasks, target.ID, other.ID) if err := configs.Delete(context.Background(), target.ID); err != nil { t.Fatalf("delete tenant: %v", err) } assertDeletedTasks(t, db, tasks, created[:2]) assertRewrittenTasks(t, db, created[2:]) } func TestDeleteTenantReconcilesAiProbe(t *testing.T) { for _, keepOther := range []bool{false, true} { name := fmt.Sprintf("keepOther=%v", keepOther) t.Run(name, func(t *testing.T) { configs, tasks, db := newTenantDeleteEnv(t, &fakeClient{}) target, other := seedDeleteTenants(t, db) seedProbeChannels(t, db, target.ID, other.ID, keepOther) tasks.SyncAiProbeTask(context.Background()) probe := loadAiProbe(t, db) seedTaskHistory(t, db, probe.ID) if err := configs.Delete(context.Background(), target.ID); err != nil { t.Fatalf("delete tenant: %v", err) } assertAiProbeResult(t, db, tasks, probe.ID, keepOther) }) } } func seedProbeChannels(t *testing.T, db *gorm.DB, target, other uint, keepOther bool) { t.Helper() mustCreate(t, db, &model.AiChannel{Name: "target", OciConfigID: target, Region: "r1"}) if keepOther { mustCreate(t, db, &model.AiChannel{Name: "other", OciConfigID: other, Region: "r1"}) } } func loadAiProbe(t *testing.T, db *gorm.DB) model.Task { t.Helper() var task model.Task if err := db.Where("type = ?", model.TaskTypeAiProbe).First(&task).Error; err != nil { t.Fatalf("load AI probe: %v", err) } return task } func seedTaskHistory(t *testing.T, db *gorm.DB, taskID uint) { t.Helper() updates := map[string]any{"last_error": "old", "run_count": 2, "last_run_at": time.Now()} if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(updates).Error; err != nil { t.Fatalf("seed task history: %v", err) } mustCreate(t, db, &model.TaskLog{TaskID: taskID, Message: "old"}) mustCreate(t, db, &model.Setting{Key: deadAliasKey(taskID), Value: "old"}) } func assertAiProbeResult(t *testing.T, db *gorm.DB, tasks *TaskService, id uint, keep bool) { t.Helper() want := int64(0) if keep { want = 1 } assertCount(t, db, &model.Task{}, "id = ?", []any{id}, want) assertCount(t, db, &model.TaskLog{}, "task_id = ?", []any{id}, 0) assertCount(t, db, &model.Setting{}, "key = ?", []any{deadAliasKey(id)}, 0) if !keep { if _, ok := tasks.entries[id]; ok { t.Errorf("AI probe %d still scheduled", id) } return } probe := loadAiProbe(t, db) if probe.RunCount != 0 || probe.LastRunAt != nil || probe.LastError != "" { t.Errorf("AI probe history not reset: %+v", probe) } } func seedTenantTasks(t *testing.T, tasks *TaskService, target, other uint) []model.Task { t.Helper() inputs := []CreateTaskInput{ {Name: "snatch", Type: model.TaskTypeSnatch, CronExpr: "0 0 * * *", Payload: []byte(fmt.Sprintf(`{"ociConfigId":%d,"instance":{"displayName":"vm","region":"r","availabilityDomain":"a","subnetId":"s","shape":"x","imageId":"i"}}`, target))}, {Name: "single", Type: model.TaskTypeCost, CronExpr: "0 0 * * *", Payload: []byte(fmt.Sprintf(`{"ociConfigIds":[%d]}`, target))}, {Name: "mixed", Type: model.TaskTypeHealthCheck, CronExpr: "0 0 * * *", Payload: []byte(fmt.Sprintf(`{"ociConfigIds":[%d,%d]}`, target, other))}, {Name: "global", Type: model.TaskTypeCost, CronExpr: "0 0 * * *", Payload: []byte(`{"ociConfigIds":[]}`)}, {Name: "other", Type: model.TaskTypeHealthCheck, CronExpr: "0 0 * * *", Payload: []byte(fmt.Sprintf(`{"ociConfigIds":[%d]}`, other))}, } return createTasksWithHistory(t, tasks, inputs) } func createTasksWithHistory(t *testing.T, tasks *TaskService, inputs []CreateTaskInput) []model.Task { t.Helper() out := make([]model.Task, 0, len(inputs)) for _, input := range inputs { task, err := tasks.CreateTask(context.Background(), input) if err != nil { t.Fatalf("create task %s: %v", input.Name, err) } tasks.db.Model(task).Updates(map[string]any{"last_error": "old", "run_count": 3, "last_run_at": time.Now()}) mustCreate(t, tasks.db, &model.TaskLog{TaskID: task.ID, Message: "old"}) mustCreate(t, tasks.db, &model.Setting{Key: deadAliasKey(task.ID), Value: `["target"]`}) out = append(out, *task) } return out } func assertDeletedTasks(t *testing.T, db *gorm.DB, tasks *TaskService, deleted []model.Task) { t.Helper() for _, task := range deleted { assertCount(t, db, &model.Task{}, "id = ?", []any{task.ID}, 0) assertCount(t, db, &model.TaskLog{}, "task_id = ?", []any{task.ID}, 0) assertCount(t, db, &model.Setting{}, "key = ?", []any{deadAliasKey(task.ID)}, 0) if _, ok := tasks.entries[task.ID]; ok { t.Errorf("task %d still scheduled", task.ID) } } } func assertRewrittenTasks(t *testing.T, db *gorm.DB, tasks []model.Task) { t.Helper() wantPayload := []string{`{"ociConfigIds":[2]}`, `{"ociConfigIds":[]}`, `{"ociConfigIds":[2]}`} for i, original := range tasks { var got model.Task if err := db.First(&got, original.ID).Error; err != nil { t.Fatalf("load task %d: %v", original.ID, err) } if got.Payload != wantPayload[i] { t.Errorf("task %d payload = %s, want %s", got.ID, got.Payload, wantPayload[i]) } wantHistory := int64(0) if original.Name == "other" { wantHistory = 1 } else if got.RunCount != 0 || got.LastRunAt != nil || got.LastError != "" { t.Errorf("task %d history fields not reset: %+v", got.ID, got) } assertCount(t, db, &model.TaskLog{}, "task_id = ?", []any{got.ID}, wantHistory) assertCount(t, db, &model.Setting{}, "key = ?", []any{deadAliasKey(got.ID)}, wantHistory) } } func TestDeleteTenantRollback(t *testing.T) { configs, tasks, db := newTenantDeleteEnv(t, &fakeClient{}) target, _ := seedDeleteTenants(t, db) mustCreate(t, db, &model.CheckSnapshot{OciConfigID: target.ID}) task := createHealthTask(t, tasks, target.ID) seedTaskHistory(t, db, task.ID) registerDeleteFailure(t, db, "cost_snapshots") err := configs.Delete(context.Background(), target.ID) if err == nil || !errors.Is(err, errInjectedTenantDelete) { t.Fatalf("delete error = %v, want injected failure", err) } assertCount(t, db, &model.OciConfig{}, "id = ?", []any{target.ID}, 1) assertCount(t, db, &model.CheckSnapshot{}, "oci_config_id = ?", []any{target.ID}, 1) assertCount(t, db, &model.Task{}, "id = ?", []any{task.ID}, 1) assertCount(t, db, &model.TaskLog{}, "task_id = ?", []any{task.ID}, 1) assertCount(t, db, &model.Setting{}, "key = ?", []any{deadAliasKey(task.ID)}, 1) } func TestDeleteTenantWaitsForRunningTask(t *testing.T) { client := &blockingClient{fakeClient: &fakeClient{}, started: make(chan struct{}, 1), release: make(chan struct{})} configs, tasks, db := newTenantDeleteEnv(t, client) target, _ := seedDeleteTenants(t, db) setTenantPrivateKey(t, configs, target.ID) task := createHealthTask(t, tasks, target.ID) executed := make(chan struct{}) go func() { tasks.execute(task.ID) close(executed) }() <-client.started deleted := make(chan error, 1) go func() { deleted <- configs.Delete(context.Background(), target.ID) }() assertDeleteBlocked(t, deleted) close(client.release) <-executed if err := <-deleted; err != nil { t.Fatalf("delete tenant: %v", err) } assertCount(t, db, &model.Task{}, "id = ?", []any{task.ID}, 0) assertCount(t, db, &model.TaskLog{}, "task_id = ?", []any{task.ID}, 0) } func TestVerifyDoesNotResurrectDeletedTenant(t *testing.T) { client := &blockingClient{fakeClient: &fakeClient{}, started: make(chan struct{}, 1), release: make(chan struct{})} configs, _, db := newTenantDeleteEnv(t, client) target, _ := seedDeleteTenants(t, db) setTenantPrivateKey(t, configs, target.ID) verified := make(chan error, 1) go func() { _, _, err := configs.Verify(context.Background(), target.ID) verified <- err }() <-client.started if err := configs.Delete(context.Background(), target.ID); err != nil { t.Fatalf("delete tenant: %v", err) } close(client.release) if err := <-verified; !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatalf("verify error = %v, want record not found", err) } assertCount(t, db, &model.OciConfig{}, "id = ?", []any{target.ID}, 0) } func TestScopeCacheRejectsDeletedTenant(t *testing.T) { configs, _, db := newTenantDeleteEnv(t, &fakeClient{}) target, _ := seedDeleteTenants(t, db) if err := configs.Delete(context.Background(), target.ID); err != nil { t.Fatalf("delete tenant: %v", err) } err := configs.saveRegionCache(context.Background(), target.ID, []oci.RegionSubscription{{Key: "PHX", Name: "us-phoenix-1"}}) if !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatalf("save region cache error = %v, want record not found", err) } err = configs.saveCompartmentCache(context.Background(), target.ID, []oci.Compartment{{ID: "compartment", Name: "deleted"}}) if !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatalf("save compartment cache error = %v, want record not found", err) } assertCount(t, db, &model.RegionCache{}, "oci_config_id = ?", []any{target.ID}, 0) assertCount(t, db, &model.CompartmentCache{}, "oci_config_id = ?", []any{target.ID}, 0) } func setTenantPrivateKey(t *testing.T, configs *OciConfigService, id uint) { t.Helper() encrypted, err := configs.cipher.EncryptString("private-key") if err != nil { t.Fatalf("encrypt private key: %v", err) } if err := configs.db.Model(&model.OciConfig{}).Where("id = ?", id). Update("private_key_enc", encrypted).Error; err != nil { t.Fatalf("set private key: %v", err) } } func createHealthTask(t *testing.T, tasks *TaskService, cfgID uint) *model.Task { t.Helper() payload := []byte(fmt.Sprintf(`{"ociConfigIds":[%d]}`, cfgID)) task, err := tasks.CreateTask(context.Background(), CreateTaskInput{ Name: "running", Type: model.TaskTypeHealthCheck, CronExpr: "0 0 * * *", Payload: payload, }) if err != nil { t.Fatalf("create task: %v", err) } return task } func assertDeleteBlocked(t *testing.T, deleted <-chan error) { t.Helper() select { case err := <-deleted: t.Fatalf("delete returned before running task finished: %v", err) case <-time.After(50 * time.Millisecond): } } func TestPersistTaskRunDoesNotResurrectDeletedTask(t *testing.T) { _, tasks, db := newTenantDeleteEnv(t, &fakeClient{}) task := &model.Task{Name: "stale", Type: model.TaskTypeCost, Status: model.TaskStatusActive} mustCreate(t, db, task) stale := *task if err := db.Delete(task).Error; err != nil { t.Fatalf("delete task: %v", err) } stored, err := tasks.persistTaskRun(context.Background(), &stale) if err != nil || stored { t.Fatalf("persist stale task = (%v, %v), want (false, nil)", stored, err) } assertCount(t, db, &model.Task{}, "id = ?", []any{task.ID}, 0) } func TestAiLogsRejectDeletedTenantParents(t *testing.T) { configs, _, db := newTenantDeleteEnv(t, &fakeClient{}) target, _ := seedDeleteTenants(t, db) channel := model.AiChannel{Name: "target", OciConfigID: target.ID, Region: "r1"} mustCreate(t, db, &channel) gw := NewAiGatewayService(db, configs, &fakeClient{}) callID := gw.LogCall(model.AiCallLog{ChannelID: channel.ID, ChannelName: channel.Name}) if callID == 0 { t.Fatal("initial call log was not created") } if err := configs.Delete(context.Background(), target.ID); err != nil { t.Fatalf("delete tenant: %v", err) } lateID := gw.LogCall(model.AiCallLog{ChannelID: channel.ID, ChannelName: channel.Name}) if lateID != 0 { t.Errorf("late call ID = %d, want 0", lateID) } gw.LogContent(model.AiContentLog{CallLogID: callID, RequestBody: "late"}) gw.LogContent(model.AiContentLog{CallLogID: 0, RequestBody: "orphan"}) assertCount(t, db, &model.AiCallLog{}, "channel_id = ?", []any{channel.ID}, 0) assertCount(t, db, &model.AiContentLog{}, "", nil, 0) } var errInjectedTenantDelete = errors.New("injected tenant delete failure") func registerDeleteFailure(t *testing.T, db *gorm.DB, table string) { t.Helper() name := "test:tenant_delete_failure" err := db.Callback().Delete().Before("gorm:delete").Register(name, func(tx *gorm.DB) { if tx.Statement.Table == table { tx.AddError(errInjectedTenantDelete) } }) if err != nil { t.Fatalf("register callback: %v", err) } t.Cleanup(func() { _ = db.Callback().Delete().Remove(name) }) } func mustCreate(t *testing.T, db *gorm.DB, value any) { t.Helper() if err := db.Create(value).Error; err != nil { t.Fatalf("create %T: %v", value, err) } } func assertCount(t *testing.T, db *gorm.DB, value any, query string, args []any, want int64) { t.Helper() q := db.Model(value) if query != "" { q = q.Where(query, args...) } var got int64 if err := q.Count(&got).Error; err != nil { t.Fatalf("count %T: %v", value, err) } if got != want { t.Errorf("count %T = %d, want %d", value, got, want) } }