diff --git a/src/app/data/models/twitch/search-channel/model.js b/src/app/data/models/twitch/search-channel/model.js index 91ab6ca044..9c951876f3 100644 --- a/src/app/data/models/twitch/search-channel/model.js +++ b/src/app/data/models/twitch/search-channel/model.js @@ -1,10 +1,84 @@ +import { computed } from "@ember/object"; +import { inject as service } from "@ember/service"; +import attr from "ember-data/attr"; import Model from "ember-data/model"; import { belongsTo } from "ember-data/relationships"; +import { DEFAULT_VODCAST_REGEXP } from "data/models/settings/streams/fragment"; -export default Model.extend({ - channel: belongsTo( "twitchChannel", { async: false } ) +// noinspection JSValidateTypes +export default Model.extend( /** @class TwitchSearchChannel */ { + /** @type {IntlService} */ + intl: service(), + /** @type {SettingsService} */ + settings: service(), + + /** @type {TwitchUser} */ + user: belongsTo( "twitch-user", { async: true } ), + /** @type {string} */ + broadcaster_language: attr( "string" ), + /** @type {string} */ + broadcaster_login: attr( "string" ), + /** @type {string} */ + display_name: attr( "string" ), + /** @type {string} */ + game: belongsTo( "twitch-game", { async: true } ), + /** @type {string} */ + game_name: attr( "string" ), + /** @type {boolean} */ + is_live: attr( "boolean" ), + //** @type {TwitchStreamTag[]} */ + //tag_ids: hasMany( "twitch-stream-tag", { async: true } ), + /** @type {string} */ + thumbnail_url: attr( "string" ), + /** @type {string} */ + title: attr( "string" ), + /** @type {Date} */ + started_at: attr( "date" ), + + + /** @type {(null|RegExp)} */ + reVodcast: computed( "settings.content.streams.vodcast_regexp", function() { + /** @this {TwitchStream} */ + const vodcast_regexp = this.settings.content.streams.vodcast_regexp; + + if ( vodcast_regexp.length && !vodcast_regexp.trim().length ) { + return null; + } + try { + return new RegExp( vodcast_regexp || DEFAULT_VODCAST_REGEXP, "i" ); + } catch ( e ) { + return null; + } + }), + + /** @type {boolean} */ + isVodcast: computed( "reVodcast", "title", function() { + /** @this {TwitchStream} */ + const { reVodcast, title } = this; + + return reVodcast && title && reVodcast.test( title ); + }), + + /** @type {string} */ + titleStartedAt: computed( "intl.locale", "started_at", function() { + /** @this {TwitchStream} */ + const { started_at } = this; + return !started_at + ? this.intl.t( + "models.twitch.search-channel.started-at.offline" + ) + : new Date() - started_at < 24 * 3600 * 1000 + ? this.intl.t( + "models.twitch.search-channel.started-at.less-than-24h", + { started_at } + ) + : this.intl.t( + "models.twitch.search-channel.started-at.more-than-24h", + { started_at } + ); + }) }).reopenClass({ - toString() { return "kraken/search/channels"; } + toString() { return "helix/search/channels"; } }); diff --git a/src/app/data/models/twitch/search-channel/serializer.js b/src/app/data/models/twitch/search-channel/serializer.js index 48bc5b0e12..00d3ee435d 100644 --- a/src/app/data/models/twitch/search-channel/serializer.js +++ b/src/app/data/models/twitch/search-channel/serializer.js @@ -3,25 +3,13 @@ import TwitchSerializer from "data/models/twitch/serializer"; export default TwitchSerializer.extend({ modelNameFromPayloadKey() { - return "twitchSearchChannel"; - }, - - attrs: { - channel: { deserialize: "records" } - }, - - normalizeResponse( store, primaryModelClass, payload, id, requestType ) { - // fix payload format - payload.channels = ( payload.channels || [] ).map( channel => ({ channel }) ); - - return this._super( store, primaryModelClass, payload, id, requestType ); + return "twitch-search-channel"; }, normalize( modelClass, resourceHash, prop ) { - const foreignKey = this.store.serializerFor( "twitchChannel" ).primaryKey; - - // get the id of the embedded TwitchChannel record and apply it here - resourceHash[ this.primaryKey ] = resourceHash.channel[ foreignKey ]; + resourceHash.user = resourceHash[ this.primaryKey ]; + resourceHash.game = resourceHash.game_id; + delete resourceHash[ "game_id" ]; return this._super( modelClass, resourceHash, prop ); } diff --git a/src/app/locales/de/models.yml b/src/app/locales/de/models.yml index f395edb585..386917339a 100644 --- a/src/app/locales/de/models.yml +++ b/src/app/locales/de/models.yml @@ -19,3 +19,8 @@ twitch: one {Eine Person schaut zu} other {# Leute schauen zu} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online seit {started_at, time, medium}" + more-than-24h: "Online seit {started_at, time, long}" diff --git a/src/app/locales/en/models.yml b/src/app/locales/en/models.yml index 10bee412f1..b16e9ede27 100644 --- a/src/app/locales/en/models.yml +++ b/src/app/locales/en/models.yml @@ -19,3 +19,8 @@ twitch: one {One person is watching} other {# people are watching} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online since {started_at, time, medium}" + more-than-24h: "Online since {started_at, time, long}" diff --git a/src/app/locales/es/models.yml b/src/app/locales/es/models.yml index 3f70b033c2..b29b39cc25 100644 --- a/src/app/locales/es/models.yml +++ b/src/app/locales/es/models.yml @@ -19,3 +19,8 @@ twitch: one {Una persona está viendo} other {# personas están viendo} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online desde las {started_at, time, medium}" + more-than-24h: "Online desde las {started_at, time, long}" diff --git a/src/app/locales/fr/models.yml b/src/app/locales/fr/models.yml index d28ceaebdc..05581b2fa0 100644 --- a/src/app/locales/fr/models.yml +++ b/src/app/locales/fr/models.yml @@ -19,3 +19,8 @@ twitch: one {Une personne est en train de regarder} other {# personnes sont en train de regarder} } + search-channel: + started-at: + offline: "Hors ligne" + less-than-24h: "En ligne depuis le {started_at, time, medium}" + more-than-24h: "En ligne depuis le {started_at, time, long}" diff --git a/src/app/locales/it/models.yml b/src/app/locales/it/models.yml index b8d07bfa9b..9a66c72550 100644 --- a/src/app/locales/it/models.yml +++ b/src/app/locales/it/models.yml @@ -19,3 +19,8 @@ twitch: one {Una persona sta guardando} other {# persone stanno guardando} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online da {started_at, time, medium}" + more-than-24h: "Online da {started_at, time, long}" diff --git a/src/app/locales/pt-br/models.yml b/src/app/locales/pt-br/models.yml index 43697cc5fb..f02f347a88 100644 --- a/src/app/locales/pt-br/models.yml +++ b/src/app/locales/pt-br/models.yml @@ -19,3 +19,8 @@ twitch: one {Uma pessoa está assistindo} other {# estão assistindo} } + search-channel: + started-at: + offline: "Offline" + less-than-24h: "Online desde {started_at, time, medium}" + more-than-24h: "Online desde {started_at, time, long}" diff --git a/src/app/locales/ru/models.yml b/src/app/locales/ru/models.yml index 3b2a62023c..e1bded3edb 100644 --- a/src/app/locales/ru/models.yml +++ b/src/app/locales/ru/models.yml @@ -25,3 +25,8 @@ twitch: many {# зрителей} other {Зрителей: #} } + search-channel: + started-at: + offline: "не в сети" + less-than-24h: "Запущен в {started_at, time, medium}" + more-than-24h: "Запущен в {started_at, time, long}" diff --git a/src/app/locales/zh-tw/models.yml b/src/app/locales/zh-tw/models.yml index 14f6aebd26..297f3c77de 100644 --- a/src/app/locales/zh-tw/models.yml +++ b/src/app/locales/zh-tw/models.yml @@ -19,3 +19,8 @@ twitch: one {目前有 1 位觀眾} other {目前有 # 位觀眾} } + search-channel: + started-at: + offline: "離線" + less-than-24h: "從 {started_at, time, medium} 開始" + more-than-24h: "從 {started_at, time, long} 開始" diff --git a/src/test/fixtures/data/models/twitch/search-channel.json b/src/test/fixtures/data/models/twitch/search-channel.json deleted file mode 100644 index abd9948458..0000000000 --- a/src/test/fixtures/data/models/twitch/search-channel.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "request": { - "url": "https://api.twitch.tv/kraken/search/channels", - "method": "GET", - "query": { - "query": "foo" - } - }, - "response": { - "channels": [ - { - "_id": 1 - }, - { - "_id": 2 - } - ] - } -} diff --git a/src/test/fixtures/data/models/twitch/search-channel.yml b/src/test/fixtures/data/models/twitch/search-channel.yml new file mode 100644 index 0000000000..3bf99a560f --- /dev/null +++ b/src/test/fixtures/data/models/twitch/search-channel.yml @@ -0,0 +1,52 @@ +search-channel: + request: + method: "GET" + url: "https://api.twitch.tv/helix/search/channels" + query: + query: "foo" + live_only: true + response: + data: + - id: "1" + broadcaster_language: "en" + broadcaster_login: "foo" + display_name: "FOO" + game_id: "1" + game_name: "some game" + is_live: true + tag_ids: [] + thumbnail_url: "https://localhost/data/twitch-search-stream/1/thumbnail-{width}x{height}.jpg" + title: "some title" + started_at: "2000-01-01T00:00:00Z" + - id: "2" + broadcaster_language: "de" + broadcaster_login: "bar" + display_name: "BAR" + game_id: "2" + game_name: "another game" + is_live: true + tag_ids: [] + thumbnail_url: "https://localhost/data/twitch-search-stream/2/thumbnail-{width}x{height}.jpg" + title: "another title" + started_at: "1999-12-31T23:59:59Z" + pagination: {} +user: + request: + method: "GET" + url: "https://api.twitch.tv/helix/users" + query: + id: "1,2" + response: + data: + - id: "1" + - id: "2" +game: + request: + method: "GET" + url: "https://api.twitch.tv/helix/games" + query: + id: "1,2" + response: + data: + - id: "1" + - id: "2" diff --git a/src/test/tests/data/models/twitch/search-channel.js b/src/test/tests/data/models/twitch/search-channel.js index e7c27a5eaf..4dc807cdea 100644 --- a/src/test/tests/data/models/twitch/search-channel.js +++ b/src/test/tests/data/models/twitch/search-channel.js @@ -1,68 +1,229 @@ import { module, test } from "qunit"; -import { buildOwner, runDestroy } from "test-utils"; -import { setupStore, adapterRequest } from "store-utils"; +import { setupTest } from "ember-qunit"; +import { buildResolver } from "test-utils"; +import { adapterRequestFactory, assertRelationships, setupStore } from "store-utils"; import { FakeIntlService } from "intl-utils"; +import sinon from "sinon"; + +import { set } from "@ember/object"; import Service from "@ember/service"; -import SearchChannel from "data/models/twitch/search-channel/model"; -import SearchChannelSerializer from "data/models/twitch/search-channel/serializer"; -import Channel from "data/models/twitch/channel/model"; -import ChannelSerializer from "data/models/twitch/channel/serializer"; +import Model from "ember-data/model"; + import TwitchAdapter from "data/models/twitch/adapter"; -import TwitchSearchChannelFixtures from "fixtures/data/models/twitch/search-channel.json"; +import TwitchSearchChannel from "data/models/twitch/search-channel/model"; +import TwitchSearchChannelSerializer from "data/models/twitch/search-channel/serializer"; +import TwitchSearchChannelFixtures from "fixtures/data/models/twitch/search-channel.yml"; +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 TwitchGame from "data/models/twitch/game/model"; +import TwitchGameAdapter from "data/models/twitch/game/adapter"; +import TwitchGameSerializer from "data/models/twitch/game/serializer"; -let owner, env; +module( "data/models/twitch/search-channel", function( hooks ) { + setupTest( hooks, { + resolver: buildResolver({ + IntlService: FakeIntlService, + SettingsService: Service.extend({ + content: { + streams: { + vodcast_regexp: "" + } + } + }), + TwitchSearchChannel, + TwitchSearchChannelSerializer, + TwitchUser, + TwitchUserAdapter, + TwitchUserSerializer, + TwitchGame, + TwitchGameAdapter, + TwitchGameSerializer, + TwitchChannel: Model.extend() + }) + }); + hooks.beforeEach(function() { + this.fakeTimer = sinon.useFakeTimers({ + toFake: [ "Date" ], + global: window + }); -module( "data/models/twitch/search-channel", { - beforeEach() { - owner = buildOwner(); + setupStore( this.owner, { adapter: TwitchAdapter } ); + }); - owner.register( "service:auth", Service.extend() ); - owner.register( "service:intl", FakeIntlService ); - owner.register( "model:twitch-search-channel", SearchChannel ); - owner.register( "serializer:twitch-search-channel", SearchChannelSerializer ); - owner.register( "model:twitch-channel", Channel ); - owner.register( "serializer:twitch-channel", ChannelSerializer ); + hooks.afterEach(function() { + this.fakeTimer.restore(); + }); - env = setupStore( owner, { adapter: TwitchAdapter } ); - }, - afterEach() { - runDestroy( owner ); - owner = env = null; - } -}); + test( "Module relationships", function( assert ) { + /** @type {DS.Store} */ + const store = this.owner.lookup( "service:store" ); + /** @type {TwitchSearchChannel} */ + const model = store.modelFor( "twitch-search-channel" ); + assertRelationships( assert, model, [ + { + key: "user", + kind: "belongsTo", + type: "twitch-user", + options: { async: true } + }, + { + key: "game", + kind: "belongsTo", + type: "twitch-game", + options: { async: true } + } + ]); + }); -test( "Adapter and Serializer", assert => { + test( "Computed properties - vodcast", function( assert ) { + /** @type {DS.Store} */ + const store = this.owner.lookup( "service:store" ); - env.adapter.ajax = ( url, method, query ) => - adapterRequest( assert, TwitchSearchChannelFixtures, url, method, query ); + /** @type {TwitchSearchChannel} */ + const record = store.createRecord( "twitch-search-channel", {} ); - return env.store.query( "twitchSearchChannel", { query: "foo" } ) - .then( records => { - assert.deepEqual( - records.map( record => record.toJSON({ includeId: true }) ), - [ - { - id: "1", - channel: "1" - }, - { - id: "2", - channel: "2" - } - ], - "Models have the correct id and attributes" - ); - - assert.ok( - env.store.hasRecordForId( "twitchChannel", "1" ) - && env.store.hasRecordForId( "twitchChannel", "2" ), - "Has all Channel records registered in the data store" - ); + assert.ok( record.reVodcast instanceof RegExp, "Has a default vodcast RegExp" ); + + set( record, "settings.content.streams.vodcast_regexp", " " ); + assert.strictEqual( record.reVodcast, null, "Returns null on empty RegExp" ); + + set( record, "settings.content.streams.vodcast_regexp", "(" ); + assert.strictEqual( record.reVodcast, null, "Returns null on invalid RegExp" ); + + set( record, "settings.content.streams.vodcast_regexp", "I'm a vodcast" ); + assert.ok( record.reVodcast.test( "I'M A VODCAST" ), "Has a custom vodcast RegExp" ); + + assert.notOk( record.isVodcast, "Not a vodcast" ); + + set( record, "title", "I'm a vodcast" ); + assert.ok( record.isVodcast, "Is a vodcast because of its title" ); + }); + + test( "Computed properties - i18n", function( assert ) { + /** @type {DS.Store} */ + const store = this.owner.lookup( "service:store" ); + + /** @type {TwitchSearchChannel} */ + const record = store.createRecord( "twitch-search-channel", {} ); + + const day = 3600 * 24 * 1000; + this.fakeTimer.setSystemTime( 2 * day ); + + assert.strictEqual( + record.titleStartedAt, + "models.twitch.search-channel.started-at.offline", + "Shows an offline title for streams without started_at attribute" + ); + + set( record, "started_at", new Date( Date.now() - 1 ) ); + assert.strictEqual( + record.titleStartedAt, + "models.twitch.search-channel.started-at.less-than-24h" + + "{\"started_at\":\"1970-01-02T23:59:59.999Z\"}", + "Shows a shorthand title for streams running for less than 24h" + ); + + set( record, "started_at", new Date( Date.now() - day ) ); + assert.strictEqual( + record.titleStartedAt, + "models.twitch.search-channel.started-at.more-than-24h" + + "{\"started_at\":\"1970-01-02T00:00:00.000Z\"}", + "Shows an extended title for streams running for more than 24h" + ); + }); + + test( "query", async function( assert ) { + /** @type {DS.Store} */ + const store = this.owner.lookup( "service:store" ); + + const searchChannelResponseStub + = store.adapterFor( "twitch-search-channel" ).ajax + = adapterRequestFactory( assert, TwitchSearchChannelFixtures, "search-channel" ); + const userResponseStub + = store.adapterFor( "twitch-user" ).ajax + = adapterRequestFactory( assert, TwitchSearchChannelFixtures, "user" ); + const gameResponseStub + = store.adapterFor( "twitch-game" ).ajax + = adapterRequestFactory( assert, TwitchSearchChannelFixtures, "game" ); + + const records = await store.query( "twitch-search-channel", { + query: "foo", + live_only: true }); + assert.propEqual( + records.map( record => record.toJSON({ includeId: true }) ), + [ + { + id: "1", + user: "1", + broadcaster_language: "en", + broadcaster_login: "foo", + display_name: "FOO", + game: "1", + game_name: "some game", + is_live: true, + thumbnail_url: + "https://localhost/data/twitch-search-stream/1/thumbnail-{width}x{height}.jpg", + title: "some title", + started_at: "2000-01-01T00:00:00.000Z" + }, + { + id: "2", + user: "2", + broadcaster_language: "de", + broadcaster_login: "bar", + display_name: "BAR", + game: "2", + game_name: "another game", + is_live: true, + thumbnail_url: + "https://localhost/data/twitch-search-stream/2/thumbnail-{width}x{height}.jpg", + title: "another title", + started_at: "1999-12-31T23:59:59.000Z" + } + ], + "All records have the correct IDs, attributes and relationship IDs" + ); + assert.ok( + store.hasRecordForId( "twitch-search-channel", "1" ) + && store.hasRecordForId( "twitch-search-channel", "2" ), + "Has the TwitchSearchChannel records registered in the data store" + ); + assert.notOk( + store.hasRecordForId( "twitch-user", "1" ) + || store.hasRecordForId( "twitch-user", "2" ), + "Has no TwitchUser record registered in the data store" + ); + assert.notOk( + store.hasRecordForId( "twitch-game", "1" ) + || store.hasRecordForId( "twitch-game", "2" ), + "Has no TwitchGame record registered in the data store" + ); + assert.ok( searchChannelResponseStub.calledOnce, "Queries API once for search-channels" ); + assert.notOk( userResponseStub.called, "Hasn't queried API for user" ); + assert.notOk( gameResponseStub.called, "Hasn't queried API for game" ); + + await Promise.all( records.map( record => record.user.promise ) ); + assert.ok( + store.hasRecordForId( "twitch-user", "1" ) + && store.hasRecordForId( "twitch-user", "2" ), + "Has TwitchUser records registered in the data store" + ); + assert.ok( userResponseStub.calledOnce, "Has queried API for user" ); + + await Promise.all( records.map( record => record.game.promise ) ); + assert.ok( + store.hasRecordForId( "twitch-game", "1" ) + && store.hasRecordForId( "twitch-game", "2" ), + "Has TwitchGame records registered in the data store" + ); + assert.ok( userResponseStub.calledOnce, "Has queried API for game" ); + }); });