From d645d3ad9d4c505ed86b278848ec016ba3faee9f Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 12 May 2026 16:13:10 +0200 Subject: [PATCH] feat: add Flutter quality checks and tests for category chips in inventory and pantry screens --- .github/workflows/test.yml | 37 +++++++++ .gitignore | 1 + .../docs/SESSION_CHECKPOINT_2026-05-11.md | 49 +++++++++++ filanalys.md | 67 +++++++++++++++ .../presentation/inventory_screen.dart | 19 +++-- .../pantry/presentation/pantry_screen.dart | 24 ++++-- .../swipeable_inventory_tile_test.dart | 63 ++++++++++++++ .../pantry_screen_category_chip_test.dart | 82 +++++++++++++++++++ 8 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 filanalys.md create mode 100644 flutter/test/features/inventory/presentation/swipeable_inventory_tile_test.dart create mode 100644 flutter/test/features/pantry/presentation/pantry_screen_category_chip_test.dart diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7fe7d6b4..103bcb65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,3 +48,40 @@ jobs: working-directory: ./backend run: npm run build continue-on-error: true + + flutter-quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Detect Flutter-related changes + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + flutter: + - 'flutter/**' + - '.github/workflows/test.yml' + + - name: Setup Flutter + if: steps.filter.outputs.flutter == 'true' + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.9' + + - name: Install dependencies (flutter) + if: steps.filter.outputs.flutter == 'true' + working-directory: ./flutter + run: flutter pub get + + - name: Analyze Flutter code + if: steps.filter.outputs.flutter == 'true' + working-directory: ./flutter + run: flutter analyze + + - name: Run Flutter tests + if: steps.filter.outputs.flutter == 'true' + working-directory: ./flutter + run: flutter test diff --git a/.gitignore b/.gitignore index 829ff0ea..d7aee28c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ backend/tsconfig.tsbuildinfo # Dart/Flutter generated files with absolute host paths — must not be committed .dart_tool/ */.dart_tool/ +flutter/build/ diff --git a/_archive/docs/SESSION_CHECKPOINT_2026-05-11.md b/_archive/docs/SESSION_CHECKPOINT_2026-05-11.md index 49e01d39..86493d99 100644 --- a/_archive/docs/SESSION_CHECKPOINT_2026-05-11.md +++ b/_archive/docs/SESSION_CHECKPOINT_2026-05-11.md @@ -37,6 +37,55 @@ 2. Besluta om sortering ska följa visad kategori (djupaste nod) eller fortsatt L1. 3. Commit/pusha ändringarna när du är nöjd. +--- + +## Uppdatering 2026-05-12 + +### Klart nu + +1. Widgettester tillagda för category-chip/tooltip: + - `flutter/test/features/inventory/presentation/swipeable_inventory_tile_test.dart` + - `flutter/test/features/pantry/presentation/pantry_screen_category_chip_test.dart` +2. Sorteringsbeslut implementerat: + - Sortering på `Kategori (A-O)` följer nu visad kategori (djupaste nod), inte enbart L1. + - Uppdaterat i både inventory- och baslagervyn. + +### Deploy + manuell produktionstest (körplan) + +Kör deploy: + +```bash +./deploy.sh --backend --flutter +``` + +Om du vill tvinga kontroll av nya basimages: + +```bash +./deploy.sh --backend --flutter --pull-always +``` + +Verifiera drift efter deploy: + +```bash +docker compose -f compose.yml -f compose.flutter.yml ps +curl http://localhost:8080/api/health +curl http://localhost:8080/api/health/db +``` + +Manuell produktionschecklista: + +- [ ] Receipt import: verifiera att alla rader syns (scroll-fix). +- [ ] Receipt import: verifiera att okända varor visar `Kategoriförslag`. +- [ ] Inventory: verifiera category-chip (djupaste nod) + tooltip (full path). +- [ ] Baslager: verifiera category-chip (djupaste nod) + tooltip (full path). +- [ ] Admin: verifiera rename/merge av produkter. +- [ ] User: verifiera private rename/merge-behörighet och funktion. + +Fallback vid avvikelse: + +- Spara backend-loggar för receipt import och kategori-matchning. +- Notera exakt testdata (kvitto/PDF, produktnamn, förväntat vs faktiskt utfall). + ## Snabb återstart nästa gång Kör: diff --git a/filanalys.md b/filanalys.md new file mode 100644 index 00000000..a63afec0 --- /dev/null +++ b/filanalys.md @@ -0,0 +1,67 @@ +Du är en senior utvecklare och säkerhetsexpert. Analysera alla commit-kandidater i detta Next.js/TypeScript-projekt (backend: NestJS + Prisma, frontend: Next.js, databas: MariaDB). + +Arbetsordning för filurval: +1. Primärt: analysera alla staged filer. +2. Om inga staged filer finns: analysera commit-kandidater i working tree (modified + untracked). +3. Exkludera alltid irrelevanta filer: node_modules, .git, build/cache-artifacts, binärfiler, genererade filer som inte ska committas. + +Inled rapporten med en kort Scope-sektion som anger: +- Vilken urvalsregel som användes (staged eller commit-kandidater). +- Exakt vilka filer som analyserades. +- Vilka filer som exkluderades och varför. + +Ge en detaljerad rapport enligt följande struktur: + +--- +### **1. Allmän kodkvalitet** +- **Optimeringar**: + - Finns det ineffektiva algoritmer (t.ex. O(n²) istället för O(n))? + - Kan loopar, databaserfrågor (Prisma) eller API-anrop optimeras (t.ex. med caching, batch-behandling)? + - Finns det onödig kod (död kod, duplicerad logik)? + - Kan minne eller CPU-användning reduceras (t.ex. undvika djupa kopior, använda streams)? + +- **Läsbarhet/underhållbarhet**: + - Finns det bristande namngivning (variabler, funktioner, klasser)? + - Saknas kommentarer för komplex logik? + - Kan modulariseringen förbättras (t.ex. splitta stora funktioner/klasser)? + - Följs TypeScript-bäst-praxis (t.ex. starka typer, interfaces, SOLID-principer)? + +--- +### **2. Säkerhetsanalys** +- **Sårbarheter**: + - Finns det risk för SQL-injection (Prisma), XSS, CSRF, eller insecure deserialization? + - Används osäkra bibliotek (t.ex. föråldrade versioner av `axios`, `lodash`, `express`)? + - Finns det hårdkodade lösenord, API-nycklar eller tokens? + - Saknas input-validering (t.ex. för filupp laddningar, användarinmatning)? + +- **Autentisering/auktorisation**: + - Finns det brister i JWT-hantering (t.ex. svaga algoritmer, saknade `exp`-fält)? + - Används HTTP istället för HTTPS? + - Saknas rate limiting för känsliga endpoints? + +- **Datahantering**: + - Lagras känslig data (t.ex. lösenord) i klartext? + - Finns det loggning av känslig data? + - Används säkra krypteringsmetoder (t.ex. AES-256, bcrypt)? + +--- +### **3. Sammanfattning** +- **Topp 6 kritiska åtgärder** (prioriterade efter risk/vinst). +- **Uppskattad tid** för att implementera förslagen. +- **Rekommenderade verktyg** för automatiserade kontroller (t.ex. `ESLint`, `Prisma Lint`, `OWASP Dependency-Check`). + +--- +### **Regler för analysen** +- Var **specifik**: Ge **kod-exempel** för varje förslag. +- Var **praktisk**: Fokusera på **realistiska förbättringar** som kan implementeras nu. +- Var **kritisk**: Peka ut **allvarliga risker** (t.ex. säkerhetshål) först. +- Använd **severity** per fynd: `Critical`, `High`, `Medium`, `Low`. +- För varje fynd: ange fil, kort riskbeskrivning, varför det är ett problem, och konkret åtgärd. +- Om inga allvarliga risker hittas: skriv det explicit och lyft kvarvarande risker/testluckor. +- Ignorera filer som inte är relevanta (t.ex. node_modules, .git, binärfiler). + +--- +### **Kontext för projektet** +- **Backend**: NestJS + Prisma + MariaDB (Docker-container). +- **Frontend**: Next.js + TypeScript. +- **Mål**: Förbereda för produktion, minska teknisk skuld, säkra känslig data. \ No newline at end of file diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index 94483714..e159f039 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; +import '../../../core/utils/display_labels.dart'; import '../../auth/data/auth_providers.dart'; import '../domain/inventory_item.dart'; import '../data/inventory_providers.dart'; @@ -19,6 +20,7 @@ class InventoryScreen extends ConsumerStatefulWidget { class _InventoryScreenState extends ConsumerState { final Set _selectedIds = {}; + static const _sortByDisplayedCategory = 'l1CategoryAsc'; static const _locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; @@ -32,7 +34,7 @@ class _InventoryScreenState extends ConsumerState { (value: 'nameAsc', label: context.l10n.inventorySortNameAsc), (value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc), (value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc), - (value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'), + (value: _sortByDisplayedCategory, label: 'Kategori (A-O)'), ]; void _startSelection(int id) { @@ -263,11 +265,18 @@ class _InventoryScreenState extends ConsumerState { List _sortedVisibleItems(List items, String sort) { final visibleItems = [...items]; - if (sort == 'l1CategoryAsc') { + if (sort == _sortByDisplayedCategory) { + final displayedCategoryById = { + for (final item in visibleItems) + item.id: categoryChipText( + categoryPath: item.categoryPath, + fallbackL1: item.l1Category, + ).label.toLowerCase(), + }; visibleItems.sort((a, b) { - final byCategory = a.l1Category.toLowerCase().compareTo( - b.l1Category.toLowerCase(), - ); + final aCategory = displayedCategoryById[a.id] ?? ''; + final bCategory = displayedCategoryById[b.id] ?? ''; + final byCategory = aCategory.compareTo(bCategory); if (byCategory != 0) return byCategory; return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); }); diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index 43b02250..efbc8664 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -24,13 +24,14 @@ class PantryScreen extends ConsumerStatefulWidget { class _PantryScreenState extends ConsumerState { static const _locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; + static const _sortByDisplayedCategory = 'l1CategoryAsc'; String _locationFilter = ''; String _sort = 'nameAsc'; List<({String value, String label})> _sortOptions() => const [ (value: 'nameAsc', label: 'Namn (A-O)'), (value: 'nameDesc', label: 'Namn (O-A)'), - (value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'), + (value: _sortByDisplayedCategory, label: 'Kategori (A-O)'), (value: 'locationAsc', label: 'Plats (A-O)'), ]; @@ -172,7 +173,7 @@ class _PantryScreenState extends ConsumerState { SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')), ); } catch (error) { - _logger.severe('Failed to add item to inventory: $error'); + _logger.severe('Failed to add item to inventory'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)), @@ -206,7 +207,7 @@ class _PantryScreenState extends ConsumerState { await ref.read(pantryRepositoryProvider).deletePantryItem(item.id, token: token); ref.invalidate(pantryProvider); } catch (error) { - _logger.severe('Failed to remove pantry item: $error'); + _logger.severe('Failed to remove pantry item'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)), @@ -231,7 +232,7 @@ class _PantryScreenState extends ConsumerState { if (pantryAsync.hasError) { final error = pantryAsync.error; - _logger.severe('Error loading pantry or products: $error'); + _logger.severe('Error loading pantry or products'); return buildCopyableErrorPanel( context: context, message: mapErrorToUserMessage(error ?? 'Okänt fel', context), @@ -250,9 +251,12 @@ class _PantryScreenState extends ConsumerState { return (item.location ?? '').trim() == _locationFilter; }).toList(); - final l1LowerByItemId = { + final displayedCategoryLowerByItemId = { for (final item in filteredItems) - item.id: _resolveL1Category(item).toLowerCase(), + item.id: categoryChipText( + categoryPath: item.categoryPath, + fallbackL1: _resolveL1Category(item), + ).label.toLowerCase(), }; filteredItems.sort((a, b) { @@ -266,9 +270,11 @@ class _PantryScreenState extends ConsumerState { if (byLocation != 0) return byLocation; return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); } - if (_sort == 'l1CategoryAsc') { - final byL1 = (l1LowerByItemId[a.id] ?? '').compareTo(l1LowerByItemId[b.id] ?? ''); - if (byL1 != 0) return byL1; + if (_sort == _sortByDisplayedCategory) { + final byCategory = (displayedCategoryLowerByItemId[a.id] ?? '').compareTo( + displayedCategoryLowerByItemId[b.id] ?? '', + ); + if (byCategory != 0) return byCategory; return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); } return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); diff --git a/flutter/test/features/inventory/presentation/swipeable_inventory_tile_test.dart b/flutter/test/features/inventory/presentation/swipeable_inventory_tile_test.dart new file mode 100644 index 00000000..30d01bfa --- /dev/null +++ b/flutter/test/features/inventory/presentation/swipeable_inventory_tile_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/features/inventory/domain/inventory_item.dart'; +import 'package:recipe_flutter/features/inventory/presentation/swipeable_inventory_tile.dart'; + +Widget _wrap(Widget child) { + return ProviderScope( + child: MaterialApp( + home: Scaffold(body: child), + ), + ); +} + +void main() { + testWidgets('shows deepest category in chip and full path in tooltip', ( + tester, + ) async { + const item = InventoryItem( + id: 1, + productId: 99, + productName: 'Jordgubbssylt', + productCanonicalName: 'Jordgubbssylt', + categoryPath: 'Skafferi > Sylt, mos & marmelad > Sylt', + quantity: 1, + unit: 'st', + opened: false, + ); + + await tester.pumpWidget(_wrap(const SwipeableInventoryTile(item: item))); + + expect(find.text('Sylt'), findsOneWidget); + final tooltipFinder = find.ancestor( + of: find.byType(Chip).first, + matching: find.byType(Tooltip), + ); + final tooltip = tester.widget(tooltipFinder); + expect(tooltip.message, 'Skafferi > Sylt, mos & marmelad > Sylt'); + }); + + testWidgets('falls back to L1 when category path is missing', (tester) async { + const item = InventoryItem( + id: 2, + productId: 100, + productName: 'Okategoriserad vara', + productCanonicalName: 'Okategoriserad vara', + categoryPath: null, + quantity: 2, + unit: 'st', + opened: false, + ); + + await tester.pumpWidget(_wrap(const SwipeableInventoryTile(item: item))); + + expect(find.text('Övrigt'), findsOneWidget); + final tooltipFinder = find.ancestor( + of: find.byType(Chip).first, + matching: find.byType(Tooltip), + ); + final tooltip = tester.widget(tooltipFinder); + expect(tooltip.message, 'Övrigt'); + }); +} diff --git a/flutter/test/features/pantry/presentation/pantry_screen_category_chip_test.dart b/flutter/test/features/pantry/presentation/pantry_screen_category_chip_test.dart new file mode 100644 index 00000000..5eff2ef9 --- /dev/null +++ b/flutter/test/features/pantry/presentation/pantry_screen_category_chip_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/core/l10n/l10n.dart'; +import 'package:recipe_flutter/features/pantry/data/pantry_providers.dart'; +import 'package:recipe_flutter/features/pantry/domain/pantry_item.dart'; +import 'package:recipe_flutter/features/pantry/presentation/pantry_screen.dart'; + +Widget _buildTestApp(List items) { + return ProviderScope( + overrides: [ + pantryProvider.overrideWith((ref) => items), + ], + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('sv'), + home: const Scaffold(body: PantryScreen()), + ), + ); +} + +void main() { + testWidgets('shows deepest category in pantry chip and full path in tooltip', ( + tester, + ) async { + const items = [ + PantryItem( + id: 1, + productId: 7, + productName: 'Jordgubbssylt', + canonicalName: 'Jordgubbssylt', + categoryPath: 'Skafferi > Sylt, mos & marmelad > Sylt', + location: 'Skafferi', + ), + ]; + + await tester.pumpWidget(_buildTestApp(items)); + await tester.pumpAndSettle(); + + expect(find.text('Sylt'), findsOneWidget); + final tooltipFinder = find.ancestor( + of: find.text('Sylt'), + matching: find.byType(Tooltip), + ); + final tooltip = tester.widget(tooltipFinder); + expect(tooltip.message, 'Skafferi > Sylt, mos & marmelad > Sylt'); + }); + + testWidgets('falls back to L1/category when category path is missing', ( + tester, + ) async { + const items = [ + PantryItem( + id: 2, + productId: 8, + productName: 'Vetemjol', + canonicalName: 'Vetemjol', + category: 'Skafferi', + categoryPath: null, + location: 'Skafferi', + ), + ]; + + await tester.pumpWidget(_buildTestApp(items)); + await tester.pumpAndSettle(); + + expect(find.text('Skafferi'), findsWidgets); + final itemTooltipFinder = find.ancestor( + of: find.byType(Chip).last, + matching: find.byType(Tooltip), + ); + final tooltip = tester.widget(itemTooltipFinder); + expect(tooltip.message, 'Skafferi'); + }); +}