发布 0.1.0:通知渠道、告警规则、令牌版本与安全加固
CI / test (push) Successful in 30s
Release / release (push) Successful in 49s

This commit is contained in:
Wang Defa
2026-07-10 17:38:34 +08:00
parent 4af6a0ca92
commit dbba1f4905
78 changed files with 6898 additions and 551 deletions
+609
View File
@@ -0,0 +1,609 @@
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)
}
}