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 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.Settings
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Home 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.Settings
import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.icons.outlined.Warehouse
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
internal enum class TopLevelDestination( internal enum class TopLevelDestination(
@ -22,30 +23,30 @@ internal enum class TopLevelDestination(
route = Screen.Dashboard, route = Screen.Dashboard,
selectedIcon = Icons.Filled.Home, selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home, unselectedIcon = Icons.Outlined.Home,
label = "Übersicht" label = "Overview"
), ),
INVENTORY( INVENTORY(
route = Screen.ItemList, route = Screen.ItemList,
selectedIcon = Icons.Outlined.Inventory2, selectedIcon = Icons.Filled.Warehouse,
unselectedIcon = Icons.Outlined.Inventory2, unselectedIcon = Icons.Outlined.Warehouse,
label = "Inventur" label = "Storage"
), ),
WARNINGS( WARNINGS(
route = Screen.Warnings, route = Screen.Warnings,
selectedIcon = Icons.Filled.Warning, selectedIcon = Icons.Filled.Warning,
unselectedIcon = Icons.Outlined.Warning, unselectedIcon = Icons.Outlined.Warning,
label = "Warnungen" label = "Warnings"
), ),
MESSAGES( MESSAGES(
route = Screen.UserList, route = Screen.UserList,
selectedIcon = Icons.AutoMirrored.Filled.Message, selectedIcon = Icons.AutoMirrored.Filled.Message,
unselectedIcon = Icons.AutoMirrored.Outlined.Message, unselectedIcon = Icons.AutoMirrored.Outlined.Message,
label = "Nachrichten" label = "Chat"
), ),
SETTINGS( SETTINGS(
route = Screen.Settings, route = Screen.Settings,
selectedIcon = Icons.Filled.Settings, selectedIcon = Icons.Filled.Settings,
unselectedIcon = Icons.Outlined.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.Settings
import de.krisenvorrat.server.db.Users import de.krisenvorrat.server.db.Users
import de.krisenvorrat.server.model.InventoryStatsDto import de.krisenvorrat.server.model.InventoryStatsDto
import de.krisenvorrat.server.model.InventoryStatsPerInventoryDto
import de.krisenvorrat.server.model.InventoryWithUsersDto import de.krisenvorrat.server.model.InventoryWithUsersDto
import de.krisenvorrat.server.model.UserDto import de.krisenvorrat.server.model.UserDto
import de.krisenvorrat.shared.model.CategoryDto 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 { fun getAggregatedStats(): InventoryStatsDto {
return transaction { return transaction {
val totalItems = Items.selectAll().count() val totalItems = Items.selectAll().count()

View file

@ -175,5 +175,16 @@ internal fun Route.adminRoutes(userRepository: UserRepository, inventoryReposito
} }
call.respond(HttpStatusCode.OK, inventoryRepository.getAggregatedStats()) 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 { background: #fff; border-radius: 8px; padding: 28px; width: 400px; max-width: 95vw; }
.modal h3 { margin-bottom: 16px; } .modal h3 { margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 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; } footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; }
</style> </style>
</head> </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-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 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> </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> </section>
<!-- User Management --> <!-- User Management -->
@ -103,7 +137,38 @@
<!-- Inventory Overview --> <!-- Inventory Overview -->
<section class="card"> <section class="card">
<h2>Inventar-Übersicht</h2> <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> </section>
</div> </div>
</main> </main>
@ -153,6 +218,19 @@
let accessToken = sessionStorage.getItem('accessToken') || ''; let accessToken = sessionStorage.getItem('accessToken') || '';
let pendingUserId = null; let pendingUserId = null;
let allInventories = []; 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(); if (accessToken) tryLoadUsers();
@ -197,20 +275,26 @@
} }
async function loadAll() { 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/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/inventories', { 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; } if (!usersRes.ok) { logout(); return; }
renderUsers(await usersRes.json()); renderUsers(await usersRes.json());
if (inventoriesRes.ok) { if (inventoriesRes.ok) {
allInventories = await inventoriesRes.json(); allInventories = await inventoriesRes.json();
renderInventories(allInventories);
} }
if (statsRes.ok) { if (statsRes.ok) {
renderStats(await statsRes.json()); renderStats(await statsRes.json());
} }
if (invStatsRes.ok) {
allInvStats = await invStatsRes.json();
renderInvStatsTable();
invData = allInvStats;
applyInvFilter();
}
} }
function renderStats(stats) { 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) { function renderUsers(users) {
const tbody = document.getElementById('users-body'); const tbody = document.getElementById('users-body');
tbody.innerHTML = ''; 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() { function toggleAddForm() {
document.getElementById('add-user-form').classList.toggle('open'); 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.db.DatabaseFactory
import de.krisenvorrat.server.model.InventoryStatsDto import de.krisenvorrat.server.model.InventoryStatsDto
import de.krisenvorrat.server.model.InventoryStatsPerInventoryDto
import de.krisenvorrat.shared.model.CategoryDto import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto import de.krisenvorrat.shared.model.ItemDto
@ -14,6 +15,7 @@ import io.ktor.server.testing.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class InventoryStatsTest { class InventoryStatsTest {
@ -119,4 +121,35 @@ class InventoryStatsTest {
assertEquals(2L, stats.totalCategories) assertEquals(2L, stats.totalCategories)
assertEquals(2L, stats.recentTransactions) 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 package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Categories
import de.krisenvorrat.server.db.DatabaseFactory 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.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto 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.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -26,11 +36,16 @@ class InventoryRepositoryTest {
adminPassword = "test-admin-pw" adminPassword = "test-admin-pw"
) )
repository = InventoryRepository() repository = InventoryRepository()
// Clear any leftover data from previous test // Clear all data for a clean test environment
repository.saveInventory( transaction {
testUserId, DeletedItems.deleteAll()
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()) Items.deleteAll()
) Settings.deleteAll()
Categories.deleteAll()
Locations.deleteAll()
Users.deleteAll()
Inventories.deleteAll()
}
} }
@Test @Test
@ -286,4 +301,64 @@ class InventoryRepositoryTest {
val delta = repository.loadInventorySince(testUserId, 0L) val delta = repository.loadInventorySince(testUserId, 0L)
assertTrue(delta.deletedItemIds.contains("item-1")) 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)
}
} }