git.iko.soy/parseable-android
on icon help sign in
parseable-android/app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt
286 lines 9.1 kB history raw
1package com.parseable.android
3import com.parseable.android.data.model.ApiResult
4import com.parseable.android.data.model.SchemaField
5import com.parseable.android.data.model.StreamSchema
6import com.parseable.android.data.repository.ParseableRepository
7import com.parseable.android.ui.screens.logviewer.LogViewerViewModel
8import com.parseable.android.ui.screens.logviewer.TimeRange
9import io.mockk.*
10import kotlinx.coroutines.Dispatchers
11import kotlinx.coroutines.ExperimentalCoroutinesApi
12import kotlinx.coroutines.test.*
13import kotlinx.serialization.json.JsonObject
14import kotlinx.serialization.json.JsonPrimitive
15import org.junit.After
16import org.junit.Assert.*
17import org.junit.Before
18import org.junit.Test
20@OptIn(ExperimentalCoroutinesApi::class)
21class LogViewerViewModelTest {
23 private lateinit var repository: ParseableRepository
24 private lateinit var viewModel: LogViewerViewModel
25 private val testDispatcher = UnconfinedTestDispatcher()
27 @Before
28 fun setup() {
29 Dispatchers.setMain(testDispatcher)
30 repository = mockk(relaxed = true)
31 coEvery { repository.getStreamSchema(any()) } returns ApiResult.Success(
32 StreamSchema(fields = listOf(
33 SchemaField(name = "message"),
34 SchemaField(name = "level"),
35 SchemaField(name = "p_timestamp"),
36 ))
37 )
38 coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns ApiResult.Success(emptyList())
39 viewModel = LogViewerViewModel(repository)
40 }
42 @After
43 fun tearDown() {
44 Dispatchers.resetMain()
45 }
47 @Test
48 fun `initialize sets stream name and loads schema`() {
49 viewModel.initialize("test_stream")
51 val state = viewModel.state.value
52 assertEquals("test_stream", state.streamName)
53 assertEquals(3, state.columns.size)
54 assertTrue(state.columns.contains("message"))
55 }
57 @Test
58 fun `initialize is idempotent for same stream`() {
59 viewModel.initialize("test_stream")
60 viewModel.initialize("test_stream")
62 coVerify(exactly = 1) { repository.getStreamSchema("test_stream") }
63 }
65 @Test
66 fun `onTimeRangeChange updates state and refreshes`() {
67 viewModel.initialize("test")
68 viewModel.onTimeRangeChange(TimeRange.LAST_24H)
70 assertEquals(TimeRange.LAST_24H, viewModel.state.value.selectedTimeRange)
71 assertEquals(500, viewModel.state.value.currentLimit)
72 }
74 @Test
75 fun `loadMore increments limit by 500`() {
76 viewModel.initialize("test")
77 viewModel.loadMore()
79 assertEquals(1000, viewModel.state.value.currentLimit)
80 }
82 @Test
83 fun `loadMore caps at MAX_LOAD_LIMIT of 5000`() {
84 viewModel.initialize("test")
85 // Load more 10 times (500 + 10*500 = 5500, but capped at 5000)
86 repeat(10) { viewModel.loadMore() }
88 assertEquals(5000, viewModel.state.value.currentLimit)
89 assertFalse(viewModel.state.value.hasMore) // Should be false once at cap
90 }
92 @Test
93 fun `addFilter adds clause and display to state`() {
94 viewModel.initialize("test")
95 viewModel.addFilter("level", "=", "ERROR")
97 val state = viewModel.state.value
98 assertEquals(1, state.activeFilters.size)
99 assertEquals("level = ERROR", state.activeFilters[0])
100 assertEquals(1, state.filterClauses.size)
101 }
103 @Test
104 fun `removeFilter removes correct filter by index`() {
105 viewModel.initialize("test")
106 viewModel.addFilter("level", "=", "ERROR")
107 viewModel.addFilter("message", "ILIKE", "timeout")
109 assertEquals(2, viewModel.state.value.activeFilters.size)
111 viewModel.removeFilter("level = ERROR")
113 val state = viewModel.state.value
114 assertEquals(1, state.activeFilters.size)
115 assertEquals("message ILIKE timeout", state.activeFilters[0])
116 }
118 @Test
119 fun `clearFilters removes all filters`() {
120 viewModel.initialize("test")
121 viewModel.addFilter("level", "=", "ERROR")
122 viewModel.addFilter("level", "=", "WARN")
123 viewModel.clearFilters()
125 assertTrue(viewModel.state.value.activeFilters.isEmpty())
126 assertTrue(viewModel.state.value.filterClauses.isEmpty())
127 }
129 @Test
130 fun `refresh with error sets error state`() {
131 coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns
132 ApiResult.Error("Server error", 500)
134 viewModel.initialize("test")
136 assertNotNull(viewModel.state.value.error)
137 }
139 @Test
140 fun `refresh with success clears error`() {
141 val logs = listOf(
142 JsonObject(mapOf("message" to JsonPrimitive("hello")))
143 )
144 coEvery { repository.queryLogs(any(), any(), any(), any(), any()) } returns
145 ApiResult.Success(logs)
147 viewModel.initialize("test")
149 assertNull(viewModel.state.value.error)
150 assertEquals(1, viewModel.state.value.logs.size)
151 }
153 @Test
154 fun `search builds ILIKE clause for all non-internal columns`() {
155 viewModel.initialize("test")
156 viewModel.onSearchQueryChange("error")
158 // Advance past debounce
159 testDispatcher.scheduler.advanceTimeBy(400)
161 coVerify {
162 repository.queryLogs(
163 stream = "test",
164 startTime = any(),
165 endTime = any(),
166 filterSql = match {
167 it.contains("\"message\" ILIKE '%error%'") &&
168 it.contains("\"level\" ILIKE '%error%'") &&
169 !it.contains("p_timestamp ILIKE") // Internal columns excluded
170 },
171 limit = any(),
172 )
173 }
174 }
176 @Test
177 fun `setCustomTimeRange updates state correctly`() {
178 viewModel.initialize("test")
179 viewModel.setCustomTimeRange(1000L, 2000L)
181 val state = viewModel.state.value
182 assertEquals(TimeRange.CUSTOM, state.selectedTimeRange)
183 assertEquals(1000L, state.customStartTime)
184 assertEquals(2000L, state.customEndTime)
185 assertEquals(500, state.currentLimit) // Reset
186 }
188 @Test
189 fun `toggleStreaming starts and stops`() {
190 viewModel.initialize("test")
192 viewModel.toggleStreaming()
193 assertTrue(viewModel.state.value.isStreaming)
195 viewModel.toggleStreaming()
196 assertFalse(viewModel.state.value.isStreaming)
197 }
199 @Test
200 fun `stopStreaming stops but preserves streaming error`() {
201 viewModel.initialize("test")
202 viewModel.toggleStreaming()
203 viewModel.stopStreaming()
205 val state = viewModel.state.value
206 assertFalse(state.isStreaming)
207 }
209 @Test
210 fun `dismissStreamingError clears the error`() {
211 viewModel.initialize("test")
212 viewModel.toggleStreaming()
213 viewModel.stopStreaming()
214 viewModel.dismissStreamingError()
216 assertNull(viewModel.state.value.streamingError)
217 }
219 @Test
220 fun `executeCustomSql rejects non-SELECT queries`() {
221 viewModel.initialize("test")
222 viewModel.executeCustomSql("DELETE FROM test_stream")
224 assertEquals("Only SELECT queries are allowed", viewModel.state.value.error)
225 }
227 @Test
228 fun `executeCustomSql appends LIMIT when missing`() {
229 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
231 viewModel.initialize("test")
232 viewModel.executeCustomSql("SELECT * FROM test_stream")
234 coVerify {
235 repository.queryLogsRaw(
236 match { it.contains("LIMIT 5000") },
237 any(),
238 any(),
239 )
240 }
241 }
243 @Test
244 fun `executeCustomSql preserves existing LIMIT`() {
245 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
247 viewModel.initialize("test")
248 viewModel.executeCustomSql("SELECT * FROM test_stream LIMIT 50")
250 coVerify {
251 repository.queryLogsRaw(
252 match { it.trim() == "SELECT * FROM test_stream LIMIT 50" },
253 any(),
254 any(),
255 )
256 }
257 }
259 @Test
260 fun `addFilter escapes column names`() {
261 viewModel.initialize("test")
262 viewModel.addFilter("col\"name", "=", "value")
264 val clause = viewModel.state.value.filterClauses[0]
265 assertTrue(clause.contains("col\"\"name")) // Double-escaped
266 }
268 @Test
269 fun `addFilter escapes SQL values`() {
270 viewModel.initialize("test")
271 viewModel.addFilter("col", "=", "it's")
273 val clause = viewModel.state.value.filterClauses[0]
274 assertTrue(clause.contains("it''s")) // Escaped quote
275 }
277 @Test
278 fun `addFilter handles IS NULL without value`() {
279 viewModel.initialize("test")
280 viewModel.addFilter("col", "IS NULL", "")
282 val clause = viewModel.state.value.filterClauses[0]
283 assertTrue(clause.endsWith("IS NULL"))
284 assertFalse(clause.contains("''"))
285 }