From 32ed321df284cc86a4ce0fee803fac90616a82e9 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 10:56:22 +0200 Subject: [PATCH] feat: Admin-Statistiken pro Inventar & Inventar-Tabelle mit Paging/Sortierung/Filter/Suche - Neues DTO: InventoryStatsPerInventoryDto (inventoryId, inventoryName, totalItems, totalLocations, totalCategories, recentTransactions, lastUpdated, userCount) - InventoryRepository.getStatsPerInventory(): Stats pro Inventar - Neuer Endpoint: GET /api/admin/stats/inventories (Admin-only) - Admin-UI: aufklappbarer Bereich Statistiken pro Inventar (sortierbar) - Admin-UI: Inventar-Karten durch Tabelle ersetzt mit - Paging (10/25/50 Eintraege pro Seite) - Sortierung per Klick auf Spaltenheader - Filter (alle / mit Benutzern / ohne Benutzer) - Freitextsuche nach Name oder Inventar-ID - Tests: 3 neue InventoryRepositoryTests, 3 neue InventoryStatsTests (401/403/Inhalt fuer neuen Endpoint), setUp bereinigt alle Tabellen - Alle 148 Tests gruen --- .../java/de/krisenvorrat/app/ui/MainScreen.kt | 2 +- .../app/ui/navigation/TopLevelDestination.kt | 17 +- .../model/InventoryStatsPerInventoryDto.kt | 15 ++ .../server/repository/InventoryRepository.kt | 34 +++ .../krisenvorrat/server/routes/AdminRoutes.kt | 11 + .../main/resources/static/admin/index.html | 252 ++++++++++++++++-- .../krisenvorrat/server/InventoryStatsTest.kt | 33 +++ .../repository/InventoryRepositoryTest.kt | 85 +++++- 8 files changed, 407 insertions(+), 42 deletions(-) create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsPerInventoryDto.kt diff --git a/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt index d80d03a..e25d5dd 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt @@ -97,7 +97,7 @@ internal fun MainScreen() { contentDescription = destination.label ) }, - label = { Text(destination.label) } + label = null ) } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt b/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt index 78d2642..aef0c68 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/navigation/TopLevelDestination.kt @@ -7,9 +7,10 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.Home -import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.filled.Warehouse import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material.icons.outlined.Warehouse import androidx.compose.ui.graphics.vector.ImageVector internal enum class TopLevelDestination( @@ -22,30 +23,30 @@ internal enum class TopLevelDestination( route = Screen.Dashboard, selectedIcon = Icons.Filled.Home, unselectedIcon = Icons.Outlined.Home, - label = "Übersicht" + label = "Overview" ), INVENTORY( route = Screen.ItemList, - selectedIcon = Icons.Outlined.Inventory2, - unselectedIcon = Icons.Outlined.Inventory2, - label = "Inventur" + selectedIcon = Icons.Filled.Warehouse, + unselectedIcon = Icons.Outlined.Warehouse, + label = "Storage" ), WARNINGS( route = Screen.Warnings, selectedIcon = Icons.Filled.Warning, unselectedIcon = Icons.Outlined.Warning, - label = "Warnungen" + label = "Warnings" ), MESSAGES( route = Screen.UserList, selectedIcon = Icons.AutoMirrored.Filled.Message, unselectedIcon = Icons.AutoMirrored.Outlined.Message, - label = "Nachrichten" + label = "Chat" ), SETTINGS( route = Screen.Settings, selectedIcon = Icons.Filled.Settings, unselectedIcon = Icons.Outlined.Settings, - label = "Einstellungen" + label = "Settings" ); } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsPerInventoryDto.kt b/server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsPerInventoryDto.kt new file mode 100644 index 0000000..415a8ba --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/model/InventoryStatsPerInventoryDto.kt @@ -0,0 +1,15 @@ +package de.krisenvorrat.server.model + +import kotlinx.serialization.Serializable + +@Serializable +internal data class InventoryStatsPerInventoryDto( + val inventoryId: String, + val inventoryName: String, + val totalItems: Long, + val totalLocations: Long, + val totalCategories: Long, + val recentTransactions: Long, + val lastUpdated: Long?, + val userCount: Long +) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt index 7180034..ab30b68 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt @@ -8,6 +8,7 @@ import de.krisenvorrat.server.db.Locations import de.krisenvorrat.server.db.Settings import de.krisenvorrat.server.db.Users import de.krisenvorrat.server.model.InventoryStatsDto +import de.krisenvorrat.server.model.InventoryStatsPerInventoryDto import de.krisenvorrat.server.model.InventoryWithUsersDto import de.krisenvorrat.server.model.UserDto import de.krisenvorrat.shared.model.CategoryDto @@ -370,6 +371,39 @@ internal class InventoryRepository { } } + // TODO: N+1 query pattern – 6 queries per inventory. Acceptable for small admin datasets; + // consider consolidating with JOIN queries if inventory count grows. + fun getStatsPerInventory(): List = transaction { + val thirtyDaysAgo = System.currentTimeMillis() - 30L * 24 * 60 * 60 * 1000 + Inventories.selectAll().map { invRow -> + val invId = invRow[Inventories.id] + val invName = invRow[Inventories.name] + val totalItems = Items.selectAll().where { Items.inventoryId eq invId }.count() + val totalLocations = Locations.selectAll().where { Locations.inventoryId eq invId }.count() + val totalCategories = Categories.selectAll().where { Categories.inventoryId eq invId }.count() + val recentTransactions = Items.selectAll() + .where { (Items.inventoryId eq invId) and (Items.lastUpdated greaterEq thirtyDaysAgo) } + .count() + val lastUpdatedMax = Items.lastUpdated.max() + val lastUpdated = Items + .select(lastUpdatedMax) + .where { Items.inventoryId eq invId } + .map { it[lastUpdatedMax] } + .singleOrNull() + val userCount = Users.selectAll().where { Users.inventoryId eq invId }.count() + InventoryStatsPerInventoryDto( + inventoryId = invId, + inventoryName = invName, + totalItems = totalItems, + totalLocations = totalLocations, + totalCategories = totalCategories, + recentTransactions = recentTransactions, + lastUpdated = lastUpdated, + userCount = userCount + ) + } + } + fun getAggregatedStats(): InventoryStatsDto { return transaction { val totalItems = Items.selectAll().count() diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt index 8870a8e..c4a5224 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/AdminRoutes.kt @@ -175,5 +175,16 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito } call.respond(HttpStatusCode.OK, inventoryRepository.getAggregatedStats()) } + + // Per-inventory statistics for admin dashboard + get("/api/admin/stats/inventories") { + val principal = call.principal() + ?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized")) + if (!principal.isAdmin) { + call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required")) + return@get + } + call.respond(HttpStatusCode.OK, inventoryRepository.getStatsPerInventory()) + } } diff --git a/server/src/main/resources/static/admin/index.html b/server/src/main/resources/static/admin/index.html index d7d31c3..da1a477 100644 --- a/server/src/main/resources/static/admin/index.html +++ b/server/src/main/resources/static/admin/index.html @@ -42,6 +42,23 @@ .modal { background: #fff; border-radius: 8px; padding: 28px; width: 400px; max-width: 95vw; } .modal h3 { margin-bottom: 16px; } .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; } + /* Per-inventory stats */ + .inv-stats-toggle { background: none; border: none; cursor: pointer; font-size: .85rem; color: #2980b9; padding: 0; margin-top: 12px; text-decoration: underline; } + #inv-stats-section { display: none; margin-top: 16px; } + #inv-stats-section.open { display: block; } + #inv-stats-table th { cursor: pointer; user-select: none; white-space: nowrap; } + #inv-stats-table th:hover { background: #eef; } + #inv-stats-table th .sort-arrow { margin-left: 4px; color: #aaa; font-size: .8rem; } + /* Inventory list table */ + .table-controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; } + .table-controls input[type=text] { flex: 1; min-width: 180px; margin: 0; } + .table-controls select { width: auto; margin: 0; } + #inv-table th { cursor: pointer; user-select: none; white-space: nowrap; } + #inv-table th:hover { background: #eef; } + #inv-table th .sort-arrow { margin-left: 4px; color: #aaa; font-size: .8rem; } + .paging-bar { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; } + .paging-bar button { padding: 4px 10px; font-size: .85rem; } + .paging-bar span { font-size: .85rem; color: #666; } footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; } @@ -72,6 +89,23 @@
Änderungen (30 Tage)
Letzte Änderung
+ +
+ + + + + + + + + + + + + +
Name Artikel Lagerorte Kategorien Änd. 30T Letzte Änd. Benutzer
+
@@ -103,7 +137,38 @@

