feat: Add tests, update notes implementation

This commit is contained in:
Anton Stubenbord
2023-12-31 15:26:20 +01:00
parent d7f297a4df
commit 55aa42e4ab
29 changed files with 273 additions and 115 deletions

View File

@@ -9,4 +9,9 @@ class InfoMessageException implements Exception {
this.message,
this.stackTrace,
});
@override
String toString() {
return 'InfoMessageException(code: $code, message: $message, stackTrace: $stackTrace)';
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_api/src/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_offline_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/dio_unauthorized_interceptor.dart';
import 'package:paperless_mobile/core/interceptor/retry_on_connection_change_interceptor.dart';

View File

@@ -82,5 +82,7 @@ String translateError(BuildContext context, ErrorCode code) {
'Could not load custom field.', //TODO: INTL
ErrorCode.customFieldDeleteFailed =>
'Could not delete custom field, please try again.', //TODO: INTL
ErrorCode.deleteNoteFailed => 'Could not delete note, please try again.',
ErrorCode.addNoteFailed => 'Could not create note, please try again.',
};
}

View File

@@ -311,4 +311,17 @@ class DocumentDetailsCubit extends Cubit<DocumentDetailsState> {
_notifier.removeListener(this);
await super.close();
}
Future<void> addNote(String text) async {
assert(state.status == LoadingStatus.loaded);
try {
final updatedDocument = await _api.addNote(
document: state.document!,
text: text,
);
_notifier.notifyUpdated(updatedDocument);
} on PaperlessApiException catch (err) {
addError(TransientPaperlessApiError(code: err.code));
}
}
}

View File

@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
class AddNotePage extends StatefulWidget {
final DocumentModel document;
const AddNotePage({super.key, required this.document});
@override
State<AddNotePage> createState() => _AddNotePageState();
}
class _AddNotePageState extends State<AddNotePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(S.of(context)!.addNote),
),
body: Column(
children: [
TextField(
decoration: InputDecoration(
labelText: S.of(context)!.content,
),
),
ElevatedButton(
onPressed: () {},
child: Text(S.of(context)!.save),
),
],
),
);
}
}

View File

@@ -8,18 +8,65 @@ import 'package:paperless_mobile/features/document_details/cubit/document_detail
import 'package:paperless_mobile/generated/l10n/app_localizations.dart';
import 'package:paperless_mobile/helpers/message_helpers.dart';
class DocumentNotesWidget extends StatelessWidget {
class DocumentNotesWidget extends StatefulWidget {
final DocumentModel document;
const DocumentNotesWidget({super.key, required this.document});
@override
State<DocumentNotesWidget> createState() => _DocumentNotesWidgetState();
}
class _DocumentNotesWidgetState extends State<DocumentNotesWidget> {
final _noteContentController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _noteContentController,
maxLines: null,
validator: (value) {
if (value?.isEmpty ?? true) {
return S.of(context)!.thisFieldIsRequired;
}
return null;
},
decoration: InputDecoration(
hintText: 'Your note here...',
labelText: 'New note',
floatingLabelBehavior: FloatingLabelBehavior.always,
),
).padded(),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
icon: Icon(Icons.note_add_outlined),
label: Text("Add note"),
onPressed: () {
_formKey.currentState?.save();
if (_formKey.currentState?.validate() ?? false) {
context
.read<DocumentDetailsCubit>()
.addNote(_noteContentController.text);
}
},
).padded(),
),
],
).padded(),
),
),
SliverList.separated(
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final note = document.notes.elementAt(index);
final note = widget.document.notes.elementAt(index);
return Card(
// borderRadius: BorderRadius.circular(8),
// elevation: 1,
@@ -51,13 +98,6 @@ class DocumentNotesWidget extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Spacer(),
IconButton(
icon: Icon(Icons.edit),
onPressed: () {
// Push edit page
},
),
IconButton(
icon: Icon(Icons.delete),
onPressed: () {
@@ -74,7 +114,7 @@ class DocumentNotesWidget extends StatelessWidget {
).padded(16),
);
},
itemCount: document.notes.length,
itemCount: widget.document.notes.length,
),
],
);

