diff --git a/src/app/services/notification/badge.js b/src/app/services/notification/badge.js index 442bd4fcb3..2a17655bb8 100644 --- a/src/app/services/notification/badge.js +++ b/src/app/services/notification/badge.js @@ -1,34 +1,30 @@ -import { get, observer } from "@ember/object"; +import { observer } from "@ember/object"; import { and } from "@ember/object/computed"; import { default as Evented, on } from "@ember/object/evented"; import Mixin from "@ember/object/mixin"; import { inject as service } from "@ember/service"; -import nwWindow from "nwjs/Window"; export default Mixin.create( Evented, { + /** @type {NwjsService} */ + nwjs: service(), + /** @type {SettingsService} */ settings: service(), // will be overridden by NotificationService running: false, - _badgeEnabled: and( "running", "settings.notification.badgelabel" ), + _badgeEnabled: and( "running", "settings.content.notification.badgelabel" ), _badgeEnabledObserver: observer( "_badgeEnabled", function() { - if ( !get( this, "_badgeEnabled" ) ) { - this.badgeSetLabel( "" ); + if ( !this._badgeEnabled ) { + this.nwjs.setBadgeLabel( "" ); } }), _badgeStreamsAllListener: on( "streams-all", function( streams ) { - if ( streams && get( this, "_badgeEnabled" ) ) { - const length = get( streams, "length" ); - this.badgeSetLabel( String( length ) ); + if ( streams && this._badgeEnabled ) { + this.nwjs.setBadgeLabel( `${streams.length}` ); } - }), - - badgeSetLabel( label ) { - // update badge label or remove it - nwWindow.setBadgeLabel( label ); - } + }) }); diff --git a/src/app/services/notification/cache/index.js b/src/app/services/notification/cache/index.js index 2091d80ba0..a45ca507e3 100644 --- a/src/app/services/notification/cache/index.js +++ b/src/app/services/notification/cache/index.js @@ -29,6 +29,8 @@ export function cacheAdd( stream ) { * @returns {TwitchStream[]} */ export function cacheFill( streams, firstRun ) { + streams = streams.slice(); + // figure out which streams are new for ( let item, idx, i = 0, l = cache.length; i < l; i++ ) { item = cache[ i ]; diff --git a/src/app/services/notification/cache/item.js b/src/app/services/notification/cache/item.js index 7f5135de52..6dde80105c 100644 --- a/src/app/services/notification/cache/item.js +++ b/src/app/services/notification/cache/item.js @@ -1,13 +1,10 @@ -import { get } from "@ember/object"; - - export default class NotificationStreamCacheItem { /** * @param {TwitchStream} stream */ constructor( stream ) { - this.id = get( stream, "id" ); - this.since = get( stream, "created_at" ); + this.id = stream.id; + this.since = stream.started_at; this.fails = 0; } @@ -16,8 +13,8 @@ export default class NotificationStreamCacheItem { * @returns {Number} */ findStreamIndex( streams ) { - for ( let id = this.id, i = 0, l = get( streams, "length" ); i < l; i++ ) { - if ( get( streams[ i ], "id" ) === id ) { + for ( let id = this.id, i = 0, l = streams.length; i < l; i++ ) { + if ( streams[ i ].id === id ) { return i; } } @@ -29,6 +26,6 @@ export default class NotificationStreamCacheItem { * @returns {Boolean} */ isNotNewer( stream ) { - return this.since >= get( stream, "created_at" ); + return this.since >= stream.started_at; } } diff --git a/src/app/services/notification/dispatch.js b/src/app/services/notification/dispatch.js index 2f4b76969d..64707cb27f 100644 --- a/src/app/services/notification/dispatch.js +++ b/src/app/services/notification/dispatch.js @@ -1,4 +1,3 @@ -import { get } from "@ember/object"; import { default as Evented, on } from "@ember/object/evented"; import Mixin from "@ember/object/mixin"; import { inject as service } from "@ember/service"; @@ -33,9 +32,9 @@ export default Mixin.create( Evented, { */ async function( streams ) { if ( !streams ) { return; } - const length = get( streams, "length" ); + const { length } = streams; - if ( length > 1 && get( this, "settings.notification.grouping" ) ) { + if ( length > 1 && this.settings.content.notification.grouping ) { // merge multiple notifications and show a single one const data = this._getNotificationDataGroup( streams ); await this._showNotification( data ); @@ -43,9 +42,9 @@ export default Mixin.create( Evented, { } else if ( length > 0 ) { await Promise.all( streams.map( async stream => { // download channel icon first and save it into a local temp dir... - await iconDownload( stream ); + const icon = await iconDownload( stream ); // show notification - const data = this._getNotificationDataSingle( stream ); + const data = this._getNotificationDataSingle( stream, icon ); await this._showNotification( data ); }) ); } @@ -58,13 +57,13 @@ export default Mixin.create( Evented, { * @returns {NotificationData} */ _getNotificationDataGroup( streams ) { - const settings = get( this, "settings.notification.click_group" ); + const settings = this.settings.content.notification.click_group; return new NotificationData({ title: this.intl.t( "services.notification.dispatch.group" ).toString(), message: streams.map( stream => ({ - title: get( stream, "channel.display_name" ), - message: get( stream, "channel.status" ) || "" + title: stream.user_name, + message: stream.title || "" }) ), icon: iconGroup, click: () => this._notificationClick( streams, settings ), @@ -75,16 +74,17 @@ export default Mixin.create( Evented, { /** * Show a notification for each stream * @param {TwitchStream} stream + * @param {string} icon * @returns {NotificationData} */ - _getNotificationDataSingle( stream ) { - const settings = get( this, "settings.notification.click" ); - const name = get( stream, "channel.display_name" ); + _getNotificationDataSingle( stream, icon ) { + const settings = this.settings.content.notification.click; + const name = stream.user_name; return new NotificationData({ title: this.intl.t( "services.notification.dispatch.single", { name } ).toString(), - message: get( stream, "channel.status" ) || "", - icon: get( stream, "logo" ) || iconGroup, + message: stream.title || "", + icon: icon || iconGroup, click: () => this._notificationClick( [ stream ], settings ), settings }); @@ -101,13 +101,13 @@ export default Mixin.create( Evented, { return; } - logDebug( "Notification click", () => ({ + await logDebug( "Notification click", () => ({ action, streams: streams.mapBy( "id" ) }) ); // restore the window - if ( get( this, "settings.notification.click_restore" ) ) { + if ( this.settings.content.notification.click_restore ) { setMinimized( false ); setVisibility( true ); setFocused( true ); @@ -117,7 +117,7 @@ export default Mixin.create( Evented, { this.router.transitionTo( "user.followedStreams" ); } else if ( action === ATTR_NOTIFY_CLICK_STREAM ) { - const streaming = get( this, "streaming" ); + const { streaming } = this; await Promise.all( streams.map( async stream => { // don't await startStream promise and ignore errors streaming.startStream( stream ) @@ -125,13 +125,11 @@ export default Mixin.create( Evented, { }) ); } else if ( action === ATTR_NOTIFY_CLICK_STREAMANDCHAT ) { - const streaming = get( this, "streaming" ); - const openGlobal = get( this, "settings.streams.chat_open" ); - const chat = get( this, "chat" ); + const { streaming, chat } = this; + const openGlobal = this.settings.content.streams.chat_open; await Promise.all( streams.map( async stream => { - const channel = get( stream, "channel" ); - const { streams_chat_open: openChannel } = await channel.getChannelSettings(); + const { streams_chat_open: openChannel } = await stream.getChannelSettings(); // don't await startStream promise and ignore errors streaming.startStream( stream ) @@ -150,7 +148,7 @@ export default Mixin.create( Evented, { return; } // don't await openChat promise and ignore errors - chat.openChat( channel ) + chat.openChat( stream.user ) .catch( () => {} ); }) ); } @@ -161,7 +159,7 @@ export default Mixin.create( Evented, { * @returns {Promise} */ async _showNotification( data ) { - const provider = get( this, "settings.notification.provider" ); + const provider = this.settings.content.notification.provider; // don't await the notification promise here showNotification( provider, data, false ) diff --git a/src/app/services/notification/icons.js b/src/app/services/notification/icons.js index 59b019f7a7..215a4c313f 100644 --- a/src/app/services/notification/icons.js +++ b/src/app/services/notification/icons.js @@ -1,4 +1,3 @@ -import { get, set } from "@ember/object"; import { files as filesConfig, notification as notificationConfig } from "config"; import { cachedir } from "utils/node/platform"; import mkdirp from "utils/node/fs/mkdirp"; @@ -16,6 +15,9 @@ const { } = notificationConfig; const iconCacheDir = join( cachedir, cacheName ); +/** @type {Map} */ +const userIconCache = new Map(); + // TODO: implement an icon resolver for Linux icon themes export const iconGroup = resolve( bigIcon ); @@ -38,17 +40,19 @@ export async function iconDirClear() { /** * @param {TwitchStream} stream - * @returns {Promise} + * @returns {Promise} */ export async function iconDownload( stream ) { // don't download logo again if it has already been downloaded - if ( get( stream, "logo" ) ) { - return; + const user = stream.user; + await user.promise; + const { id, profile_image_url } = user.content; + + let file = userIconCache.get( id ); + if ( !file ) { + file = await download( profile_image_url, iconCacheDir ); + userIconCache.set( id, file ); } - const logo = get( stream, "channel.logo" ); - const file = await download( logo, iconCacheDir ); - - // set the local channel logo on the twitchStream record - set( stream, "logo", file ); + return file; } diff --git a/src/app/services/notification/polling.js b/src/app/services/notification/polling.js index c06b0000f7..77d47c7a48 100644 --- a/src/app/services/notification/polling.js +++ b/src/app/services/notification/polling.js @@ -1,13 +1,13 @@ import { A } from "@ember/array"; -import { get, set, setProperties, observer } from "@ember/object"; +import { set, setProperties, observer } from "@ember/object"; import Evented from "@ember/object/evented"; import Mixin from "@ember/object/mixin"; -import { cancel, later } from "@ember/runloop"; import { inject as service } from "@ember/service"; import { notification as notificationConfig } from "config"; import { cacheClear, cacheFill } from "./cache"; import { iconDirCreate, iconDirClear } from "./icons"; import { logError } from "./logger"; +import { setTimeout, clearTimeout } from "timers"; const { @@ -20,13 +20,19 @@ const { error: intervalError }, query: { - limit + first, + maxQueries } } = notificationConfig; -export default Mixin.create( Evented, { +// TODO: rewrite this as a generic PollingService with a better design and better tests +export default Mixin.create( Evented, /** @class NotificatonServicePollingMixin */ { + /** @type {AuthService} */ + auth: service(), + /** @type {SettingsService} */ settings: service(), + /** @type {DS.Store} */ store: service(), // NotificationService properties @@ -36,12 +42,12 @@ export default Mixin.create( Evented, { // state _pollNext: null, _pollTries: 0, - _pollInitializedPromise: null, + _pollInitialized: false, _pollPromise: null, _pollObserver: observer( "running", function() { - if ( get( this, "running" ) ) { + if ( this.running ) { this._pollPromise = this.start(); } else { this.reset(); @@ -52,7 +58,7 @@ export default Mixin.create( Evented, { reset() { // unqueue if ( this._pollNext ) { - cancel( this._pollNext ); + clearTimeout( this._pollNext ); } // reset state @@ -70,18 +76,17 @@ export default Mixin.create( Evented, { this.reset(); // wait for initialization to complete - if ( !this._pollInitializedPromise ) { - this._pollInitializedPromise = Promise.resolve() - .then( iconDirCreate ) - .then( iconDirClear ); + if ( !this._pollInitialized ) { + await iconDirCreate(); + await iconDirClear(); + this._pollInitialized = true; } - await this._pollInitializedPromise; // start polling await this._poll( true ); } catch ( e ) { - logError( e ); + await logError( e ); } }, @@ -90,7 +95,7 @@ export default Mixin.create( Evented, { * @returns {Promise} */ async _poll( firstRun ) { - if ( !get( this, "running" ) ) { return; } + if ( !this.running ) { return; } let streams; try { @@ -101,7 +106,7 @@ export default Mixin.create( Evented, { return this._pollFailure(); } - if ( !get( this, "running" ) ) { return; } + if ( !this.running ) { return; } await this._pollResult( streams, firstRun ); // requeue @@ -117,7 +122,7 @@ export default Mixin.create( Evented, { }, _pollFailure() { - let tries = get( this, "_pollTries" ); + let tries = this._pollTries; // did we reach the retry limit yet? if ( ++tries > failsRequests ) { @@ -136,37 +141,41 @@ export default Mixin.create( Evented, { }, _pollRequeue( time ) { - if ( !get( this, "running" ) ) { return; } + if ( !this.running ) { return; } this._pollPromise = new Promise( resolve => - set( this, "_pollNext", later( resolve, time ) ) + set( this, "_pollNext", setTimeout( resolve, time ) ) ) .then( () => this._poll( false ) ) .catch( logError ); }, /** - * @returns {Promise.} + * @returns {Promise} */ async _pollQuery() { - const store = get( this, "store" ); + const { store } = this; const allStreams = A(); + const params = { + user_id: this.auth.session.user_id, + first + }; + let num = 0; // eslint-disable-next-line no-constant-condition - while ( true ) { - const streams = await store.query( "twitchStreamFollowed", { - offset: get( allStreams, "length" ), - limit - }); + while ( ++num <= maxQueries ) { + const streams = await store.query( "twitch-stream-followed", params ); // add new streams to the overall streams list allStreams.push( ...streams.mapBy( "stream" ) ); // stop querying as soon as a request doesn't have enough items // otherwise query again with an increased offset - if ( get( streams, "length" ) < limit ) { + if ( streams.length < first || !streams.meta.pagination ) { break; } + + params.after = streams.meta.pagination.cursor; } // remove any potential duplicates that may have occured between multiple requests @@ -191,25 +200,25 @@ export default Mixin.create( Evented, { this.trigger( "streams-filtered", filteredStreams ); } catch ( e ) { - logError( e ); + await logError( e ); } }, /** * @param {TwitchStream[]} streams - * @returns {Promise.} + * @returns {Promise} */ async _filterStreams( streams ) { - const filter = get( this, "settings.notification.filter" ); - const filter_vodcasts = get( this, "settings.notification.filter_vodcasts" ); + const filter = this.settings.content.notification.filter; // filter vodcasts before loading channel settings - streams = streams.filter( stream => filter_vodcasts ? !get( stream, "isVodcast" ) : true ); + if ( this.settings.content.notification.filter_vodcasts ) { + streams = streams.filter( stream => !stream.isVodcast ); + } // get a list of all streams and their channel's individual settings const streamSettingsObjects = await Promise.all( streams.map( async stream => { - const channel = get( stream, "channel" ); - const { notification_enabled } = await channel.getChannelSettings(); + const { notification_enabled } = await stream.getChannelSettings(); return { stream, notification_enabled }; }) ); diff --git a/src/app/services/notification/service.js b/src/app/services/notification/service.js index efc448bdf8..e9d1080914 100644 --- a/src/app/services/notification/service.js +++ b/src/app/services/notification/service.js @@ -12,16 +12,19 @@ export default Service.extend( NotificationServiceDispatchMixin, NotificationServiceBadgeMixin, NotificationServiceTrayMixin, + /** @class NotificationService */ { + /** @type {AuthService} */ auth: service(), /** @type {IntlService} */ intl: service(), + /** @type {SettingsService} */ settings: service(), error: false, paused: false, - enabled: and( "auth.session.isLoggedIn", "settings.notification.enabled" ), + enabled: and( "auth.session.isLoggedIn", "settings.content.notification.enabled" ), running: computed( "enabled", "paused", function() { return get( this, "enabled" ) && !get( this, "paused" ); diff --git a/src/app/services/nwjs.js b/src/app/services/nwjs.js index 9c15801118..b8da30c4ba 100644 --- a/src/app/services/nwjs.js +++ b/src/app/services/nwjs.js @@ -113,6 +113,13 @@ export default Service.extend( /** @class NwjsService */ { } }, + /** + * @param {string?} label + */ + setBadgeLabel( label = "" ) { + nwWindow.setBadgeLabel( `${label}` ); + }, + /** * @param {MouseEvent} event * @param {nw.MenuItem[]} items diff --git a/src/config/notification.json b/src/config/notification.json index 16e9d2adcb..ef5ce6a929 100644 --- a/src/config/notification.json +++ b/src/config/notification.json @@ -13,7 +13,8 @@ "error": 120000 }, "query": { - "limit": 100 + "first": 100, + "maxQueries": 5 }, "provider": { "snoretoast": { diff --git a/src/test/fixtures/services/notification/polling.yml b/src/test/fixtures/services/notification/polling.yml new file mode 100644 index 0000000000..ee0d8365d2 --- /dev/null +++ b/src/test/fixtures/services/notification/polling.yml @@ -0,0 +1,36 @@ +- request: + method: "GET" + url: "https://api.twitch.tv/helix/streams/followed" + query: + user_id: "123" + first: 2 + response: + data: + - user_id: "1" + - user_id: "2" + pagination: + cursor: "cursor1" +- request: + method: "GET" + url: "https://api.twitch.tv/helix/streams/followed" + query: + user_id: "123" + first: 2 + after: "cursor1" + response: + data: + - user_id: "3" + - user_id: "4" + pagination: + cursor: "cursor2" +- request: + method: "GET" + url: "https://api.twitch.tv/helix/streams/followed" + query: + user_id: "123" + first: 2 + after: "cursor2" + response: + data: + - user_id: "4" + - user_id: "5" diff --git a/src/test/tests/services/notification/badge.js b/src/test/tests/services/notification/badge.js index 018728bc1d..ee934e1dca 100644 --- a/src/test/tests/services/notification/badge.js +++ b/src/test/tests/services/notification/badge.js @@ -1,71 +1,81 @@ import { module, test } from "qunit"; -import { buildOwner, runDestroy } from "test-utils"; +import { setupTest } from "ember-qunit"; +import { buildResolver } from "test-utils"; +import sinon from "sinon"; + import { set } from "@ember/object"; -import { run } from "@ember/runloop"; import Service from "@ember/service"; -import notificationServiceBadgeMixinInjector - from "inject-loader?nwjs/Window!services/notification/badge"; - - -module( "services/notification/badge" ); - - -test( "Badge", assert => { +import NotificationServiceBadgeMixin from "services/notification/badge"; - assert.expect( 5 ); - let expected = ""; +module( "services/notification/badge", function( hooks ) { + setupTest( hooks, { + resolver: buildResolver({ + NotificationService: Service.extend( NotificationServiceBadgeMixin ) + }) + }); - const { default: NotificationServiceBadgeMixin } = notificationServiceBadgeMixinInjector({ - "nwjs/Window": { - setBadgeLabel( label ) { - assert.strictEqual( label, expected, "Sets the badge label" ); + /** @typedef {Object} TestContextNotificationServiceBadgeMixin */ + /** @this {TestContextNotificationServiceBadgeMixin} */ + hooks.beforeEach(function() { + this.setBadgeLabelSpy = sinon.spy(); + + this.owner.register( "service:nwjs", Service.extend({ + setBadgeLabel: this.setBadgeLabelSpy + }) ); + this.owner.register( "service:settings", Service.extend({ + content: { + notification: { + badgelabel: false + } } - } + }) ); }); - const owner = buildOwner(); - - owner.register( "service:settings", Service.extend({ - notification: { - badgelabel: false - } - }) ); - owner.register( "service:notification", Service.extend( NotificationServiceBadgeMixin ) ); - - const settings = owner.lookup( "service:settings" ); - const service = owner.lookup( "service:notification" ); - - // doesn't update the label when not running or disabled in settings - service.trigger( "streams-all", [ {}, {} ] ); - - run( () => set( settings, "notification.badgelabel", true ) ); - - // doesn't update the label when enabled, but not running - service.trigger( "streams-all", [ {}, {} ] ); - - run( () => set( service, "running", true ) ); - - // updates the label when running and enabled - expected = "2"; - service.trigger( "streams-all", [ {}, {} ] ); - expected = "3"; - service.trigger( "streams-all", [ {}, {}, {} ] ); - - // clears label when it gets disabled - expected = ""; - run( () => set( settings, "notification.badgelabel", false ) ); - - // doesn't reset label, requires a new streams-all event - run( () => set( settings, "notification.badgelabel", true ) ); - expected = "1"; - service.trigger( "streams-all", [ {} ] ); - - // clears label when service stops - expected = ""; - run( () => set( service, "running", false ) ); - - runDestroy( owner ); - + /** @this {TestContextNotificationServiceBadgeMixin} */ + test( "Badge", function( assert ) { + const settings = this.owner.lookup( "service:settings" ); + const service = this.owner.lookup( "service:notification" ); + + service.trigger( "streams-all", [ {}, {} ] ); + assert.notOk( + this.setBadgeLabelSpy.called, + "Doesn't update the label when not running or disabled in settings" + ); + + set( settings, "content.notification.badgelabel", true ); + service.trigger( "streams-all", [ {}, {} ] ); + assert.notOk( + this.setBadgeLabelSpy.called, + "Doesn't update the label when enabled, but not running" + ); + + set( service, "running", true ); + + service.trigger( "streams-all", [ {}, {} ] ); + assert.propEqual( this.setBadgeLabelSpy.args, [[ "2" ]], "Sets label to 2" ); + this.setBadgeLabelSpy.resetHistory(); + + service.trigger( "streams-all", [ {}, {}, {} ] ); + assert.propEqual( this.setBadgeLabelSpy.args, [[ "3" ]], "Sets label to 3" ); + this.setBadgeLabelSpy.resetHistory(); + + set( settings, "content.notification.badgelabel", false ); + assert.propEqual( this.setBadgeLabelSpy.args, [[ "" ]], "Clears label when disabled" ); + this.setBadgeLabelSpy.resetHistory(); + + set( settings, "content.notification.badgelabel", true ); + assert.notOk( + this.setBadgeLabelSpy.called, + "Doesn't reset label, requires a new streams-all event" + ); + + service.trigger( "streams-all", [ {} ] ); + assert.propEqual( this.setBadgeLabelSpy.args, [[ "1" ]], "Sets label to 1" ); + this.setBadgeLabelSpy.resetHistory(); + + set( service, "running", false ); + assert.propEqual( this.setBadgeLabelSpy.args, [[ "" ]], "Clears label when service stops" ); + }); }); diff --git a/src/test/tests/services/notification/cache.js b/src/test/tests/services/notification/cache.js index 489ca2966b..c93f40f9cc 100644 --- a/src/test/tests/services/notification/cache.js +++ b/src/test/tests/services/notification/cache.js @@ -4,112 +4,109 @@ import CacheItem from "services/notification/cache/item"; import cacheInjector from "inject-loader?config!services/notification/cache"; -module( "services/notification/cache" ); - - -test( "NotificationStreamCacheItem", assert => { - - const item = new CacheItem({ id: "foo", created_at: 1000 }); - assert.propEqual( item, { - id: "foo", - since: 1000, - fails: 0 - }, "Has the correct properties set" ); - - assert.ok( item.isNotNewer({ created_at: 1000 }), "Is not newer" ); - assert.notOk( item.isNotNewer({ created_at: 1001 }), "Is newer" ); - - const streams = [ - { id: "baz" }, - { id: "bar" }, - { id: "foo" } - ]; - - assert.strictEqual( item.findStreamIndex( streams ), 2, "Finds a stream with the same ID" ); - streams.pop(); - assert.strictEqual( item.findStreamIndex( streams ), -1, "Doesn't finds any streams" ); - -}); - - -test( "NotificationStreamCache", assert => { - - const { cache, cacheClear, cacheAdd, cacheFill } = cacheInjector({ - config: { - notification: { fails: { channels: 3 } } - } +module( "services/notification/cache", function() { + test( "NotificationStreamCacheItem", function( assert ) { + const item = new CacheItem({ + id: "1", + started_at: 1000 + }); + assert.propEqual( item, { + id: "1", + since: 1000, + fails: 0 + }, "Has the correct properties set" ); + + assert.ok( item.isNotNewer({ started_at: 1000 }), "Is not newer" ); + assert.notOk( item.isNotNewer({ started_at: 1001 }), "Is newer" ); + + const streams = [ + { id: "3" }, + { id: "2" }, + { id: "1" } + ]; + + assert.strictEqual( item.findStreamIndex( streams ), 2, "Finds a stream with the same ID" ); + streams.pop(); + assert.strictEqual( item.findStreamIndex( streams ), -1, "Doesn't finds any streams" ); }); - assert.propEqual( cache, [], "Cache is empty initially" ); - - cacheAdd({ id: "foo", created_at: 1000 }); - assert.strictEqual( cache.length, 1, "Adds a new item to the cache" ); - - cacheAdd({ id: "bar", created_at: 2000 }); - cacheAdd({ id: "foo", created_at: 3000 }); - cacheAdd({ id: "bar", created_at: 4000 }); - assert.strictEqual( cache.length, 2, "Doesn't add duplicates to the cache" ); - - cacheClear(); - assert.strictEqual( cache.length, 0, "Clears the cache" ); - - // initial streams - let ret = cacheFill([ - { id: "foo", created_at: 1000 }, - { id: "bar", created_at: 2000 } - ], true ); - assert.propEqual( ret, [], "Returns an empty array on first run" ); - assert.propEqual( cache, [ - { id: "foo", since: 1000, fails: 0 }, - { id: "bar", since: 2000, fails: 0 } - ], "Cache has the correct items" ); - - // new streams - ret = cacheFill([ - { id: "foo", created_at: 1000 }, - { id: "bar", created_at: 2000 }, - { id: "baz", created_at: 3000 }, - { id: "qux", created_at: 4000 } - ]); - assert.propEqual( ret, [ - { id: "baz", created_at: 3000 }, - { id: "qux", created_at: 4000 } - ], "Returns the new streams" ); - assert.propEqual( cache, [ - { id: "foo", since: 1000, fails: 0 }, - { id: "bar", since: 2000, fails: 0 }, - { id: "baz", since: 3000, fails: 0 }, - { id: "qux", since: 4000, fails: 0 } - ], "Cache has the correct items" ); - - // reset fails of known streams, increase fails of offline streams and update uptime - // artificially set fail counters - cache[0].fails = 1; - cache[1].fails = 2; - ret = cacheFill([ - { id: "foo", created_at: 1000 }, - { id: "baz", created_at: 5000 } - ]); - assert.propEqual( ret, [ - { id: "baz", created_at: 5000 } - ], "Returns streams with new uptime" ); - assert.propEqual( cache, [ - { id: "foo", since: 1000, fails: 0 }, - { id: "bar", since: 2000, fails: 3 }, - { id: "qux", since: 4000, fails: 1 }, - { id: "baz", since: 5000, fails: 0 } - ], "Cache has the correct items" ); - - // remove items with failure counters that exceeded the limit (3) - ret = cacheFill([ - { id: "foo", created_at: 1000 }, - { id: "baz", created_at: 5000 } - ]); - assert.propEqual( ret, [], "Doesn't return new items" ); - assert.propEqual( cache, [ - { id: "foo", since: 1000, fails: 0 }, - { id: "qux", since: 4000, fails: 2 }, - { id: "baz", since: 5000, fails: 0 } - ], "Cache has the correct items" ); - + test( "NotificationStreamCache", function( assert ) { + const { cache, cacheClear, cacheAdd, cacheFill } = cacheInjector({ + config: { + notification: { fails: { channels: 3 } } + } + }); + + assert.propEqual( cache, [], "Cache is empty initially" ); + + cacheAdd({ id: "1", started_at: 1000 }); + assert.strictEqual( cache.length, 1, "Adds a new item to the cache" ); + + cacheAdd({ id: "2", started_at: 2000 }); + cacheAdd({ id: "1", started_at: 3000 }); + cacheAdd({ id: "2", started_at: 4000 }); + assert.strictEqual( cache.length, 2, "Doesn't add duplicates to the cache" ); + + cacheClear(); + assert.strictEqual( cache.length, 0, "Clears the cache" ); + + // initial streams + let ret = cacheFill([ + { id: "1", started_at: 1000 }, + { id: "2", started_at: 2000 } + ], true ); + assert.propEqual( ret, [], "Returns an empty array on first run" ); + assert.propEqual( cache, [ + { id: "1", since: 1000, fails: 0 }, + { id: "2", since: 2000, fails: 0 } + ], "Cache has the correct items" ); + + // new streams + ret = cacheFill([ + { id: "1", started_at: 1000 }, + { id: "2", started_at: 2000 }, + { id: "3", started_at: 3000 }, + { id: "4", started_at: 4000 } + ]); + assert.propEqual( ret, [ + { id: "3", started_at: 3000 }, + { id: "4", started_at: 4000 } + ], "Returns the new streams" ); + assert.propEqual( cache, [ + { id: "1", since: 1000, fails: 0 }, + { id: "2", since: 2000, fails: 0 }, + { id: "3", since: 3000, fails: 0 }, + { id: "4", since: 4000, fails: 0 } + ], "Cache has the correct items" ); + + // reset fails of known streams, increase fails of offline streams and update uptime + // artificially set fail counters + cache[0].fails = 1; + cache[1].fails = 2; + ret = cacheFill([ + { id: "1", started_at: 1000 }, + { id: "3", started_at: 5000 } + ]); + assert.propEqual( ret, [ + { id: "3", started_at: 5000 } + ], "Returns streams with new uptime" ); + assert.propEqual( cache, [ + { id: "1", since: 1000, fails: 0 }, + { id: "2", since: 2000, fails: 3 }, + { id: "4", since: 4000, fails: 1 }, + { id: "3", since: 5000, fails: 0 } + ], "Cache has the correct items" ); + + // remove items with failure counters that exceeded the limit (3) + ret = cacheFill([ + { id: "1", started_at: 1000 }, + { id: "3", started_at: 5000 } + ]); + assert.propEqual( ret, [], "Doesn't return new items" ); + assert.propEqual( cache, [ + { id: "1", since: 1000, fails: 0 }, + { id: "4", since: 4000, fails: 2 }, + { id: "3", since: 5000, fails: 0 } + ], "Cache has the correct items" ); + }); }); diff --git a/src/test/tests/services/notification/dispatch.js b/src/test/tests/services/notification/dispatch.js index f91654c787..c03b5abdd5 100644 --- a/src/test/tests/services/notification/dispatch.js +++ b/src/test/tests/services/notification/dispatch.js @@ -1,6 +1,7 @@ import { module, test } from "qunit"; -import { buildOwner, runDestroy } from "test-utils"; +import { buildResolver } from "test-utils"; import { FakeIntlService } from "intl-utils"; + import { set } from "@ember/object"; import Service from "@ember/service"; import sinon from "sinon"; @@ -13,12 +14,19 @@ import { ATTR_NOTIFY_CLICK_STREAM, ATTR_NOTIFY_CLICK_STREAMANDCHAT } from "data/models/settings/notification/fragment"; +import { setupTest } from "ember-qunit"; -module( "services/notification/dispatch", { - beforeEach() { - this.owner = buildOwner(); +module( "services/notification/dispatch", function( hooks ) { + setupTest( hooks, { + resolver: buildResolver({ + IntlService: FakeIntlService + }) + }); + /** @typedef {TestContext} TestContextNotificationServiceDispatchMixin */ + /** @this {TestContextNotificationServiceDispatchMixin} */ + hooks.beforeEach(function() { this.iconDownloadStub = sinon.stub(); this.logDebugSpy = sinon.spy(); this.showNotificationStub = sinon.stub(); @@ -51,318 +59,332 @@ module( "services/notification/dispatch", { this.owner.register( "service:chat", Service.extend({ openChat: this.openChatStub }) ); - this.owner.register( "service:intl", FakeIntlService ); this.owner.register( "service:router", Service.extend({ transitionTo: this.transitionToSpy }) ); this.owner.register( "service:settings", Service.extend({ - notification: {}, - streams: {} + content: { + notification: {}, + streams: {} + } }) ); this.owner.register( "service:streaming", Service.extend({ startStream: this.startStreamStub }) ); this.subject = this.owner.lookup( "service:notification" ); - }, - - afterEach() { - runDestroy( this.owner ); - } -}); - - -test( "dispatchNotifications", async function( assert ) { - - /** @type Sinon.SinonStub icon */ - const iconStub = this.iconDownloadStub; - const groupStub = sinon.stub(); - const singleStub = sinon.stub(); - const showSpy = sinon.spy(); - - function reset() { - singleStub.reset(); - groupStub.reset(); - iconStub.reset(); - showSpy.resetHistory(); - } - - this.subject.reopen({ - _getNotificationDataGroup: groupStub, - _getNotificationDataSingle: singleStub, - _showNotification: showSpy }); - const streamA = { foo: 1 }; - const streamB = { bar: 2 }; - - // don't do anything on missing streams - await this.subject.dispatchNotifications(); - assert.notOk( singleStub.called, "Does not create single notification" ); - assert.notOk( groupStub.called, "Does not create group notification" ); - assert.notOk( iconStub.called, "Does not download icons" ); - assert.notOk( showSpy.called, "Does not show notifications" ); - - // don't do anything on empty streams - await this.subject.dispatchNotifications( [] ); - assert.notOk( singleStub.called, "Does not create single notification" ); - assert.notOk( groupStub.called, "Does not create group notification" ); - assert.notOk( iconStub.called, "Does not download icons" ); - assert.notOk( showSpy.called, "Does not show notifications" ); - - // enable grouping - set( this.subject, "settings.notification.grouping", true ); - - // show single notification on a single stream if grouping is enabled - singleStub.returns({ one: 1 }); - await this.subject.dispatchNotifications([ streamA ]); - assert.notOk( groupStub.called, "Does not create group notification" ); - assert.propEqual( iconStub.args, [ [ streamA ] ], "Downloads icon" ); - assert.propEqual( singleStub.args, [ [ streamA ] ], "Creates single notification" ); - assert.ok( iconStub.calledBefore( singleStub ), "Downloads icon first" ); - assert.propEqual( showSpy.lastCall.args, [{ one: 1 }], "Shows notification" ); - reset(); - - // show a group notification on multiple streams if grouping is enabled - groupStub.returns({ two: 2 }); - await this.subject.dispatchNotifications([ streamA, streamB ]); - assert.notOk( singleStub.called, "Does not show single notification" ); - assert.propEqual( - groupStub.lastCall.args, - [ [ streamA, streamB ] ], - "Shows group notification" - ); - assert.notOk( iconStub.called, "Does not download icons" ); - assert.propEqual( showSpy.lastCall.args, [{ two: 2 }], "Shows notification" ); - reset(); - - // disable grouping - set( this.subject, "settings.notification.grouping", false ); - - // show multiple single notifications if grouping is disabled - singleStub.onFirstCall().returns({ three: 3 }); - singleStub.onSecondCall().returns({ four: 4 }); - await this.subject.dispatchNotifications([ streamA, streamB ]); - assert.notOk( groupStub.called, "Does not create group notification" ); - assert.propEqual( iconStub.args, [ [ streamA ], [ streamB ] ], "Downloads icons" ); - assert.propEqual( - singleStub.args, - [ [ streamA ], [ streamB ] ], - "Creates single notifications" - ); - assert.ok( iconStub.firstCall.calledBefore( singleStub.firstCall ), "Downloads icons first" ); - assert.ok( iconStub.lastCall.calledBefore( singleStub.lastCall ), "Downloads icons first" ); - assert.propEqual( showSpy.args, [ [{ three: 3 }], [{ four: 4 }] ], "Shows both notification" ); - reset(); - - // fail icon download - iconStub.onFirstCall().rejects( new Error( "fail" ) ); - iconStub.onSecondCall().resolves(); - singleStub.withArgs( streamA ).returns({ five: 5 }); - singleStub.withArgs( streamB ).returns({ six: 6 }); - await assert.rejects( - this.subject.dispatchNotifications([ streamA, streamB ]), - new Error( "fail" ), - "Rejects on download error, but tries to show all notifications" - ); - assert.notOk( groupStub.called, "Does not create group notification" ); - assert.propEqual( iconStub.args, [ [ streamA ], [ streamB ] ], "Downloads all icons" ); - assert.propEqual( singleStub.args, [ [ streamB ] ], "Creates second single notification" ); - assert.propEqual( showSpy.args, [ [{ six: 6 }] ], "Shows second notification" ); - -}); + /** @this {TestContextNotificationServiceDispatchMixin} */ + test( "dispatchNotifications", async function( assert ) { + const iconStub = this.iconDownloadStub; + const groupStub = sinon.stub(); + const singleStub = sinon.stub(); + const showSpy = sinon.spy(); -test( "Group and single notification data", function( assert ) { - - /** @type {NotificationData} notification */ - let notification; - - const streamA = { - channel: { - display_name: "foo", - status: "123" - }, - logo: "logo" - }; - const streamB = { - channel: { - display_name: "bar" + function reset() { + singleStub.reset(); + groupStub.reset(); + iconStub.resetHistory(); + showSpy.resetHistory(); } - }; - const clickSpy = sinon.spy(); + this.subject.reopen({ + _getNotificationDataGroup: groupStub, + _getNotificationDataSingle: singleStub, + _showNotification: showSpy + }); - this.subject.reopen({ - _notificationClick: clickSpy + const streamA = { foo: 1, icon: "iconA" }; + const streamB = { bar: 2, icon: "iconB" }; + + iconStub.callsFake( async stream => stream.icon ); + + // don't do anything on missing streams + await this.subject.dispatchNotifications(); + assert.notOk( singleStub.called, "Does not create single notification" ); + assert.notOk( groupStub.called, "Does not create group notification" ); + assert.notOk( iconStub.called, "Does not download icons" ); + assert.notOk( showSpy.called, "Does not show notifications" ); + + // don't do anything on empty streams + await this.subject.dispatchNotifications( [] ); + assert.notOk( singleStub.called, "Does not create single notification" ); + assert.notOk( groupStub.called, "Does not create group notification" ); + assert.notOk( iconStub.called, "Does not download icons" ); + assert.notOk( showSpy.called, "Does not show notifications" ); + + // enable grouping + set( this.subject, "settings.content.notification.grouping", true ); + + // show single notification on a single stream if grouping is enabled + singleStub.returns({ one: 1 }); + await this.subject.dispatchNotifications([ streamA ]); + assert.notOk( groupStub.called, "Does not create group notification" ); + assert.propEqual( iconStub.args, [[ streamA ]], "Downloads icon" ); + assert.propEqual( singleStub.args, [[ streamA, "iconA" ]], "Creates single notification" ); + assert.ok( iconStub.calledBefore( singleStub ), "Downloads icon first" ); + assert.propEqual( showSpy.lastCall.args, [{ one: 1 }], "Shows notification" ); + reset(); + + // show a group notification on multiple streams if grouping is enabled + groupStub.returns({ two: 2 }); + await this.subject.dispatchNotifications([ streamA, streamB ]); + assert.notOk( singleStub.called, "Does not show single notification" ); + assert.propEqual( + groupStub.lastCall.args, + [ [ streamA, streamB ] ], + "Shows group notification" + ); + assert.notOk( iconStub.called, "Does not download icons" ); + assert.propEqual( showSpy.lastCall.args, [{ two: 2 }], "Shows notification" ); + reset(); + + // disable grouping + set( this.subject, "settings.content.notification.grouping", false ); + + // show multiple single notifications if grouping is disabled + singleStub.onFirstCall().returns({ three: 3 }); + singleStub.onSecondCall().returns({ four: 4 }); + await this.subject.dispatchNotifications([ streamA, streamB ]); + assert.notOk( groupStub.called, "Does not create group notification" ); + assert.propEqual( iconStub.args, [ [ streamA ], [ streamB ] ], "Downloads icons" ); + assert.propEqual( + singleStub.args, + [ [ streamA, "iconA" ], [ streamB, "iconB" ] ], + "Creates single notifications" + ); + assert.ok( + iconStub.firstCall.calledBefore( singleStub.firstCall ), + "Downloads icons first" + ); + assert.ok( + iconStub.lastCall.calledBefore( singleStub.lastCall ), + "Downloads icons first" + ); + assert.propEqual( + showSpy.args, + [ [ { three: 3 } ], [ { four: 4 } ] ], + "Shows both notification" + ); + reset(); + + // fail icon download + iconStub.onFirstCall().rejects( new Error( "fail" ) ); + singleStub.withArgs( streamA ).returns({ five: 5 }); + singleStub.withArgs( streamB ).returns({ six: 6 }); + await assert.rejects( + this.subject.dispatchNotifications([ streamA, streamB ]), + new Error( "fail" ), + "Rejects on download error, but tries to show all notifications" + ); + assert.notOk( groupStub.called, "Does not create group notification" ); + assert.propEqual( iconStub.args, [ [ streamA ], [ streamB ] ], "Downloads all icons" ); + assert.propEqual( + singleStub.args, + [ [ streamB, "iconB" ] ], + "Creates second single notification" + ); + assert.propEqual( showSpy.args, [ [{ six: 6 }] ], "Shows second notification" ); }); - // show group notification - set( this.subject, "settings.notification.click_group", 1 ); - notification = this.subject._getNotificationDataGroup([ streamA, streamB ]); - notification.click(); - assert.propEqual( notification, { - title: "services.notification.dispatch.group", - message: [{ - title: "foo", - message: "123" - }, { - title: "bar", - message: "" - }], - icon: "group-icon-path", - click: () => {}, - settings: 1 - }, "Returns correct group notification data" ); - assert.propEqual( clickSpy.args, [ [ [ streamA, streamB ], 1 ] ], "Group click callback" ); - clickSpy.resetHistory(); - - // show single notification with logo - set( this.subject, "settings.notification.click", 2 ); - notification = this.subject._getNotificationDataSingle( streamA ); - notification.click(); - assert.propEqual( notification, { - title: "services.notification.dispatch.single{\"name\":\"foo\"}", - message: "123", - icon: "logo", - click: () => {}, - settings: 2 - }, "Returns correct single notification data" ); - assert.propEqual( clickSpy.args, [ [ [ streamA ], 2 ] ], "Single click callback" ); - clickSpy.resetHistory(); - - // show single notification without logo - set( this.subject, "settings.notification.click", 3 ); - notification = this.subject._getNotificationDataSingle( streamB ); - notification.click(); - assert.propEqual( notification, { - title: "services.notification.dispatch.single{\"name\":\"bar\"}", - message: "", - icon: "group-icon-path", - click: () => {}, - settings: 3 - }, "Returns correct single notification data with group icon" ); - assert.propEqual( clickSpy.args, [ [ [ streamB ], 3 ] ], "Single click callback" ); + /** @this {TestContextNotificationServiceDispatchMixin} */ + test( "Group and single notification data", function( assert ) { + /** @type {NotificationData} notification */ + let notification; -}); + const streamA = { + user_name: "foo", + title: "123" + }; + const streamB = { + user_name: "bar" + }; + const clickSpy = sinon.spy(); -test( "Notification click", async function( assert ) { + this.subject.reopen({ + _notificationClick: clickSpy + }); - this.startStreamStub.rejects(); - this.openChatStub.rejects(); + // show group notification + set( this.subject, "settings.content.notification.click_group", 1 ); + notification = this.subject._getNotificationDataGroup([ streamA, streamB ]); + notification.click(); + assert.propEqual( notification, { + title: "services.notification.dispatch.group", + message: [{ + title: "foo", + message: "123" + }, { + title: "bar", + message: "" + }], + icon: "group-icon-path", + click: () => {}, + settings: 1 + }, "Returns correct group notification data" ); + assert.propEqual( clickSpy.args, [ [ [ streamA, streamB ], 1 ] ], "Group click callback" ); + clickSpy.resetHistory(); + + // show single notification with logo + set( this.subject, "settings.content.notification.click", 2 ); + notification = this.subject._getNotificationDataSingle( streamA, "logo" ); + notification.click(); + assert.propEqual( notification, { + title: "services.notification.dispatch.single{\"name\":\"foo\"}", + message: "123", + icon: "logo", + click: () => {}, + settings: 2 + }, "Returns correct single notification data" ); + assert.propEqual( clickSpy.args, [ [ [ streamA ], 2 ] ], "Single click callback" ); + clickSpy.resetHistory(); + + // show single notification without logo + set( this.subject, "settings.content.notification.click", 3 ); + notification = this.subject._getNotificationDataSingle( streamB ); + notification.click(); + assert.propEqual( notification, { + title: "services.notification.dispatch.single{\"name\":\"bar\"}", + message: "", + icon: "group-icon-path", + click: () => {}, + settings: 3 + }, "Returns correct single notification data with group icon" ); + assert.propEqual( clickSpy.args, [ [ [ streamB ], 3 ] ], "Single click callback" ); + }); - const reset = () => { - this.logDebugSpy.resetHistory(); - this.transitionToSpy.resetHistory(); - this.setMinimizedSpy.resetHistory(); - this.setVisibilitySpy.resetHistory(); - this.setFocusedSpy.resetHistory(); - this.startStreamStub.resetHistory(); - this.openChatStub.resetHistory(); - }; + /** @this {TestContextNotificationServiceDispatchMixin} */ + test( "Notification click", async function( assert ) { + this.startStreamStub.rejects(); + this.openChatStub.rejects(); - class Channel { - constructor( settings ) { - this.settings = settings; - } - async getChannelSettings() { - return this.settings; + class FakeTwitchStream { + constructor( id, user, settings ) { + this.id = id; + this.user = user; + this.getChannelSettings = async () => settings; + } } - } - - const cA = new Channel({ streams_chat_open: null }); - const cB = new Channel({ streams_chat_open: false }); - const cC = new Channel({ streams_chat_open: true }); - const sA = { channel: cA }; - const sB = { channel: cB }; - const sC = { channel: cC }; - - // noop - await this.subject._notificationClick( [ sA, sB ], ATTR_NOTIFY_CLICK_NOOP ); - assert.notOk( this.logDebugSpy.called, "Doesn't do anything" ); - reset(); - - // restore GUI (only test ATTR_NOTIFY_CLICK_FOLLOWED) - set( this.subject, "settings.notification.click_restore", true ); - - // transitionTo with restore option - await this.subject._notificationClick( [ sA, sB ], ATTR_NOTIFY_CLICK_FOLLOWED ); - assert.ok( this.logDebugSpy.called, "Logs click" ); - assert.propEqual( this.transitionToSpy.args, [ [ "user.followedStreams" ] ], "Shows followed" ); - assert.propEqual( this.setMinimizedSpy.args, [ [ false ] ], "Restore" ); - assert.ok( this.setMinimizedSpy.calledBefore( this.transitionToSpy ), "Restore first" ); - assert.propEqual( this.setVisibilitySpy.args, [ [ true ] ], "Unhide" ); - assert.ok( this.setVisibilitySpy.calledBefore( this.transitionToSpy ), "Unhide first" ); - assert.propEqual( this.setFocusedSpy.args, [ [ true ] ], "Focus" ); - assert.ok( this.setFocusedSpy.calledBefore( this.transitionToSpy ), "Focus first" ); - reset(); - - // disable restore option for now - set( this.subject, "settings.notification.click_restore", false ); - - // transitionTo - await this.subject._notificationClick( [ sA, sB ], ATTR_NOTIFY_CLICK_FOLLOWED ); - assert.ok( this.logDebugSpy.called, "Logs click" ); - assert.notOk( this.setMinimizedSpy.called, "Does not restore" ); - assert.notOk( this.setVisibilitySpy.called, "Does not unhide" ); - assert.notOk( this.setFocusedSpy.called, "Does not focus" ); - assert.propEqual( this.transitionToSpy.args, [ [ "user.followedStreams" ] ], "Shows followed" ); - reset(); - - // launch streams and always resolve - await this.subject._notificationClick( [ sA, sB, sC ], ATTR_NOTIFY_CLICK_STREAM ); - assert.ok( this.logDebugSpy.called, "Logs click" ); - assert.propEqual( this.startStreamStub.args, [ [ sA ], [ sB ], [ sC ] ], "Launches streams" ); - reset(); - - // launch streams+chats and always resolve (global open_chat is false) - set( this.subject, "settings.streams.chat_open", false ); - await this.subject._notificationClick( [ sA, sB, sC ], ATTR_NOTIFY_CLICK_STREAMANDCHAT ); - assert.ok( this.logDebugSpy.called, "Logs click" ); - assert.propEqual( this.startStreamStub.args, [ [ sA ], [ sB ], [ sC ] ], "Launches streams" ); - assert.propEqual( this.openChatStub.args, [ [ cA ], [ cB ] ], "Opens chats A and B" ); - reset(); - - // launch streams+chats and always resolve (global open_chat is true) - set( this.subject, "settings.streams.chat_open", true ); - await this.subject._notificationClick( [ sA, sB, sC ], ATTR_NOTIFY_CLICK_STREAMANDCHAT ); - assert.ok( this.logDebugSpy.called, "Logs click" ); - assert.propEqual( this.startStreamStub.args, [ [ sA ], [ sB ], [ sC ] ], "Launches streams" ); - assert.propEqual( this.openChatStub.args, [ [ cB ] ], "Opens chat B" ); - -}); - -test( "Show notficiation", async function( assert ) { - - const settings = this.owner.lookup( "service:settings" ); - set( settings, "notification.provider", "provider" ); - - const notification = { foo: 1 }; - - // resolve - this.showNotificationStub.resolves(); - await this.subject._showNotification( notification ); - assert.propEqual( - this.showNotificationStub.args, - [ [ "provider", notification, false ] ], - "Shows notification" - ); - this.showNotificationStub.reset(); - - // reject - this.showNotificationStub.rejects(); - await this.subject._showNotification( notification ); - assert.ok( this.showNotificationStub.called, "Doesn't reject when showNotification rejects" ); - this.showNotificationStub.reset(); - - // reject later - let reject; - const promise = new Promise( ( _, r ) => reject = r ); - this.showNotificationStub.returns( promise ); - await this.subject._showNotification( notification ); - reject(); - assert.ok( this.showNotificationStub.called, "Doesn't reject when showNotification rejects" ); + const uA = {}; + const uB = {}; + const uC = {}; + const sA = new FakeTwitchStream( "1", uA, { streams_chat_open: null } ); + const sB = new FakeTwitchStream( "2", uB, { streams_chat_open: false } ); + const sC = new FakeTwitchStream( "3", uC, { streams_chat_open: true } ); + + // noop + await this.subject._notificationClick( [ sA, sB ], ATTR_NOTIFY_CLICK_NOOP ); + assert.notOk( this.logDebugSpy.called, "Doesn't do anything" ); + sinon.resetHistory(); + + // restore GUI (only test ATTR_NOTIFY_CLICK_FOLLOWED) + set( this.subject, "settings.content.notification.click_restore", true ); + + // transitionTo with restore option + await this.subject._notificationClick( [ sA, sB ], ATTR_NOTIFY_CLICK_FOLLOWED ); + assert.ok( this.logDebugSpy.called, "Logs click" ); + assert.propEqual( + this.transitionToSpy.args, + [ [ "user.followedStreams" ] ], + "Shows followed" + ); + assert.propEqual( this.setMinimizedSpy.args, [ [ false ] ], "Restore" ); + assert.ok( this.setMinimizedSpy.calledBefore( this.transitionToSpy ), "Restore first" ); + assert.propEqual( this.setVisibilitySpy.args, [ [ true ] ], "Unhide" ); + assert.ok( this.setVisibilitySpy.calledBefore( this.transitionToSpy ), "Unhide first" ); + assert.propEqual( this.setFocusedSpy.args, [ [ true ] ], "Focus" ); + assert.ok( this.setFocusedSpy.calledBefore( this.transitionToSpy ), "Focus first" ); + sinon.resetHistory(); + + // disable restore option for now + set( this.subject, "settings.content.notification.click_restore", false ); + + // transitionTo + await this.subject._notificationClick( [ sA, sB ], ATTR_NOTIFY_CLICK_FOLLOWED ); + assert.ok( this.logDebugSpy.called, "Logs click" ); + assert.notOk( this.setMinimizedSpy.called, "Does not restore" ); + assert.notOk( this.setVisibilitySpy.called, "Does not unhide" ); + assert.notOk( this.setFocusedSpy.called, "Does not focus" ); + assert.propEqual( + this.transitionToSpy.args, + [ [ "user.followedStreams" ] ], + "Shows followed" + ); + sinon.resetHistory(); + + // launch streams and always resolve + await this.subject._notificationClick( [ sA, sB, sC ], ATTR_NOTIFY_CLICK_STREAM ); + assert.ok( this.logDebugSpy.called, "Logs click" ); + assert.propEqual( + this.startStreamStub.args, + [ [ sA ], [ sB ], [ sC ] ], + "Launches streams" + ); + sinon.resetHistory(); + + // launch streams+chats and always resolve (global open_chat is false) + set( this.subject, "settings.content.streams.chat_open", false ); + await this.subject._notificationClick( [ sA, sB, sC ], ATTR_NOTIFY_CLICK_STREAMANDCHAT ); + assert.ok( this.logDebugSpy.called, "Logs click" ); + assert.propEqual( + this.startStreamStub.args, + [ [ sA ], [ sB ], [ sC ] ], + "Launches streams" + ); + assert.propEqual( this.openChatStub.args, [ [ uA ], [ uB ] ], "Opens chats A and B" ); + sinon.resetHistory(); + + // launch streams+chats and always resolve (global open_chat is true) + set( this.subject, "settings.content.streams.chat_open", true ); + await this.subject._notificationClick( [ sA, sB, sC ], ATTR_NOTIFY_CLICK_STREAMANDCHAT ); + assert.ok( this.logDebugSpy.called, "Logs click" ); + assert.propEqual( + this.startStreamStub.args, + [ [ sA ], [ sB ], [ sC ] ], + "Launches streams" + ); + assert.propEqual( this.openChatStub.args, [ [ uB ] ], "Opens chat B" ); + }); + /** @this {TestContextNotificationServiceDispatchMixin} */ + test( "Show notficiation", async function( assert ) { + const settings = this.owner.lookup( "service:settings" ); + set( settings, "content.notification.provider", "provider" ); + + const notification = { foo: 1 }; + + // resolve + this.showNotificationStub.resolves(); + await this.subject._showNotification( notification ); + assert.propEqual( + this.showNotificationStub.args, + [ [ "provider", notification, false ] ], + "Shows notification" + ); + this.showNotificationStub.reset(); + + // reject + this.showNotificationStub.rejects(); + await this.subject._showNotification( notification ); + assert.ok( + this.showNotificationStub.called, + "Doesn't reject when showNotification rejects" + ); + this.showNotificationStub.reset(); + + // reject later + let reject; + const promise = new Promise( ( _, r ) => reject = r ); + this.showNotificationStub.returns( promise ); + await this.subject._showNotification( notification ); + reject(); + assert.ok( + this.showNotificationStub.called, + "Doesn't reject when showNotification rejects" + ); + }); }); diff --git a/src/test/tests/services/notification/icons.js b/src/test/tests/services/notification/icons.js index 49a7671dfa..adb55e68a6 100644 --- a/src/test/tests/services/notification/icons.js +++ b/src/test/tests/services/notification/icons.js @@ -8,8 +8,10 @@ import notificationIconsMixinInjector -module( "services/notification/icons", { - beforeEach() { +module( "services/notification/icons", function( hooks ) { + /** @typedef {TestContext} TestContextNotificationServiceIcons */ + /** @this {TestContextNotificationServiceIcons} */ + hooks.beforeEach(function() { this.mkdirpStub = sinon.stub(); this.clearfolderStub = sinon.stub(); this.downloadStub = sinon.stub(); @@ -39,81 +41,115 @@ module( "services/notification/icons", { "utils/node/fs/clearfolder": this.clearfolderStub, "utils/node/fs/download": this.downloadStub }); - } -}); - - -test( "NotificationService icons", async function( assert ) { - - const error = new Error( "fail" ); - - const { - iconGroup, - iconDirCreate, - iconDirClear, - iconDownload - } = this.subject( false ); - - assert.strictEqual( - iconGroup, - resolve( "bigIconPath" ), - "Exports the resolved absolute path of the iconGroup constant" - ); - - this.mkdirpStub.rejects( error ); - await assert.rejects( iconDirCreate(), error, "Rejects on mkdirp failure" ); - assert.ok( - this.mkdirpStub.calledWithExactly( "/home/user/.cache/my-app/icons" ), - "Tries to create correct icon cache dir" - ); - - this.mkdirpStub.reset(); - this.mkdirpStub.resolves(); - await iconDirCreate(); - assert.ok( - this.mkdirpStub.calledWithExactly( "/home/user/.cache/my-app/icons" ), - "Creates correct icon cache dir" - ); - - this.clearfolderStub.rejects( error ); - await iconDirClear(); - assert.ok( - this.clearfolderStub.calledWithExactly( "/home/user/.cache/my-app/icons", 1234 ), - "Always resolves iconDirClear" - ); - - this.clearfolderStub.reset(); - this.clearfolderStub.resolves(); - await iconDirClear(); - assert.ok( - this.clearfolderStub.calledWithExactly( "/home/user/.cache/my-app/icons", 1234 ), - "Always resolves iconDirClear" - ); - - const stream = { channel: { logo: "logo-url" } }; - this.downloadStub.rejects( error ); - await assert.rejects( - iconDownload( stream ), - error, - "Rejects on download failure" - ); - assert.ok( - this.downloadStub.calledWithExactly( "logo-url", "/home/user/.cache/my-app/icons" ), - "Downloads logo into cache directory" - ); - assert.strictEqual( stream.logo, undefined, "Doesn't set logo property on failure" ); - - this.downloadStub.reset(); - this.downloadStub.resolves( "file-path" ); - await iconDownload( stream ); - assert.ok( - this.downloadStub.calledWithExactly( "logo-url", "/home/user/.cache/my-app/icons" ), - "Downloads logo into cache directory" - ); - assert.strictEqual( stream.logo, "file-path", "Sets the logo property on the stream record" ); - - this.downloadStub.resetHistory(); - await iconDownload( stream ); - assert.notOk( this.downloadStub.called, "Doesn't try to download icons twice" ); - + }); + + + /** @this {TestContextNotificationServiceIcons} */ + test( "iconGroup", function( assert ) { + const { iconGroup } = this.subject(); + + assert.strictEqual( + iconGroup, + resolve( "bigIconPath" ), + "Exports the resolved absolute path of the iconGroup constant" + ); + }); + + /** @this {TestContextNotificationServiceIcons} */ + test( "iconDirCreate", async function( assert ) { + const { iconDirCreate } = this.subject(); + const error = new Error(); + + this.mkdirpStub.rejects( error ); + await assert.rejects( iconDirCreate(), error, "Rejects on mkdirp failure" ); + assert.ok( + this.mkdirpStub.calledOnceWithExactly( "/home/user/.cache/my-app/icons" ), + "Tries to create correct icon cache dir" + ); + + this.mkdirpStub.reset(); + this.mkdirpStub.resolves(); + await iconDirCreate(); + assert.ok( + this.mkdirpStub.calledOnceWithExactly( "/home/user/.cache/my-app/icons" ), + "Creates correct icon cache dir" + ); + }); + + /** @this {TestContextNotificationServiceIcons} */ + test( "iconDirClear", async function( assert ) { + const { iconDirClear } = this.subject(); + const error = new Error( "fail" ); + + this.clearfolderStub.rejects( error ); + await iconDirClear(); + assert.ok( + this.clearfolderStub.calledOnceWithExactly( "/home/user/.cache/my-app/icons", 1234 ), + "Always resolves iconDirClear" + ); + + this.clearfolderStub.reset(); + this.clearfolderStub.resolves(); + await iconDirClear(); + assert.ok( + this.clearfolderStub.calledOnceWithExactly( "/home/user/.cache/my-app/icons", 1234 ), + "Always resolves iconDirClear" + ); + }); + + /** @this {TestContextNotificationServiceIcons} */ + test( "iconDownload", async function( assert ) { + const { iconDownload } = this.subject(); + const error = new Error(); + + class FakeTwitchStream { + constructor( id, icon ) { + const user = this.user = {}; + user.promise = new Promise( resolve => { + user.content = { + id, + profile_image_url: icon + }; + resolve(); + }); + } + } + + this.downloadStub.rejects( error ); + await assert.rejects( + iconDownload( new FakeTwitchStream( "1", "logo1" ) ), + error, + "Rejects on download failure" + ); + assert.ok( + this.downloadStub.calledOnceWithExactly( "logo1", "/home/user/.cache/my-app/icons" ), + "Downloads logo into cache directory" + ); + + this.downloadStub.reset(); + this.downloadStub.resolves( "file1" ); + assert.strictEqual( + await iconDownload( new FakeTwitchStream( "1", "logo1" ) ), + "file1", + "Returns the file path of the downloaded icon" + ); + assert.ok( + this.downloadStub.calledOnceWithExactly( "logo1", "/home/user/.cache/my-app/icons" ), + "Downloads logo into cache directory" + ); + + this.downloadStub.resetHistory(); + assert.strictEqual( + await iconDownload( new FakeTwitchStream( "1", "logo1" ) ), + "file1", + "Returns the file path of the downloaded icon" + ); + assert.notOk( this.downloadStub.called, "Doesn't try to download icons twice" ); + + await iconDownload( new FakeTwitchStream( "2", "logo2" ) ); + assert.ok( + this.downloadStub.calledOnceWithExactly( "logo2", "/home/user/.cache/my-app/icons" ), + "Downloads second logo into cache directory" + ); + }); }); diff --git a/src/test/tests/services/notification/polling.js b/src/test/tests/services/notification/polling.js index 9d37008f37..ff6fac4791 100644 --- a/src/test/tests/services/notification/polling.js +++ b/src/test/tests/services/notification/polling.js @@ -1,510 +1,437 @@ -// TODO: properly rewrite tests using ember-qunit and sinon import { module, test } from "qunit"; -import { buildOwner, runDestroy } from "test-utils"; -import { setupStore } from "store-utils"; +import { setupTest } from "ember-qunit"; +import { buildResolver } from "test-utils"; +import { adapterRequestFactory, setupStore } from "store-utils"; import { FakeIntlService } from "intl-utils"; import sinon from "sinon"; +import { setTimeout } from "timers"; -import { get, set } from "@ember/object"; +import { set } from "@ember/object"; import { on } from "@ember/object/evented"; -import { run } from "@ember/runloop"; import Service from "@ember/service"; -import RESTAdapter from "ember-data/adapters/rest"; +import Model from "ember-data/model"; import notificationPollingMixinInjector from "inject-loader?config&./cache&./icons&./logger!services/notification/polling"; -import StreamFollowed from "data/models/twitch/stream-followed/model"; -import StreamFollowedSerializer from "data/models/twitch/stream-followed/serializer"; -import Stream from "data/models/twitch/stream/model"; -import StreamSerializer from "data/models/twitch/stream/serializer"; -import Channel from "data/models/twitch/channel/model"; -import ChannelSerializer from "data/models/twitch/channel/serializer"; -import imageInjector from "inject-loader?config!data/models/twitch/image/model"; -import ImageSerializer from "data/models/twitch/image/serializer"; - - -const { later } = run; - -let owner, env; - -const config = { - notification: { - fails: { - requests: 2 - }, - interval: { - request: 1, - retry: 1, - error: 1 - }, - query: { - limit: 2 - } - } -}; - - -module( "services/notification/polling", { - beforeEach() { - owner = buildOwner(); - - env = setupStore( owner, { adapter: RESTAdapter } ); - - owner.register( "service:intl", FakeIntlService ); - owner.register( "service:settings", Service.extend({ - notification: {} - }) ); - }, - - afterEach() { - runDestroy( owner ); - owner = env = null; - } -}); - - -test( "Start / reset", async assert => { - - assert.expect( 33 ); - - const { default: NotificationPollingMixin } = notificationPollingMixinInjector({ - config, - "./logger": { - logError( e ) { - assert.strictEqual( e.message, "foo", "Calls logError" ); - } - }, - "./cache": { - cacheClear() { - assert.step( "cacheClear" ); - } - }, - "./icons": { - iconDirCreate() { - assert.step( "iconDirCreate" ); +import TwitchAdapter from "data/models/twitch/adapter"; +import TwitchStreamFollowed from "data/models/twitch/stream-followed/model"; +import TwitchStreamFollowedSerializer from "data/models/twitch/stream-followed/serializer"; +import TwitchStream from "data/models/twitch/stream/model"; +import TwitchStreamSerializer from "data/models/twitch/stream/serializer"; +import TwitchUser from "data/models/twitch/user/model"; +import TwitchUserAdapter from "data/models/twitch/user/adapter"; +import TwitchUserSerializer from "data/models/twitch/user/serializer"; + +import fixturesTwitchStreamFollowed from "fixtures/services/notification/polling.yml"; + + +module( "services/notification/polling", function( hooks ) { + const config = { + notification: { + fails: { + requests: 2 }, - iconDirClear() { - assert.step( "iconDirClear" ); + interval: { + request: 1, + retry: 1, + error: 1 + }, + query: { + first: 2, + maxQueries: 5 } } - }); + }; - owner.register( "service:notification", Service.extend( NotificationPollingMixin, { - start() { - assert.step( "start" ); - return this._super( ...arguments ); - }, - reset() { - assert.step( "reset" ); - return this._super( ...arguments ); - }, - _poll( firstRun ) { - assert.step( "poll" ); - assert.ok( firstRun, "Sets the firstRun parameter" ); - } - }) ); - const service = owner.lookup( "service:notification" ); - - // initialization - assert.notOk( get( service, "running" ), "Does not poll initially" ); - - // turn polling on - run( () => set( service, "running", true ) ); - await service._pollPromise; - assert.checkSteps( - [ "start", "reset", "cacheClear", "iconDirCreate", "iconDirClear", "poll" ], - "Resets before each new start" - ); - - // turn polling off - run( () => { - // fake a queued function call - set( service, "_pollNext", later( () => { - throw new Error(); - }, 5 ) ); - set( service, "running", false ); + setupTest( hooks, { + resolver: buildResolver({ + IntlService: FakeIntlService, + TwitchStreamFollowed, + TwitchStreamFollowedSerializer, + TwitchStream, + TwitchStreamSerializer, + TwitchUser, + TwitchUserAdapter, + TwitchUserSerializer, + TwitchGame: Model.extend(), + TwitchChannel: Model.extend() + }) }); - assert.checkSteps( [ "reset", "cacheClear" ], "Resets" ); - assert.strictEqual( get( service, "_pollNext" ), null, "Clears queued polling function" ); - - // don't execute initialization twice - run( () => set( service, "running", true ) ); - await service._pollPromise; - assert.checkSteps( - [ "start", "reset", "cacheClear", "poll" ], - "Doesn't execute initialization twice" - ); - run( () => set( service, "running", false ) ); - assert.clearSteps(); - - // turn on and off without waiting for _poll() to resolve - run( () => set( service, "running", true ) ); - await service._pollInitializedPromise; - run( () => set( service, "running", false ) ); - assert.checkSteps( - [ "start", "reset", "cacheClear", "poll", "reset", "cacheClear" ], - "Executes all async functions in correct order" - ); - - // exceptions - service.reopen({ - _poll() { - throw new Error( "foo" ); - } - }); - run( () => set( service, "running", true ) ); - await service._pollPromise; - - assert.clearSteps(); - -}); - - -test( "Polling", async assert => { - assert.expect( 46 ); + /** @typedef {TestContext} TestContextNotificationServicePollingMixin */ + /** @this {TestContextNotificationServicePollingMixin} */ + hooks.beforeEach(function() { + setupStore( this.owner, { adapter: TwitchAdapter } ); - let streams; - let expectFirstRun = true; - let requeue = false; - let failQuery = false; - - const { default: NotificationPollingMixin } = notificationPollingMixinInjector({ - config, - "./logger": { - logError( e ) { - assert.strictEqual( e.message, "foo", "Calls logError" ); - } - } - }); - - owner.register( "service:notification", Service.extend( NotificationPollingMixin, { - // manually start/stop polling - _pollObserver: null, - reset() { - assert.step( "reset" ); - return this._super( ...arguments ); - }, - async _pollQuery() { - assert.step( "query" ); - if ( failQuery ) { - throw new Error(); + this.owner.register( "service:auth", Service.extend({ + session: { + user_id: "123" } - streams = [ { id: 1 } ]; - return streams; - }, - async _pollResult( allStreams, firstRun ) { - assert.step( "result" ); - assert.strictEqual( allStreams, streams, "passes streams to queryResult" ); - assert.strictEqual( firstRun, expectFirstRun, "passes firstRun to queryResult" ); - }, - _pollSuccess() { - assert.step( "success" ); - return this._super( ...arguments ); - }, - _pollFailure() { - assert.step( "failure" ); - return this._super( ...arguments ); - }, - _pollRequeue() { - if ( requeue ) { - assert.step( "requeue" ); - return this._super( ...arguments ); + }) ); + this.owner.register( "service:settings", Service.extend({ + content: { + notification: {} } - } - }) ); - - const service = owner.lookup( "service:notification" ); - - // not running - await service._poll(); - assert.checkSteps( [], "Doesn't do anything if not running" ); - - // running now... (observer disabled in this test) - run( () => set( service, "running", true ) ); - - // successful poll without queue - await service._poll( true ); - assert.checkSteps( - [ "query", "result", "success" ], - "Executes all methods in correct order" - ); - - // disable polling while querying - let promise = service._poll( true ); - run( () => set( service, "running", false ) ); - await promise; - assert.checkSteps( [ "query" ], "Only gets streams and doesn't dispatch notifications" ); - run( () => set( service, "running", true ) ); - - // requeue on success - requeue = true; - await service._poll( true ); - expectFirstRun = false; - await service._pollPromise; - requeue = false; - await service._pollPromise; - expectFirstRun = true; - assert.checkSteps( - [ - "query", "result", "success", "requeue", - "query", "result", "success", "requeue", - "query", "result", "success" - ], - "Executes all methods in correct order when queuing" - ); - - // requeue on failure - requeue = true; - failQuery = true; - await service._poll( true ); - assert.strictEqual( get( service, "_pollTries" ), 1, "Increases the tries count on failure" ); - assert.strictEqual( get( service, "error" ), false, "Doesn't report an error yet" ); - expectFirstRun = false; - await service._pollPromise; - assert.strictEqual( get( service, "_pollTries" ), 2, "Increases the tries count on failure" ); - assert.strictEqual( get( service, "error" ), false, "Doesn't report an error yet" ); - await service._pollPromise; - assert.strictEqual( get( service, "_pollTries" ), 0, "Resets tries count on final failure" ); - assert.strictEqual( get( service, "error" ), true, "Reports an error now" ); - assert.checkSteps( - [ - "query", "failure", "requeue", - "query", "failure", "requeue", - "query", "failure", "reset", "requeue" - ], - "Executes all methods in correct order when queuing and failing" - ); - assert.ok( get( service, "_pollNext" ), "Keeps polling" ); - service.reset(); - assert.clearSteps(); - requeue = false; - failQuery = false; - expectFirstRun = true; - -}); - + }) ); -test( "Polling results", async assert => { + this.logErrorSpy = sinon.spy(); + this.cacheClearStub = sinon.stub(); + this.cacheFillStub = sinon.stub(); + this.iconDirCreateSpy = sinon.spy(); + this.iconDirClearSpy = sinon.spy(); + const { default: NotificationPollingMixin } = notificationPollingMixinInjector({ + config, + "./logger": { + logError: this.logErrorSpy + }, + "./cache": { + cacheClear: this.cacheClearStub, + cacheFill: this.cacheFillStub + }, + "./icons": { + iconDirCreate: this.iconDirCreateSpy, + iconDirClear: this.iconDirClearSpy + } + }); + this.owner.register( "service:notification", Service.extend( NotificationPollingMixin ) ); + }); - const allStreams = [ { id: 1 }, { id: 2 } ]; - const newStreams = [ ...allStreams ]; - const filteredStreams = [ ...newStreams ]; - let error; - const logErrorSpy = sinon.spy(); - const cacheFillStub = sinon.stub().callsFake( () => newStreams ); - const filterStreamsStub = sinon.stub().callsFake( async () => filteredStreams ); - const onStreamsAllSpy = sinon.spy(); - const onStreamsNewSpy = sinon.spy(); - const onStreamsFilteredSpy = sinon.spy(); + /** @this {TestContextNotificationServicePollingMixin} */ + test( "Start / reset", async function( assert ) { + /** @type {NotificatonServicePollingMixin} */ + const service = this.owner.lookup( "service:notification" ); + const startSpy = sinon.spy( service, "start" ); + const resetSpy = sinon.spy( service, "reset" ); + const pollStub = sinon.stub( service, "_poll" ); + + // initialization + assert.notOk( service.running, "Does not poll initially" ); + + // turn polling on + set( service, "running", true ); + await service._pollPromise; + // Resets before each new start + sinon.assert.callOrder( + startSpy, + resetSpy, + this.cacheClearStub, + this.iconDirCreateSpy, + this.iconDirClearSpy, + pollStub + ); + assert.propEqual( pollStub.args, [[ true ]], "Calls _poll() with firstRun" ); + sinon.resetHistory(); + + // turn polling off + // fake a queued function call + const errorOnNext = sinon.spy(); + set( service, "_pollNext", setTimeout( errorOnNext, 5 ) ); + set( service, "running", false ); + await new Promise( resolve => setTimeout( resolve, 10 ) ); + sinon.assert.callOrder( resetSpy, this.cacheClearStub ); + assert.notOk( errorOnNext.called, "Clears queued polling function" ); + assert.strictEqual( service._pollNext, null, "Unsets queued polling function" ); + + // don't execute initialization twice + set( service, "running", true ); + await service._pollPromise; + assert.strictEqual( this.iconDirCreateSpy.callCount, 0, "Only initializes once" ); + assert.strictEqual( this.iconDirClearSpy.callCount, 0, "Only initializes once" ); + set( service, "running", false ); + sinon.resetHistory(); - const { default: NotificationPollingMixin } = notificationPollingMixinInjector({ - config, - "./logger": { - logError: logErrorSpy - }, - "./cache": { - cacheFill: cacheFillStub - } + // turn on and off without waiting for _poll() to resolve + const pollPromise = new Promise( resolve => setTimeout( resolve, 10 ) ); + pollStub.callsFake( pollPromise ); + set( service, "running", true ); + set( service, "running", false ); + await pollPromise; + // Executes all async functions in correct order + sinon.assert.callOrder( + startSpy, + resetSpy, + this.cacheClearStub, + pollStub, + resetSpy, + this.cacheClearStub + ); + + // exceptions + const error = new Error( "foo" ); + pollStub.rejects( error ); + set( service, "running", true ); + await service._pollPromise; + assert.ok( this.logErrorSpy.calledWithExactly( error ) ); }); - owner.register( "service:notification", Service.extend( NotificationPollingMixin, { - _filterStreams: filterStreamsStub, - _onAllStreams: on( "streams-all", onStreamsAllSpy ), - _onNewStreams: on( "streams-new", onStreamsNewSpy ), - _onFilteredStreams: on( "streams-filtered", onStreamsFilteredSpy ) - }) ); - - const service = owner.lookup( "service:notification" ); - - // success - - await service._pollResult( allStreams, true ); - assert.ok( onStreamsAllSpy.calledOnceWithExactly( allStreams ), "Triggers streams-all" ); - assert.ok( cacheFillStub.calledOnceWithExactly( allStreams, true ), "Calls cacheFill" ); - assert.ok( onStreamsNewSpy.calledOnceWithExactly( newStreams ), "Triggers streams-new" ); - assert.ok( - filterStreamsStub.calledOnceWithExactly( newStreams ), - "Passes new streams to filterStreams" - ); - assert.ok( - onStreamsFilteredSpy.calledOnceWithExactly( filteredStreams ), - "Triggers streams-filtered" - ); - sinon.assert.callOrder( - onStreamsAllSpy, - cacheFillStub, - onStreamsNewSpy, - filterStreamsStub, - onStreamsFilteredSpy - ); - - // catch all exceptions thrown by event listeners - - error = new Error( "streams-filtered" ); - service.reopen({ - _onFilteredStreamsFailure: on( "streams-filtered", () => { throw error; } ) + /** @this {TestContextNotificationServicePollingMixin} */ + test( "Polling", async function( assert ) { + const streams = [ { id: 1 } ]; + + const service = this.owner.lookup( "service:notification" ); + const resetSpy = sinon.spy( service, "reset" ); + const pollSpy = sinon.spy( service, "_poll" ); + const pollQueryStub = sinon.stub( service, "_pollQuery" ).resolves( streams ); + const pollResultStub = sinon.stub( service, "_pollResult" ); + const pollSuccessSpy = sinon.spy( service, "_pollSuccess" ); + const pollFailureSpy = sinon.spy( service, "_pollFailure" ); + const pollRequeueStub = sinon.stub( service, "_pollRequeue" ); + + service.reopen({ + // manually start/stop polling + _pollObserver: null + }); + + // not running + await service._poll(); + assert.notOk( pollQueryStub.called, "Doesn't do anything if not running" ); + + // running now... (observer disabled in this test) + set( service, "running", true ); + + // successful poll without queue + await service._poll( true ); + // Executes all methods in correct order + sinon.assert.callOrder( + pollQueryStub, + pollResultStub, + pollSuccessSpy + ); + sinon.resetHistory(); + + // disable polling while querying + let promise = service._poll( true ); + set( service, "running", false ); + await promise; + assert.ok( pollQueryStub.calledOnce, "Queries streams" ); + assert.notOk( pollResultStub.called, "Doesn't dispatch notifications" ); + assert.notOk( pollSuccessSpy.called, "Doesn't requeue polling" ); + set( service, "running", true ); + sinon.resetHistory(); + + // requeue on success + pollRequeueStub.callThrough(); + await service._poll( true ); + assert.ok( + pollResultStub.calledOnceWithExactly( streams, true ), + "Passes streams to _pollResult with firstRun being true" + ); + sinon.assert.callOrder( + pollQueryStub, pollResultStub, pollSuccessSpy, pollRequeueStub + ); + sinon.resetHistory(); + + await service._pollPromise; + assert.ok( + pollResultStub.calledOnceWithExactly( streams, false ), + "Passes new streams to _pollResult with firstRun being false" + ); + sinon.assert.callOrder( + pollQueryStub, pollResultStub, pollSuccessSpy, pollRequeueStub + ); + sinon.resetHistory(); + + pollRequeueStub.reset(); + await service._pollPromise; + // Executes all methods in correct order when queuing + sinon.assert.callOrder( + pollQueryStub, pollResultStub, pollSuccessSpy + ); + sinon.resetHistory(); + + // requeue on failure + pollQueryStub.rejects( new Error() ); + pollRequeueStub.callThrough(); + await service._poll( true ); + assert.propEqual( pollSpy.args, [[ true ]], "First poll attempt" ); + assert.notOk( resetSpy.called, "Doesn't call reset yet" ); + assert.strictEqual( service._pollTries, 1, "Increases tries count on failure" ); + assert.strictEqual( service.error, false, "Doesn't report an error yet" ); + sinon.assert.callOrder( + pollQueryStub, pollFailureSpy, pollRequeueStub + ); + sinon.resetHistory(); + + await service._pollPromise; + assert.propEqual( pollSpy.args, [[ false ]], "Second poll attempt" ); + assert.notOk( resetSpy.called, "Doesn't call reset yet" ); + assert.strictEqual( service._pollTries, 2, "Increases tries count on failure" ); + assert.strictEqual( service.error, false, "Doesn't report an error yet" ); + sinon.assert.callOrder( + pollQueryStub, pollFailureSpy, pollRequeueStub + ); + sinon.resetHistory(); + + await service._pollPromise; + assert.propEqual( pollSpy.args, [[ false ]], "Third poll attempt" ); + assert.strictEqual( service._pollTries, 0, "Resets tries count on last failure" ); + assert.strictEqual( service.error, true, "Reports an error now" ); + sinon.assert.callOrder( + pollQueryStub, pollFailureSpy, resetSpy, pollRequeueStub + ); + sinon.resetHistory(); + assert.ok( service._pollNext, "Keeps polling" ); + + service.reset(); + assert.notOk( service._pollNext, "Clears poll queue" ); }); - await service._pollResult( allStreams, true ); - assert.ok( logErrorSpy.calledOnceWith( error ), "Logs the streams-filtered error" ); - logErrorSpy.resetHistory(); - error = new Error( "streams-new" ); - service.reopen({ - _onNewStreamsFailure: on( "streams-new", () => { throw error; } ) + /** @this {TestContextNotificationServicePollingMixin} */ + test( "Polling results", async function( assert ) { + const allStreams = [ { id: 1 }, { id: 2 } ]; + const newStreams = [ ...allStreams ]; + const filteredStreams = [ ...newStreams ]; + let error; + + this.cacheFillStub.callsFake( () => newStreams ); + + const filterStreamsStub = sinon.stub().callsFake( async () => filteredStreams ); + const onStreamsAllSpy = sinon.spy(); + const onStreamsNewSpy = sinon.spy(); + const onStreamsFilteredSpy = sinon.spy(); + + const service = this.owner.lookup( "service:notification" ); + service.reopen({ + _filterStreams: filterStreamsStub, + _onAllStreams: on( "streams-all", onStreamsAllSpy ), + _onNewStreams: on( "streams-new", onStreamsNewSpy ), + _onFilteredStreams: on( "streams-filtered", onStreamsFilteredSpy ) + }); + + // success + + await service._pollResult( allStreams, true ); + assert.ok( + onStreamsAllSpy.calledOnceWithExactly( allStreams ), + "Triggers streams-all" + ); + assert.ok( + this.cacheFillStub.calledOnceWithExactly( allStreams, true ), + "Calls cacheFill" + ); + assert.ok( + onStreamsNewSpy.calledOnceWithExactly( newStreams ), + "Triggers streams-new" + ); + assert.ok( + filterStreamsStub.calledOnceWithExactly( newStreams ), + "Passes new streams to filterStreams" + ); + assert.ok( + onStreamsFilteredSpy.calledOnceWithExactly( filteredStreams ), + "Triggers streams-filtered" + ); + sinon.assert.callOrder( + onStreamsAllSpy, + this.cacheFillStub, + onStreamsNewSpy, + filterStreamsStub, + onStreamsFilteredSpy + ); + + // catch all exceptions thrown by event listeners + + error = new Error( "streams-filtered" ); + service.reopen({ + _onFilteredStreamsFailure: on( "streams-filtered", sinon.stub().throws( error ) ) + }); + await service._pollResult( allStreams, true ); + assert.ok( this.logErrorSpy.calledOnceWith( error ), "Logs the streams-filtered error" ); + this.logErrorSpy.resetHistory(); + + error = new Error( "streams-new" ); + service.reopen({ + _onNewStreamsFailure: on( "streams-new", sinon.stub().throws( error ) ) + }); + await service._pollResult( allStreams, true ); + assert.ok( this.logErrorSpy.calledOnceWith( error ), "Logs the streams-new error" ); + this.logErrorSpy.resetHistory(); + + error = new Error( "streams-all" ); + service.reopen({ + _onAllStreamsFailure: on( "streams-all", sinon.stub().throws( error ) ) + }); + await service._pollResult( allStreams, true ); + assert.ok( this.logErrorSpy.calledOnceWith( error ), "Logs the streams-all error" ); }); - await service._pollResult( allStreams, true ); - assert.ok( logErrorSpy.calledOnceWith( error ), "Logs the streams-new error" ); - logErrorSpy.resetHistory(); - error = new Error( "streams-all" ); - service.reopen({ - _onAllStreamsFailure: on( "streams-all", () => { throw error; } ) + /** @this {TestContextNotificationServicePollingMixin} */ + test( "Query streams", async function( assert ) { + /** @type {DS.Store} */ + const store = this.owner.lookup( "service:store" ); + + const responseStub + = store.adapterFor( "twitch-stream-followed" ).ajax + = adapterRequestFactory( assert, fixturesTwitchStreamFollowed ); + + const service = this.owner.lookup( "service:notification" ); + const streams = await service._pollQuery(); + assert.propEqual( + streams.mapBy( "id" ), + [ "1", "2", "3", "4", "5" ], + "Returns all streams without duplicates" + ); + assert.strictEqual( responseStub.callCount, 3, "Queries API 3 times" ); }); - await service._pollResult( allStreams, true ); - assert.ok( logErrorSpy.calledOnceWith( error ), "Logs the streams-all error" ); - -}); + /** @this {TestContextNotificationServicePollingMixin} */ + test( "Filter streams", async function( assert ) { + let streams; -test( "Query streams", async assert => { - - assert.expect( 4 ); - - const { default: NotificationPollingMixin } = notificationPollingMixinInjector({ - config, - "./logger": { - logError() {} - } - }); - - const { default: TwitchImage } = imageInjector({ - config: { - vars: {} + class FakeTwitchStream { + constructor( id, isVodcast, settings ) { + this.id = id; + this.isVodcast = isVodcast; + this.getChannelSettings = async () => settings; + } } - }); - owner.register( "model:twitch-stream-followed", StreamFollowed ); - owner.register( "serializer:twitch-stream-followed", StreamFollowedSerializer ); - owner.register( "model:twitch-stream", Stream ); - owner.register( "serializer:twitch-stream", StreamSerializer ); - owner.register( "model:twitch-channel", Channel ); - owner.register( "serializer:twitch-channel", ChannelSerializer ); - owner.register( "model:twitch-image", TwitchImage ); - owner.register( "serializer:twitch-image", ImageSerializer ); - - owner.register( "service:notification", Service.extend( NotificationPollingMixin ) ); - - let calls = 0; - env.adapter.ajax = async ( url, method, query ) => { - switch ( ++calls ) { - case 1: - assert.propEqual( query, { data: { offset: 0, limit: 2 } }, "First query" ); - return { - streams: [ - { _id: 1, channel: { _id: 1 } }, - { _id: 2, channel: { _id: 2 } } - ] - }; - case 2: - assert.propEqual( query, { data: { offset: 2, limit: 2 } }, "Second query" ); - return { - streams: [ - { _id: 3, channel: { _id: 3 } }, - { _id: 4, channel: { _id: 4 } } - ] - }; - case 3: - assert.propEqual( query, { data: { offset: 4, limit: 2 } }, "Third query" ); - return { - streams: [ - { _id: 4, channel: { _id: 4 } } - ] - }; - } - throw new Error(); - }; - - const service = owner.lookup( "service:notification" ); - - let streams = await service._pollQuery(); - assert.strictEqual( get( streams, "length" ), 4, "Returns all streams and removes duplicates" ); - -}); - - -test( "Filter streams", async assert => { - - let streams; - - const { default: NotificationPollingMixin } = notificationPollingMixinInjector({ - config, - "./logger": { - logError() {} - } + const twitchStreams = [ + new FakeTwitchStream( 1, false, { notification_enabled: null } ), + new FakeTwitchStream( 2, false, { notification_enabled: true } ), + new FakeTwitchStream( 3, false, { notification_enabled: false } ), + new FakeTwitchStream( 4, true, { notification_enabled: null } ), + new FakeTwitchStream( 5, true, { notification_enabled: true } ), + new FakeTwitchStream( 6, true, { notification_enabled: false } ) + ]; + + const service = this.owner.lookup( "service:notification" ); + + set( service, "settings.content.notification.filter_vodcasts", false ); + + set( service, "settings.content.notification.filter", true ); + streams = await service._filterStreams( twitchStreams ); + assert.propEqual( + streams.map( stream => stream.id ), + [ 1, 2, 4, 5 ], + "Return unknown and enabled streams and ignore vodcast settings" + ); + + set( service, "settings.content.notification.filter", false ); + streams = await service._filterStreams( twitchStreams ); + assert.propEqual( + streams.map( stream => stream.id ), + [ 2, 5 ], + "Return enabled streams and ignore vodcast settings" + ); + + set( service, "settings.content.notification.filter_vodcasts", true ); + + set( service, "settings.content.notification.filter", true ); + streams = await service._filterStreams( twitchStreams ); + assert.propEqual( + streams.map( stream => stream.id ), + [ 1, 2 ], + "Return unknown, enabled and live streams" + ); + + set( service, "settings.content.notification.filter", false ); + streams = await service._filterStreams( twitchStreams ); + assert.propEqual( + streams.map( stream => stream.id ), + [ 2 ], + "Return enabled and live streams" + ); }); - - owner.register( "service:notification", Service.extend( NotificationPollingMixin ) ); - - class TwitchStream { - constructor( id, isVodcast, settings ) { - this.id = id; - this.isVodcast = isVodcast; - this.channel = { - async getChannelSettings() { - return settings; - } - }; - } - } - - const twitchStreams = [ - new TwitchStream( 1, false, { notification_enabled: null } ), - new TwitchStream( 2, false, { notification_enabled: true } ), - new TwitchStream( 3, false, { notification_enabled: false } ), - new TwitchStream( 4, true, { notification_enabled: null } ), - new TwitchStream( 5, true, { notification_enabled: true } ), - new TwitchStream( 6, true, { notification_enabled: false } ) - ]; - - const service = owner.lookup( "service:notification" ); - const settings = owner.lookup( "service:settings" ); - - set( settings, "notification.filter_vodcasts", false ); - - set( settings, "notification.filter", true ); - streams = await service._filterStreams( twitchStreams ); - assert.propEqual( - streams.map( stream => stream.id ), - [ 1, 2, 4, 5 ], - "Return unknown and enabled streams and ignore vodcast settings" - ); - - set( settings, "notification.filter", false ); - streams = await service._filterStreams( twitchStreams ); - assert.propEqual( - streams.map( stream => stream.id ), - [ 2, 5 ], - "Return enabled streams and ignore vodcast settings" - ); - - set( settings, "notification.filter_vodcasts", true ); - - set( settings, "notification.filter", true ); - streams = await service._filterStreams( twitchStreams ); - assert.propEqual( - streams.map( stream => stream.id ), - [ 1, 2 ], - "Return unknown, enabled and live streams" - ); - - set( settings, "notification.filter", false ); - streams = await service._filterStreams( twitchStreams ); - assert.propEqual( - streams.map( stream => stream.id ), - [ 2 ], - "Return enabled and live streams" - ); - }); diff --git a/src/test/tests/services/notification/service.js b/src/test/tests/services/notification/service.js index 653161177d..7c228389ef 100644 --- a/src/test/tests/services/notification/service.js +++ b/src/test/tests/services/notification/service.js @@ -1,70 +1,72 @@ import { module, test } from "qunit"; -import { buildOwner, runDestroy } from "test-utils"; +import { setupTest } from "ember-qunit"; +import { buildResolver } from "test-utils"; import { FakeIntlService } from "intl-utils"; -import { get, set } from "@ember/object"; -import { run } from "@ember/runloop"; + +import { set } from "@ember/object"; import Service from "@ember/service"; import notificationServiceInjector from "inject-loader?./polling&./dispatch&./badge&./tray!services/notification/service"; -module( "services/notification" ); - - -test( "NotificationService", assert => { - - const owner = buildOwner(); - - const { default: NotificationService } = notificationServiceInjector({ - "./polling": {}, - "./dispatch": {}, - "./badge": {}, - "./tray": {} +module( "services/notification", function( hooks ) { + setupTest( hooks, { + resolver: buildResolver({ + IntlService: FakeIntlService + }) }); - owner.register( "service:auth", Service.extend({ - session: { - isLoggedIn: false - } - }) ); - owner.register( "service:intl", FakeIntlService ); - owner.register( "service:settings", Service.extend({ - notification: { - enabled: false - } - }) ); - owner.register( "service:notification", NotificationService ); - - const auth = owner.lookup( "service:auth" ); - const settings = owner.lookup( "service:settings" ); - const service = owner.lookup( "service:notification" ); - - assert.notOk( get( service, "error" ), "Error flag is not set" ); - assert.notOk( get( service, "enabled" ), "Is not enabled" ); - assert.notOk( get( service, "paused" ), "Is not paused" ); - assert.notOk( get( service, "running" ), "Is not running when being logged out or disabled" ); - assert.strictEqual( get( service, "statusText" ), "services.notification.status.disabled" ); + hooks.beforeEach(function() { + const { default: NotificationService } = notificationServiceInjector({ + "./polling": {}, + "./dispatch": {}, + "./badge": {}, + "./tray": {} + }); + + this.owner.register( "service:notification", NotificationService ); + this.owner.register( "service:auth", Service.extend({ + session: { + isLoggedIn: false + } + }) ); + this.owner.register( "service:settings", Service.extend({ + content: { + notification: { + enabled: false + } + } + }) ); + }); - run( () => set( settings, "notification.enabled", true ) ); - assert.notOk( get( service, "running" ), "Is not running when being logged out and enabled" ); + test( "NotificationService", function( assert ) { + /** @type {NotificationService} */ + const service = this.owner.lookup( "service:notification" ); - run( () => set( auth, "session.isLoggedIn", true ) ); - assert.ok( get( service, "running" ), "Is running when being logged in and enabled" ); - assert.strictEqual( get( service, "statusText" ), "services.notification.status.enabled" ); + assert.notOk( service.error, "Error flag is not set" ); + assert.notOk( service.enabled, "Is not enabled" ); + assert.notOk( service.paused, "Is not paused" ); + assert.notOk( service.running, "Is not running when being logged out or disabled" ); + assert.strictEqual( service.statusText, "services.notification.status.disabled" ); - run( () => set( service, "error", true ) ); - assert.strictEqual( get( service, "statusText" ), "services.notification.status.error" ); + set( service, "settings.content.notification.enabled", true ); + assert.notOk( service.running, "Is not running when being logged out and enabled" ); - run( () => set( service, "paused", true ) ); - assert.notOk( get( service, "running" ), "Is not running when paused" ); - assert.strictEqual( get( service, "statusText" ), "services.notification.status.paused" ); + set( service, "auth.session.isLoggedIn", true ); + assert.ok( service.running, "Is running when being logged in and enabled" ); + assert.strictEqual( service.statusText, "services.notification.status.enabled" ); - run( () => set( settings, "notification.enabled", false ) ); - assert.notOk( get( service, "running" ), "Is not running when being logged in and disabled" ); - run( () => set( service, "paused", false ) ); - assert.notOk( get( service, "running" ), "Still not running when unpausing again" ); + set( service, "error", true ); + assert.strictEqual( service.statusText, "services.notification.status.error" ); - runDestroy( owner ); + set( service, "paused", true ); + assert.notOk( service.running, "Is not running when paused" ); + assert.strictEqual( service.statusText, "services.notification.status.paused" ); + set( service, "settings.content.notification.enabled", false ); + assert.notOk( service.running, "Is not running when being logged in and disabled" ); + set( service, "paused", false ); + assert.notOk( service.running, "Still not running when unpausing again" ); + }); });