git.iko.soy/parseable-android
on master help sign in
parseable-android/app/src/test/java/com/parseable/android/LogViewerViewModelTest.kt
340 lines 11 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("CAST(\"message\" AS VARCHAR) ILIKE '%error%'") &&
168 it.contains("CAST(\"level\" AS VARCHAR) ILIKE '%error%'") &&
169 !it.contains("p_timestamp") // 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 `pullRefresh is a no-op while streaming`() {
190 viewModel.initialize("test")
191 // Page out so currentLimit is no longer at its initial 500.
192 viewModel.loadMore()
193 assertEquals(1000, viewModel.state.value.currentLimit)
195 viewModel.toggleStreaming()
196 assertTrue(viewModel.state.value.isStreaming)
198 // While live-tailing, pullRefresh must not run refresh() (which would reset the limit
199 // to 500 and race the streaming poller). Streaming stays on and the limit is untouched.
200 viewModel.pullRefresh()
202 assertTrue(viewModel.state.value.isStreaming)
203 assertEquals(1000, viewModel.state.value.currentLimit)
204 }
206 @Test
207 fun `toggleStreaming starts and stops`() {
208 viewModel.initialize("test")
210 viewModel.toggleStreaming()
211 assertTrue(viewModel.state.value.isStreaming)
213 viewModel.toggleStreaming()
214 assertFalse(viewModel.state.value.isStreaming)
215 }
217 @Test
218 fun `stopStreaming stops but preserves streaming error`() {
219 viewModel.initialize("test")
220 viewModel.toggleStreaming()
221 viewModel.stopStreaming()
223 val state = viewModel.state.value
224 assertFalse(state.isStreaming)
225 }
227 @Test
228 fun `dismissStreamingError clears the error`() {
229 viewModel.initialize("test")
230 viewModel.toggleStreaming()
231 viewModel.stopStreaming()
232 viewModel.dismissStreamingError()
234 assertNull(viewModel.state.value.streamingError)
235 }
237 @Test
238 fun `executeCustomSql rejects non-SELECT queries`() {
239 viewModel.initialize("test")
240 viewModel.executeCustomSql("DELETE FROM test_stream")
242 assertEquals("Only SELECT queries are allowed", viewModel.state.value.error)
243 }
245 @Test
246 fun `executeCustomSql appends LIMIT when missing`() {
247 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
249 viewModel.initialize("test")
250 viewModel.executeCustomSql("SELECT * FROM test_stream")
252 coVerify {
253 repository.queryLogsRaw(
254 match { it.contains("LIMIT 5000") },
255 any(),
256 any(),
257 )
258 }
259 }
261 @Test
262 fun `executeCustomSql preserves existing LIMIT`() {
263 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
265 viewModel.initialize("test")
266 viewModel.executeCustomSql("SELECT * FROM test_stream LIMIT 50")
268 coVerify {
269 repository.queryLogsRaw(
270 match { it.trim() == "SELECT * FROM test_stream LIMIT 50" },
271 any(),
272 any(),
273 )
274 }
275 }
277 @Test
278 fun `executeCustomSql strips trailing semicolon before appending LIMIT`() {
279 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
281 viewModel.initialize("test")
282 // Without stripping the ';', the appended LIMIT would land after the terminator
283 // ("...test_stream; LIMIT 5000") and produce invalid SQL.
284 viewModel.executeCustomSql("SELECT * FROM test_stream;")
286 coVerify {
287 repository.queryLogsRaw(
288 match { it.trim() == "SELECT * FROM test_stream LIMIT 5000" },
289 any(),
290 any(),
291 )
292 }
293 }
295 @Test
296 fun `executeCustomSql adds outer LIMIT when only a subquery has one`() {
297 coEvery { repository.queryLogsRaw(any(), any(), any()) } returns ApiResult.Success(emptyList())
299 viewModel.initialize("test")
300 // The inner LIMIT doesn't bound the outer result set, so the safety LIMIT must still
301 // be appended.
302 viewModel.executeCustomSql("SELECT * FROM (SELECT * FROM test_stream LIMIT 10) t")
304 coVerify {
305 repository.queryLogsRaw(
306 match { it.contains("LIMIT 10") && it.trimEnd().endsWith("LIMIT 5000") },
307 any(),
308 any(),
309 )
310 }
311 }
313 @Test
314 fun `addFilter escapes column names`() {
315 viewModel.initialize("test")
316 viewModel.addFilter("col\"name", "=", "value")
318 val clause = viewModel.state.value.filterClauses[0]
319 assertTrue(clause.contains("col\"\"name")) // Double-escaped
320 }
322 @Test
323 fun `addFilter escapes SQL values`() {
324 viewModel.initialize("test")
325 viewModel.addFilter("col", "=", "it's")
327 val clause = viewModel.state.value.filterClauses[0]
328 assertTrue(clause.contains("it''s")) // Escaped quote
329 }
331 @Test
332 fun `addFilter handles IS NULL without value`() {
333 viewModel.initialize("test")
334 viewModel.addFilter("col", "IS NULL", "")
336 val clause = viewModel.state.value.filterClauses[0]
337 assertTrue(clause.endsWith("IS NULL"))
338 assertFalse(clause.contains("''"))
339 }