mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-07-01 08:15:46 +00:00
1025 lines
42 KiB
Go
1025 lines
42 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",
|
|
"user_settings",
|
|
"system_settings",
|
|
"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, hasIndex(t, db, "transcriptions", "idx_transcriptions_user_source_created"))
|
|
assert.True(t, hasIndex(t, db, "transcriptions", "idx_transcriptions_user_files_created"))
|
|
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"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.User{}, "status"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.User{}, "last_login_at"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.User{}, "password_changed_at"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.UserSettings{}, "default_profile_id"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.UserSettings{}, "auto_transcription_enabled"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.UserSettings{}, "auto_rename_enabled"))
|
|
assert.True(t, db.Migrator().HasColumn(&models.SystemSetting{}, "value_json"))
|
|
var schedulerSetting models.SystemSetting
|
|
require.NoError(t, db.First(&schedulerSetting, "key = ?", "queue.scheduler").Error)
|
|
assert.JSONEq(t, `{"policy":"priority"}`, schedulerSetting.ValueJSON)
|
|
|
|
title := "Fresh transcription"
|
|
user := models.User{Username: "fresh-user", Password: "pw"}
|
|
require.NoError(t, repository.NewUserRepository(db).Create(t.Context(), &user))
|
|
assert.Equal(t, models.UserStatusActive, user.Status)
|
|
var userSettings models.UserSettings
|
|
require.NoError(t, db.First(&userSettings, "user_id = ?", user.ID).Error)
|
|
assert.True(t, userSettings.AutoTranscriptionEnabled)
|
|
assert.True(t, userSettings.AutoRenameEnabled)
|
|
|
|
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.ASRParams{
|
|
Model: "medium",
|
|
ModelFamily: "whisper",
|
|
},
|
|
CreatedAt: base,
|
|
UpdatedAt: base,
|
|
}
|
|
profileB := models.TranscriptionProfile{
|
|
ID: "upgrade-profile-b",
|
|
UserID: userB.ID,
|
|
Name: "profile-b",
|
|
IsDefault: true,
|
|
Parameters: models.ASRParams{
|
|
Model: "large-v3",
|
|
ModelFamily: "whisper",
|
|
},
|
|
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 TestSchemaUpgradeBackfillsMissingUserSettingsAndListIndex(t *testing.T) {
|
|
db := openUnmigratedTestDB(t, "schema-upgrade-user-settings.db")
|
|
|
|
defaultProfileID := "profile-default"
|
|
user := models.User{
|
|
ID: 81,
|
|
Username: "settings-missing",
|
|
Password: "pw",
|
|
DefaultProfileID: &defaultProfileID,
|
|
AutoTranscriptionEnabled: true,
|
|
AutoRenameEnabled: true,
|
|
}
|
|
require.NoError(t, db.Create(&user).Error)
|
|
require.NoError(t, db.AutoMigrate(&models.UserSettings{}))
|
|
require.NoError(t, db.Exec("DELETE FROM user_settings WHERE user_id = ?", user.ID).Error)
|
|
require.NoError(t, recordSchemaVersion(db, 12))
|
|
|
|
require.NoError(t, Migrate(db))
|
|
|
|
assert.Equal(t, latestSchemaVersion, schemaVersion(t, db))
|
|
assert.True(t, hasIndex(t, db, "transcriptions", "idx_transcriptions_user_source_created"))
|
|
assert.True(t, hasIndex(t, db, "transcriptions", "idx_transcriptions_user_files_created"))
|
|
var settings models.UserSettings
|
|
require.NoError(t, db.First(&settings, "user_id = ?", user.ID).Error)
|
|
assert.Equal(t, defaultProfileID, *settings.DefaultProfileID)
|
|
assert.True(t, settings.AutoTranscriptionEnabled)
|
|
assert.True(t, settings.AutoRenameEnabled)
|
|
}
|
|
|
|
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.GetActiveByUser(t.Context(), user.ID)
|
|
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")
|
|
var schedulerSetting models.SystemSetting
|
|
require.NoError(t, db.First(&schedulerSetting, "key = ?", "queue.scheduler").Error)
|
|
assert.JSONEq(t, `{"policy":"priority"}`, schedulerSetting.ValueJSON)
|
|
}
|
|
|
|
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.ASRParams{Model: "medium", ModelFamily: "whisper", 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.ASRParams{Model: "medium", ModelFamily: "whisper", 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
|
|
}
|