diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md new file mode 100644 index 0000000..9166ee2 --- /dev/null +++ b/.agents/skills/testing/SKILL.md @@ -0,0 +1,49 @@ +--- +name: testing +description: How to write tests in Foliary. Make sure to use this skill whenever the user asks to add, update, fix, refactor, or review unit tests, DAO tests, repository tests, ViewModel tests, or test fixtures. Use it even when the request only mentions a production file if the work clearly requires test coverage. +--- +# Writing Tests in Foliary + +Follow the existing `commonTest` conventions already used in Foliary. Keep tests small, deterministic, and aligned with the production abstraction being exercised. + +## General rules + +### Fixtures First + +Never manually build domain or entity objects inside tests when the object can be represented by a fixture. + +1. Reuse an existing `fixture` helper first. +2. If no fixture exists yet, create one in `commonTest` near the tested type. Before creating or updating fixture functions you MUST read [`references/fixture.md`](./references/fixture.md). + +### General testing rules + +1. Put tests in the `commonTest` source set unless there is a clear reason to use another source set. +2. Use Kotest assertions such as `shouldBe`. Do not use plain `assert()`. +3. Use `runTest` for suspend and flow-based tests. +4. Name test methods with backticks and sentence-style names. +5. Before writing or updating this kind of test, read `templates/base-unit-test.md`. +6. Mock collaborators instead of standing up integration infrastructure unless the test is explicitly integration-oriented. +7. Use Mokkery with patterns like `mock()`, `every { ... } returns ...`, and `verifySuspend { ... }` when verification is needed. +8. Mokkery cannot mock final classes, and Kotlin classes are final by default, so regular classes that need to be mocked in tests must be annotated with `@Open`. + +## DAO Tests + +1. DAO tests must extend the shared `DaoTest` base class. +2. Use the real in-memory database and obtain the DAO from `database`. +3. Before writing or updating this kind of test, read `templates/dao-unit-test.md`. + +## ViewModel Tests + +1. ViewModel tests must extend the shared `ViewModelTest` base class. +2. Implement `createViewModel()` and build the subject there. +3. Use the inherited `test { ... }` helper rather than calling Orbit test APIs directly. +4. Assert states with `expectState(...)` and side effects with `expectSideEffect(...)`. +5. Use `advanceTimeBy(...)` or `advanceUntilIdle()` when the ViewModel relies on delays or queued coroutines. +6. Before writing or updating this kind of test, read `templates/view-model-unit-test.md`. + +## Implementation Checklist + +1. Check whether a shared base class already exists for this test type. +2. Check whether a fixture already exists for every object you need. +3. Create or extend fixtures before manually constructing objects. +4. Keep assertions focused on the behavior under test. diff --git a/.agents/skills/testing/evals/evals.json b/.agents/skills/testing/evals/evals.json new file mode 100644 index 0000000..a96b445 --- /dev/null +++ b/.agents/skills/testing/evals/evals.json @@ -0,0 +1,70 @@ +{ + "skill_name": "testing", + "evals": [ + { + "id": 1, + "prompt": "Add a focused JVM test for `TaskRepositoryImpl.findTodayTasks()` in `commonTest`. Reuse existing task fixtures, mock `TaskDao` and `TimeProvider` with Mokkery, and assert the returned `Flow` with `.first()`.", + "expected_output": "A repository-style unit test that stays in commonTest, uses Mokkery mocks, uses `Task.fixture()`, stubs the DAO with `flowOf(...)`, and asserts with Kotest `shouldBe`.", + "files": [], + "expectations": [ + "The solution keeps the test in `commonTest`.", + "The solution uses Mokkery mocks for `TaskDao` and `TimeProvider`.", + "The solution reuses `Task.fixture()` instead of manually building task objects.", + "The solution stubs the DAO flow with `flowOf(...)` and reads it with `.first()`.", + "The solution uses Kotest assertions such as `shouldBe`." + ] + }, + { + "id": 2, + "prompt": "Update `TaskDaoTest` coverage for `findTodayTasks()` so it verifies overdue tasks and tasks due today. Use the real in-memory database rather than mocks.", + "expected_output": "A DAO test that extends `DaoTest`, gets the DAO from `database`, inserts fixture entities, and reads the result flow with `.first()`.", + "files": [], + "expectations": [ + "The solution extends `DaoTest`.", + "The solution gets the DAO from `database` instead of mocking it.", + "The solution inserts fixture entities rather than manually constructing them.", + "The solution reads DAO flow results with `.first()`.", + "The solution keeps the assertion focused on the query behavior." + ] + }, + { + "id": 3, + "prompt": "Create a `SignInViewModel` test for the loading state and the magic-link request flow. Keep it aligned with our Orbit test helpers.", + "expected_output": "A ViewModel test that extends `ViewModelTest`, builds the subject in `createViewModel()`, uses the inherited `test { ... }` helper, asserts states with `expectState(...)`, and verifies the repository call with Mokkery.", + "files": [], + "expectations": [ + "The solution extends `ViewModelTest`.", + "The solution creates the subject in `createViewModel()`.", + "The solution uses the inherited `test { ... }` helper.", + "The solution asserts states with `expectState(...)`.", + "The solution verifies the collaborator call with Mokkery when needed." + ] + }, + { + "id": 4, + "prompt": "Create a fixture function from scratch for this new entity and follow Foliary's fixture conventions exactly:\n\n```kotlin\npackage dev.appoutlet.foliary.feature.garden.model\n\nimport dev.appoutlet.foliary.data.task.database.entity.Location\nimport kotlin.uuid.Uuid\n\ndata class Garden(\n val id: Uuid,\n val name: String,\n val description: String?,\n val location: Location?,\n val tags: List,\n) {\n companion object\n}\n```\n\nReturn the `GardenFixture.kt` content you would add under `commonTest`.", + "expected_output": "A `GardenFixture.kt` implementation under the same package in `commonTest` that defines `fun Garden.Companion.fixture(...)`, mirrors the constructor signature exactly, keeps defaults non-null and realistic, uses `Location.fixture()` for the nested entity, and provides at least one default tag.", + "files": [], + "expectations": [ + "The solution places the fixture in the same package as `Garden`, but under `commonTest`.", + "The solution defines `fun Garden.Companion.fixture(...)`.", + "The solution mirrors the constructor signature exactly, including argument order, names, types, and nullability.", + "The solution uses non-null, realistic defaults for every parameter, including `description`.", + "The solution uses `Location.fixture()` as the default value for `location`.", + "The solution gives `tags` a default list with at least one element." + ] + }, + { + "id": 5, + "prompt": "Fix this failing test setup. `ReminderRepositoryImpl` depends on a regular Kotlin class `ReminderTimeProvider` that Mokkery cannot mock. Show the minimal production-code change needed to make mocking work and explain why.\n\n```kotlin\npackage dev.appoutlet.foliary.data.reminder\n\nclass ReminderTimeProvider {\n fun now(): Instant = Clock.System.now()\n}\n\nclass ReminderRepositoryImpl(private val timeProvider: ReminderTimeProvider) {\n fun currentTime(): Instant = timeProvider.now()\n}\n\nclass ReminderRepositoryImplTest {\n private val timeProvider = mock()\n private val subject = ReminderRepositoryImpl(timeProvider)\n}\n```", + "expected_output": "A short answer that explains Kotlin classes are final by default, Mokkery cannot mock final classes, and the fix is to annotate `ReminderTimeProvider` with `@Open` (or make it an `interface` / `open class`).", + "files": [], + "expectations": [ + "The solution explains that regular Kotlin classes are final by default.", + "The solution states that Mokkery cannot mock final classes.", + "The solution mentions applying the `@Open` annotation to `ReminderTimeProvider`.", + "The solution mentions an acceptable alternative such as making `ReminderTimeProvider` an interface or an `open class`." + ] + } + ] +} diff --git a/.agents/skills/testing/evals/trigger-evals.json b/.agents/skills/testing/evals/trigger-evals.json new file mode 100644 index 0000000..3a4bc36 --- /dev/null +++ b/.agents/skills/testing/evals/trigger-evals.json @@ -0,0 +1,34 @@ +[ + { + "query": "Add a focused JVM test for `TaskRepositoryImpl.findTodayTasks()` and reuse the existing task fixtures instead of hand-building objects.", + "should_trigger": true + }, + { + "query": "Fix `TaskDaoTest` after I changed the Room query for today's tasks. Keep it using the real database and update the assertions.", + "should_trigger": true + }, + { + "query": "Write a `SignInViewModel` test for the magic-link flow using our Orbit test helpers and whatever shared base class the repo already has.", + "should_trigger": true + }, + { + "query": "There is no fixture for this new entity yet. Create the companion `fixture()` function from scratch in `commonTest` before you add the repository test.", + "should_trigger": true + }, + { + "query": "Create a new task details screen in Compose Multiplatform and wire it into navigation.", + "should_trigger": false + }, + { + "query": "Update `AGENTS.md` so it tells agents to load the testing skill before writing tests.", + "should_trigger": false + }, + { + "query": "Run `./gradlew foliary:jvmTest --tests \"dev.appoutlet.foliary.data.task.TaskRepositoryImplTest\"` and tell me whether it passes.", + "should_trigger": false + }, + { + "query": "Refactor `TaskRepositoryImpl` production logic to use a different query, but do not add or change any tests yet.", + "should_trigger": false + } +] diff --git a/.agents/skills/testing/references/fixture.md b/.agents/skills/testing/references/fixture.md new file mode 100644 index 0000000..c894fcb --- /dev/null +++ b/.agents/skills/testing/references/fixture.md @@ -0,0 +1,34 @@ +# Fixture Creation In Foliary + +Read this file before creating or updating any fixture function. + +## Workflow + +1. Read the entity primary constructor in `commonMain`. +2. Create the fixture file in `commonTest` under the exact same package as the entity. +3. Name the file `[EntityName]Fixture.kt`. +4. Implement the fixture as a companion extension function: `fun Entity.Companion.fixture(...)`. +5. Copy the fixture function arguments from the entity primary constructor exactly. +6. Keep the same argument names, types, nullability, and order as the entity constructor. +7. Return the entity by passing those arguments through directly. +8. If you need a concrete shape reference, read the relevant example in `templates/` before writing the fixture. + +## Strict Rules + +1. The fixture file must live in the same package as the entity, but under the `commonTest` source set. +2. The fixture function arguments must match the entity primary constructor exactly. +3. The fixture default values must be sensible and represent a realistic object. +4. Default values must never be `null`, even when the parameter type is nullable. +5. When a parameter is another entity, use that entity's fixture as the default value. +6. When a parameter type is a collection, the default value must contain at least one element. +7. Do not invent fixture-only parameters. The fixture signature must mirror the entity constructor. +8. Do not reorder parameters in the fixture. + +## Default Value Guidance + +1. For IDs, use a generated but valid ID such as `Uuid.random()`. +2. For names, titles, descriptions, and URLs, use readable real-looking values. +3. For dates, choose values that make sense together as one coherent object. +4. For nullable timestamps, enums, nested objects, and strings, still provide a non-null default. +5. For nested entities, call that nested entity's `fixture()`. +6. For collections, create at least one realistic child element using fixtures where appropriate. diff --git a/.agents/skills/testing/templates/base-unit-test.md b/.agents/skills/testing/templates/base-unit-test.md new file mode 100644 index 0000000..47d8fe2 --- /dev/null +++ b/.agents/skills/testing/templates/base-unit-test.md @@ -0,0 +1,27 @@ +# Base Unit Test Pattern + +Use this shape for repository tests and other unit tests that mock collaborators. + +```kotlin +private val mockTaskDao = mock() +private val mockTimeProvider = mock() +private val subject = TaskRepositoryImpl(mockTaskDao, mockTimeProvider) + +@Test +fun `should return tasks due today and overdue tasks`() = runTest { + val endOfToday = Instant.parse("2026-07-22T23:59:59.999999999Z") + val fixtureTasks = listOf(Task.fixture()) + + every { mockTimeProvider.endOfToday() } returns endOfToday + every { mockTaskDao.findTodayTasks(endOfToday) } returns flowOf(fixtureTasks) + + subject.findTodayTasks().first() shouldBe fixtureTasks +} +``` + +## Notes + +1. Use Mokkery for collaborators. +2. Prefer fixtures for returned objects. +3. For `Flow`-based APIs, stub with `flowOf(...)` and assert with `.first()`. +4. Keep the test focused on the unit boundary instead of persistence details. diff --git a/.agents/skills/testing/templates/dao-unit-test.md b/.agents/skills/testing/templates/dao-unit-test.md new file mode 100644 index 0000000..0858afc --- /dev/null +++ b/.agents/skills/testing/templates/dao-unit-test.md @@ -0,0 +1,30 @@ +# DAO Unit Test Pattern + +Use this shape for DAO tests against the real in-memory Room database. + +```kotlin +class TaskDaoTest : DaoTest() { + private val dao by lazy { database.taskDao() } + + @Test + fun `should return overdue tasks and tasks due today`() = runTest { + val endOfToday = Instant.parse("2026-07-21T23:59:59.999999999Z") + val overdueTask = Task.fixture( + dueDate = Instant.parse("2026-07-20T22:00:00Z"), + completionDate = null, + ) + + dao.save(overdueTask) + + val result = dao.findTodayTasks(endOfToday).first() + result.map { it.id } shouldBe listOf(overdueTask.id) + } +} +``` + +## Notes + +1. Extend `DaoTest`. +2. Use fixtures for inserted entities. +3. Read flow results with `.first()`. +4. When full entity equality is noisy because of persistence normalization, compare stable fields such as IDs. diff --git a/.agents/skills/testing/templates/view-model-unit-test.md b/.agents/skills/testing/templates/view-model-unit-test.md new file mode 100644 index 0000000..74a17e8 --- /dev/null +++ b/.agents/skills/testing/templates/view-model-unit-test.md @@ -0,0 +1,45 @@ +# ViewModel Unit Test Pattern + +Use this shape for Orbit-based ViewModel tests. + +```kotlin +class SignInViewModelTest : ViewModelTest() { + private val sessionStatusFlow = MutableStateFlow(SessionStatus.Initializing) + private val mockAuthenticationRepository = mock(MockMode.autoUnit) + .apply { every { sessionStatus() } returns sessionStatusFlow } + + override fun createViewModel() = SignInViewModel( + authenticationRepository = mockAuthenticationRepository, + deeplinkDispatcher = DeepLinkDispatcher, + ) + + @Test + fun `should show loading when session is initializing`() = test { + expectState(SignInViewData.Loading) + } + + @Test + fun `should request magic link`() { + val email = "user@example.com" + sessionStatusFlow.value = SessionStatus.NotAuthenticated() + + test { + expectState(SignInViewData.NotAuthenticated()) + + viewModel.onEvent(SignInEvent.OnSendMagicLink(email)) + + expectState(SignInViewData.NotAuthenticated(requestingMagicLink = true)) + verifySuspend { mockAuthenticationRepository.requestMagicLink(email) } + expectState(SignInViewData.MagicLinkSent(email)) + } + } +} +``` + +## Notes + +1. Extend `ViewModelTest`. +2. Build the subject in `createViewModel()`. +3. Use the inherited `test { ... }` helper. +4. Assert states and side effects through the provided Orbit helpers. +5. Set initial collaborator state before entering the `test { ... }` block when startup behavior depends on it. diff --git a/.gitignore b/.gitignore index d6a1a10..c0f5dff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ Pods/ *yarn.lock .opencode/ i18n.cache +.agents/skills/*-workspace diff --git a/AGENTS.md b/AGENTS.md index b7fa43d..d51c39c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Additionally, the project contains email templates for authentication (e.g., sig | Goal | Command | |------|---------| | Run single test class | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest"` | -| Run single test method | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest.should return todays tasks"` | +| Run single test method | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest.should return tasks due today and overdue tasks"` | ### CI & Hooks - **Pre-push hook**: `config/githooks/pre-push.sh` runs `./gradlew detekt` @@ -52,7 +52,7 @@ Additionally, the project contains email templates for authentication (e.g., sig | Functions/Properties | `camelCase` | `onEvent`, `userName` | | Constants | `PascalCase` | `DefaultTimeout` | | Test classes | `PascalCase` + `Test` | `TaskRepositoryImplTest` | -| Test methods | Backticks | `` `should return todays tasks` `` | +| Test methods | Backticks | `` `should return tasks due today and overdue tasks` `` | ### Types & Immutability - Prefer `val` over `var`; use `data class` with `val` properties - Use `sealed interface` for state/actions @@ -68,15 +68,4 @@ Additionally, the project contains email templates for authentication (e.g., sig **Important**: Always use Kotest assertions (e.g., `result shouldBe expected`) instead of standard `assert()`. -```kotlin -class FeatureTest { - @Test - fun `should do something`() = runTest { - val result = subject.doSomething() - result shouldBe expectedValue - } -} -``` - -### UI Tests -Use `runComposeUiTest` and `onNodeWithTag` (via `modifier.testTag("TagName")`). +**Skill**: Before writing, updating, or reviewing tests, load the `testing` skill. diff --git a/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json b/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json index a1936f1..fe3f87c 100644 --- a/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json +++ b/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "588df0fceb5b7c0bd658e710a4ae336f", + "identityHash": "5b116516a481542d4cc6d3c7547ced00", "entities": [ { "tableName": "ApplicationVersion", @@ -85,11 +85,76 @@ "accessToken" ] } + }, + { + "tableName": "Task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `creationDate` INTEGER NOT NULL, `dueDate` INTEGER, `completionDate` INTEGER, `priority` TEXT, `url` TEXT, `latitude` REAL, `longitude` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "creationDate", + "columnName": "creationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionDate", + "columnName": "completionDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "location.latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "location.longitude", + "columnName": "longitude", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '588df0fceb5b7c0bd658e710a4ae336f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5b116516a481542d4cc6d3c7547ced00')" ] } } \ No newline at end of file diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt index 2630a14..d590812 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt @@ -22,6 +22,9 @@ class DatabaseModule { @Single fun provideApplicationVersionDao(database: FoliaryDatabase) = database.applicationVersionDao() + + @Single + fun provideTaskDao(database: FoliaryDatabase) = database.taskDao() } @Module diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt index 4e0fdf4..7c5e790 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt @@ -3,14 +3,21 @@ package dev.appoutlet.foliary.core.database import androidx.room3.ConstructedBy import androidx.room3.Database import androidx.room3.RoomDatabase +import androidx.room3.TypeConverters +import dev.appoutlet.foliary.core.database.typeconverter.InstantTypeConverter +import dev.appoutlet.foliary.core.database.typeconverter.UuidTypeConverter import dev.appoutlet.foliary.data.applicationversion.ApplicationVersionDao import dev.appoutlet.foliary.data.applicationversion.model.ApplicationVersion import dev.appoutlet.foliary.data.authentication.database.SessionDao import dev.appoutlet.foliary.data.authentication.model.Session +import dev.appoutlet.foliary.data.task.database.TaskDao +import dev.appoutlet.foliary.data.task.database.entity.Task -@Database(entities = [ApplicationVersion::class, Session::class], version = 1) +@Database(entities = [ApplicationVersion::class, Session::class, Task::class], version = 1) @ConstructedBy(FoliaryDatabaseConstructor::class) +@TypeConverters(UuidTypeConverter::class, InstantTypeConverter::class) abstract class FoliaryDatabase : RoomDatabase() { abstract fun applicationVersionDao(): ApplicationVersionDao abstract fun sessionDao(): SessionDao + abstract fun taskDao(): TaskDao } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt new file mode 100644 index 0000000..7c4b766 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt @@ -0,0 +1,12 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import androidx.room3.TypeConverter +import kotlin.time.Instant + +class InstantTypeConverter { + @TypeConverter + fun toInstant(epochMilliseconds: Long) = Instant.fromEpochMilliseconds(epochMilliseconds) + + @TypeConverter + fun fromInstant(instant: Instant) = instant.toEpochMilliseconds() +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt new file mode 100644 index 0000000..0c6bccb --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt @@ -0,0 +1,12 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import androidx.room3.TypeConverter +import kotlin.uuid.Uuid + +class UuidTypeConverter { + @TypeConverter + fun fromUuid(uuid: Uuid): String = uuid.toString() + + @TypeConverter + fun toUuid(string: String) = Uuid.parse(string) +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt index ab0d7ec..536f162 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt @@ -1,7 +1,8 @@ package dev.appoutlet.foliary.data.task -import dev.appoutlet.foliary.domain.Task +import dev.appoutlet.foliary.data.task.database.entity.Task +import kotlinx.coroutines.flow.Flow interface TaskRepository { - suspend fun findTodaysTasks(): List + fun findTodayTasks(): Flow> } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt index 5f19d92..5bfb838 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt @@ -1,9 +1,17 @@ package dev.appoutlet.foliary.data.task -import dev.appoutlet.foliary.domain.Task +import dev.appoutlet.foliary.data.task.database.TaskDao +import dev.appoutlet.foliary.data.task.database.entity.Task +import dev.appoutlet.foliary.data.time.TimeProvider +import kotlinx.coroutines.flow.Flow +import org.koin.core.annotation.Single -class TaskRepositoryImpl : TaskRepository { - override suspend fun findTodaysTasks(): List { - return emptyList() +@Single +class TaskRepositoryImpl( + private val taskDao: TaskDao, + private val timeProvider: TimeProvider, +) : TaskRepository { + override fun findTodayTasks(): Flow> { + return taskDao.findTodayTasks(timeProvider.endOfToday()) } } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt new file mode 100644 index 0000000..e1a6724 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt @@ -0,0 +1,21 @@ +package dev.appoutlet.foliary.data.task.database + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import dev.appoutlet.foliary.data.task.database.entity.Task +import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant + +@Dao +interface TaskDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(vararg task: Task) + + @Query("SELECT * FROM Task") + suspend fun findAll(): List + + @Query("SELECT * FROM Task WHERE dueDate <= :endOfToday AND completionDate IS NULL ORDER BY dueDate DESC") + fun findTodayTasks(endOfToday: Instant): Flow> +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt new file mode 100644 index 0000000..193fa72 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt @@ -0,0 +1,8 @@ +package dev.appoutlet.foliary.data.task.database.entity + +data class Location( + val latitude: Double, + val longitude: Double, +) { + companion object +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt new file mode 100644 index 0000000..a8f8735 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt @@ -0,0 +1,5 @@ +package dev.appoutlet.foliary.data.task.database.entity + +enum class Priority { + LOWEST, LOW, MEDIUM, HIGH, HIGHEST, BLOCKER +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt new file mode 100644 index 0000000..e886f08 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt @@ -0,0 +1,22 @@ +package dev.appoutlet.foliary.data.task.database.entity + +import androidx.room3.Embedded +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import kotlin.time.Instant +import kotlin.uuid.Uuid + +@Entity +data class Task( + @PrimaryKey val id: Uuid, + val title: String, + val description: String?, + val creationDate: Instant, + val dueDate: Instant?, + val completionDate: Instant?, + val priority: Priority?, + val url: String?, + @Embedded val location: Location?, +) { + companion object +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt new file mode 100644 index 0000000..80216fd --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt @@ -0,0 +1,26 @@ +package dev.appoutlet.foliary.data.time + +import dev.appoutlet.foliary.core.allopen.Open +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.koin.core.annotation.Single +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +private val LastNanosecondOfTheDay = LocalTime.fromNanosecondOfDay((1.days.inWholeNanoseconds - 1)) + +@Single +@Open +class TimeProvider(private val clock: Clock = Clock.System) { + fun now(): Instant = clock.now() + + fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant { + return now().toLocalDateTime(timeZone).date + .atTime(LastNanosecondOfTheDay) + .toInstant(timeZone) + } +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt deleted file mode 100644 index c59827b..0000000 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt +++ /dev/null @@ -1,3 +0,0 @@ -package dev.appoutlet.foliary.domain - -data class Task(val id: String) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt new file mode 100644 index 0000000..c6792b3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt @@ -0,0 +1,27 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.time.Instant + +class InstantTypeConverterTest { + private val converter = InstantTypeConverter() + + @Test + fun `should convert epoch milliseconds to instant`() { + val epochMilliseconds = 1_726_131_200_000L + + val result = converter.toInstant(epochMilliseconds) + + result shouldBe Instant.fromEpochMilliseconds(epochMilliseconds) + } + + @Test + fun `should convert instant to epoch milliseconds`() { + val instant = Instant.fromEpochMilliseconds(1_726_131_200_000L) + + val result = converter.fromInstant(instant) + + result shouldBe 1_726_131_200_000L + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt new file mode 100644 index 0000000..393a17c --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt @@ -0,0 +1,27 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.uuid.Uuid + +class UuidTypeConverterTest { + private val converter = UuidTypeConverter() + + @Test + fun `should convert uuid to string`() { + val uuid = Uuid.parse("123e4567-e89b-12d3-a456-426614174000") + + val result = converter.fromUuid(uuid) + + result shouldBe "123e4567-e89b-12d3-a456-426614174000" + } + + @Test + fun `should convert string to uuid`() { + val string = "123e4567-e89b-12d3-a456-426614174000" + + val result = converter.toUuid(string) + + result shouldBe Uuid.parse(string) + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt index 058e6a0..8b7c1f9 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt @@ -1,16 +1,34 @@ package dev.appoutlet.foliary.data.task -import io.kotest.matchers.collections.shouldHaveSize +import dev.appoutlet.foliary.data.task.database.TaskDao +import dev.appoutlet.foliary.data.task.database.entity.Task +import dev.appoutlet.foliary.data.task.database.entity.fixture +import dev.appoutlet.foliary.data.time.TimeProvider +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.time.Instant class TaskRepositoryImplTest { - private val subject = TaskRepositoryImpl() + private val mockTaskDao = mock() + private val mockTimeProvider = mock() + private val subject = TaskRepositoryImpl(mockTaskDao, mockTimeProvider) @Test - fun `should return todays tasks`() = runTest { - val result = subject.findTodaysTasks() + fun `should return tasks due today and overdue tasks`() = runTest { + val endOfToday = Instant.parse("2026-07-22T23:59:59.999999999Z") + val fixtureTasks = listOf(Task.fixture()) - result shouldHaveSize 0 + every { mockTimeProvider.endOfToday() } returns endOfToday + every { mockTaskDao.findTodayTasks(endOfToday) } returns flowOf(fixtureTasks) + + val result = subject.findTodayTasks().first() + + result shouldBe fixtureTasks } } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt new file mode 100644 index 0000000..8a52ec3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt @@ -0,0 +1,57 @@ +package dev.appoutlet.foliary.data.task.database + +import dev.appoutlet.foliary.core.testing.DaoTest +import dev.appoutlet.foliary.data.task.database.entity.Task +import dev.appoutlet.foliary.data.task.database.entity.fixture +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +class TaskDaoTest : DaoTest() { + private val dao by lazy { database.taskDao() } + + @Test + fun `should return overdue tasks and tasks due today`() = runTest { + val endOfToday = Instant.parse("2026-07-21T23:59:59.999999999Z") + + val overdueTask = Task.fixture( + title = "Overdue task", + dueDate = Instant.parse("2026-07-20T22:00:00Z"), + completionDate = null, + ) + + val dueTodayTask = Task.fixture( + title = "Due today task", + dueDate = Instant.parse("2026-07-21T11:00:00Z"), + completionDate = null, + ) + + val dueAtEndOfDayTask = Task.fixture( + title = "Due at end of day task", + dueDate = Instant.parse("2026-07-21T23:59:59.999Z"), + completionDate = null, + ) + + val futureTask = Task.fixture( + title = "Future task", + dueDate = endOfToday + 1.milliseconds, + completionDate = null, + ) + + val completedTask = Task.fixture( + title = "Completed task", + dueDate = Instant.parse("2026-07-20T21:00:00Z"), + completionDate = Instant.parse("2026-07-21T10:00:00Z"), + ) + + dao.save(overdueTask, dueTodayTask, dueAtEndOfDayTask, futureTask, completedTask) + + val result = dao.findTodayTasks(endOfToday).first() + val resultIds = result.map { it.id } + + resultIds shouldBe listOf(dueAtEndOfDayTask.id, dueTodayTask.id, overdueTask.id) + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt new file mode 100644 index 0000000..93adba3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt @@ -0,0 +1,11 @@ +package dev.appoutlet.foliary.data.task.database.entity + +import kotlin.random.Random + +fun Location.Companion.fixture( + latitude: Double = Random.nextDouble(-90.0, 90.0), + longitude: Double = Random.nextDouble(-180.0, 180.0), +) = Location( + latitude = latitude, + longitude = longitude +) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt new file mode 100644 index 0000000..dc907e3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt @@ -0,0 +1,28 @@ +package dev.appoutlet.foliary.data.task.database.entity + +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant +import kotlin.uuid.Uuid + +fun Task.Companion.fixture( + id: Uuid = Uuid.random(), + title: String = "Task fixture", + description: String? = "Task description", + creationDate: Instant = Clock.System.now(), + dueDate: Instant? = creationDate.plus(30.days), + completionDate: Instant? = creationDate.plus(15.days), + priority: Priority? = Priority.MEDIUM, + url: String? = "https://foliary.appoutlet.dev/", + location: Location? = Location.fixture() +) = Task( + id = id, + title = title, + description = description, + creationDate = creationDate, + dueDate = dueDate, + completionDate = completionDate, + priority = priority, + url = url, + location = location +) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt new file mode 100644 index 0000000..fc9ea44 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt @@ -0,0 +1,29 @@ +package dev.appoutlet.foliary.data.time + +import io.kotest.matchers.longs.shouldBeAtLeast +import io.kotest.matchers.shouldBe +import kotlinx.datetime.TimeZone +import kotlin.test.Test +import kotlin.time.Clock +import kotlin.time.Instant + +class TimeProviderTest { + @Test + fun `should return current instant`() { + val now = Clock.System.now() + val subject = TimeProvider() + subject.now().toEpochMilliseconds() shouldBeAtLeast now.toEpochMilliseconds() + } + + @Test + fun `should return the end of today for the configured time zone`() { + val timezone = TimeZone.UTC + val today = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = TimeProvider(clock = FixedClock(today)) + subject.endOfToday(timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z") + } + + private class FixedClock(private val instant: Instant) : Clock { + override fun now(): Instant = instant + } +}