diff --git a/lib/src/common/github.dart b/lib/src/common/github.dart index 6c64f390..71facc8e 100644 --- a/lib/src/common/github.dart +++ b/lib/src/common/github.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart' as http_parser; import 'package:meta/meta.dart'; +import 'graphql_service.dart'; + /// The Main GitHub Client /// /// ## Example @@ -63,6 +65,7 @@ class GitHub { UrlShortenerService? _urlShortener; UsersService? _users; ChecksService? _checks; + GraphQLService? _graphql; /// The maximum number of requests that the consumer is permitted to make per /// hour. @@ -143,6 +146,9 @@ class GitHub { /// See https://developer.github.com/v3/checks/ ChecksService get checks => _checks ??= ChecksService(this); + /// Service for GraphQL related methods of the GitHub API. + GraphQLService get graphql => _graphql ??= GraphQLService(this); + /// Handles Get Requests that respond with JSON /// [path] can either be a path like '/repos' or a full url. /// [statusCode] is the expected status code. If it is null, it is ignored. diff --git a/lib/src/common/graphql_service.dart b/lib/src/common/graphql_service.dart new file mode 100644 index 00000000..5a45a68f --- /dev/null +++ b/lib/src/common/graphql_service.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:graphql/client.dart'; + +import 'github.dart'; + +/// Service for handling GraphQL requests. +class GraphQLService { + final GitHub github; + late final GraphQLClient _client; + + GraphQLService(this.github) { + final httpLink = HttpLink('https://api.github.com/graphql'); + + final authLink = AuthLink( + getToken: () async => 'Bearer ${github.auth.token}', + ); + + final link = authLink.concat(httpLink); + + _client = GraphQLClient(cache: GraphQLCache(), link: link); + } + + /// Performs a GraphQL query. + Future query( + String query, { + Map? variables, + }) async { + final options = QueryOptions( + document: gql(query), + variables: variables ?? const {}, + ); + return _client.query(options); + } + + /// Performs a GraphQL mutation. + Future mutate( + String mutation, { + Map? variables, + }) async { + final options = MutationOptions( + document: gql(mutation), + variables: variables ?? const {}, + ); + return _client.mutate(options); + } +} diff --git a/lib/src/services/issues_service.dart b/lib/src/services/issues_service.dart index 8d3499b1..946b6d4f 100644 --- a/lib/src/services/issues_service.dart +++ b/lib/src/services/issues_service.dart @@ -566,4 +566,31 @@ class IssuesService extends Service { statusCode: 204, ); } + + /// Deletes an issue. + /// + /// This uses the GraphQL API, since issue deletion is not available in the REST API. + /// + /// API docs: https://docs.github.com/en/graphql/reference/mutations#deleteissue + Future deleteIssue(RepositorySlug slug, int issueNumber) async { + final issue = await get(slug, issueNumber); + final issueId = issue.nodeId; + + const mutation = r''' + mutation DeleteIssue($issueId: ID!) { + deleteIssue(input: {issueId: $issueId}) { + clientMutationId + } + } + '''; + + final result = await github.graphql.mutate( + mutation, + variables: {'issueId': issueId}, + ); + + if (result.hasException) { + throw result.exception!; + } + } } diff --git a/pubspec.yaml b/pubspec.yaml index 24c9bba1..27849647 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + graphql: ^5.2.1 http: ^1.3.0 http_parser: json_annotation: ^4.9.0 diff --git a/test/unit/issues_test.dart b/test/unit/issues_test.dart index b3c60072..f468576c 100644 --- a/test/unit/issues_test.dart +++ b/test/unit/issues_test.dart @@ -1,6 +1,9 @@ import 'dart:convert'; -import 'package:github_flutter/src/models/issues.dart'; +import 'package:github_flutter/src/common.dart'; +import 'package:github_flutter/src/common/graphql_service.dart'; +import 'package:graphql/client.dart'; +import 'package:http/http.dart' as http; import 'package:test/test.dart'; const String testIssueCommentJson = ''' @@ -50,6 +53,76 @@ const String testIssueCommentJson = ''' } '''; +typedef GetJSONCallback = + Future Function( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }); + +// A mock implementation of GitHub that uses noSuchMethod to avoid implementing +// all methods of the GitHub class. +class MockGitHub implements GitHub { + @override + late final GraphQLService graphql; + + late GetJSONCallback onGetJSON; + + MockGitHub(this.graphql); + + @override + Future getJSON( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) { + return onGetJSON( + path, + statusCode: statusCode, + fail: fail, + headers: headers, + params: params, + convert: convert, + preview: preview, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + // This is needed to avoid implementing all methods of the GitHub class. + // We only care about getJSON and graphql. + } +} + +// A manual mock for GraphQLService to avoid mockito issues. +class MockGraphQLService implements GraphQLService { + // Callback to be set by the test. + late Future Function(String, Map?) onMutate; + + @override + Future mutate( + String mutation, { + Map? variables, + }) { + return onMutate(mutation, variables); + } + + // Unimplemented members + @override + GitHub get github => throw UnimplementedError(); + @override + Future query(String query, {Map? variables}) => + throw UnimplementedError(); +} + void main() { group('Issue Comments', () { test('IssueComment from Json', () { @@ -61,4 +134,152 @@ void main() { expect('CaseyHillers', issueComment.user!.login); }); }); + + group('IssuesService', () { + late IssuesService issuesService; + late MockGitHub mockGitHub; + late MockGraphQLService mockGraphQLService; + + setUp(() { + mockGraphQLService = MockGraphQLService(); + mockGitHub = MockGitHub(mockGraphQLService); + issuesService = IssuesService(mockGitHub); + }); + + test('deleteIssue success', () async { + // Arrange + String? capturedMutation; + Map? capturedVariables; + final slug = RepositorySlug('owner', 'repo'); + const issueNumber = 1; + const issueNodeId = 'issue-node-id-456'; + + mockGitHub.onGetJSON = ( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) async { + if (path == '/repos/owner/repo/issues/1') { + final issueJson = { + 'id': 1, + 'node_id': issueNodeId, + 'number': issueNumber, + 'state': 'open', + 'title': 'Test Issue', + 'url': 'https://api.github.com/repos/owner/repo/issues/1', + 'html_url': 'https://github.com/owner/repo/issues/1', + 'body': 'Test Body', + }; + final issue = convert!(issueJson as S); + return issue; + } + throw Exception('Unexpected path: $path'); + }; + + mockGraphQLService.onMutate = (mutation, variables) { + capturedMutation = mutation; + capturedVariables = variables; + return Future.value( + QueryResult( + options: QueryOptions( + document: gql(''), + ), // ignore: deprecated_member_use + source: QueryResultSource.network, + data: const { + 'deleteIssue': {'clientMutationId': '1234'}, + }, + ), + ); + }; + + // Act + await issuesService.deleteIssue(slug, issueNumber); + + // Assert + expect(capturedMutation, contains('mutation DeleteIssue')); + expect(capturedVariables, {'issueId': issueNodeId}); + }); + + test('deleteIssue failure on get', () async { + // Arrange + final slug = RepositorySlug('owner', 'repo'); + const issueNumber = 1; + + mockGitHub.onGetJSON = ( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) async { + throw Exception('Failed to get issue'); + }; + + // Act & Assert + expect( + () => issuesService.deleteIssue(slug, issueNumber), + throwsA(isA()), + ); + }); + + test('deleteIssue failure on mutate', () async { + // Arrange + final slug = RepositorySlug('owner', 'repo'); + const issueNumber = 1; + const issueNodeId = 'issue-node-id-456'; + + mockGitHub.onGetJSON = ( + String path, { + int? statusCode, + void Function(http.Response)? fail, + Map? headers, + Map? params, + JSONConverter? convert, + String? preview, + }) async { + if (path == '/repos/owner/repo/issues/1') { + final issueJson = { + 'id': 1, + 'node_id': issueNodeId, + 'number': issueNumber, + 'state': 'open', + 'title': 'Test Issue', + 'url': 'https://api.github.com/repos/owner/repo/issues/1', + 'html_url': 'https://github.com/owner/repo/issues/1', + 'body': 'Test Body', + }; + final issue = convert!(issueJson as S); + return issue; + } + throw Exception('Unexpected path: $path'); + }; + + final exception = OperationException( + graphqlErrors: [const GraphQLError(message: 'Failed to delete')], + ); + mockGraphQLService.onMutate = (mutation, variables) { + return Future.value( + QueryResult( + options: QueryOptions( + document: gql(''), + ), // ignore: deprecated_member_use + source: QueryResultSource.network, + exception: exception, + ), + ); + }; + + // Act & Assert + expect( + () => issuesService.deleteIssue(slug, issueNumber), + throwsA(isA()), + ); + }); + }); }