diff --git a/backend/src/flyer-import/flyer-import.controller.ts b/backend/src/flyer-import/flyer-import.controller.ts index ede1fb4d..808bfc8c 100644 --- a/backend/src/flyer-import/flyer-import.controller.ts +++ b/backend/src/flyer-import/flyer-import.controller.ts @@ -1,7 +1,10 @@ import { BadRequestException, Controller, + Get, HttpCode, + Param, + ParseIntPipe, Post, Request, UnauthorizedException, @@ -47,6 +50,29 @@ export class FlyerImportController { throw new BadRequestException('Otillåten filtyp. Använd PDF, textfil eller bild (PNG, JPEG, WebP).'); } + const userId = this.getUserId(req); + + return this.flyerImportService.parseAndMatch(file, userId); + } + + @Get('sessions/latest') + @Throttle({ default: { ttl: 60_000, limit: 30 } }) + async getLatestSession(@Request() req?: any): Promise { + const userId = this.getUserId(req); + return this.flyerImportService.getLatestSession(userId); + } + + @Get('sessions/:sessionId') + @Throttle({ default: { ttl: 60_000, limit: 30 } }) + async getSession( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.flyerImportService.getSession(sessionId, userId); + } + + private getUserId(req?: any): number { const userId = typeof req?.user?.id === 'number' ? req.user.id @@ -58,6 +84,6 @@ export class FlyerImportController { throw new UnauthorizedException('Kunde inte identifiera användaren.'); } - return this.flyerImportService.parseAndMatch(file, userId); + return userId; } } diff --git a/backend/src/flyer-import/flyer-import.service.spec.ts b/backend/src/flyer-import/flyer-import.service.spec.ts new file mode 100644 index 00000000..2205db13 --- /dev/null +++ b/backend/src/flyer-import/flyer-import.service.spec.ts @@ -0,0 +1,86 @@ +import { NotFoundException } from '@nestjs/common'; +import { FlyerImportService } from './flyer-import.service'; + +describe('FlyerImportService', () => { + const prismaMock = { + flyerSession: { + findFirst: jest.fn(), + }, + }; + + const createService = () => + new FlyerImportService( + prismaMock as any, + {} as any, + {} as any, + {} as any, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSession', () => { + it('throws NotFoundException when session is missing', async () => { + prismaMock.flyerSession.findFirst.mockResolvedValue(null); + const service = createService(); + + await expect(service.getSession(123, 1)).rejects.toBeInstanceOf(NotFoundException); + expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({ + where: { id: 123, userId: 1 }, + include: { items: { orderBy: { id: 'asc' } } }, + }); + }); + + it('returns mapped response for owned session', async () => { + prismaMock.flyerSession.findFirst.mockResolvedValue({ + id: 42, + items: [ + { + id: 99, + rawName: 'Tomat', + normalizedName: 'tomat', + categoryHint: 'Gronsaker', + price: { toNumber: () => 19.9 }, + priceUnit: 'kg', + comparisonPrice: null, + comparisonUnit: null, + offerText: 'Max 2 kop/hushall', + parseConfidence: 0.9, + parseReasons: ['ai_parsed'], + matchedProductId: 5, + matchedProductName: 'Tomat', + matchedVia: 'exact', + matchConfidence: 0.95, + matchReasons: ['normalized_exact'], + }, + ], + }); + const service = createService(); + + const result = await service.getSession(42, 1); + + expect(result.sessionId).toBe(42); + expect(result.items).toHaveLength(1); + expect(result.items[0].flyerItemId).toBe(99); + expect(result.items[0].matchedVia).toBe('exact'); + }); + }); + + describe('getLatestSession', () => { + it('returns empty response when no sessions exist', async () => { + prismaMock.flyerSession.findFirst.mockResolvedValue(null); + const service = createService(); + + const result = await service.getLatestSession(1); + + expect(result.sessionId).toBeNull(); + expect(result.items).toEqual([]); + expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({ + where: { userId: 1 }, + orderBy: { createdAt: 'desc' }, + include: { items: { orderBy: { id: 'asc' } } }, + }); + }); + }); +}); diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index bbec079d..5541b5fe 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -2,8 +2,9 @@ import { BadRequestException, Injectable, Logger, + NotFoundException, ServiceUnavailableException, -} from '@nestjs/common'; +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { normalizeName } from '../common/utils/normalize-name'; @@ -51,7 +52,7 @@ type ProductLite = { }; @Injectable() -export class FlyerImportService { +export class FlyerImportService { private readonly logger = new Logger(FlyerImportService.name); constructor( @@ -61,7 +62,7 @@ export class FlyerImportService { private readonly normalizer: FlyerNormalizerService, ) {} - async parseAndMatch(file: Express.Multer.File, userId: number): Promise { + async parseAndMatch(file: Express.Multer.File, userId: number): Promise { const parsed = await this.parseViaInternal(file); const [products, aliases] = await Promise.all([ @@ -123,14 +124,47 @@ export class FlyerImportService { const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items); - return { - sessionId: persistedItems.sessionId, - retailer: parsed.retailer, - parserVersion: parsed.parserVersion, - items: persistedItems.items, - warnings: parsed.warnings, - }; - } + return { + sessionId: persistedItems.sessionId, + retailer: parsed.retailer, + parserVersion: parsed.parserVersion, + items: persistedItems.items, + warnings: parsed.warnings, + }; + } + + async getSession(sessionId: number, userId: number): Promise { + const session = await this.prisma.flyerSession.findFirst({ + where: { id: sessionId, userId }, + include: { items: { orderBy: { id: 'asc' } } }, + }); + + if (!session) { + throw new NotFoundException('Flyer-session hittades inte.'); + } + + return this.toFlyerImportResponseFromSession(session); + } + + async getLatestSession(userId: number): Promise { + const latest = await this.prisma.flyerSession.findFirst({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + include: { items: { orderBy: { id: 'asc' } } }, + }); + + if (!latest) { + return { + sessionId: null, + retailer: 'willys', + parserVersion: 'v1', + items: [], + warnings: [], + }; + } + + return this.toFlyerImportResponseFromSession(latest); + } private async persistSessionWithItems( userId: number, @@ -377,7 +411,7 @@ export class FlyerImportService { return allowed.has(cleaned) ? cleaned : cleaned; } - private async parseViaInternal(file: Express.Multer.File): Promise { + private async parseViaInternal(file: Express.Multer.File): Promise { try { this.logger.debug(`Parsing flyer file: ${file.originalname}`); @@ -431,5 +465,92 @@ export class FlyerImportService { `Fel vid tolkning av flyer: ${err instanceof Error ? err.message : String(err)}`, ); } - } -} + } + + private toFlyerImportItem(item: { + id: number; + rawName: string; + normalizedName: string; + categoryHint: string | null; + price: Prisma.Decimal | null; + priceUnit: string | null; + comparisonPrice: Prisma.Decimal | null; + comparisonUnit: string | null; + offerText: string | null; + parseConfidence: number; + parseReasons: Prisma.JsonValue | null; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: string | null; + matchConfidence: number | null; + matchReasons: Prisma.JsonValue | null; + }): FlyerImportItem { + const toStringArray = (value: Prisma.JsonValue | null): string[] => { + if (!Array.isArray(value)) return []; + return value.map((entry) => String(entry)); + }; + + const normalizedMatchVia = + item.matchedVia === 'alias' || item.matchedVia === 'exact' || item.matchedVia === 'token' + ? item.matchedVia + : 'none'; + + const offerLimitText = this.extractOfferLimitText(item.offerText); + const offerSignals = this.extractOfferSignals(item.offerText); + + return { + flyerItemId: item.id, + rawName: item.rawName, + normalizedName: item.normalizedName, + category: item.categoryHint, + price: item.price != null ? item.price.toNumber() : offerSignals.price, + priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit, + comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice, + comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit, + offerText: item.offerText, + isOffer: + item.price != null + || item.comparisonPrice != null + || !!item.offerText?.trim() + || offerSignals.hasCampaignPattern, + offerLimitText, + parseConfidence: item.parseConfidence, + parseReasons: toStringArray(item.parseReasons), + matchedProductId: item.matchedProductId, + matchedProductName: item.matchedProductName, + matchedVia: normalizedMatchVia, + matchConfidence: item.matchConfidence ?? 0, + matchReasons: toStringArray(item.matchReasons), + }; + } + + private toFlyerImportResponseFromSession(session: { + id: number; + items: Array<{ + id: number; + rawName: string; + normalizedName: string; + categoryHint: string | null; + price: Prisma.Decimal | null; + priceUnit: string | null; + comparisonPrice: Prisma.Decimal | null; + comparisonUnit: string | null; + offerText: string | null; + parseConfidence: number; + parseReasons: Prisma.JsonValue | null; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: string | null; + matchConfidence: number | null; + matchReasons: Prisma.JsonValue | null; + }>; + }): FlyerImportResponse { + return { + sessionId: session.id, + retailer: 'willys', + parserVersion: 'v1', + items: session.items.map((item) => this.toFlyerImportItem(item)), + warnings: [], + }; + } +} diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 98c6fe57..01e71aa1 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -40,6 +40,8 @@ class ReceiptImportApiPaths { class FlyerImportApiPaths { static const parse = '/flyer-import/parse'; + static const latestSession = '/flyer-import/sessions/latest'; + static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId'; } class FlyerSelectionApiPaths { diff --git a/flutter/lib/features/import/data/flyer_import_session.dart b/flutter/lib/features/import/data/flyer_import_session.dart new file mode 100644 index 00000000..b07d9b5c --- /dev/null +++ b/flutter/lib/features/import/data/flyer_import_session.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../domain/flyer_import_result.dart'; + +class FlyerImportSession { + final int? sessionId; + final String? fileName; + final FlyerImportResult? result; + final Map selected; + + const FlyerImportSession({ + this.sessionId, + this.fileName, + this.result, + this.selected = const {}, + }); + + FlyerImportSession copyWith({ + int? sessionId, + String? fileName, + FlyerImportResult? result, + Map? selected, + }) { + return FlyerImportSession( + sessionId: sessionId ?? this.sessionId, + fileName: fileName ?? this.fileName, + result: result ?? this.result, + selected: selected ?? this.selected, + ); + } + + Map toJson() { + return { + 'sessionId': sessionId, + 'fileName': fileName, + 'selected': selected.map((k, v) => MapEntry(k.toString(), v)), + }; + } + + factory FlyerImportSession.fromJson(Map json) { + final selectedRaw = (json['selected'] as Map? ?? {}); + final selected = {}; + for (final entry in selectedRaw.entries) { + final key = int.tryParse(entry.key); + if (key == null) continue; + selected[key] = entry.value == true; + } + + return FlyerImportSession( + sessionId: (json['sessionId'] as num?)?.toInt(), + fileName: json['fileName'] as String?, + result: null, + selected: selected, + ); + } +} + +class FlyerImportSessionNotifier extends Notifier { + static const _storageKey = 'flyer_import_session_v1'; + + @override + FlyerImportSession? build() => null; + + Future restore() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) return; + + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + state = FlyerImportSession.fromJson(decoded); + } + } catch (_) { + await prefs.remove(_storageKey); + } + } + + void setImportedResult({ + required FlyerImportResult result, + required Map selected, + String? fileName, + }) { + state = FlyerImportSession( + sessionId: result.sessionId, + fileName: fileName, + result: result, + selected: selected, + ); + unawaited(_persist()); + } + + void setSelected(int index, bool value) { + if (state == null) return; + final selected = Map.from(state!.selected)..[index] = value; + state = state!.copyWith(selected: selected); + unawaited(_persist()); + } + + void setSelectedForAll(int count, bool value) { + if (state == null) return; + final selected = {for (var i = 0; i < count; i++) i: value}; + state = state!.copyWith(selected: selected); + unawaited(_persist()); + } + + Future clear() async { + state = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_storageKey); + } + + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + if (state == null) { + await prefs.remove(_storageKey); + return; + } + await prefs.setString(_storageKey, jsonEncode(state!.toJson())); + } +} + +final flyerImportSessionProvider = + NotifierProvider( + FlyerImportSessionNotifier.new, +); diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index 1da9bedd..36e9bf4d 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -191,6 +191,67 @@ class ImportRepository { return FlyerImportResult.fromJson(parsed); } + Future getLatestFlyerImportSession({String? token}) async { + final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.latestSession}'); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte hämta senaste flyer-session: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar vid hämtning av flyer-session.', + ); + } + + return FlyerImportResult.fromJson(parsed); + } + + Future getFlyerImportSession({ + required int sessionId, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.bySession(sessionId)}'); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte hämta flyer-session: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar vid hämtning av flyer-session.', + ); + } + + return FlyerImportResult.fromJson(parsed); + } + Future>> createFlyerSelectionsBulk({ required int sessionId, required List> items, diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart index 68f2a2b7..c1e37cc1 100644 --- a/flutter/lib/features/import/domain/flyer_import_item.dart +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -37,7 +37,7 @@ class FlyerImportItem { this.matchConfidence, }); - factory FlyerImportItem.fromJson(Map json) { + factory FlyerImportItem.fromJson(Map json) { return FlyerImportItem( flyerItemId: (json['flyerItemId'] as num?)?.toInt(), rawName: json['rawName'] as String? ?? '', @@ -56,6 +56,28 @@ class FlyerImportItem { matchedProductName: json['matchedProductName'] as String?, matchedVia: json['matchedVia'] as String?, matchConfidence: (json['matchConfidence'] as num?)?.toDouble(), - ); - } -} + ); + } + + Map toJson() { + return { + 'flyerItemId': flyerItemId, + 'rawName': rawName, + 'normalizedName': normalizedName, + 'category': category, + 'price': price, + 'priceUnit': priceUnit, + 'offerText': offerText, + 'isOffer': isOffer, + 'offerLimitText': offerLimitText, + 'comparisonPrice': comparisonPrice, + 'comparisonUnit': comparisonUnit, + 'parseConfidence': parseConfidence, + 'parseReasons': parseReasons, + 'matchedProductId': matchedProductId, + 'matchedProductName': matchedProductName, + 'matchedVia': matchedVia, + 'matchConfidence': matchConfidence, + }; + } +} diff --git a/flutter/lib/features/import/domain/flyer_import_result.dart b/flutter/lib/features/import/domain/flyer_import_result.dart index 9b4bc073..a68db965 100644 --- a/flutter/lib/features/import/domain/flyer_import_result.dart +++ b/flutter/lib/features/import/domain/flyer_import_result.dart @@ -26,4 +26,12 @@ class FlyerImportResult { warnings: warnings, ); } + + Map toJson() { + return { + 'sessionId': sessionId, + 'items': items.map((item) => item.toJson()).toList(), + 'warnings': warnings, + }; + } } diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 6444bf75..234bf4e0 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/utils/pdf_opener.dart'; import '../../auth/data/auth_providers.dart'; +import '../data/flyer_import_session.dart'; import '../data/import_providers.dart'; import '../domain/flyer_import_item.dart'; import '../domain/flyer_import_result.dart'; @@ -23,6 +24,89 @@ class _FlyerImportTabState extends ConsumerState { FlyerImportResult? _result; final Map _selected = {}; + @override + void initState() { + super.initState(); + _restoreSession(); + } + + Future _restoreSession() async { + final notifier = ref.read(flyerImportSessionProvider.notifier); + await notifier.restore(); + var session = ref.read(flyerImportSessionProvider); + + final token = await ref.read(authStateProvider.future); + final repo = ref.read(importRepositoryProvider); + + if (session?.sessionId != null) { + try { + final serverResult = await repo.getFlyerImportSession( + sessionId: session!.sessionId!, + token: token, + ); + if (!mounted) return; + final selected = session.selected.isEmpty + ? { + for (var i = 0; i < serverResult.items.length; i++) + i: serverResult.items[i].matchedProductId != null, + } + : session.selected; + setState(() { + _result = serverResult; + _selected + ..clear() + ..addAll(selected); + }); + notifier.setImportedResult( + result: serverResult, + selected: selected, + fileName: session.fileName, + ); + return; + } catch (_) { + // fallback nedan + } + } + + try { + final latest = await repo.getLatestFlyerImportSession(token: token); + if (!mounted) return; + if (latest.sessionId != null && latest.items.isNotEmpty) { + final selected = { + for (var i = 0; i < latest.items.length; i++) + i: latest.items[i].matchedProductId != null, + }; + setState(() { + _result = latest; + _selected + ..clear() + ..addAll(selected); + }); + notifier.setImportedResult( + result: latest, + selected: selected, + fileName: session?.fileName, + ); + } else if (session?.result != null) { + setState(() { + _result = session!.result; + _selected + ..clear() + ..addAll(session.selected); + }); + } + } catch (_) { + session = ref.read(flyerImportSessionProvider); + if (!mounted || session?.result == null) return; + setState(() { + _result = session!.result; + _selected + ..clear() + ..addAll(session.selected); + }); + } + } + Future _pickFile() async { final result = await FilePicker.pickFiles( type: FileType.custom, @@ -58,6 +142,11 @@ class _FlyerImportTabState extends ConsumerState { ..clear() ..addAll(selected); }); + ref.read(flyerImportSessionProvider.notifier).setImportedResult( + result: parsed, + selected: selected, + fileName: file.name, + ); } catch (e) { if (mounted) showErrorDialog(context, 'Flyerimport misslyckades: $e'); } finally { @@ -329,14 +418,15 @@ class _FlyerImportTabState extends ConsumerState { children: [ Text('${items.length} rader hittades', style: theme.textTheme.titleSmall), TextButton( - onPressed: () { - final target = selectedCount < items.length; - setState(() { - for (var i = 0; i < items.length; i++) { - _selected[i] = target; - } - }); - }, + onPressed: () { + final target = selectedCount < items.length; + setState(() { + for (var i = 0; i < items.length; i++) { + _selected[i] = target; + } + }); + ref.read(flyerImportSessionProvider.notifier).setSelectedForAll(items.length, target); + }, child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), ), ], @@ -352,9 +442,13 @@ class _FlyerImportTabState extends ConsumerState { ? '' : _removeLimitTextFromOfferText(item.offerText!, limitText); - return CheckboxListTile( - value: _selected[index] ?? false, - onChanged: (value) => setState(() => _selected[index] = value ?? false), + return CheckboxListTile( + value: _selected[index] ?? false, + onChanged: (value) { + final checked = value ?? false; + setState(() => _selected[index] = checked); + ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked); + }, title: Row( children: [ Expanded(child: Text(item.rawName)), diff --git a/flutter/test/features/import/data/flyer_import_session_test.dart b/flutter/test/features/import/data/flyer_import_session_test.dart new file mode 100644 index 00000000..4b0ca71c --- /dev/null +++ b/flutter/test/features/import/data/flyer_import_session_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:recipe_flutter/features/import/data/flyer_import_session.dart'; +import 'package:recipe_flutter/features/import/domain/flyer_import_item.dart'; +import 'package:recipe_flutter/features/import/domain/flyer_import_result.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FlyerImportSessionNotifier', () { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + }); + + test('persists only lightweight session payload', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(flyerImportSessionProvider.notifier); + notifier.setImportedResult( + result: FlyerImportResult( + sessionId: 12, + items: [ + FlyerImportItem( + flyerItemId: 1, + rawName: 'Tomat', + normalizedName: 'tomat', + isOffer: true, + ), + ], + warnings: const [], + ), + selected: const {0: true}, + fileName: 'flyer.pdf', + ); + + await Future.delayed(Duration.zero); + + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('flyer_import_session_v1'); + expect(raw, isNotNull); + expect(raw!, isNot(contains('rawName'))); + expect(raw, isNot(contains('normalizedName'))); + expect(raw, contains('"sessionId":12')); + }); + + test('restore reads sessionId and selection from storage', () async { + SharedPreferences.setMockInitialValues({ + 'flyer_import_session_v1': '{"sessionId":77,"fileName":"flyer.pdf","selected":{"0":true,"2":false}}', + }); + + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(flyerImportSessionProvider.notifier); + await notifier.restore(); + + final restored = container.read(flyerImportSessionProvider); + expect(restored, isNotNull); + expect(restored!.sessionId, 77); + expect(restored.fileName, 'flyer.pdf'); + expect(restored.selected[0], true); + expect(restored.selected[2], false); + expect(restored.result, isNull); + }); + }); +}