diff --git a/.travis.yml b/.travis.yml index c29faa1f..f0541e12 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,4 @@ dart: dart_task: - test: -p vm - dartfmt - -script: - - dartanalyzer --strong --fatal-warnings --fatal-lints . + - dartanalyzer --strong --fatal-warnings --fatal-lints diff --git a/lib/src/reddit.dart b/lib/src/reddit.dart index 5584841d..a8478a34 100644 --- a/lib/src/reddit.dart +++ b/lib/src/reddit.dart @@ -108,6 +108,13 @@ class Reddit { } } + Reddit.fromAuthenticator(Authenticator auth) { + if (auth == null) { + throw new DRAWAuthenticationError('auth cannot be null.'); + } + _initializationCallback(auth); + } + void _initializationCallback(Authenticator auth) { _auth = auth; _initializedCompleter.complete(true); diff --git a/pubspec.yaml b/pubspec.yaml index 79408d70..7f8260a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ authors: dependencies: http: 'any' oauth2: '>= 1.1.0' + test: '^0.12.5' dev_dependencies: - test: '^0.12.5' + reply: '^0.1.2-dev' diff --git a/test/api_v1_me_test.dart b/test/api_v1_me_test.dart new file mode 100644 index 00000000..8f051e2c --- /dev/null +++ b/test/api_v1_me_test.dart @@ -0,0 +1,28 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:draw/draw.dart'; +import 'package:draw/src/auth.dart'; + +import 'test_authenticator.dart'; + +Future main() async { + test('api/v1/me_RawTest', () async { + final testAuth = new TestAuthenticator('test/records/api_v1_me_raw.json'); + final reddit = new Reddit.fromAuthenticator(testAuth); + await reddit.initialized; + final response = + await reddit.auth.get(Uri.parse('https://oauth.reddit.com/api/v1/me')); + expect(response['is_employee'], equals(false)); + expect(response['name'], equals('DRAWApiOfficial')); + expect(response['created'], equals(1501830779.0)); + expect(response['features'], isNot(null)); + final features = response['features']; + expect(features['do_not_track'], equals(true)); + }); +} diff --git a/test/records/api_v1_me_raw.json b/test/records/api_v1_me_raw.json new file mode 100644 index 00000000..23031a55 --- /dev/null +++ b/test/records/api_v1_me_raw.json @@ -0,0 +1 @@ +[{"always":true,"request":["https://oauth.reddit.com/api/v1/me"],"response":{"is_employee":false,"features":{"do_not_track":true,"show_amp_link":true,"live_happening_now":true,"adserver_reporting":true,"give_hsts_grants":true,"legacy_search_pref":true,"listing_service_rampup":true,"mobile_web_targeting":true,"default_srs_holdout":{"owner":"relevance","variant":"control_2","experiment_id":171},"adzerk_do_not_track":true,"users_listing":true,"show_user_sr_name":true,"whitelisted_pms":true,"sticky_comments":true,"personalization_prefs":true,"upgrade_cookies":true,"interest_targeting":true,"new_report_flow":true,"block_user_by_report":true,"post_embed":true,"ads_auto_refund":true,"orangereds_as_emails":true,"mweb_xpromo_modal_listing_click_daily_dismissible_ios":true,"mweb_xpromo_ad_feed_android":{"owner":"channels","variant":"control_2","experiment_id":195},"expando_events":true,"eu_cookie_policy":true,"utm_comment_links":true,"force_https":true,"mobile_native_banner":true,"post_to_profile_beta":true,"reddituploads_redirect":true,"outbound_clicktracking":true,"new_loggedin_cache_policy":true,"inbox_push":true,"https_redirect":true,"search_dark_traffic":true,"mweb_xpromo_interstitial_comments_ios":true,"pause_ads":true,"programmatic_ads":true,"geopopular":true,"show_recommended_link":true,"mweb_xpromo_interstitial_comments_android":true,"ads_auction":true,"screenview_events":true,"new_report_dialog":true,"moat_tracking":true,"subreddit_rules":true,"mobile_settings":true,"adzerk_reporting_2":true,"activity_service_write":true,"ads_auto_extend":true,"geopopular_au_holdout":{"owner":"relevance","variant":"control_2","experiment_id":206},"scroll_events":true,"mweb_xpromo_modal_listing_click_daily_dismissible_android":true,"adblock_test":true,"activity_service_read":true},"pref_no_profanity":true,"is_suspended":false,"pref_geopopular":"","subreddit":null,"is_sponsor":false,"gold_expiration":null,"id":"9b8dzfo","suspension_expiration_utc":null,"verified":false,"new_modmail_exists":true,"over_18":false,"is_gold":false,"is_mod":true,"has_verified_email":false,"has_mod_mail":false,"oauth_client_id":"Db_4C6XcNCNqow","hide_from_robots":false,"link_karma":1,"inbox_count":1,"pref_top_karma_subreddits":null,"has_mail":true,"pref_show_snoovatar":false,"name":"DRAWApiOfficial","created":1501830779.0,"gold_creddits":0,"created_utc":1501801979.0,"in_beta":false,"comment_karma":0,"has_subscribed":false}}] \ No newline at end of file diff --git a/test/test_authenticator.dart b/test/test_authenticator.dart new file mode 100644 index 00000000..16b54024 --- /dev/null +++ b/test/test_authenticator.dart @@ -0,0 +1,107 @@ +// Copyright (c) 2017, the Dart Reddit API Wrapper project authors. +// Please see the AUTHORS file for details. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:reply/reply.dart'; + +import 'package:draw/src/auth.dart'; +import 'package:draw/src/exceptions.dart'; + +/// A drop-in replacement for [Authenticator], used for recording and replaying +/// Reddit API interactions, used primarily for testing. +class TestAuthenticator extends Authenticator { + final String _recordingPath; + final Authenticator _recordAuth; + final _recorder = new Recorder(); + bool get isRecording => (_recordAuth == null); + Recording _recording; + + /// Creates a [TestAuthenticator] object which either reads a recording from + /// [recordingPath] or records Reddit API requests and responses if + /// [recordAuth] is provided. If [recordAuth] is provided, it must be a + /// valid Authenticator with valid OAuth2 credentials that is capable of + /// making requests to the Reddit API. Note: when recording Reddit API + /// interactions, [writeRecording] must be called to write all prior records + /// to the file at [recordingPath]. + TestAuthenticator(String recordingPath, {Authenticator recordAuth}) + : _recordingPath = recordingPath, + _recordAuth = recordAuth, + super(null, null) { + if (isRecording) { + final rawRecording = new File(recordingPath).readAsStringSync(); + _recording = new Recording.fromJson(JSON.decode(rawRecording), + toRequest: (q) => q, + toResponse: (r) => r, + requestEquality: const ListEquality()); + } + } + + @override + Future refresh() async { + if (isRecording) { + throw new DRAWAuthenticationError('cannot refresh a TestAuthenticator.'); + } + return _recordAuth.refresh(); + } + + @override + Future revoke() async { + if (isRecording) { + throw new DRAWAuthenticationError('cannot revoke a TestAuthenticator.'); + } + return _recordAuth.revoke(); + } + + @override + Future get(Uri path) async { + Map result; + if (isRecording) { + // TODO(bkonyi): grab the response based on query. + return _recording.reply([path.toString()]); + } else { + print(path.toString()); + result = await _recordAuth.get(path); + // TODO(bkonyi): do we always want to reply? + _recorder.given([path.toString()]).reply(result).always(); + } + return result; + } + + @override + Future post(Uri path, Map body) async { + Map result; + if (isRecording) { + return _recording.reply([path.toString(), body]); + } else { + print(path.toString()); + result = await _recordAuth.post(path, body); + _recorder.given([path.toString(), body]).reply(result).always(); + } + return result; + } + + @override + bool get isValid { + return _recordAuth?.isValid ?? true; + } + + /// Writes the recorded Reddit API requests and their corresponding responses + /// to [recordingPath] and returns a [Future], which is the file that + /// has been written to, when in recording mode. When not in recording mode, + /// does nothing and returns null. + Future writeRecording() { + if (!isRecording) { + return (new File(_recordingPath)).writeAsString(JSON + .encode(_recorder.toRecording().toJsonEncodable( + encodeRequest: (q) => q, encodeResponse: (r) => r)) + .toString()); + } + return null; + } +}