Inventar-Übersicht

-
+
+ + + +
+ + + + + + + + + + + + + +
Name Artikel Lagerorte Kategorien Benutzer Letzte Änderung Aktionen
+
+ + + +
@@ -153,6 +218,19 @@ let accessToken = sessionStorage.getItem('accessToken') || ''; let pendingUserId = null; let allInventories = []; + let allInvStats = []; + + // Inventory table state + let invData = []; + let invFiltered = []; + let invPage = 0; + let invPageSize = 10; + let invSortCol = 'inventoryName'; + let invSortAsc = true; + + // Per-inventory stats table state + let invStatsSortCol = 'inventoryName'; + let invStatsSortAsc = true; if (accessToken) tryLoadUsers(); @@ -197,20 +275,26 @@ } async function loadAll() { - const [usersRes, inventoriesRes, statsRes] = await Promise.all([ + const [usersRes, inventoriesRes, statsRes, invStatsRes] = await Promise.all([ fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }), fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } }), - fetch('/api/admin/stats', { headers: { 'Authorization': 'Bearer ' + accessToken } }) + fetch('/api/admin/stats', { headers: { 'Authorization': 'Bearer ' + accessToken } }), + fetch('/api/admin/stats/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } }) ]); if (!usersRes.ok) { logout(); return; } renderUsers(await usersRes.json()); if (inventoriesRes.ok) { allInventories = await inventoriesRes.json(); - renderInventories(allInventories); } if (statsRes.ok) { renderStats(await statsRes.json()); } + if (invStatsRes.ok) { + allInvStats = await invStatsRes.json(); + renderInvStatsTable(); + invData = allInvStats; + applyInvFilter(); + } } function renderStats(stats) { @@ -226,6 +310,142 @@ } } + // ── Per-inventory stats table ──────────────────────────────────────────── + + function toggleInvStats() { + const section = document.getElementById('inv-stats-section'); + const btn = document.querySelector('.inv-stats-toggle'); + const open = section.classList.toggle('open'); + btn.textContent = (open ? '▼' : '▶') + ' Statistiken pro Inventar ' + (open ? 'ausblenden' : 'anzeigen'); + } + + function sortInvStats(col) { + if (invStatsSortCol === col) { invStatsSortAsc = !invStatsSortAsc; } + else { invStatsSortCol = col; invStatsSortAsc = true; } + renderInvStatsTable(); + } + + function renderInvStatsTable() { + const data = [...allInvStats].sort((a, b) => { + const va = a[invStatsSortCol] ?? ''; + const vb = b[invStatsSortCol] ?? ''; + if (typeof va === 'number' && typeof vb === 'number') return invStatsSortAsc ? va - vb : vb - va; + return invStatsSortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); + }); + const cols = ['inventoryName', 'totalItems', 'totalLocations', 'totalCategories', 'recentTransactions', 'lastUpdated', 'userCount']; + cols.forEach(c => { + const el = document.getElementById('isort-' + c); + if (el) el.textContent = invStatsSortCol === c ? (invStatsSortAsc ? '▲' : '▼') : ''; + }); + const tbody = document.getElementById('inv-stats-body'); + tbody.innerHTML = ''; + if (!data.length) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); td.colSpan = 7; td.textContent = 'Keine Inventare vorhanden.'; td.style.textAlign = 'center'; td.style.color = '#999'; + tr.appendChild(td); tbody.appendChild(tr); + return; + } + data.forEach(inv => { + const tr = document.createElement('tr'); + const name = inv.inventoryName || inv.inventoryId.slice(0, 8) + '…'; + const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '–'; + [name, inv.totalItems, inv.totalLocations, inv.totalCategories, inv.recentTransactions, lastUpdFmt, inv.userCount].forEach(v => { + const td = document.createElement('td'); td.textContent = v; tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + } + + // ── Inventory list table ───────────────────────────────────────────────── + + function applyInvFilter() { + const search = document.getElementById('inv-search').value.toLowerCase(); + const filter = document.getElementById('inv-filter').value; + invFiltered = invData.filter(inv => { + const name = (inv.inventoryName || inv.inventoryId || '').toLowerCase(); + const id = (inv.inventoryId || '').toLowerCase(); + if (search && !name.includes(search) && !id.includes(search)) return false; + if (filter === 'with-users' && inv.userCount === 0) return false; + if (filter === 'without-users' && inv.userCount > 0) return false; + return true; + }); + invPage = 0; + renderInvTable(); + } + + function sortInv(col) { + if (invSortCol === col) { invSortAsc = !invSortAsc; } + else { invSortCol = col; invSortAsc = true; } + renderInvTable(); + } + + function renderInvTable() { + const sorted = [...invFiltered].sort((a, b) => { + const va = a[invSortCol] ?? ''; + const vb = b[invSortCol] ?? ''; + if (typeof va === 'number' && typeof vb === 'number') return invSortAsc ? va - vb : vb - va; + return invSortAsc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); + }); + const cols = ['inventoryName', 'totalItems', 'totalLocations', 'totalCategories', 'userCount', 'lastUpdated']; + cols.forEach(c => { + const el = document.getElementById('sort-' + c); + if (el) el.textContent = invSortCol === c ? (invSortAsc ? '▲' : '▼') : ''; + }); + + const total = sorted.length; + const totalPages = Math.max(1, Math.ceil(total / invPageSize)); + if (invPage >= totalPages) invPage = totalPages - 1; + const start = invPage * invPageSize; + const page = sorted.slice(start, start + invPageSize); + + const tbody = document.getElementById('inv-table-body'); + tbody.innerHTML = ''; + if (!page.length) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); td.colSpan = 7; td.textContent = 'Keine Inventare gefunden.'; td.style.textAlign = 'center'; td.style.color = '#999'; + tr.appendChild(td); tbody.appendChild(tr); + } else { + page.forEach(inv => { + const tr = document.createElement('tr'); + const name = inv.inventoryName ? inv.inventoryName : '—'; + const lastUpdFmt = inv.lastUpdated ? new Date(inv.lastUpdated).toLocaleDateString('de-DE') : '–'; + + const tdName = document.createElement('td'); + tdName.textContent = name; + if (inv.inventoryName) { + const badge = document.createElement('span'); badge.className = 'inv-badge'; badge.title = inv.inventoryId; + badge.textContent = inv.inventoryId.slice(0, 8) + '…'; badge.style.marginLeft = '6px'; + tdName.appendChild(badge); + } + + const tds = [tdName]; + [inv.totalItems, inv.totalLocations, inv.totalCategories, inv.userCount, lastUpdFmt].forEach(v => { + const td = document.createElement('td'); td.textContent = v; tds.push(td); + }); + + const tdAct = document.createElement('td'); tdAct.className = 'actions'; + const btnUsers = document.createElement('button'); btnUsers.className = 'info'; btnUsers.textContent = 'Benutzer'; + btnUsers.onclick = () => showInvUsers(inv.inventoryId); + tdAct.appendChild(btnUsers); + tds.push(tdAct); + + tds.forEach(td => tr.appendChild(td)); + tbody.appendChild(tr); + }); + } + + document.getElementById('inv-page-info').textContent = total === 0 ? 'Keine Einträge' : `Seite ${invPage + 1} von ${totalPages} (${total} Inventare)`; + document.getElementById('inv-prev').disabled = invPage === 0; + document.getElementById('inv-next').disabled = invPage >= totalPages - 1; + } + + function showInvUsers(inventoryId) { + const inv = allInventories.find(i => i.inventoryId === inventoryId); + if (!inv) { alert('Keine Benutzerinformationen verfügbar.'); return; } + const names = inv.users.length ? inv.users.map(u => u.username + (u.isAdmin ? ' [Admin]' : '')).join('\n') : '(keine Benutzer)'; + alert('Inventar ' + inventoryId.slice(0, 8) + '…\n\nBenutzer:\n' + names); + } + function renderUsers(users) { const tbody = document.getElementById('users-body'); tbody.innerHTML = ''; @@ -260,30 +480,6 @@ }); } - function renderInventories(inventories) { - const container = document.getElementById('inventories-body'); - container.innerHTML = ''; - if (!inventories.length) { - container.textContent = 'Keine Inventare vorhanden.'; - return; - } - inventories.forEach(inv => { - const div = document.createElement('div'); div.className = 'inv-group'; - const h3 = document.createElement('h3'); - h3.textContent = 'Inventar ' + inv.inventoryId.slice(0, 8) + '… (' + inv.users.length + ' ' + (inv.users.length === 1 ? 'Benutzer' : 'Benutzer') + ')'; - const ul = document.createElement('ul'); - if (inv.users.length === 0) { - const li = document.createElement('li'); li.textContent = '(keine Benutzer)'; ul.appendChild(li); - } else { - inv.users.forEach(u => { - const li = document.createElement('li'); li.textContent = u.username + (u.isAdmin ? ' [Admin]' : ''); ul.appendChild(li); - }); - } - div.append(h3, ul); - container.appendChild(div); - }); - } - function toggleAddForm() { document.getElementById('add-user-form').classList.toggle('open'); } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/InventoryStatsTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/InventoryStatsTest.kt index afc3ee4..93dee92 100644 --- a/server/src/test/kotlin/de/krisenvorrat/server/InventoryStatsTest.kt +++ b/server/src/test/kotlin/de/krisenvorrat/server/InventoryStatsTest.kt @@ -2,6 +2,7 @@ package de.krisenvorrat.server import de.krisenvorrat.server.db.DatabaseFactory import de.krisenvorrat.server.model.InventoryStatsDto +import de.krisenvorrat.server.model.InventoryStatsPerInventoryDto import de.krisenvorrat.shared.model.CategoryDto import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.ItemDto @@ -14,6 +15,7 @@ import io.ktor.server.testing.* import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class InventoryStatsTest { @@ -119,4 +121,35 @@ class InventoryStatsTest { assertEquals(2L, stats.totalCategories) assertEquals(2L, stats.recentTransactions) } + + @Test + fun test_getStatsPerInventory_asAdmin_returnsListWithEntry() = testApp { + // When + val response = client.get("/api/admin/stats/inventories") { bearerAuth(adminToken) } + + // Then + assertEquals(HttpStatusCode.OK, response.status) + val statsList = json.decodeFromString>(response.bodyAsText()) + // Admin user's inventory is created during DB seeding, so at least one entry exists + assertTrue(statsList.isNotEmpty()) + assertTrue(statsList.all { it.userCount >= 0 }) + } + + @Test + fun test_getStatsPerInventory_asNonAdmin_returns403() = testApp { + // When + val response = client.get("/api/admin/stats/inventories") { bearerAuth(userToken) } + + // Then + assertEquals(HttpStatusCode.Forbidden, response.status) + } + + @Test + fun test_getStatsPerInventory_noToken_returns401() = testApp { + // When + val response = client.get("/api/admin/stats/inventories") + + // Then + assertEquals(HttpStatusCode.Unauthorized, response.status) + } } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt index 592f57e..46f9389 100644 --- a/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt +++ b/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt @@ -1,12 +1,22 @@ package de.krisenvorrat.server.repository +import de.krisenvorrat.server.db.Categories import de.krisenvorrat.server.db.DatabaseFactory +import de.krisenvorrat.server.db.DeletedItems +import de.krisenvorrat.server.db.Inventories +import de.krisenvorrat.server.db.Items +import de.krisenvorrat.server.db.Locations +import de.krisenvorrat.server.db.Settings +import de.krisenvorrat.server.db.Users import de.krisenvorrat.shared.model.CategoryDto import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.ItemDto import de.krisenvorrat.shared.model.LocationDto import de.krisenvorrat.shared.model.SettingDto +import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.transactions.transaction import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -26,11 +36,16 @@ class InventoryRepositoryTest { adminPassword = "test-admin-pw" ) repository = InventoryRepository() - // Clear any leftover data from previous test - repository.saveInventory( - testUserId, - InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()) - ) + // Clear all data for a clean test environment + transaction { + DeletedItems.deleteAll() + Items.deleteAll() + Settings.deleteAll() + Categories.deleteAll() + Locations.deleteAll() + Users.deleteAll() + Inventories.deleteAll() + } } @Test @@ -286,4 +301,64 @@ class InventoryRepositoryTest { val delta = repository.loadInventorySince(testUserId, 0L) assertTrue(delta.deletedItemIds.contains("item-1")) } + + @Test + fun test_getStatsPerInventory_emptyDatabase_returnsEmptyList() { + // setUp clears all tables, so no inventories exist + val stats = repository.getStatsPerInventory() + assertTrue(stats.isEmpty()) + } + + @Test + fun test_getStatsPerInventory_withData_returnsCorrectCounts() { + // Given – create a dedicated inventory so we control the data precisely + val invId = repository.createInventory("Testvorrat") + val inventory = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Kat1"), CategoryDto(id = 2, name = "Kat2")), + locations = listOf(LocationDto(id = 1, name = "Keller")), + items = listOf( + ItemDto( + id = "inv-item-1", name = "Dosenbrot", categoryId = 1, quantity = 3.0, + unit = "Stk", unitPrice = 1.5, kcalPerKg = null, expiryDate = null, + locationId = 1, notes = "", lastUpdated = 1715000000L + ) + ), + settings = emptyList() + ) + repository.saveInventory(invId, inventory) + + // When + val statsList = repository.getStatsPerInventory() + val stats = statsList.find { it.inventoryId == invId } + + // Then + assertNotNull(stats) + assertEquals("Testvorrat", stats!!.inventoryName) + assertEquals(1L, stats.totalItems) + assertEquals(1L, stats.totalLocations) + assertEquals(2L, stats.totalCategories) + assertEquals(1715000000L, stats.lastUpdated) + // lastUpdated = 1715000000L is ~May 2024, more than 30 days ago + assertEquals(0L, stats.recentTransactions) + } + + @Test + fun test_getStatsPerInventory_userCount_reflectsAssignedUsers() { + // Given + val invId = repository.createInventory("UserCountTest") + val userRepo = UserRepository() + val uid1 = "stats-user-1" + val uid2 = "stats-user-2" + userRepo.create(uid1, "statsuser1", "hash1") + userRepo.create(uid2, "statsuser2", "hash2") + repository.assignUserToInventory(uid1, invId) + repository.assignUserToInventory(uid2, invId) + + // When + val stats = repository.getStatsPerInventory().find { it.inventoryId == invId } + + // Then + assertNotNull(stats) + assertEquals(2L, stats!!.userCount) + } }