Files
Scriberr/internal/database/database_test.go
2026-05-02 14:32:15 -07:00

975 lines
39 KiB
Go

package database
import (
"path/filepath"
"testing"
"time"
"scriberr/internal/models"
"scriberr/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type legacyUserTable legacyUser
func (legacyUserTable) TableName() string { return "users" }
type legacyAPIKeyTable legacyAPIKey
func (legacyAPIKeyTable) TableName() string { return "api_keys" }
type legacyRefreshTokenTable legacyRefreshToken
func (legacyRefreshTokenTable) TableName() string { return "refresh_tokens" }
type legacyTranscriptionProfileTable legacyTranscriptionProfile
func (legacyTranscriptionProfileTable) TableName() string { return "transcription_profiles" }
type legacyTranscriptionJobTable legacyTranscriptionJob
func (legacyTranscriptionJobTable) TableName() string { return "transcription_jobs" }
type legacyTranscriptionExecutionTable legacyTranscriptionExecution
func (legacyTranscriptionExecutionTable) TableName() string { return "transcription_job_executions" }
type legacySpeakerMappingTable legacySpeakerMapping
func (legacySpeakerMappingTable) TableName() string { return "speaker_mappings" }
type legacySummaryTemplateTable legacySummaryTemplate
func (legacySummaryTemplateTable) TableName() string { return "summary_templates" }
type legacySummarySettingTable legacySummarySetting
func (legacySummarySettingTable) TableName() string { return "summary_settings" }
type legacySummaryTable legacySummary
func (legacySummaryTable) TableName() string { return "summaries" }
type legacyLLMConfigTable legacyLLMConfig
func (legacyLLMConfigTable) TableName() string { return "llm_configs" }
func TestFreshSchemaInitialization(t *testing.T) {
db := openMigratedTestDB(t, "fresh.db")
expectedTables := []string{
"schema_migrations",
"users",
"api_keys",
"refresh_tokens",
"transcription_profiles",
"transcriptions",
"transcription_executions",
"speaker_mappings",
"summary_templates",
"summaries",
"audio_tags",
"audio_tag_assignments",
"recording_sessions",
"recording_chunks",
"transcript_annotations",
"transcript_annotation_entries",
"chat_sessions",
"chat_context_sources",
"chat_messages",
"chat_generation_runs",
"chat_context_summaries",
"llm_profiles",
}
for _, table := range expectedTables {
assert.True(t, db.Migrator().HasTable(table), "expected table %s", table)
}
assert.Equal(t, latestSchemaVersion, schemaVersion(t, db))
assert.Equal(t, "wal", pragmaString(t, db, "journal_mode"))
assert.True(t, hasIndex(t, db, "speaker_mappings", "idx_speaker_mappings_unique"))
assert.True(t, hasIndex(t, db, "transcription_executions", "idx_transcription_executions_unique"))
assert.True(t, hasIndex(t, db, "transcription_profiles", "idx_transcription_profiles_user_default_unique"))
assert.True(t, hasIndex(t, db, "summary_templates", "idx_summary_templates_user_default_unique"))
assert.True(t, hasIndex(t, db, "llm_profiles", "idx_llm_profiles_user_default_unique"))
assert.True(t, hasIndex(t, db, "audio_tags", "idx_audio_tags_user_normalized_name_active_unique"))
assert.True(t, hasIndex(t, db, "audio_tag_assignments", "idx_audio_tag_assignments_user_tag_transcription_active_unique"))
assert.True(t, hasIndex(t, db, "audio_tag_assignments", "idx_audio_tag_assignments_user_transcription_created_at"))
assert.True(t, hasIndex(t, db, "audio_tag_assignments", "idx_audio_tag_assignments_user_tag_transcription"))
assert.True(t, hasIndex(t, db, "recording_sessions", "idx_recording_sessions_user_created_at"))
assert.True(t, hasIndex(t, db, "recording_sessions", "idx_recording_sessions_finalize_claim"))
assert.True(t, hasIndex(t, db, "recording_sessions", "idx_recording_sessions_claim_expires_at"))
assert.True(t, hasIndex(t, db, "recording_sessions", "idx_recording_sessions_status_expires_at"))
assert.True(t, hasIndex(t, db, "recording_sessions", "idx_recording_sessions_artifact_cleanup"))
assert.True(t, hasIndex(t, db, "recording_chunks", "idx_recording_chunks_session_index_unique"))
assert.True(t, hasIndex(t, db, "transcript_annotations", "idx_transcript_annotations_user_transcription_created_at"))
assert.True(t, hasIndex(t, db, "transcript_annotations", "idx_transcript_annotations_user_kind_updated_at"))
assert.True(t, hasIndex(t, db, "transcript_annotations", "idx_transcript_annotations_transcription_time"))
assert.True(t, hasIndex(t, db, "chat_sessions", "idx_chat_sessions_user_parent_updated_at"))
assert.True(t, hasIndex(t, db, "chat_context_sources", "idx_chat_context_sources_session_enabled_position"))
assert.True(t, hasIndex(t, db, "chat_context_sources", "idx_chat_context_sources_user_transcription"))
assert.True(t, hasIndex(t, db, "chat_messages", "idx_chat_messages_session_created_at"))
assert.True(t, hasIndex(t, db, "chat_generation_runs", "idx_chat_generation_runs_session_created_at"))
assert.True(t, hasIndex(t, db, "chat_generation_runs", "idx_chat_generation_runs_status_created_at"))
assert.True(t, db.Migrator().HasColumn(&models.TranscriptionJob{}, "llm_description"))
assert.True(t, db.Migrator().HasColumn(&models.TranscriptionJob{}, "llm_description_generated_at"))
assert.True(t, db.Migrator().HasColumn(&models.TranscriptionJob{}, "llm_description_source_summary_id"))
assert.True(t, db.Migrator().HasColumn(&models.AudioTag{}, "when_to_use"))
title := "Fresh transcription"
job := models.TranscriptionJob{UserID: 1, Title: &title, Status: models.StatusUploaded, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
mapping1 := models.SpeakerMapping{UserID: job.UserID, TranscriptionJobID: job.ID, OriginalSpeaker: "SPEAKER_00", CustomName: "Alice"}
require.NoError(t, db.Create(&mapping1).Error)
mapping2 := models.SpeakerMapping{UserID: job.UserID, TranscriptionJobID: job.ID, OriginalSpeaker: "SPEAKER_00", CustomName: "Bob"}
require.Error(t, db.Create(&mapping2).Error)
}
func TestChatSchemaValidationAndCascade(t *testing.T) {
db := openMigratedTestDB(t, "chat-schema.db")
user := models.User{Username: "chat-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Chat parent transcript"
job := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusCompleted, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
session := models.ChatSession{
UserID: user.ID,
ParentTranscriptionID: job.ID,
Title: "Transcript chat",
Provider: "openai_compatible",
Model: "qwen3.5-4B",
}
require.NoError(t, db.Create(&session).Error)
assert.NotEmpty(t, session.ID)
assert.Equal(t, models.ChatSessionStatusActive, session.Status)
assert.Equal(t, "{}", session.ContextPolicyJSON)
plainText := "Speaker 1: hello"
source := models.ChatContextSource{
UserID: user.ID,
ChatSessionID: session.ID,
TranscriptionID: job.ID,
Kind: models.ChatContextSourceKindParentTranscript,
PlainTextSnapshot: &plainText,
}
require.NoError(t, db.Create(&source).Error)
assert.True(t, source.Enabled)
assert.Equal(t, models.ChatContextCompactionStatusNone, source.CompactionStatus)
assert.Equal(t, "{}", source.MetadataJSON)
userMessage := models.ChatMessage{
UserID: user.ID,
ChatSessionID: session.ID,
Role: models.ChatMessageRoleUser,
Content: "Summarize the objections.",
}
require.NoError(t, db.Create(&userMessage).Error)
assert.NotEmpty(t, userMessage.ID)
assert.Equal(t, models.ChatMessageStatusCompleted, userMessage.Status)
assistantMessage := models.ChatMessage{
UserID: user.ID,
ChatSessionID: session.ID,
Role: models.ChatMessageRoleAssistant,
Status: models.ChatMessageStatusStreaming,
Content: "",
ReasoningContent: "checking the transcript",
Provider: ptr("openai_compatible"),
Model: ptr("qwen3.5-4B"),
}
require.NoError(t, db.Create(&assistantMessage).Error)
run := models.ChatGenerationRun{
UserID: user.ID,
ChatSessionID: session.ID,
AssistantMessageID: &assistantMessage.ID,
Status: models.ChatGenerationRunStatusStreaming,
Provider: "openai_compatible",
Model: "qwen3.5-4B",
ContextWindow: 32768,
}
require.NoError(t, db.Create(&run).Error)
assistantMessage.RunID = &run.ID
require.NoError(t, db.Save(&assistantMessage).Error)
summary := models.ChatContextSummary{
UserID: user.ID,
ChatSessionID: session.ID,
SummaryType: models.ChatContextSummaryTypeSession,
SourceMessageThroughID: &userMessage.ID,
Content: "The earlier conversation asked for objections.",
Provider: "openai_compatible",
Model: "qwen3.5-4B",
}
require.NoError(t, db.Create(&summary).Error)
invalidMessage := models.ChatMessage{
UserID: user.ID,
ChatSessionID: session.ID,
Role: models.ChatMessageRole("critic"),
Content: "bad role",
}
require.Error(t, db.Create(&invalidMessage).Error)
require.NoError(t, db.Unscoped().Delete(&session).Error)
for table, column := range map[string]string{
"chat_context_sources": "id",
"chat_messages": "id",
"chat_generation_runs": "id",
"chat_context_summaries": "id",
} {
var count int64
require.NoError(t, db.Table(table).Where(column+" <> ''").Count(&count).Error)
assert.Zero(t, count, "expected %s rows to cascade with chat session", table)
}
}
func TestAudioTagSchemaValidationUniquenessAndSoftDelete(t *testing.T) {
db := openMigratedTestDB(t, "audio-tags.db")
user := models.User{Username: "tag-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Tagged transcript"
job := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusCompleted, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
whenToUse := "Use for customer-facing calls"
tag := models.AudioTag{UserID: user.ID, Name: " Client Call ", WhenToUse: &whenToUse}
require.NoError(t, db.Create(&tag).Error)
assert.NotEmpty(t, tag.ID)
assert.Equal(t, "Client Call", tag.Name)
assert.Equal(t, "client call", tag.NormalizedName)
require.NotNil(t, tag.WhenToUse)
assert.Equal(t, "Use for customer-facing calls", *tag.WhenToUse)
assert.Equal(t, "{}", tag.MetadataJSON)
duplicate := models.AudioTag{UserID: user.ID, Name: "client call"}
require.Error(t, db.Create(&duplicate).Error)
assignment := models.AudioTagAssignment{UserID: user.ID, TagID: tag.ID, TranscriptionID: job.ID}
require.NoError(t, db.Create(&assignment).Error)
assert.NotEmpty(t, assignment.ID)
duplicateAssignment := models.AudioTagAssignment{UserID: user.ID, TagID: tag.ID, TranscriptionID: job.ID}
require.Error(t, db.Create(&duplicateAssignment).Error)
require.NoError(t, db.Delete(&assignment).Error)
recreatedAssignment := models.AudioTagAssignment{UserID: user.ID, TagID: tag.ID, TranscriptionID: job.ID}
require.NoError(t, db.Create(&recreatedAssignment).Error)
require.NoError(t, db.Delete(&tag).Error)
recreatedTag := models.AudioTag{UserID: user.ID, Name: "CLIENT CALL"}
require.NoError(t, db.Create(&recreatedTag).Error)
}
func TestAudioTagHardDeleteCascadesAssignments(t *testing.T) {
db := openMigratedTestDB(t, "audio-tag-cascade.db")
user := models.User{Username: "tag-cascade-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Cascade tagged transcript"
job := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusCompleted, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
tag := models.AudioTag{UserID: user.ID, Name: "Research"}
require.NoError(t, db.Create(&tag).Error)
assignment := models.AudioTagAssignment{UserID: user.ID, TagID: tag.ID, TranscriptionID: job.ID}
require.NoError(t, db.Create(&assignment).Error)
require.NoError(t, db.Unscoped().Delete(&job).Error)
var count int64
require.NoError(t, db.Unscoped().Model(&models.AudioTagAssignment{}).Where("id = ?", assignment.ID).Count(&count).Error)
assert.Zero(t, count)
secondJob := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusCompleted, AudioPath: "/tmp/audio-2.wav"}
require.NoError(t, db.Create(&secondJob).Error)
secondAssignment := models.AudioTagAssignment{UserID: user.ID, TagID: tag.ID, TranscriptionID: secondJob.ID}
require.NoError(t, db.Create(&secondAssignment).Error)
require.NoError(t, db.Unscoped().Delete(&tag).Error)
require.NoError(t, db.Unscoped().Model(&models.AudioTagAssignment{}).Where("id = ?", secondAssignment.ID).Count(&count).Error)
assert.Zero(t, count)
}
func TestRecordingSchemaValidationUniquenessAndCascade(t *testing.T) {
db := openMigratedTestDB(t, "recordings.db")
user := models.User{Username: "recording-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Team sync"
session := models.RecordingSession{
UserID: user.ID,
Title: &title,
MimeType: "audio/webm;codecs=opus",
ChunkDurationMs: ptr(int64(3000)),
}
require.NoError(t, db.Create(&session).Error)
assert.NotEmpty(t, session.ID)
assert.Equal(t, models.RecordingStatusRecording, session.Status)
assert.Equal(t, models.RecordingSourceKindMicrophone, session.SourceKind)
assert.Equal(t, "{}", session.MetadataJSON)
assert.Equal(t, "{}", session.TranscriptionOptionsJSON)
assert.False(t, session.StartedAt.IsZero())
invalidStatus := models.RecordingSession{
UserID: user.ID,
Status: models.RecordingStatus("paused"),
MimeType: "audio/webm;codecs=opus",
}
require.Error(t, db.Create(&invalidStatus).Error)
chunk := models.RecordingChunk{
UserID: user.ID,
SessionID: session.ID,
ChunkIndex: 0,
Path: "/tmp/chunk-000000.webm",
MimeType: "audio/webm;codecs=opus",
SizeBytes: 128,
}
require.NoError(t, db.Create(&chunk).Error)
assert.NotEmpty(t, chunk.ID)
assert.False(t, chunk.ReceivedAt.IsZero())
duplicateChunk := models.RecordingChunk{
UserID: user.ID,
SessionID: session.ID,
ChunkIndex: 0,
Path: "/tmp/chunk-000000-retry.webm",
MimeType: "audio/webm;codecs=opus",
SizeBytes: 128,
}
require.Error(t, db.Create(&duplicateChunk).Error)
missingSession := models.RecordingChunk{
UserID: user.ID,
SessionID: "missing",
ChunkIndex: 1,
Path: "/tmp/chunk-000001.webm",
MimeType: "audio/webm;codecs=opus",
SizeBytes: 128,
}
require.Error(t, db.Create(&missingSession).Error)
require.NoError(t, db.Unscoped().Delete(&session).Error)
var count int64
require.NoError(t, db.Unscoped().Model(&models.RecordingChunk{}).Where("id = ?", chunk.ID).Count(&count).Error)
assert.Zero(t, count)
}
func TestTranscriptAnnotationSchemaValidationAndSoftDelete(t *testing.T) {
db := openMigratedTestDB(t, "transcript-annotations.db")
user := models.User{Username: "annotation-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Annotated transcript"
job := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusCompleted, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
content := "Follow up"
color := "yellow"
startWord := 2
endWord := 5
annotation := models.TranscriptAnnotation{
UserID: user.ID,
TranscriptionID: job.ID,
Kind: models.AnnotationKindNote,
Content: &content,
Color: &color,
Quote: "important quote",
AnchorStartMS: 1200,
AnchorEndMS: 3400,
AnchorStartWord: &startWord,
AnchorEndWord: &endWord,
}
require.NoError(t, db.Create(&annotation).Error)
assert.NotEmpty(t, annotation.ID)
assert.Equal(t, models.AnnotationStatusActive, annotation.Status)
assert.Equal(t, "{}", annotation.MetadataJSON)
invalidKind := models.TranscriptAnnotation{
UserID: user.ID,
TranscriptionID: job.ID,
Kind: models.AnnotationKind("bookmark"),
Quote: "bad kind",
AnchorStartMS: 100,
AnchorEndMS: 200,
}
require.Error(t, db.Create(&invalidKind).Error)
invalidRange := models.TranscriptAnnotation{
UserID: user.ID,
TranscriptionID: job.ID,
Kind: models.AnnotationKindHighlight,
Quote: "bad range",
AnchorStartMS: 500,
AnchorEndMS: 100,
}
require.Error(t, db.Create(&invalidRange).Error)
missingTranscription := models.TranscriptAnnotation{
UserID: user.ID,
TranscriptionID: "missing",
Kind: models.AnnotationKindHighlight,
Quote: "missing parent",
AnchorStartMS: 100,
AnchorEndMS: 200,
}
require.Error(t, db.Create(&missingTranscription).Error)
require.NoError(t, db.Delete(&annotation).Error)
var visibleCount int64
require.NoError(t, db.Model(&models.TranscriptAnnotation{}).Where("id = ?", annotation.ID).Count(&visibleCount).Error)
assert.Zero(t, visibleCount)
var storedCount int64
require.NoError(t, db.Unscoped().Model(&models.TranscriptAnnotation{}).Where("id = ?", annotation.ID).Count(&storedCount).Error)
assert.Equal(t, int64(1), storedCount)
}
func TestTranscriptAnnotationHardDeleteCascadesWithTranscription(t *testing.T) {
db := openMigratedTestDB(t, "transcript-annotation-cascade.db")
user := models.User{Username: "cascade-annotation-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Cascade transcript"
job := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusCompleted, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
annotation := models.TranscriptAnnotation{
UserID: user.ID,
TranscriptionID: job.ID,
Kind: models.AnnotationKindHighlight,
Quote: "highlighted quote",
AnchorStartMS: 1000,
AnchorEndMS: 2000,
}
require.NoError(t, db.Create(&annotation).Error)
require.NoError(t, db.Unscoped().Delete(&job).Error)
var count int64
require.NoError(t, db.Unscoped().Model(&models.TranscriptAnnotation{}).Where("id = ?", annotation.ID).Count(&count).Error)
assert.Zero(t, count)
}
func TestCreateExecutionAssignsSequentialNumbers(t *testing.T) {
db := openMigratedTestDB(t, "execution-sequence.db")
user := models.User{Username: "execution-user", Password: "pw"}
require.NoError(t, db.Create(&user).Error)
title := "Execution job"
job := models.TranscriptionJob{UserID: user.ID, Title: &title, Status: models.StatusUploaded, AudioPath: "/tmp/audio.wav"}
require.NoError(t, db.Create(&job).Error)
jobRepo := repository.NewJobRepository(db)
execution1 := &models.TranscriptionJobExecution{
TranscriptionJobID: job.ID,
UserID: user.ID,
Status: models.StatusProcessing,
StartedAt: time.Now(),
}
require.NoError(t, jobRepo.CreateExecution(t.Context(), execution1))
assert.Equal(t, 1, execution1.ExecutionNumber)
var persistedJob models.TranscriptionJob
require.NoError(t, db.First(&persistedJob, "id = ?", job.ID).Error)
assert.NotNil(t, persistedJob.LatestExecutionID)
assert.Equal(t, execution1.ID, *persistedJob.LatestExecutionID)
execution2 := &models.TranscriptionJobExecution{
TranscriptionJobID: job.ID,
UserID: user.ID,
Status: models.StatusFailed,
StartedAt: time.Now(),
}
require.NoError(t, jobRepo.CreateExecution(t.Context(), execution2))
assert.Equal(t, 2, execution2.ExecutionNumber)
require.NoError(t, db.First(&persistedJob, "id = ?", job.ID).Error)
assert.NotNil(t, persistedJob.LatestExecutionID)
assert.Equal(t, execution2.ID, *persistedJob.LatestExecutionID)
var executions []models.TranscriptionJobExecution
require.NoError(t, db.Where("transcription_id = ?", job.ID).Order("execution_number ASC").Find(&executions).Error)
require.Len(t, executions, 2)
assert.Equal(t, 1, executions[0].ExecutionNumber)
assert.Equal(t, 2, executions[1].ExecutionNumber)
}
func TestDefaultRecordsAreScopedPerUser(t *testing.T) {
db := openUnmigratedTestDB(t, "default-records.db")
userA := models.User{Username: "default-user-a", Password: "pw-a"}
userB := models.User{Username: "default-user-b", Password: "pw-b"}
require.NoError(t, db.Create(&userA).Error)
require.NoError(t, db.Create(&userB).Error)
base := time.Now().Truncate(time.Second)
// Create duplicate per-user defaults for legacy cleanup to normalize.
profileAFirst := models.TranscriptionProfile{ID: "profile-a-old", UserID: userA.ID, Name: "profile-old", IsDefault: true, CreatedAt: base, UpdatedAt: base}
profileASecond := models.TranscriptionProfile{ID: "profile-a-new", UserID: userA.ID, Name: "profile-new", IsDefault: true, CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)}
profileB := models.TranscriptionProfile{ID: "profile-b", UserID: userB.ID, Name: "profile-b", IsDefault: true, CreatedAt: base, UpdatedAt: base}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&profileAFirst).Error)
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&profileASecond).Error)
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&profileB).Error)
summaryTemplateAFirst := models.SummaryTemplate{ID: "template-a-old", UserID: userA.ID, Name: "template-old", Prompt: "prompt", IsDefault: true, Model: "gpt", CreatedAt: base, UpdatedAt: base}
summaryTemplateASecond := models.SummaryTemplate{ID: "template-a-new", UserID: userA.ID, Name: "template-new", Prompt: "prompt", IsDefault: true, Model: "gpt", CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)}
summaryTemplateB := models.SummaryTemplate{ID: "template-b", UserID: userB.ID, Name: "template-b", Prompt: "prompt", IsDefault: true, Model: "gpt", CreatedAt: base, UpdatedAt: base}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&summaryTemplateAFirst).Error)
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&summaryTemplateASecond).Error)
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&summaryTemplateB).Error)
llmAFirst := models.LLMConfig{ID: 1000, UserID: userA.ID, Provider: "provider-a", APIKey: ptr("k1"), IsDefault: true, CreatedAt: base, UpdatedAt: base}
llmASecond := models.LLMConfig{ID: 1001, UserID: userA.ID, Provider: "provider-b", APIKey: ptr("k2"), IsDefault: true, CreatedAt: base.Add(time.Minute), UpdatedAt: base.Add(time.Minute)}
llmB := models.LLMConfig{ID: 1002, UserID: userB.ID, Provider: "provider-c", APIKey: ptr("k3"), IsDefault: true, CreatedAt: base, UpdatedAt: base}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&llmAFirst).Error)
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&llmASecond).Error)
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&llmB).Error)
require.NoError(t, recordSchemaVersion(db, 1))
require.NoError(t, Migrate(db))
var profileA, profileBDef models.TranscriptionProfile
require.NoError(t, db.Where("user_id = ? AND id = ?", userA.ID, "profile-a-new").First(&profileA).Error)
require.NoError(t, db.Where("user_id = ?", userB.ID).Where("is_default = ?", true).First(&profileBDef).Error)
assert.True(t, profileA.IsDefault)
assert.True(t, profileBDef.IsDefault)
var summaryCountA int64
require.NoError(t, db.Model(&models.SummaryTemplate{}).Where("user_id = ? AND is_default = ?", userA.ID, true).Count(&summaryCountA).Error)
assert.Equal(t, int64(1), summaryCountA)
var llmCountA int64
require.NoError(t, db.Model(&models.LLMConfig{}).Where("user_id = ? AND is_default = ?", userA.ID, true).Count(&llmCountA).Error)
assert.Equal(t, int64(1), llmCountA)
profileRepo := repository.NewProfileRepository(db)
llmRepo := repository.NewLLMConfigRepository(db)
userADefProfile, err := profileRepo.FindDefaultByUser(t.Context(), userA.ID)
require.NoError(t, err)
assert.Equal(t, "profile-a-new", userADefProfile.ID)
userBDefProfile, err := profileRepo.FindDefaultByUser(t.Context(), userB.ID)
require.NoError(t, err)
assert.Equal(t, "profile-b", userBDefProfile.ID)
var userADefTemplate models.SummaryTemplate
require.NoError(t, db.Where("user_id = ? AND is_default = ?", userA.ID, true).First(&userADefTemplate).Error)
assert.Equal(t, "template-a-new", userADefTemplate.ID)
userAActiveLLM, err := llmRepo.GetActiveByUser(t.Context(), userA.ID)
require.NoError(t, err)
assert.Equal(t, "provider-b", userAActiveLLM.Provider)
userBActiveLLM, err := llmRepo.GetActiveByUser(t.Context(), userB.ID)
require.NoError(t, err)
assert.Equal(t, "provider-c", userBActiveLLM.Provider)
}
func TestSchemaUpgradeRunsVersionedBackfill(t *testing.T) {
db := openUnmigratedTestDB(t, "schema-upgrade.db")
userA := models.User{Username: "upgrade-a", Password: "pw-a"}
userB := models.User{Username: "upgrade-b", Password: "pw-b"}
require.NoError(t, db.Create(&userA).Error)
require.NoError(t, db.Create(&userB).Error)
base := time.Now().Truncate(time.Second)
profileA := models.TranscriptionProfile{
ID: "upgrade-profile-a",
UserID: userA.ID,
Name: "profile-a",
IsDefault: true,
Parameters: models.WhisperXParams{
Model: "medium",
ModelFamily: "whisper",
Device: "cpu",
ComputeType: "float32",
},
CreatedAt: base,
UpdatedAt: base,
}
profileB := models.TranscriptionProfile{
ID: "upgrade-profile-b",
UserID: userB.ID,
Name: "profile-b",
IsDefault: true,
Parameters: models.WhisperXParams{
Model: "large-v3",
ModelFamily: "whisper",
Device: "cuda",
ComputeType: "float16",
},
CreatedAt: base,
UpdatedAt: base,
}
require.NoError(t, db.Create(&profileA).Error)
require.NoError(t, db.Create(&profileB).Error)
require.NoError(t, recordSchemaVersion(db, 1))
require.NoError(t, db.Exec("UPDATE transcription_profiles SET config_json = ''").Error)
require.NoError(t, Migrate(db))
assert.Equal(t, latestSchemaVersion, schemaVersion(t, db))
var reloadedA, reloadedB models.TranscriptionProfile
require.NoError(t, db.First(&reloadedA, "id = ?", profileA.ID).Error)
require.NoError(t, db.First(&reloadedB, "id = ?", profileB.ID).Error)
assert.True(t, reloadedA.IsDefault)
assert.True(t, reloadedB.IsDefault)
assert.Equal(t, "medium", reloadedA.Parameters.Model)
assert.Equal(t, "large-v3", reloadedB.Parameters.Model)
}
func TestSchemaUpgradePreservesUpdatedAt(t *testing.T) {
db := openUnmigratedTestDB(t, "schema-upgrade-updated-at.db")
originalUpdatedAt := time.Date(2024, 12, 31, 12, 0, 0, 0, time.UTC)
user := models.User{
ID: 77,
Username: "preserve-updated-at",
Password: "pw",
UpdatedAt: originalUpdatedAt,
}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&user).Error)
require.NoError(t, recordSchemaVersion(db, 1))
require.NoError(t, db.Exec("UPDATE users SET settings_json = '' WHERE id = ?", user.ID).Error)
require.NoError(t, Migrate(db))
var reloaded models.User
require.NoError(t, db.First(&reloaded, "id = ?", user.ID).Error)
assert.True(t, reloaded.UpdatedAt.Equal(originalUpdatedAt))
}
func TestSchemaUpgradeDoesNotInventCompletionOrFailureTimestamps(t *testing.T) {
db := openMigratedTestDB(t, "schema-upgrade-no-invented-timestamps.db")
title := "legacy-completed-without-timestamp"
job := models.TranscriptionJob{
ID: "job-no-completed-at",
UserID: 1,
Title: &title,
Status: models.StatusCompleted,
AudioPath: "/tmp/audio.wav",
}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&job).Error)
require.NoError(t, db.Exec("UPDATE transcriptions SET completed_at = NULL, metadata_json = '' WHERE id = ?", job.ID).Error)
execution := models.TranscriptionJobExecution{
ID: "exec-no-failed-at",
TranscriptionJobID: job.ID,
UserID: 1,
ExecutionNumber: 1,
Status: models.StatusFailed,
StartedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
require.NoError(t, db.Session(&gorm.Session{SkipHooks: true}).Create(&execution).Error)
require.NoError(t, db.Exec("UPDATE transcription_executions SET failed_at = NULL, request_json = '', config_json = '' WHERE id = ?", execution.ID).Error)
require.NoError(t, db.Exec("DELETE FROM schema_migrations").Error)
require.NoError(t, recordSchemaVersion(db, 1))
require.NoError(t, Migrate(db))
var reloadedJob models.TranscriptionJob
require.NoError(t, db.First(&reloadedJob, "id = ?", job.ID).Error)
assert.Nil(t, reloadedJob.CompletedAt)
var reloadedExec models.TranscriptionJobExecution
require.NoError(t, db.First(&reloadedExec, "id = ?", execution.ID).Error)
assert.Nil(t, reloadedExec.FailedAt)
}
func TestDetectLegacySchemaWithLegacySameNameTables(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-same-name.db")
createLegacyDatabase(t, dbPath, false)
db, err := Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB(db)) })
require.NoError(t, db.Exec("DROP TABLE transcription_jobs").Error)
require.NoError(t, db.Exec("DROP TABLE transcription_job_executions").Error)
require.NoError(t, db.Exec("DROP TABLE llm_configs").Error)
require.NoError(t, db.Exec("DROP TABLE summary_settings").Error)
legacy, err := detectLegacySchema(db)
require.NoError(t, err)
assert.True(t, legacy)
}
func TestExtractNormalizedWherePredicateAcceptsEquivalentPartialIndexPredicates(t *testing.T) {
cases := []struct {
name string
sql string
}{
{
name: "quoted identifier",
sql: `CREATE UNIQUE INDEX idx_profiles_default ON transcription_profiles(user_id) WHERE ("is_default" = 1)`,
},
{
name: "true literal",
sql: `CREATE UNIQUE INDEX idx_profiles_default ON transcription_profiles(user_id) WHERE is_default = TRUE`,
},
{
name: "newline before where",
sql: "CREATE UNIQUE INDEX idx_profiles_default ON transcription_profiles(user_id)\nWHERE is_default = 1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, "is_default=1", extractNormalizedWherePredicate(tc.sql))
})
}
}
func TestLegacyMigrationPreservesData(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
createLegacyDatabase(t, dbPath, true)
db, err := Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB(db)) })
require.NoError(t, Migrate(db))
require.NoError(t, Migrate(db))
assert.True(t, db.Migrator().HasTable("transcriptions"))
assert.True(t, db.Migrator().HasTable("transcription_jobs"), "legacy table should be preserved")
assert.True(t, db.Migrator().HasTable("legacy_users"), "same-name legacy table should be archived")
assert.Equal(t, latestSchemaVersion, schemaVersion(t, db))
var user models.User
require.NoError(t, db.First(&user, "id = ?", 7).Error)
assert.Equal(t, "legacy-admin", user.Username)
assert.True(t, user.AutoTranscriptionEnabled)
require.NotNil(t, user.DefaultProfileID)
assert.Equal(t, "profile-1", *user.DefaultProfileID)
assert.Equal(t, "gpt-4o-mini", user.SummaryDefaultModel)
var transcription models.TranscriptionJob
require.NoError(t, db.First(&transcription, "id = ?", "job-1").Error)
assert.Equal(t, models.StatusPending, transcription.Status)
assert.Equal(t, "/legacy/audio.wav", transcription.AudioPath)
require.NotNil(t, transcription.Transcript)
assert.Contains(t, *transcription.Transcript, "hello world")
require.NotNil(t, transcription.Summary)
assert.Equal(t, "legacy summary cache", *transcription.Summary)
assert.Equal(t, "medium", transcription.Parameters.Model)
require.NotNil(t, transcription.LatestExecutionID)
var execution models.TranscriptionJobExecution
require.NoError(t, db.First(&execution, "id = ?", *transcription.LatestExecutionID).Error)
assert.Equal(t, "job-1", execution.TranscriptionJobID)
assert.Equal(t, 1, execution.ExecutionNumber)
assert.Equal(t, models.StatusCompleted, execution.Status)
assert.Equal(t, "medium", execution.ActualParameters.Model)
var mappings []models.SpeakerMapping
require.NoError(t, db.Where("transcription_id = ?", "job-1").Find(&mappings).Error)
require.Len(t, mappings, 1)
assert.Equal(t, "Latest Alice", mappings[0].CustomName)
var template models.SummaryTemplate
require.NoError(t, db.First(&template, "id = ?", "template-1").Error)
assert.Equal(t, "gpt-4o", template.Model)
assert.True(t, template.IncludeSpeakerInfo)
var summary models.Summary
require.NoError(t, db.First(&summary, "id = ?", "summary-1").Error)
assert.Equal(t, "job-1", summary.TranscriptionID)
assert.Equal(t, "gpt-4o", summary.Model)
assert.Equal(t, "completed", summary.Status)
keyRepo := repository.NewAPIKeyRepository(db)
migratedKey, err := keyRepo.FindByKey(t.Context(), "legacy-api-key-secret")
require.NoError(t, err)
assert.Equal(t, "legacy-a", migratedKey.KeyPrefix)
assert.True(t, migratedKey.IsActive)
assert.Equal(t, "legacy description", derefString(migratedKey.Description))
tokenRepo := repository.NewRefreshTokenRepository(db)
migratedToken, err := tokenRepo.FindByHash(t.Context(), "legacy-token-hash")
require.NoError(t, err)
assert.True(t, migratedToken.Revoked)
llmRepo := repository.NewLLMConfigRepository(db)
llmConfig, err := llmRepo.GetActive(t.Context())
require.NoError(t, err)
assert.Equal(t, "openai", llmConfig.Provider)
require.NotNil(t, llmConfig.APIKey)
assert.Equal(t, "openai-secret", *llmConfig.APIKey)
require.NotNil(t, llmConfig.OpenAIBaseURL)
assert.Equal(t, "https://openai.example", *llmConfig.OpenAIBaseURL)
}
func TestLegacyMigrationOnEmptyDatabase(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-empty.db")
createLegacyDatabase(t, dbPath, false)
db, err := Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB(db)) })
require.NoError(t, Migrate(db))
assert.Equal(t, latestSchemaVersion, schemaVersion(t, db))
var count int64
require.NoError(t, db.Model(&models.User{}).Count(&count).Error)
assert.Zero(t, count, "empty legacy DB should not invent a user")
}
func openMigratedTestDB(t *testing.T, name string) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), name)
db, err := Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB(db)) })
require.NoError(t, Migrate(db))
return db
}
func openUnmigratedTestDB(t *testing.T, name string) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), name)
db, err := Open(dbPath)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, closeDB(db)) })
require.NoError(t, db.AutoMigrate(
&models.User{},
&models.TranscriptionProfile{},
&models.SummaryTemplate{},
&models.LLMConfig{},
))
return db
}
func schemaVersion(t *testing.T, db *gorm.DB) int {
t.Helper()
var migration schemaMigration
require.NoError(t, db.Order("version DESC").First(&migration).Error)
return migration.Version
}
func pragmaString(t *testing.T, db *gorm.DB, pragma string) string {
t.Helper()
var value string
require.NoError(t, db.Raw("PRAGMA "+pragma).Scan(&value).Error)
return value
}
func hasIndex(t *testing.T, db *gorm.DB, tableName, indexName string) bool {
t.Helper()
type row struct {
Name string `gorm:"column:name"`
}
var rows []row
require.NoError(t, db.Raw("PRAGMA index_list('"+tableName+"')").Scan(&rows).Error)
for _, row := range rows {
if row.Name == indexName {
return true
}
}
return false
}
func createLegacyDatabase(t *testing.T, dbPath string, withData bool) {
t.Helper()
db, err := Open(dbPath)
require.NoError(t, err)
defer func() { require.NoError(t, closeDB(db)) }()
require.NoError(t, db.AutoMigrate(
&legacyUserTable{},
&legacyAPIKeyTable{},
&legacyRefreshTokenTable{},
&legacyTranscriptionProfileTable{},
&legacyTranscriptionJobTable{},
&legacyTranscriptionExecutionTable{},
&legacySpeakerMappingTable{},
&legacySummaryTemplateTable{},
&legacySummarySettingTable{},
&legacySummaryTable{},
&legacyLLMConfigTable{},
))
if !withData {
return
}
defaultProfileID := "profile-1"
now := time.Now().UTC().Truncate(time.Second)
completedAt := now.Add(10 * time.Minute)
processingDuration := int64(600000)
transcriptJSON := `{"text":"hello world","segments":[{"start":0,"end":1,"text":"hello world"}]}`
title := "Legacy job"
profileDescription := "legacy profile"
summaryDescription := "legacy summary template"
openAIBaseURL := "https://openai.example"
openAIKey := "openai-secret"
lastUsed := now.Add(2 * time.Hour)
errorMessage := "old error"
user := legacyUser{ID: 7, Username: "legacy-admin", Password: "hashed", DefaultProfileID: &defaultProfileID, AutoTranscriptionEnabled: true, CreatedAt: now, UpdatedAt: now}
require.NoError(t, db.Table("users").Create(&user).Error)
profile := legacyTranscriptionProfile{ID: "profile-1", Name: "Legacy Profile", Description: &profileDescription, IsDefault: true, Parameters: models.WhisperXParams{Model: "medium", ModelFamily: "whisper", Device: "cpu", ComputeType: "float32", Diarize: true}, CreatedAt: now, UpdatedAt: now}
require.NoError(t, db.Table("transcription_profiles").Create(&profile).Error)
job := legacyTranscriptionJob{ID: "job-1", Title: &title, Status: "pending", AudioPath: "/legacy/audio.wav", Transcript: &transcriptJSON, Diarization: true, Summary: ptr("legacy summary cache"), ErrorMessage: &errorMessage, CreatedAt: now, UpdatedAt: completedAt, Parameters: models.WhisperXParams{Model: "medium", ModelFamily: "whisper", Device: "cpu", ComputeType: "float32", Diarize: true}}
require.NoError(t, db.Table("transcription_jobs").Create(&job).Error)
execution := legacyTranscriptionExecution{ID: "exec-1", TranscriptionJobID: "job-1", StartedAt: now, CompletedAt: &completedAt, ProcessingDuration: &processingDuration, ActualParameters: job.Parameters, Status: "completed", CreatedAt: completedAt, UpdatedAt: completedAt}
require.NoError(t, db.Table("transcription_job_executions").Create(&execution).Error)
olderMapping := legacySpeakerMapping{ID: 1, TranscriptionJobID: "job-1", OriginalSpeaker: "SPEAKER_00", CustomName: "Old Alice", CreatedAt: now, UpdatedAt: now}
newerMapping := legacySpeakerMapping{ID: 2, TranscriptionJobID: "job-1", OriginalSpeaker: "SPEAKER_00", CustomName: "Latest Alice", CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute)}
require.NoError(t, db.Table("speaker_mappings").Create(&olderMapping).Error)
require.NoError(t, db.Table("speaker_mappings").Create(&newerMapping).Error)
summaryTemplate := legacySummaryTemplate{ID: "template-1", Name: "Legacy Summary", Description: &summaryDescription, Model: "gpt-4o", Prompt: "Summarize", IncludeSpeakerInfo: true, CreatedAt: now, UpdatedAt: now}
require.NoError(t, db.Table("summary_templates").Create(&summaryTemplate).Error)
require.NoError(t, db.Table("summary_settings").Create(&legacySummarySetting{ID: 1, DefaultModel: "gpt-4o-mini", UpdatedAt: now}).Error)
require.NoError(t, db.Table("summaries").Create(&legacySummary{ID: "summary-1", TranscriptionID: "job-1", TemplateID: &summaryTemplate.ID, Model: "gpt-4o", Content: "summary body", CreatedAt: completedAt, UpdatedAt: completedAt}).Error)
llmConfig := legacyLLMConfig{ID: 3, Provider: "openai", OpenAIBaseURL: &openAIBaseURL, APIKey: &openAIKey, IsActive: true, CreatedAt: now, UpdatedAt: now}
require.NoError(t, db.Table("llm_configs").Create(&llmConfig).Error)
require.NoError(t, db.Table("api_keys").Create(&legacyAPIKey{ID: 9, Key: "legacy-api-key-secret", Name: "Legacy API key", Description: ptr("legacy description"), IsActive: true, LastUsed: &lastUsed, CreatedAt: now, UpdatedAt: now}).Error)
require.NoError(t, db.Table("refresh_tokens").Create(&legacyRefreshToken{ID: 11, UserID: 7, Hashed: "legacy-token-hash", ExpiresAt: now.Add(24 * time.Hour), Revoked: true, CreatedAt: now, UpdatedAt: completedAt}).Error)
}
func ptr[T any](v T) *T { return &v }
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}