From ac89546ac3e54b3bc1f80955846cf1abc532f2eb Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 24 Nov 2025 11:54:38 +0700 Subject: [PATCH] TF-4169 Add `Label/changes` method --- .../method/changes/changes_label_method.dart | 44 ++++ .../changes/changes_label_response.dart | 44 ++++ .../changes/changes_label_method_test.dart | 211 ++++++++++++++++++ .../method/get/get_label_method_test.dart | 4 + labels/test/method/method_fixtures.dart | 4 + .../method/set/set_label_method_test.dart | 4 +- 6 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 labels/lib/method/changes/changes_label_method.dart create mode 100644 labels/lib/method/changes/changes_label_response.dart create mode 100644 labels/test/method/changes/changes_label_method_test.dart diff --git a/labels/lib/method/changes/changes_label_method.dart b/labels/lib/method/changes/changes_label_method.dart new file mode 100644 index 000000000..fbb1851c8 --- /dev/null +++ b/labels/lib/method/changes/changes_label_method.dart @@ -0,0 +1,44 @@ +import 'package:jmap_dart_client/http/converter/account_id_converter.dart'; +import 'package:jmap_dart_client/http/converter/state_converter.dart'; +import 'package:jmap_dart_client/http/converter/unsigned_int_nullable_converter.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; +import 'package:jmap_dart_client/jmap/core/method/request/changes_method.dart'; +import 'package:jmap_dart_client/jmap/core/request/request_invocation.dart'; +import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:labels/labels.dart'; + +part 'changes_label_method.g.dart'; + +@JsonSerializable( + converters: [ + StateConverter(), + AccountIdConverter(), + UnsignedIntNullableConverter(), + ], +) +class ChangesLabelMethod extends ChangesMethod { + ChangesLabelMethod( + super.accountId, + super.sinceState, { + UnsignedInt? maxChanges, + }); + + @override + MethodName get methodName => MethodName('Label/changes'); + + @override + List get props => [accountId, sinceState, maxChanges]; + + @override + Set get requiredCapabilities => { + CapabilityIdentifier.jmapCore, + LabelsConstants.labelsCapability, + }; + + factory ChangesLabelMethod.fromJson(Map json) => + _$ChangesLabelMethodFromJson(json); + + @override + Map toJson() => _$ChangesLabelMethodToJson(this); +} diff --git a/labels/lib/method/changes/changes_label_response.dart b/labels/lib/method/changes/changes_label_response.dart new file mode 100644 index 000000000..6947936e1 --- /dev/null +++ b/labels/lib/method/changes/changes_label_response.dart @@ -0,0 +1,44 @@ +import 'package:jmap_dart_client/http/converter/account_id_converter.dart'; +import 'package:jmap_dart_client/http/converter/id_converter.dart'; +import 'package:jmap_dart_client/http/converter/state_converter.dart'; +import 'package:jmap_dart_client/jmap/core/method/response/changes_response.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'changes_label_response.g.dart'; + +@JsonSerializable( + converters: [ + StateConverter(), + AccountIdConverter(), + IdConverter(), + ], +) +class ChangesLabelResponse extends ChangesResponse { + ChangesLabelResponse( + super.accountId, + super.oldState, + super.newState, + super.hasMoreChanges, + super.created, + super.updated, + super.destroyed, + ); + + factory ChangesLabelResponse.fromJson(Map json) => + _$ChangesLabelResponseFromJson(json); + + static ChangesLabelResponse deserialize(Map json) { + return ChangesLabelResponse.fromJson(json); + } + + @override + List get props => [ + accountId, + oldState, + newState, + hasMoreChanges, + created, + updated, + destroyed, + ]; +} diff --git a/labels/test/method/changes/changes_label_method_test.dart b/labels/test/method/changes/changes_label_method_test.dart new file mode 100644 index 000000000..35e0bc476 --- /dev/null +++ b/labels/test/method/changes/changes_label_method_test.dart @@ -0,0 +1,211 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/state.dart'; +import 'package:labels/method/changes/changes_label_method.dart'; +import 'package:labels/method/changes/changes_label_response.dart'; + +import '../method_fixtures.dart'; + +void main() { + group('ChangesLabelMethod', () { + const accountIdString = + '29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6'; + const oldStateString = '2c9f1b12-b35a-43e6-9af2-0106fb53a943'; + + final accountId = AccountId(Id(accountIdString)); + final oldState = State(oldStateString); + + Map buildPayload() { + return { + "using": [ + "urn:ietf:params:jmap:core", + "com:linagora:params:jmap:labels", + ], + "methodCalls": [ + [ + "Label/changes", + { + "accountId": accountId.id.value, + "sinceState": oldStateString, + }, + "c0", + ] + ] + }; + } + + test('should parse ChangesLabelResponse correctly', () async { + // Arrange + final dio = createDio(); + final adapter = DioAdapter(dio: dio); + + adapter.onPost( + '', + (server) => server.reply(200, { + "sessionState": "S", + "methodResponses": [ + [ + "Label/changes", + { + "accountId": accountIdString, + "oldState": oldStateString, + "newState": "NEW-STATE-XYZ", + "hasMoreChanges": false, + "created": ["123456"], + "updated": ["654321"], + "destroyed": [] + }, + "c0", + ] + ] + }), + data: buildPayload(), + headers: createJMAPHeader(), + ); + + final builder = createBuilder(dio); + final method = ChangesLabelMethod(accountId, oldState); + final invocation = builder.invocation(method); + + // Act + final response = await (builder..usings(method.requiredCapabilities)) + .build() + .execute(); + + final parsed = response.parse( + invocation.methodCallId, + ChangesLabelResponse.deserialize, + ); + + // Assert + expect(parsed, isNotNull); + expect(parsed!.created, hasLength(1)); + expect(parsed.updated, hasLength(1)); + expect(parsed.destroyed, isEmpty); + expect(parsed.hasMoreChanges, isFalse); + }); + + test('should handle empty created/updated/destroyed lists', () async { + // Arrange + final dio = createDio(); + final adapter = DioAdapter(dio: dio); + + adapter.onPost( + '', + (server) => server.reply(200, { + "sessionState": "S", + "methodResponses": [ + [ + "Label/changes", + { + "accountId": accountIdString, + "oldState": oldStateString, + "newState": "newState-000", + "hasMoreChanges": false, + "created": [], + "updated": [], + "destroyed": [], + }, + "c0" + ] + ] + }), + data: buildPayload(), + headers: createJMAPHeader(), + ); + + final builder = createBuilder(dio); + final method = ChangesLabelMethod(accountId, oldState); + final invocation = builder.invocation(method); + + // Act + final response = await (builder..usings(method.requiredCapabilities)) + .build() + .execute(); + + final parsed = response.parse( + invocation.methodCallId, + ChangesLabelResponse.deserialize, + ); + + // Assert + expect(parsed!.created, isEmpty); + expect(parsed.updated, isEmpty); + expect(parsed.destroyed, isEmpty); + }); + + test('should parse hasMoreChanges = true correctly', () async { + // Arrange + final dio = createDio(); + final adapter = DioAdapter(dio: dio); + + adapter.onPost( + '', + (server) => server.reply(200, { + "sessionState": "S", + "methodResponses": [ + [ + "Label/changes", + { + "accountId": accountIdString, + "oldState": oldStateString, + "newState": "X2", + "hasMoreChanges": true, + "created": ["1"], + "updated": [], + "destroyed": [], + }, + "c0" + ] + ] + }), + data: buildPayload(), + headers: createJMAPHeader(), + ); + + final builder = createBuilder(dio); + final method = ChangesLabelMethod(accountId, oldState); + final invocation = builder.invocation(method); + + // Act + final response = await (builder..usings(method.requiredCapabilities)) + .build() + .execute(); + + final parsed = response.parse( + invocation.methodCallId, + ChangesLabelResponse.deserialize, + ); + + // Assert + expect(parsed!.hasMoreChanges, isTrue); + expect(parsed.created, contains(Id("1"))); + }); + + test('should throw DioException on server error', () async { + // Arrange + final dio = createDio(); + final adapter = DioAdapter(dio: dio); + + adapter.onPost( + '', + (server) => server.reply(500, {}), + data: buildPayload(), + headers: createJMAPHeader(), + ); + + final builder = createBuilder(dio); + final method = ChangesLabelMethod(accountId, oldState); + + // Act + final call = + (builder..usings(method.requiredCapabilities)).build().execute(); + + // Assert + expect(call, throwsA(isA())); + }); + }); +} diff --git a/labels/test/method/get/get_label_method_test.dart b/labels/test/method/get/get_label_method_test.dart index 3445dd56e..3eb389cc2 100644 --- a/labels/test/method/get/get_label_method_test.dart +++ b/labels/test/method/get/get_label_method_test.dart @@ -88,6 +88,7 @@ void main() { }, ), data: buildRequestPayload(ids: ['A', 'B']), + headers: createJMAPHeader(), ); final builder = createBuilder(dio); @@ -135,6 +136,7 @@ void main() { }, ), data: buildRequestPayload(ids: ['X1', 'X2']), + headers: createJMAPHeader(), ); final builder = createBuilder(dio); @@ -183,6 +185,7 @@ void main() { }, ), data: buildRequestPayload(ids: ['A']), + headers: createJMAPHeader(), ); final builder = createBuilder(dio); @@ -213,6 +216,7 @@ void main() { '', (server) => server.reply(500, {}), data: buildRequestPayload(ids: ['A']), + headers: createJMAPHeader(), ); final builder = createBuilder(dio); diff --git a/labels/test/method/method_fixtures.dart b/labels/test/method/method_fixtures.dart index 747eaf1b1..7adef9617 100644 --- a/labels/test/method/method_fixtures.dart +++ b/labels/test/method/method_fixtures.dart @@ -14,3 +14,7 @@ JmapRequestBuilder createBuilder(Dio dio) { ProcessingInvocation(), ); } + +Map createJMAPHeader() { + return {"accept": "application/json;jmapVersion=rfc-8621"}; +} diff --git a/labels/test/method/set/set_label_method_test.dart b/labels/test/method/set/set_label_method_test.dart index d805ecfb6..ae1237e23 100644 --- a/labels/test/method/set/set_label_method_test.dart +++ b/labels/test/method/set/set_label_method_test.dart @@ -61,7 +61,7 @@ void main() { data: buildPayload({ "4f29": {"displayName": "Important", "color": "#00ccdd"} }), - headers: {"accept": "application/json;jmapVersion=rfc-8621"}, + headers: createJMAPHeader(), ); final builder = createBuilder(dio); @@ -119,6 +119,7 @@ void main() { "A": {"displayName": "Tag A", "color": "#111111"}, "B": {"displayName": "Tag B", "color": "#222222"}, }), + headers: createJMAPHeader(), ); final builder = createBuilder(dio); @@ -159,6 +160,7 @@ void main() { data: buildPayload({ "A": {"displayName": "Err", "color": "#dddddd"} }), + headers: createJMAPHeader(), ); final builder = createBuilder(dio);