From a9d1bf606ff10333ce1feff52577dfc3b49d3526 Mon Sep 17 00:00:00 2001 From: Mathijs Miermans Date: Wed, 26 Jul 2023 11:25:43 -0700 Subject: [PATCH 1/3] feat(timeToRead): [DIS-852] Add time_to_read / timeToRead --- openapi.yml | 20 +++------ .../recommendations/recommendations.spec.ts | 3 ++ .../desktop/recommendations/response.spec.ts | 5 +++ src/api/desktop/recommendations/response.ts | 13 +++++- src/api/v3/recommendations.spec.ts | 5 ++- src/api/v3/response.spec.ts | 5 +++ src/api/v3/response.ts | 11 ++++- src/generated/graphql/types.ts | 29 +++++++++++- src/generated/openapi/types.ts | 3 ++ .../recommendations/Recommendations.graphql | 1 + .../__mocks__/recommendations.ts | 44 ++++++++++++++----- 11 files changed, 107 insertions(+), 32 deletions(-) diff --git a/openapi.yml b/openapi.yml index adb5d7f0a..c18f6cf30 100644 --- a/openapi.yml +++ b/openapi.yml @@ -152,6 +152,9 @@ components: imageUrl: type: string description: The primary image for a Recommendation. + timeToRead: + type: integer + description: Article read time in minutes LegacyFeedItem: type: object @@ -178,6 +181,8 @@ components: type: string raw_image_src: type: string + time_to_read: + type: integer LegacySettings: type: object @@ -300,21 +305,6 @@ paths: description: This region string is Fx domain language, and built from Fx expectations. Parameter values are not case sensitive. See [Firefox Home & New Tab Regional Differences](https://mozilla-hub.atlassian.net/wiki/spaces/FPS/pages/80448805/Regional+Differences). schema: type: string - enum: [ - # relevant docs: https://docs.google.com/document/d/1omclr-eETJ7zAWTMI7mvvsc3_-ns2Iiho4jPEfrmZfo - US, - CA, - DE, - GB, - IE, - FR, - ES, - IT, - IN, - CH, - AT, - BE, - ] responses: '200': description: OK diff --git a/src/api/desktop/recommendations/recommendations.spec.ts b/src/api/desktop/recommendations/recommendations.spec.ts index d228ed07d..5b18ba199 100644 --- a/src/api/desktop/recommendations/recommendations.spec.ts +++ b/src/api/desktop/recommendations/recommendations.spec.ts @@ -89,5 +89,8 @@ describe('recommendations API server', () => { expect(recommendation.tileId).toEqual( mockResponse.newTabSlate.recommendations[0].tileId ); + if (recommendation.timeToRead !== undefined) { + expect(recommendation.timeToRead).toBeGreaterThanOrEqual(1); + } }); }); diff --git a/src/api/desktop/recommendations/response.spec.ts b/src/api/desktop/recommendations/response.spec.ts index b3adefc4f..f805ecc03 100644 --- a/src/api/desktop/recommendations/response.spec.ts +++ b/src/api/desktop/recommendations/response.spec.ts @@ -52,6 +52,11 @@ describe('response', () => { `utm_source=${graphResponse.newTabSlate.utmSource}` ) ).toBeTruthy(); + // Even recommendations have timeToRead mocked to [1, 9]. + expect(res.data[0].timeToRead).toBeGreaterThanOrEqual(1); + expect(res.data[0].timeToRead).toBeLessThanOrEqual(9); + // Odd recommendations have timeToRead mocked to undefined. + expect(res.data[1].timeToRead).toBeUndefined(); } else { throw validate.errors; } diff --git a/src/api/desktop/recommendations/response.ts b/src/api/desktop/recommendations/response.ts index 834fe7d7b..65ffdd281 100644 --- a/src/api/desktop/recommendations/response.ts +++ b/src/api/desktop/recommendations/response.ts @@ -4,7 +4,7 @@ import { Logger } from '../../../logger'; import { Unpack } from '../../../types'; // unpack GraphQL generated type for 'recommendations' from NewTabRecommendationsQuery -type GraphRecommendation = Unpack< +export type GraphRecommendation = Unpack< NewTabRecommendationsQuery['newTabSlate']['recommendations'] >; @@ -44,7 +44,7 @@ export const mapRecommendation = ( recommendation: GraphRecommendation, utmSource: string ): Recommendation => { - return { + const recommendationToReturn: Recommendation = { __typename: 'Recommendation', tileId: recommendation.tileId, url: appendUtmSource( @@ -56,6 +56,15 @@ export const mapRecommendation = ( publisher: recommendation.corpusItem.publisher, imageUrl: recommendation.corpusItem.imageUrl, }; + + if (recommendation.corpusItem.timeToRead) { + return { + ...recommendationToReturn, + timeToRead: recommendation.corpusItem.timeToRead, + }; + } + + return recommendationToReturn; }; export const responseTransformer = ( diff --git a/src/api/v3/recommendations.spec.ts b/src/api/v3/recommendations.spec.ts index 663cc99f8..cbf65ef62 100644 --- a/src/api/v3/recommendations.spec.ts +++ b/src/api/v3/recommendations.spec.ts @@ -64,7 +64,7 @@ describe('v3 legacy recommendations API server', () => { expect(res.status).toEqual(200); - // response ins json + // response is json const parsedRes = JSON.parse(res.text); expect(parsedRes.recommendations?.length).toEqual(1); const recommendation: LegacyFeedItem = parsedRes.recommendations[0]; @@ -74,5 +74,8 @@ describe('v3 legacy recommendations API server', () => { expect(recommendation.id).toEqual( mockResponse.newTabSlate.recommendations[0].tileId ); + if (recommendation.time_to_read !== undefined) { + expect(recommendation.time_to_read).toBeGreaterThanOrEqual(1); + } }); }); diff --git a/src/api/v3/response.spec.ts b/src/api/v3/response.spec.ts index 1b8216876..32b5ee71f 100644 --- a/src/api/v3/response.spec.ts +++ b/src/api/v3/response.spec.ts @@ -52,6 +52,11 @@ describe('response', () => { `utm_source=${graphResponse.newTabSlate.utmSource}` ) ).toBeTruthy(); + // Even recommendations have timeToRead mocked to [1, 9]. + expect(res.recommendations[0].time_to_read).toBeGreaterThanOrEqual(1); + expect(res.recommendations[0].time_to_read).toBeLessThanOrEqual(9); + // Odd recommendations have timeToRead mocked to undefined. + expect(res.recommendations[1].time_to_read).toBeUndefined(); } else { throw validate.errors; } diff --git a/src/api/v3/response.ts b/src/api/v3/response.ts index b2e89f739..ec22be5df 100644 --- a/src/api/v3/response.ts +++ b/src/api/v3/response.ts @@ -24,7 +24,7 @@ export const mapRecommendation = ( recommendation.corpusItem.imageUrl ); - return { + const feedItemToReturn: LegacyFeedItem = { id: recommendation.tileId, url: appendUtmSource( recommendation.corpusItem.url, @@ -36,6 +36,15 @@ export const mapRecommendation = ( raw_image_src: recommendation.corpusItem.imageUrl, image_src: `https://img-getpocket.cdn.mozilla.net/direct?url=${encodedImageUrl}&resize=w450`, }; + + if (recommendation.corpusItem.timeToRead) { + return { + ...feedItemToReturn, + time_to_read: recommendation.corpusItem.timeToRead, + }; + } + + return feedItemToReturn; }; export const responseTransformer = ( diff --git a/src/generated/graphql/types.ts b/src/generated/graphql/types.ts index ed0a7b939..1c203dc60 100644 --- a/src/generated/graphql/types.ts +++ b/src/generated/graphql/types.ts @@ -245,6 +245,8 @@ export type CorpusItem = { shortUrl?: Maybe; /** If the Corpus Item is pocket owned with a specific type, this is the associated object (Collection or SyndicatedArticle). */ target?: Maybe; + /** Time to read in minutes. Is nullable. */ + timeToRead?: Maybe; /** The title of the Approved Item. */ title: Scalars['String']; /** The topic associated with the Approved Item. */ @@ -429,6 +431,14 @@ export type DomainMetadata = { name?: Maybe; }; +/** The reason a user web session is being expired. */ +export enum ExpireUserWebSessionReason { + /** Expire web session upon logging out. */ + Logout = 'LOGOUT', + /** Expire web session on account password change. */ + PasswordChanged = 'PASSWORD_CHANGED' +} + /** Input field to boost the score of an elasticsearch document based on a specific field and value */ export type FunctionalBoostField = { /** A float number to boost the score by */ @@ -872,6 +882,12 @@ export type Mutation = { * Returns firefox account ID sent as the query parameter with the request. */ deleteUserByFxaId: Scalars['ID']; + /** + * Expires a user's web session tokens by firefox account ID. + * Called by fxa-webhook proxy. Need to supply a reason why to expire user web session. + * Returns the user ID. + */ + expireUserWebSessionByFxaId: Scalars['ID']; /** * temporary mutation for apple user migration. * called by fxa-webhook proxy to update the fxaId and email of the user. @@ -1086,6 +1102,13 @@ export type MutationDeleteUserByFxaIdArgs = { }; +/** Default Mutation Type */ +export type MutationExpireUserWebSessionByFxaIdArgs = { + id: Scalars['ID']; + reason: ExpireUserWebSessionReason; +}; + + /** Default Mutation Type */ export type MutationMigrateAppleUserArgs = { email: Scalars['String']; @@ -2800,6 +2823,8 @@ export type User = { email?: Maybe; /** The users first name */ firstName?: Maybe; + /** Indicates if a user is FxA or not */ + isFxa?: Maybe; /** The user's premium status */ isPremium?: Maybe; /** The users last name */ @@ -2938,8 +2963,8 @@ export type NewTabRecommendationsQueryVariables = Exact<{ }>; -export type NewTabRecommendationsQuery = { __typename?: 'Query', newTabSlate: { __typename?: 'CorpusSlate', utmSource?: string | null, recommendations: Array<{ __typename?: 'CorpusRecommendation', tileId: number, corpusItem: { __typename?: 'CorpusItem', excerpt: string, imageUrl: any, publisher: string, title: string, url: any } }> } }; +export type NewTabRecommendationsQuery = { __typename?: 'Query', newTabSlate: { __typename?: 'CorpusSlate', utmSource?: string | null, recommendations: Array<{ __typename?: 'CorpusRecommendation', tileId: number, corpusItem: { __typename?: 'CorpusItem', excerpt: string, imageUrl: any, publisher: string, title: string, url: any, timeToRead?: number | null } }> } }; export const RecentSavesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"RecentSaves"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedItems"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"statuses"},"value":{"kind":"ListValue","values":[{"kind":"EnumValue","value":"UNREAD"}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"EnumValue","value":"CREATED_AT"}},{"kind":"ObjectField","name":{"kind":"Name","value":"sortOrder"},"value":{"kind":"EnumValue","value":"DESC"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Item"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"wordCount"}},{"kind":"Field","name":{"kind":"Name","value":"topImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}},{"kind":"Field","name":{"kind":"Name","value":"resolvedUrl"}},{"kind":"Field","name":{"kind":"Name","value":"givenUrl"}},{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}}]}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const NewTabRecommendationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NewTabRecommendations"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"region"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"count"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newTabSlate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}},{"kind":"Argument","name":{"kind":"Name","value":"region"},"value":{"kind":"Variable","name":{"kind":"Name","value":"region"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"utmSource"}},{"kind":"Field","name":{"kind":"Name","value":"recommendations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"count"},"value":{"kind":"Variable","name":{"kind":"Name","value":"count"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tileId"}},{"kind":"Field","name":{"kind":"Name","value":"corpusItem"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"publisher"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const NewTabRecommendationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"NewTabRecommendations"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"region"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"count"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newTabSlate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}},{"kind":"Argument","name":{"kind":"Name","value":"region"},"value":{"kind":"Variable","name":{"kind":"Name","value":"region"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"utmSource"}},{"kind":"Field","name":{"kind":"Name","value":"recommendations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"count"},"value":{"kind":"Variable","name":{"kind":"Name","value":"count"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tileId"}},{"kind":"Field","name":{"kind":"Name","value":"corpusItem"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"excerpt"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"publisher"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"timeToRead"}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/src/generated/openapi/types.ts b/src/generated/openapi/types.ts index 2ac545be2..14579dff8 100644 --- a/src/generated/openapi/types.ts +++ b/src/generated/openapi/types.ts @@ -113,6 +113,8 @@ export interface components { publisher: string; /** @description The primary image for a Recommendation. */ imageUrl: string; + /** @description Article read time in minutes */ + timeToRead?: number; }; LegacyFeedItem: { id: number; @@ -122,6 +124,7 @@ export interface components { domain: string; image_src: string; raw_image_src: string; + time_to_read?: number; }; LegacySettings: { spocsPerNewTabs?: number; diff --git a/src/graphql-proxy/recommendations/Recommendations.graphql b/src/graphql-proxy/recommendations/Recommendations.graphql index 97b4f3e86..97a283b72 100644 --- a/src/graphql-proxy/recommendations/Recommendations.graphql +++ b/src/graphql-proxy/recommendations/Recommendations.graphql @@ -9,6 +9,7 @@ query NewTabRecommendations($locale: String!, $region: String, $count: Int) { publisher title url + timeToRead } } } diff --git a/src/graphql-proxy/recommendations/__mocks__/recommendations.ts b/src/graphql-proxy/recommendations/__mocks__/recommendations.ts index b16125971..9794e6b7a 100644 --- a/src/graphql-proxy/recommendations/__mocks__/recommendations.ts +++ b/src/graphql-proxy/recommendations/__mocks__/recommendations.ts @@ -10,6 +10,7 @@ import common from '../../__mocks__/common'; import { NewTabRecommendationsQuery } from '../../../generated/graphql/types'; import { RecommendationsParameters } from '../recommendations'; +import { GraphRecommendation } from '../../../api/desktop/recommendations/response'; /** * faker locales do not match our own, map ours to faker locales @@ -25,22 +26,43 @@ const fakerLocales = { it: 'it', }; +/** + * + * @param hasTimeToRead If true, timeToRead is set to [1, 9], otherwise it's undefined. + */ +const fakeRecommendation = (hasTimeToRead = true): GraphRecommendation => { + const recommendationWithoutTimeToRead: GraphRecommendation = { + __typename: 'CorpusRecommendation', + tileId: faker.datatype.number(), + corpusItem: { + excerpt: common.itemExcerpt(), + imageUrl: common.itemUrl(), + publisher: common.itemDomain(), + title: common.itemTitle(), + url: common.itemUrl(), + }, + }; + + return hasTimeToRead + ? { + ...recommendationWithoutTimeToRead, + corpusItem: { + ...recommendationWithoutTimeToRead.corpusItem, + timeToRead: faker.datatype.number({ min: 1, max: 9 }), + }, + } + : recommendationWithoutTimeToRead; +}; + const fakeRecommendations = ( count ): NewTabRecommendationsQuery['newTabSlate']['recommendations'] => { return Array(count) .fill(0) - .map(() => ({ - __typename: 'CorpusRecommendation', - tileId: faker.datatype.number(), - corpusItem: { - excerpt: common.itemExcerpt(), - imageUrl: common.itemUrl(), - publisher: common.itemDomain(), - title: common.itemTitle(), - url: common.itemUrl(), - }, - })); + .map((value, index) => + // Add timeToRead only for even numbered recommendations. + fakeRecommendation(index % 2 === 0) + ); }; const recommendations = async ({ From 85bcbe59c79a475b12e4464bc65e76f120411103 Mon Sep 17 00:00:00 2001 From: Mathijs Miermans Date: Wed, 26 Jul 2023 11:43:47 -0700 Subject: [PATCH 2/3] fix(timeToRead): Lint error --- .../__mocks__/recommendations.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/graphql-proxy/recommendations/__mocks__/recommendations.ts b/src/graphql-proxy/recommendations/__mocks__/recommendations.ts index 9794e6b7a..be9bf0fcb 100644 --- a/src/graphql-proxy/recommendations/__mocks__/recommendations.ts +++ b/src/graphql-proxy/recommendations/__mocks__/recommendations.ts @@ -43,15 +43,17 @@ const fakeRecommendation = (hasTimeToRead = true): GraphRecommendation => { }, }; - return hasTimeToRead - ? { - ...recommendationWithoutTimeToRead, - corpusItem: { - ...recommendationWithoutTimeToRead.corpusItem, - timeToRead: faker.datatype.number({ min: 1, max: 9 }), - }, - } - : recommendationWithoutTimeToRead; + if (hasTimeToRead) { + return { + ...recommendationWithoutTimeToRead, + corpusItem: { + ...recommendationWithoutTimeToRead.corpusItem, + timeToRead: faker.datatype.number({ min: 1, max: 9 }), + }, + }; + } else { + return recommendationWithoutTimeToRead; + } }; const fakeRecommendations = ( From 050d9f840140f412d576291d7aeadd712cec989b Mon Sep 17 00:00:00 2001 From: Mathijs Miermans Date: Wed, 26 Jul 2023 12:49:13 -0700 Subject: [PATCH 3/3] chore(timeToRead): Convert mock to early exit --- .../__mocks__/recommendations.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/graphql-proxy/recommendations/__mocks__/recommendations.ts b/src/graphql-proxy/recommendations/__mocks__/recommendations.ts index be9bf0fcb..fcc4078ba 100644 --- a/src/graphql-proxy/recommendations/__mocks__/recommendations.ts +++ b/src/graphql-proxy/recommendations/__mocks__/recommendations.ts @@ -43,17 +43,17 @@ const fakeRecommendation = (hasTimeToRead = true): GraphRecommendation => { }, }; - if (hasTimeToRead) { - return { - ...recommendationWithoutTimeToRead, - corpusItem: { - ...recommendationWithoutTimeToRead.corpusItem, - timeToRead: faker.datatype.number({ min: 1, max: 9 }), - }, - }; - } else { + if (!hasTimeToRead) { return recommendationWithoutTimeToRead; } + + return { + ...recommendationWithoutTimeToRead, + corpusItem: { + ...recommendationWithoutTimeToRead.corpusItem, + timeToRead: faker.datatype.number({ min: 1, max: 9 }), + }, + }; }; const fakeRecommendations = (