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
This commit is contained in:
Jens Reinemann 2026-05-17 10:56:22 +02:00
parent 11d2094eef
commit 32ed321df2
8 changed files with 407 additions and 42 deletions

View file

@ -97,7 +97,7 @@ internal fun MainScreen() {
contentDescription = destination.label
)
},
label = { Text(destination.label) }
label = null
)
}
}

View file

@ -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"
);
}

View file

@ -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
)

View file

@ -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<InventoryStatsPerInventoryDto> = 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()

View file

@ -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<UserPrincipal>()
?: 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())
}
}

View file

@ -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; }
</style>
</head>
@ -72,6 +89,23 @@
<div class="stat-card"><div class="stat-value" id="stat-recent"></div><div class="stat-label">Änderungen (30 Tage)</div></div>
<div class="stat-card"><div class="stat-value" id="stat-last-updated" style="font-size:1rem"></div><div class="stat-label">Letzte Änderung</div></div>
</div>
<button class="inv-stats-toggle" onclick="toggleInvStats()">▶ Statistiken pro Inventar anzeigen</button>
<div id="inv-stats-section">
<table id="inv-stats-table">
<thead>
<tr>
<th onclick="sortInvStats('inventoryName')">Name <span class="sort-arrow" id="isort-inventoryName"></span></th>
<th onclick="sortInvStats('totalItems')">Artikel <span class="sort-arrow" id="isort-totalItems"></span></th>
<th onclick="sortInvStats('totalLocations')">Lagerorte <span class="sort-arrow" id="isort-totalLocations"></span></th>
<th onclick="sortInvStats('totalCategories')">Kategorien <span class="sort-arrow" id="isort-totalCategories"></span></th>
<th onclick="sortInvStats('recentTransactions')">Änd. 30T <span class="sort-arrow" id="isort-recentTransactions"></span></th>
<th onclick="sortInvStats('lastUpdated')">Letzte Änd. <span class="sort-arrow" id="isort-lastUpdated"></span></th>
<th onclick="sortInvStats('userCount')">Benutzer <span class="sort-arrow" id="isort-userCount"></span></th>
</tr>
</thead>
<tbody id="inv-stats-body"></tbody>
</table>
</div>
</section>
<!-- User Management -->
@ -103,7 +137,38 @@
<!-- Inventory Overview -->
<section class="card">
<h2>Inventar-Übersicht</h2>
<div id="inventories-body"></div>
<div class="table-controls">
<input type="text" id="inv-search" placeholder="Suche nach Name oder ID…" oninput="applyInvFilter()">
<select id="inv-filter" onchange="applyInvFilter()">
<option value="all">Alle Inventare</option>
<option value="with-users">Mit Benutzern</option>
<option value="without-users">Ohne Benutzer</option>
</select>
<select id="inv-page-size" onchange="invPageSize=+this.value; invPage=0; renderInvTable()">
<option value="10">10 / Seite</option>
<option value="25">25 / Seite</option>
<option value="50">50 / Seite</option>
</select>
</div>
<table id="inv-table">
<thead>
<tr>
<th onclick="sortInv('inventoryName')">Name <span class="sort-arrow" id="sort-inventoryName"></span></th>
<th onclick="sortInv('totalItems')">Artikel <span class="sort-arrow" id="sort-totalItems"></span></th>
<th onclick="sortInv('totalLocations')">Lagerorte <span class="sort-arrow" id="sort-totalLocations"></span></th>
<th onclick="sortInv('totalCategories')">Kategorien <span class="sort-arrow" id="sort-totalCategories"></span></th>
<th onclick="sortInv('userCount')">Benutzer <span class="sort-arrow" id="sort-userCount"></span></th>
<th onclick="sortInv('lastUpdated')">Letzte Änderung <span class="sort-arrow" id="sort-lastUpdated"></span></th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="inv-table-body"></tbody>
</table>
<div class="paging-bar">
<button class="secondary" onclick="invPage--; renderInvTable()" id="inv-prev"> Zurück</button>
<span id="inv-page-info"></span>
<button class="secondary" onclick="invPage++; renderInvTable()" id="inv-next">Weiter </button>
</div>
</section>
</div>
</main>
@ -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');
}

View file

@ -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<List<InventoryStatsPerInventoryDto>>(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)
}
}

View file

@ -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)
}
}