feat: add Flutter quality checks and tests for category chips in inventory and pantry screens
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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<InventoryScreen> {
|
||||
final Set<int> _selectedIds = <int>{};
|
||||
static const _sortByDisplayedCategory = 'l1CategoryAsc';
|
||||
|
||||
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
|
||||
|
||||
@@ -32,7 +34,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
|
||||
(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<InventoryScreen> {
|
||||
|
||||
List<InventoryItem> _sortedVisibleItems(List<InventoryItem> 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());
|
||||
});
|
||||
|
||||
@@ -24,13 +24,14 @@ class PantryScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
static const _locationOptions = <String>['', '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<PantryScreen> {
|
||||
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<PantryScreen> {
|
||||
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<PantryScreen> {
|
||||
|
||||
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<PantryScreen> {
|
||||
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<PantryScreen> {
|
||||
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());
|
||||
|
||||
@@ -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<Tooltip>(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<Tooltip>(tooltipFinder);
|
||||
expect(tooltip.message, 'Övrigt');
|
||||
});
|
||||
}
|
||||
@@ -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<PantryItem> 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<Tooltip>(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<Tooltip>(itemTooltipFinder);
|
||||
expect(tooltip.message, 'Skafferi');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user