From f3c6271c9ea72c396ef5bdd30783d8e297fab17f Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 24 Jul 2026 09:16:46 +0200 Subject: [PATCH] Phase 4 tests: DatabaseService migrations + ContextSelectionService DB paths 17 tests against throwaway in-memory DatabaseService instances: all v1-v8 tables/columns exist post-migration, settings CRUD, conversation save/load round-trip, instance isolation between separate in-memory queues, and ContextSelectionService's DB-coupled paths (starring, excluded-range summaries, smartSelection end to end) that were previously untestable. --- oAITests/ContextSelectionServiceDBTests.swift | 120 +++++++++++++++++ oAITests/DatabaseServiceTests.swift | 123 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 oAITests/ContextSelectionServiceDBTests.swift create mode 100644 oAITests/DatabaseServiceTests.swift diff --git a/oAITests/ContextSelectionServiceDBTests.swift b/oAITests/ContextSelectionServiceDBTests.swift new file mode 100644 index 0000000..f63f4fc --- /dev/null +++ b/oAITests/ContextSelectionServiceDBTests.swift @@ -0,0 +1,120 @@ +// +// ContextSelectionServiceDBTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("ContextSelectionService DB-coupled paths, against a throwaway in-memory queue") +struct ContextSelectionServiceDBTests { + + /// Persists messages, then loads them back so their `id`s match the DB rows + /// (saveConversation assigns fresh row ids internally, distinct from the ids + /// on the Message values passed in). + private func persistedMessages(_ db: DatabaseService, _ messages: [Message]) throws -> (conversationId: UUID, messages: [Message]) { + let saved = try db.saveConversation(name: "Test", messages: messages) + let loaded = try db.loadConversation(id: saved.id) + return (saved.id, loaded?.1 ?? []) + } + + // MARK: - isMessageStarred + + @Test("A message with no metadata row is not starred") + func unstarredByDefault() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + #expect(service.isMessageStarred(messages[0]) == false) + } + + @Test("A message starred via setMessageStarred reports starred") + func starredAfterSetting() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (_, messages) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + try db.setMessageStarred(messageId: messages[0].id, starred: true) + #expect(service.isMessageStarred(messages[0]) == true) + } + + // MARK: - getSummariesForExcludedRange + + @Test("No summaries recorded returns an empty array") + func noSummariesReturnsEmpty() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10) + #expect(summaries.isEmpty) + } + + @Test("A summary covering only excluded messages is included") + func summaryForExcludedRangeIncluded() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + // 30 total, 10 selected -> messages 0..<20 are excluded + try db.saveConversationSummary(conversationId: conversationId, startIndex: 0, endIndex: 15, summary: "early stuff", model: nil, tokenCount: nil) + + let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10) + #expect(summaries == ["early stuff"]) + } + + @Test("A summary covering messages that were kept is excluded") + func summaryForKeptRangeExcluded() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + let (conversationId, _) = try persistedMessages(db, [Message(role: .user, content: "hi")]) + + // 30 total, 10 selected -> messages 0..<20 are excluded; this summary ends at 25, inside the kept range + try db.saveConversationSummary(conversationId: conversationId, startIndex: 15, endIndex: 25, summary: "later stuff", model: nil, tokenCount: nil) + + let summaries = service.getSummariesForExcludedRange(conversationId: conversationId, totalMessages: 30, selectedCount: 10) + #expect(summaries.isEmpty) + } + + // MARK: - smartSelection (end to end through the DB seam) + + @Test("A starred older message is pulled in alongside the recent window") + func starredMessagePulledIntoSelection() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + + var all: [Message] = [] + let base = Date() + for i in 0..<15 { + all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i)))) + } + let (_, persisted) = try persistedMessages(db, all) + + // Star an old message that's outside the last-10 window (index 2) + try db.setMessageStarred(messageId: persisted[2].id, starred: true) + + let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil) + #expect(window.messages.contains { $0.id == persisted[2].id }) + } + + @Test("An unstarred, unimportant old message outside the recent window is excluded") + func unstarredOldMessageExcluded() throws { + let db = DatabaseService.makeInMemory() + let service = ContextSelectionService(db: db) + + var all: [Message] = [] + let base = Date() + for i in 0..<15 { + all.append(Message(role: i % 2 == 0 ? .user : .assistant, content: "msg \(i)", timestamp: base.addingTimeInterval(Double(i)))) + } + let (_, persisted) = try persistedMessages(db, all) + + let window = service.smartSelection(allMessages: persisted, maxTokens: 100_000, conversationId: nil) + #expect(window.messages.contains { $0.id == persisted[0].id } == false) + #expect(window.excludedCount == 5) + } +} diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift new file mode 100644 index 0000000..680f738 --- /dev/null +++ b/oAITests/DatabaseServiceTests.swift @@ -0,0 +1,123 @@ +// +// DatabaseServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("DatabaseService migrations, against a throwaway in-memory queue") +struct DatabaseServiceMigrationTests { + + @Test("All v1-v8 tables exist after migration") + func allTablesExist() { + let db = DatabaseService.makeInMemory() + let expectedTables = [ + "conversations", "messages", "settings", "command_history", + "email_logs", "message_metadata", "message_embeddings", + "conversation_embeddings", "conversation_summaries", + ] + for table in expectedTables { + #expect(db.tableExists(table), "expected table \(table) to exist") + } + } + + @Test("v4 adds modelId to messages and primaryModel to conversations") + func v4AddsModelColumns() { + let db = DatabaseService.makeInMemory() + #expect(db.columnNames(in: "messages").contains("modelId")) + #expect(db.columnNames(in: "conversations").contains("primaryModel")) + } + + @Test("messages table has the expected v1 columns") + func messagesTableColumns() { + let db = DatabaseService.makeInMemory() + let columns = Set(db.columnNames(in: "messages")) + let expected: Set = ["id", "conversationId", "role", "content", "tokens", "cost", "timestamp", "sortOrder"] + #expect(expected.isSubset(of: columns)) + } + + @Test("message_metadata table has the expected v6 columns") + func messageMetadataColumns() { + let db = DatabaseService.makeInMemory() + let columns = Set(db.columnNames(in: "message_metadata")) + #expect(columns == ["message_id", "importance_score", "user_starred", "summary", "chunk_index"]) + } + + @Test("conversation_summaries table has the expected v8 columns") + func conversationSummariesColumns() { + let db = DatabaseService.makeInMemory() + let columns = Set(db.columnNames(in: "conversation_summaries")) + #expect(columns == ["id", "conversation_id", "start_message_index", "end_message_index", "summary", "token_count", "created_at", "summary_model"]) + } + + @Test("A nonexistent table reports as absent, not a crash") + func nonexistentTableReportsAbsent() { + let db = DatabaseService.makeInMemory() + #expect(db.tableExists("not_a_real_table") == false) + } + + @Test("Two in-memory instances are isolated from each other") + func instancesAreIsolated() throws { + let dbA = DatabaseService.makeInMemory() + let dbB = DatabaseService.makeInMemory() + + _ = try dbA.saveConversation(name: "only in A", messages: [Message(role: .user, content: "hi")]) + + #expect(try dbA.listConversations().count == 1) + #expect(try dbB.listConversations().count == 0) + } +} + +@Suite("DatabaseService settings CRUD, against a throwaway in-memory queue") +struct DatabaseServiceSettingsTests { + + @Test("Round-trips a plain setting") + func roundTripsSetting() { + let db = DatabaseService.makeInMemory() + db.setSetting(key: "theme", value: "dark") + #expect((try? db.loadAllSettings()["theme"]) == "dark") + } + + @Test("Deletes a setting") + func deletesSetting() { + let db = DatabaseService.makeInMemory() + db.setSetting(key: "temp", value: "1") + db.deleteSetting(key: "temp") + #expect((try? db.loadAllSettings()["temp"]) == nil) + } +} + +@Suite("DatabaseService conversation + message persistence, against a throwaway in-memory queue") +struct DatabaseServiceConversationTests { + + @Test("Saving a conversation round-trips its messages via loadConversation") + func savesAndLoadsConversation() throws { + let db = DatabaseService.makeInMemory() + let messages = [ + Message(role: .user, content: "hello"), + Message(role: .assistant, content: "hi there"), + ] + let saved = try db.saveConversation(name: "Test Chat", messages: messages) + + let loaded = try db.loadConversation(id: saved.id) + #expect(loaded?.0.name == "Test Chat") + #expect(loaded?.1.map(\.content) == ["hello", "hi there"]) + } + + @Test("System messages are excluded from persistence") + func systemMessagesExcluded() throws { + let db = DatabaseService.makeInMemory() + let messages = [ + Message(role: .system, content: "tool call"), + Message(role: .user, content: "hello"), + ] + let saved = try db.saveConversation(name: "Test", messages: messages) + let loaded = try db.loadConversation(id: saved.id) + #expect(loaded?.1.count == 1) + #expect(loaded?.1.first?.content == "hello") + } +}