Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.core.chat.repository

import io.askimo.core.chat.domain.ChatDirective
import io.askimo.core.chat.domain.ChatSession
import io.askimo.core.db.DatabaseManager
import io.askimo.core.util.AskimoHome
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.time.Instant

class ChatSessionDirectiveRepositoryIT {

@AfterEach
fun tearDown() {
sessionRepository.deleteAll()
directiveRepository.list().forEach { directiveRepository.delete(it.id) }
}

@Test
fun `stores multiple active directives for one session`() {
val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First"))
val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second"))
val session = sessionRepository.createSession(ChatSession(id = "", title = "Multiple directives"))

assertTrue(sessionRepository.replaceSessionDirectives(session.id, setOf(first.id, second.id)))

val expectedIds = listOf(first.id, second.id).sorted()
assertEquals(expectedIds, sessionRepository.getActiveDirectiveIds(session.id).toList())
assertEquals(expectedIds.first(), sessionRepository.getSession(session.id)?.directiveId)
}

@Test
fun `toggling one directive preserves the other active directives`() {
val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First"))
val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second"))
val session = sessionRepository.createSession(ChatSession(id = "", title = "Toggle directives"))
sessionRepository.replaceSessionDirectives(session.id, setOf(first.id, second.id))

assertTrue(sessionRepository.setSessionDirectiveActive(session.id, first.id, false))

assertEquals(setOf(second.id), sessionRepository.getActiveDirectiveIds(session.id))
assertEquals(second.id, sessionRepository.getSession(session.id)?.directiveId)
}

@Test
fun `active directive state is isolated by session`() {
val first = directiveRepository.save(ChatDirective(name = "Alpha", content = "First"))
val second = directiveRepository.save(ChatDirective(name = "Beta", content = "Second"))
val firstSession = sessionRepository.createSession(ChatSession(id = "", title = "First session"))
val secondSession = sessionRepository.createSession(ChatSession(id = "", title = "Second session"))

sessionRepository.setSessionDirectiveActive(firstSession.id, first.id, true)
sessionRepository.setSessionDirectiveActive(secondSession.id, second.id, true)

assertEquals(setOf(first.id), sessionRepository.getActiveDirectiveIds(firstSession.id))
assertEquals(setOf(second.id), sessionRepository.getActiveDirectiveIds(secondSession.id))
}

@Test
fun `deleting a directive refreshes every affected session`() {
val removed = directiveRepository.save(ChatDirective(name = "Temporary", content = "Temporary"))
val retained = directiveRepository.save(ChatDirective(name = "Retained", content = "Retained"))
val firstSession = sessionRepository.createSession(ChatSession(id = "", title = "First session"))
val secondSession = sessionRepository.createSession(ChatSession(id = "", title = "Second session"))
sessionRepository.replaceSessionDirectives(firstSession.id, setOf(removed.id, retained.id))
sessionRepository.setSessionDirectiveActive(secondSession.id, removed.id, true)

assertTrue(directiveRepository.delete(removed.id))

assertEquals(setOf(retained.id), sessionRepository.getActiveDirectiveIds(firstSession.id))
assertEquals(retained.id, sessionRepository.getSession(firstSession.id)?.directiveId)
assertTrue(sessionRepository.getActiveDirectiveIds(secondSession.id).isEmpty())
assertEquals(null, sessionRepository.getSession(secondSession.id)?.directiveId)
}

@Test
fun `newer legacy server selection replaces the local active set`() {
val first = directiveRepository.save(ChatDirective(name = "First", content = "First"))
val second = directiveRepository.save(ChatDirective(name = "Second", content = "Second"))
val session = sessionRepository.createSession(ChatSession(id = "", title = "Synced session"))
sessionRepository.replaceSessionDirectives(session.id, setOf(first.id, second.id))

sessionRepository.upsertFromServer(
listOf(
session.copy(
directiveId = second.id,
updatedAt = Instant.now().plusSeconds(60),
),
),
)

assertEquals(setOf(second.id), sessionRepository.getActiveDirectiveIds(session.id))
}

@Test
fun `activating another directive preserves a delayed legacy selection`() {
val legacyDirectiveId = "legacy-directive"
val session = ChatSession(
id = "legacy-session",
title = "Delayed directive",
directiveId = legacyDirectiveId,
)
sessionRepository.upsertFromServer(listOf(session))
val additional = directiveRepository.save(ChatDirective(name = "Additional", content = "Additional"))

sessionRepository.setSessionDirectiveActive(session.id, additional.id, true)

assertEquals(setOf(additional.id), sessionRepository.getActiveDirectiveIds(session.id))
assertEquals(legacyDirectiveId, sessionRepository.getSession(session.id)?.directiveId)

directiveRepository.upsertFromServer(
listOf(ChatDirective(id = legacyDirectiveId, name = "Legacy", content = "Legacy")),
)

val expectedDirectiveIds = listOf(legacyDirectiveId, additional.id).sorted()
assertEquals(
expectedDirectiveIds,
sessionRepository.getActiveDirectiveIds(session.id).toList(),
)
assertEquals(expectedDirectiveIds.first(), sessionRepository.getSession(session.id)?.directiveId)
}

companion object {
private lateinit var testBaseScope: AskimoHome.TestBaseScope
private lateinit var databaseManager: DatabaseManager
private lateinit var sessionRepository: ChatSessionRepository
private lateinit var directiveRepository: ChatDirectiveRepository

@JvmStatic
@BeforeAll
fun setUpClass(@TempDir tempDir: Path) {
testBaseScope = AskimoHome.withTestBase(tempDir)
databaseManager = DatabaseManager.getInMemoryTestInstance(this)
sessionRepository = databaseManager.getChatSessionRepository()
directiveRepository = databaseManager.getChatDirectiveRepository()
}

@JvmStatic
@AfterAll
fun tearDownClass() {
if (::databaseManager.isInitialized) databaseManager.close()
DatabaseManager.reset()
if (::testBaseScope.isInitialized) testBaseScope.close()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.core.chat.service

import com.fasterxml.jackson.databind.ObjectMapper
import io.askimo.core.chat.domain.ChatDirective
import io.askimo.core.chat.domain.ChatSession
import io.askimo.core.chat.repository.ChatDirectiveRepository
import io.askimo.core.chat.repository.ChatSessionRepository
import io.askimo.core.db.DatabaseManager
import io.askimo.core.util.AskimoHome
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path

class ChatSessionExporterServiceIT {

@AfterEach
fun tearDown() {
sessionRepository.deleteAll()
directiveRepository.list().forEach { directiveRepository.delete(it.id) }
}

@Test
fun `json compatibility directive matches the exported active set`(@TempDir exportDir: Path) {
val legacyDirectiveId = "unresolved-legacy-directive"
val activeDirectiveId = "active-directive"
val session = ChatSession(
id = "export-session",
title = "Directive export",
directiveId = legacyDirectiveId,
)
sessionRepository.upsertFromServer(listOf(session))
directiveRepository.save(
ChatDirective(id = activeDirectiveId, name = "Active", content = "Active content"),
)
sessionRepository.setSessionDirectiveActive(session.id, activeDirectiveId, true)

assertEquals(legacyDirectiveId, sessionRepository.getSession(session.id)?.directiveId)
assertEquals(setOf(activeDirectiveId), sessionRepository.getActiveDirectiveIds(session.id))

val exportPath = exportDir.resolve("session.json")
val result = exporterService.exportToJson(session.id, exportPath.toString())

assertTrue(result.isSuccess, result.exceptionOrNull()?.message)
val exportedJson = ObjectMapper().readTree(exportPath.toFile())
assertEquals(activeDirectiveId, exportedJson["directiveId"].asText())
assertEquals(
listOf(activeDirectiveId),
exportedJson["directiveIds"].map { it.asText() },
)
}

companion object {
private lateinit var testBaseScope: AskimoHome.TestBaseScope
private lateinit var databaseManager: DatabaseManager
private lateinit var sessionRepository: ChatSessionRepository
private lateinit var directiveRepository: ChatDirectiveRepository
private lateinit var exporterService: ChatSessionExporterService

@JvmStatic
@BeforeAll
fun setUpClass(@TempDir tempDir: Path) {
testBaseScope = AskimoHome.withTestBase(tempDir)
databaseManager = DatabaseManager.getInMemoryTestInstance(this)
sessionRepository = databaseManager.getChatSessionRepository()
directiveRepository = databaseManager.getChatDirectiveRepository()
exporterService = ChatSessionExporterService(
sessionRepository = sessionRepository,
messageRepository = databaseManager.getChatMessageRepository(),
)
}

@JvmStatic
@AfterAll
fun tearDownClass() {
if (::databaseManager.isInitialized) databaseManager.close()
DatabaseManager.reset()
if (::testBaseScope.isInitialized) testBaseScope.close()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,27 @@ class ChatSessionServiceIT {
assertEquals(directive.id, result.directiveId)
}

@Test
fun `resumeSessionPaginated should return all active directives`() {
val firstDirective = directiveRepository.save(
ChatDirective(name = "First Directive", content = "First instruction"),
)
val secondDirective = directiveRepository.save(
ChatDirective(name = "Second Directive", content = "Second instruction"),
)
val session = sessionRepository.createSession(
ChatSession(id = "", title = "Session with multiple directives"),
)
service.updateSessionDirectives(session.id, setOf(firstDirective.id, secondDirective.id))

val result = service.resumeSessionPaginated(session.id, limit = 10)

assertTrue(result.success)
val expectedDirectiveIds = listOf(firstDirective.id, secondDirective.id).sorted()
assertEquals(expectedDirectiveIds, result.activeDirectiveIds.toList())
assertEquals(expectedDirectiveIds.first(), result.directiveId)
}

@Test
fun `resumeSessionPaginated should handle limit of 1`() {
// Given
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/* SPDX-License-Identifier: AGPLv3
*
* Copyright (c) 2026 Askimo
*/
package io.askimo.core.db

import io.askimo.core.util.AskimoHome
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.sql.DriverManager

class SessionDirectiveMigrationIT {

@TempDir
lateinit var tempDir: Path

private var testBaseScope: AskimoHome.TestBaseScope? = null

@AfterEach
fun tearDown() {
DatabaseManager.reset()
testBaseScope?.close()
}

@Test
fun `migrates a legacy directive once and does not reactivate it after disabling`() {
testBaseScope = AskimoHome.withTestBase(tempDir)
Files.createDirectories(AskimoHome.base())
val databasePath = AskimoHome.base().resolve("askimo.db")

DriverManager.getConnection("jdbc:sqlite:$databasePath").use { connection ->
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE chat_directives (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
)
""".trimIndent(),
)
statement.executeUpdate(
"""
CREATE TABLE chat_sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
directive_id TEXT,
folder_id TEXT,
is_starred INTEGER DEFAULT 0,
synced_at TEXT
)
""".trimIndent(),
)
statement.executeUpdate(
"INSERT INTO chat_directives (id, name, content, created_at) " +
"VALUES ('directive-1', 'Legacy', 'Legacy content', '2026-01-01T00:00:00Z')",
)
statement.executeUpdate(
"INSERT INTO chat_sessions (id, title, created_at, updated_at, directive_id) " +
"VALUES ('session-1', 'Legacy session', '2026-01-01T00:00:00Z', " +
"'2026-01-01T00:00:00Z', 'directive-1')",
)
}
}

val databaseManager = DatabaseManager.getInstance()
val repository = databaseManager.getChatSessionRepository()
assertEquals(setOf("directive-1"), repository.getActiveDirectiveIds("session-1"))

assertTrue(repository.setSessionDirectiveActive("session-1", "directive-1", false))
assertTrue(repository.getActiveDirectiveIds("session-1").isEmpty())

DatabaseManager.reset()
val reopenedRepository = DatabaseManager.getInstance().getChatSessionRepository()
assertTrue(reopenedRepository.getActiveDirectiveIds("session-1").isEmpty())
}
}
Loading