View File

@@ -1,15 +0,0 @@
import 'package:flutter/material.dart';
class EditNotePage extends StatefulWidget {
const EditNotePage({super.key});
@override
State<EditNotePage> createState() => _EditNotePageState();
}
class _EditNotePageState extends State<EditNotePage> {
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

View File

@@ -54,7 +54,9 @@ class DocumentScannerCubit extends Cubit<DocumentScannerState> {
Future<void> removeScan(File file) async {
try {
await file.delete();
if (await file.exists()) {
await file.delete();
}
} catch (error, stackTrace) {
throw InfoMessageException(
code: ErrorCode.scanRemoveFailed,

View File

@@ -14,6 +14,7 @@ import 'package:paperless_mobile/core/bloc/loading_status.dart';
import 'package:paperless_mobile/core/database/hive/hive_config.dart';
import 'package:paperless_mobile/core/database/tables/global_settings.dart';
import 'package:paperless_mobile/core/global/constants.dart';
import 'package:paperless_mobile/core/model/info_message_exception.dart';
import 'package:paperless_mobile/core/service/file_service.dart';
import 'package:paperless_mobile/features/app_drawer/view/app_drawer.dart';
import 'package:paperless_mobile/features/document_scan/cubit/document_scanner_cubit.dart';
@@ -326,6 +327,8 @@ class _ScannerPageState extends State<ScannerPage>
.removeScan(scans[index]);
} on PaperlessApiException catch (error, stackTrace) {
showErrorMessage(context, error, stackTrace);
} on InfoMessageException catch (error, stackTrace) {
showInfoMessage(context, error, stackTrace);
}
},
index: index,

View File

@@ -9,7 +9,6 @@ import 'package:paperless_mobile/features/document_bulk_action/view/widgets/full
import 'package:paperless_mobile/features/document_bulk_action/view/widgets/fullscreen_bulk_edit_tags_widget.dart';
import 'package:paperless_mobile/features/document_details/cubit/document_details_cubit.dart';
import 'package:paperless_mobile/features/document_details/view/pages/document_details_page.dart';
import 'package:paperless_mobile/features/document_details/view/widgets/add_note_page.dart';
import 'package:paperless_mobile/features/document_edit/cubit/document_edit_cubit.dart';
import 'package:paperless_mobile/features/document_edit/view/document_edit_page.dart';
import 'package:paperless_mobile/features/documents/view/pages/document_view.dart';
@@ -211,16 +210,3 @@ class BulkEditDocumentsRoute extends GoRouteData {
);
}
}
class AddNoteRoute extends GoRouteData {
final DocumentModel $extra;
AddNoteRoute({required this.$extra});
@override
Widget build(BuildContext context, GoRouterState state) {
return AddNotePage(
document: $extra,
);
}
}

View File

@@ -85,10 +85,6 @@ part 'authenticated_route.g.dart';
path: 'preview',
name: R.documentPreview,
),
TypedGoRoute<AddNoteRoute>(
path: 'add-note',
name: R.addNote,
),
],
)
],

View File

@@ -4,3 +4,4 @@ export 'src/models/models.dart';
export 'src/modules/modules.dart';
export 'src/converters/converters.dart';
export 'config/hive/hive_type_ids.dart';
export 'src/interceptor/dio_http_error_interceptor.dart';

View File

