Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/src/common/github.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions lib/src/common/graphql_service.dart
Original file line number Diff line number Diff line change
@@ -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<QueryResult> query(
String query, {
Map<String, dynamic>? variables,
}) async {
final options = QueryOptions(
document: gql(query),
variables: variables ?? const <String, dynamic>{},
);
return _client.query(options);
}

/// Performs a GraphQL mutation.
Future<QueryResult> mutate(
String mutation, {
Map<String, dynamic>? variables,
}) async {
final options = MutationOptions(
document: gql(mutation),
variables: variables ?? const <String, dynamic>{},
);
return _client.mutate(options);
}
}
27 changes: 27 additions & 0 deletions lib/src/services/issues_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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!;
}
}
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ environment:
dependencies:
flutter:
sdk: flutter
graphql: ^5.2.1
http: ^1.3.0
http_parser:
json_annotation: ^4.9.0
Expand Down
223 changes: 222 additions & 1 deletion test/unit/issues_test.dart
Original file line number Diff line number Diff line change
@@ -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 = '''
Expand Down Expand Up @@ -50,6 +53,76 @@ const String testIssueCommentJson = '''
}
''';

typedef GetJSONCallback =
Future<T> Function<S, T>(
String path, {
int? statusCode,
void Function(http.Response)? fail,
Map<String, String>? headers,
Map<String, String>? params,
JSONConverter<S, T>? 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<T> getJSON<S, T>(
String path, {
int? statusCode,
void Function(http.Response)? fail,
Map<String, String>? headers,
Map<String, String>? params,
JSONConverter<S, T>? convert,
String? preview,
}) {
return onGetJSON<S, T>(
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<QueryResult> Function(String, Map<String, dynamic>?) onMutate;

@override
Future<QueryResult> mutate(
String mutation, {
Map<String, dynamic>? variables,
}) {
return onMutate(mutation, variables);
}

// Unimplemented members
@override
GitHub get github => throw UnimplementedError();
@override
Future<QueryResult> query(String query, {Map<String, dynamic>? variables}) =>
throw UnimplementedError();
}

void main() {
group('Issue Comments', () {
test('IssueComment from Json', () {
Expand All @@ -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<String, dynamic>? capturedVariables;
final slug = RepositorySlug('owner', 'repo');
const issueNumber = 1;
const issueNodeId = 'issue-node-id-456';

mockGitHub.onGetJSON = <S, T>(
String path, {
int? statusCode,
void Function(http.Response)? fail,
Map<String, String>? headers,
Map<String, String>? params,
JSONConverter<S, T>? 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 = <S, T>(
String path, {
int? statusCode,
void Function(http.Response)? fail,
Map<String, String>? headers,
Map<String, String>? params,
JSONConverter<S, T>? convert,
String? preview,
}) async {
throw Exception('Failed to get issue');
};

// Act & Assert
expect(
() => issuesService.deleteIssue(slug, issueNumber),
throwsA(isA<Exception>()),
);
});

test('deleteIssue failure on mutate', () async {
// Arrange
final slug = RepositorySlug('owner', 'repo');
const issueNumber = 1;
const issueNodeId = 'issue-node-id-456';

mockGitHub.onGetJSON = <S, T>(
String path, {
int? statusCode,
void Function(http.Response)? fail,
Map<String, String>? headers,
Map<String, String>? params,
JSONConverter<S, T>? 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<OperationException>()),
);
});
});
}