@@ -125,8 +125,8 @@ class DocumentFilter extends Equatable {
return queryParams;
}
@override
String toString() => toQueryParameters().toString();
// @override
// String toString() => toQueryParameters().toString();
DocumentFilter copyWith({
int? pageSize,
@@ -249,9 +249,4 @@ class DocumentFilter extends Equatable {
moreLike,
selectedView,
];
// factory DocumentFilter.fromJson(Map<String, dynamic> json) =>
// _$DocumentFilterFromJson(json);
// Map<String, dynamic> toJson() => _$DocumentFilterToJson(this);
}

View File

@@ -82,7 +82,6 @@ class FilterRule with EquatableMixin {
assert(filter.tags is IdsTagsQuery);
return filter.copyWith(
tags: switch (filter.tags) {
// TODO: Handle this case.
IdsTagsQuery(include: var i, exclude: var e) => IdsTagsQuery(
include: [...i, int.parse(value!)],
exclude: e,

View File

@@ -28,4 +28,4 @@ export 'task/task.dart';
export 'task/task_status.dart';
export 'user_model.dart';
export 'exception/exceptions.dart';
export 'note_model.dart';
export 'note_model.dart' show NoteModel;

View File

@@ -1,3 +1,5 @@
// ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart';
part 'note_model.freezed.dart';
part 'note_model.g.dart';
@@ -9,9 +11,19 @@ class NoteModel with _$NoteModel {
required String? note,
required DateTime? created,
required int? document,
required int? user,
@JsonKey(fromJson: parseNoteUserFromJson) required int? user,
}) = _NoteModel;
factory NoteModel.fromJson(Map<String, dynamic> json) =>
_$NoteModelFromJson(json);
}
int? parseNoteUserFromJson(dynamic json) {
if (json == null) return null;
if (json is Map) {
return json['id'];
} else if (json is int) {
return json;
}
return null;
}

View File

@@ -11,7 +11,16 @@ class PaperlessApiException implements Exception {
this.httpStatusCode,
});
const PaperlessApiException.unknown() : this(ErrorCode.unknown);
const PaperlessApiException.unknown({
String? details,
StackTrace? stackTrace,
int? httpStatusCode,
}) : this(
ErrorCode.unknown,
details: details,
stackTrace: stackTrace,
httpStatusCode: httpStatusCode,
);
@override
String toString() {
@@ -71,5 +80,7 @@ enum ErrorCode {
updateSavedViewError,
customFieldCreateFailed,
customFieldLoadFailed,
customFieldDeleteFailed;
customFieldDeleteFailed,
deleteNoteFailed,
addNoteFailed;
}

View File

@@ -11,7 +11,7 @@ import 'date_range_unit.dart';
part 'date_range_query.g.dart';
sealed class DateRangeQuery {
sealed class DateRangeQuery with EquatableMixin {
const DateRangeQuery();
Map<String, String> toQueryParameter(DateRangeQueryField field);
@@ -28,10 +28,13 @@ class UnsetDateRangeQuery extends DateRangeQuery {
@override
bool matches(DateTime dt) => true;
@override
List<Object?> get props => [];
}
@HiveType(typeId: PaperlessApiHiveTypeIds.relativeDateRangeQuery)
class RelativeDateRangeQuery extends DateRangeQuery with EquatableMixin {
class RelativeDateRangeQuery extends DateRangeQuery {
@HiveField(0)
final int offset;
@HiveField(1)
@@ -84,7 +87,7 @@ class RelativeDateRangeQuery extends DateRangeQuery with EquatableMixin {
@JsonSerializable()
@HiveType(typeId: PaperlessApiHiveTypeIds.absoluteDateRangeQuery)
class AbsoluteDateRangeQuery extends DateRangeQuery with EquatableMixin {
class AbsoluteDateRangeQuery extends DateRangeQuery {
@LocalDateTimeJsonConverter()
@HiveField(0)
final DateTime? after;

View File

@@ -4,7 +4,7 @@ import 'package:paperless_api/config/hive/hive_type_ids.dart';
part 'id_query_parameter.g.dart';
sealed class IdQueryParameter {
sealed class IdQueryParameter with EquatableMixin {
const IdQueryParameter();
Map<String, String> toQueryParameter(String field);
bool matches(int? id);
@@ -23,6 +23,9 @@ class UnsetIdQueryParameter extends IdQueryParameter {
@override
bool matches(int? id) => true;
@override
List<Object?> get props => [];
}
// @HiveType(typeId: PaperlessApiHiveTypeIds.notAssignedIdQueryParameter)
@@ -36,6 +39,8 @@ class NotAssignedIdQueryParameter extends IdQueryParameter {
@override
bool matches(int? id) => id == null;
@override
List<Object?> get props => [];
}
// @HiveType(typeId: PaperlessApiHiveTypeIds.anyAssignedIdQueryParameter)
@@ -48,6 +53,8 @@ class AnyAssignedIdQueryParameter extends IdQueryParameter {
@override
bool matches(int? id) => id != null;
@override
List<Object?> get props => [];
}
@HiveType(typeId: PaperlessApiHiveTypeIds.setIdQueryParameter)

View File

@@ -4,7 +4,7 @@ import 'package:paperless_api/config/hive/hive_type_ids.dart';
part 'tags_query.g.dart';
sealed class TagsQuery {
sealed class TagsQuery with EquatableMixin {
const TagsQuery();
Map<String, String> toQueryParameter();
bool matches(Iterable<int> ids);
@@ -20,10 +20,13 @@ class NotAssignedTagsQuery extends TagsQuery {
@override
bool matches(Iterable<int> ids) => ids.isEmpty;
@override
List<Object?> get props => [];
}
@HiveType(typeId: PaperlessApiHiveTypeIds.anyAssignedTagsQuery)
class AnyAssignedTagsQuery extends TagsQuery with EquatableMixin {
class AnyAssignedTagsQuery extends TagsQuery {
@HiveField(0)
final List<int> tagIds;
const AnyAssignedTagsQuery({
@@ -54,7 +57,7 @@ class AnyAssignedTagsQuery extends TagsQuery with EquatableMixin {
}
@HiveType(typeId: PaperlessApiHiveTypeIds.idsTagsQuery)
class IdsTagsQuery extends TagsQuery with EquatableMixin {
class IdsTagsQuery extends TagsQuery {
@HiveField(0)
final List<int> include;
@HiveField(1)

View File

@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:hive/hive.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:paperless_api/config/hive/hive_type_ids.dart';
@@ -91,6 +92,11 @@ class TextQuery {
return other.queryText == queryText && other.queryType == queryType;
}
@override
String toString() {
return "TextQuery($queryText, $queryType)";
}
@override
int get hashCode => Object.hash(queryText, queryType);
}

View File

@@ -1,9 +1,4 @@
import 'package:paperless_api/src/models/exception/exceptions.dart';
abstract class PaperlessAuthenticationApi {
///
/// @throws [PaperlessUnauthorizedException]
///
Future<String> login({
required String username,
required String password,

View File

@@ -37,6 +37,11 @@ class PaperlessAuthenticationApiImpl implements PaperlessAuthenticationApi {
// return AuthenticationTemporaryRedirect(redirectUrl!);
} on DioException catch (exception) {
throw exception.unravel();
} catch (error, stackTrace) {
throw PaperlessApiException.unknown(
details: error.toString(),
stackTrace: stackTrace,
);
}
}
}

View File

@@ -36,4 +36,7 @@ abstract class PaperlessDocumentsApi {
Future<FieldSuggestions> findSuggestions(DocumentModel document);
Future<List<String>> autocomplete(String query, [int limit = 10]);
Future<DocumentModel> addNote(
{required DocumentModel document, required String text});
}

View File

@@ -337,7 +337,30 @@ class PaperlessDocumentsApiImpl implements PaperlessDocumentsApi {
return document.copyWith(notes: notes);
} on DioException catch (exception) {
throw exception.unravel(
orElse: const PaperlessApiException(ErrorCode.documentDeleteFailed),
orElse: const PaperlessApiException(ErrorCode.deleteNoteFailed),
);
}
}
@override
Future<DocumentModel> addNote({
required DocumentModel document,
required String text,
}) async {
try {
final response = await client.post(
"/api/documents/${document.id}/notes/",
options: Options(validateStatus: (status) => status == 200),
data: {'note': text},
);
final notes =
(response.data as List).map((e) => NoteModel.fromJson(e)).toList();
return document.copyWith(notes: notes);
} on DioException catch (exception) {
throw exception.unravel(
orElse: const PaperlessApiException(ErrorCode.addNoteFailed),
);
}
}

View File

@@ -22,6 +22,8 @@ dependencies:
jiffy: ^5.0.0
freezed_annotation: ^2.4.1
hive: ^2.2.3
mockito: ^5.4.4
http_mock_adapter: ^0.6.1
dev_dependencies:
flutter_test:

View File

@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:mockito/mockito.dart';
import 'package:paperless_api/paperless_api.dart';
void main() {
group('AuthenticationApi with DioHttpErrorIncerceptor', () {
late PaperlessAuthenticationApi authenticationApi;
late DioAdapter mockAdapter;
const token = "abcde";
const invalidCredentialsServerMessage =
"Unable to log in with provided credentials.";
setUp(() {
final dio = Dio()..interceptors.add(DioHttpErrorInterceptor());
authenticationApi = PaperlessAuthenticationApiImpl(dio);
mockAdapter = DioAdapter(dio: dio);
// Valid credentials
mockAdapter.onPost(
"/api/token/",
data: {
"username": "username",
"password": "password",
},
(server) => server.reply(200, {"token": token}),
);
// Invalid credentials
mockAdapter.onPost(
"/api/token/",
data: {
"username": "wrongUsername",
"password": "wrongPassword",
},
(server) => server.reply(400, {
"non_field_errors": [invalidCredentialsServerMessage]
}),
);
});
// tearDown(() {});
test(
'should return a valid token when logging in with valid credentials',
() {
expect(
authenticationApi.login(
username: "username",
password: "password",
),
completion(token),
);
},
);
test(
'should throw a PaperlessFormValidationException containing a reason '
'when logging in with invalid credentials',
() {
expect(
authenticationApi.login(
username: "wrongUsername",
password: "wrongPassword",
),
throwsA(isA<PaperlessFormValidationException>().having(
(e) => e.unspecificErrorMessage(),
"non-field specific error message",
equals(invalidCredentialsServerMessage),
)),
);
},
);
test(
'should return an error when logging in with invalid credentials',
() {
expect(
authenticationApi.login(
username: "wrongUsername",
password: "wrongPassword",
),
throwsA(isA<PaperlessFormValidationException>().having(
(e) => e.unspecificErrorMessage(),
"non-field specific error message",
equals(invalidCredentialsServerMessage),
)),
);
},
);
});
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:paperless_api/paperless_api.dart';
void main() {
group('Validate parsing logic from [SavedView] to [DocumentFilter]:', () {
group('Parsing [SavedView] to [DocumentFilter]:', () {
test('Values are correctly parsed if set.', () {
expect(
SavedView.fromJson({
@@ -64,7 +64,7 @@ void main() {
]
}).toDocumentFilter(),
equals(
DocumentFilter.initial.copyWith(
DocumentFilter(
correspondent: const SetIdQueryParameter(id: 42),
documentType: const SetIdQueryParameter(id: 69),
storagePath: const SetIdQueryParameter(id: 14),
@@ -83,6 +83,7 @@ void main() {
sortField: SortField.created,
sortOrder: SortOrder.descending,
query: const TextQuery.extended("Never gonna give you up"),
selectedView: 1,
),
),
);
@@ -99,7 +100,11 @@ void main() {
"sort_reverse": true,
"filter_rules": [],
}).toDocumentFilter(),
equals(DocumentFilter.initial),
equals(
const DocumentFilter(
selectedView: 1,
),
),
);
});
@@ -130,11 +135,12 @@ void main() {
},
],
}).toDocumentFilter();
final expected = DocumentFilter.initial.copyWith(
correspondent: const NotAssignedIdQueryParameter(),
documentType: const NotAssignedIdQueryParameter(),
storagePath: const NotAssignedIdQueryParameter(),
tags: const NotAssignedTagsQuery(),
const expected = DocumentFilter(
correspondent: NotAssignedIdQueryParameter(),
documentType: NotAssignedIdQueryParameter(),
storagePath: NotAssignedIdQueryParameter(),
tags: NotAssignedTagsQuery(),
selectedView: 1,
);
expect(
actual,
@@ -148,6 +154,7 @@ void main() {
expect(
SavedView.fromDocumentFilter(
DocumentFilter(
selectedView: 1,
correspondent: const SetIdQueryParameter(id: 1),
documentType: const SetIdQueryParameter(id: 2),
storagePath: const SetIdQueryParameter(id: 3),
@@ -173,6 +180,7 @@ void main() {
),
equals(
SavedView(
id: 1,
name: "test_name",
showOnDashboard: false,
showInSidebar: false,