@financial-times/cp-content-pipeline-schema 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/lib/generated/index.d.ts +24 -0
  3. package/lib/model/CapiResponse.d.ts +2 -0
  4. package/lib/model/CapiResponse.js +7 -0
  5. package/lib/model/CapiResponse.js.map +1 -1
  6. package/lib/model/Concept.d.ts +0 -2
  7. package/lib/model/Concept.js +0 -56
  8. package/lib/model/Concept.js.map +1 -1
  9. package/lib/model/Concept.test.js +0 -40
  10. package/lib/model/Concept.test.js.map +1 -1
  11. package/lib/model/Person.d.ts +21 -0
  12. package/lib/model/Person.js +101 -0
  13. package/lib/model/Person.js.map +1 -0
  14. package/lib/model/Person.test.d.ts +1 -0
  15. package/lib/model/Person.test.js +96 -0
  16. package/lib/model/Person.test.js.map +1 -0
  17. package/lib/model/Topper.d.ts +2 -1
  18. package/lib/model/Topper.js +18 -16
  19. package/lib/model/Topper.js.map +1 -1
  20. package/lib/model/Topper.test.js +29 -0
  21. package/lib/model/Topper.test.js.map +1 -1
  22. package/lib/model/schemas/capi/base-schema.d.ts +3 -0
  23. package/lib/model/schemas/capi/base-schema.js +1 -0
  24. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  25. package/lib/resolvers/content.d.ts +1 -0
  26. package/lib/resolvers/content.js +1 -0
  27. package/lib/resolvers/content.js.map +1 -1
  28. package/lib/resolvers/index.d.ts +9 -3
  29. package/lib/resolvers/index.js +2 -0
  30. package/lib/resolvers/index.js.map +1 -1
  31. package/lib/resolvers/person.d.ts +8 -0
  32. package/lib/resolvers/person.js +11 -0
  33. package/lib/resolvers/person.js.map +1 -0
  34. package/lib/resolvers/topper.d.ts +3 -3
  35. package/package.json +1 -1
  36. package/queries/article.graphql +9 -0
  37. package/src/generated/index.ts +28 -0
  38. package/src/model/CapiResponse.ts +7 -0
  39. package/src/model/Concept.test.ts +0 -49
  40. package/src/model/Concept.ts +0 -31
  41. package/src/model/Person.test.ts +110 -0
  42. package/src/model/Person.ts +74 -0
  43. package/src/model/Topper.test.ts +37 -0
  44. package/src/model/Topper.ts +18 -23
  45. package/src/model/schemas/capi/base-schema.ts +1 -0
  46. package/src/resolvers/content.ts +1 -0
  47. package/src/resolvers/index.ts +2 -0
  48. package/src/resolvers/person.ts +12 -0
  49. package/tsconfig.tsbuildinfo +1 -1
  50. package/typedefs/content.graphql +1 -0
  51. package/typedefs/person.graphql +5 -0
  52. package/typedefs/topper.graphql +3 -3
@@ -1,5 +1,6 @@
1
1
  import type { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql';
2
2
  import type { Concept as ConceptModel } from '../model/Concept';
3
+ import type { Person as PersonModel } from '../model/Person';
3
4
  import type { CapiResponse } from '../model/CapiResponse';
4
5
  import type { Image as ImageModel } from '../model/Image';
5
6
  import type { Clip as ClipModel } from '../model/Clip';
@@ -990,6 +991,7 @@ export type LiveBlogPost = Content & {
990
991
  readonly altTitle?: Maybe<AltTitle>;
991
992
  /** An array of concepts related to the article, eg. organisations or topics. */
992
993
  readonly annotations?: Maybe<ReadonlyArray<Maybe<Concept>>>;
994
+ readonly authors?: Maybe<ReadonlyArray<Maybe<Person>>>;
993
995
  /** An abstract syntax tree of the article content. */
994
996
  readonly body?: Maybe<RichText>;
995
997
  /** The raw string of the XML as returned from ContentAPI. */
@@ -1108,6 +1110,19 @@ export type OpinionTopper = Topper & TopperWithHeadshot & TopperWithTheme & {
1108
1110
 
1109
1111
 
1110
1112
  export type OpinionTopperHeadshotArgs = {
1113
+ dpr?: InputMaybe<Scalars['Int']['input']>;
1114
+ url?: InputMaybe<Scalars['String']['input']>;
1115
+ width?: InputMaybe<Scalars['Int']['input']>;
1116
+ };
1117
+
1118
+ export type Person = {
1119
+ readonly headshot?: Maybe<Scalars['String']['output']>;
1120
+ readonly prefLabel?: Maybe<Scalars['String']['output']>;
1121
+ readonly streamPage?: Maybe<Scalars['String']['output']>;
1122
+ };
1123
+
1124
+
1125
+ export type PersonHeadshotArgs = {
1111
1126
  dpr?: InputMaybe<Scalars['Int']['input']>;
1112
1127
  width?: InputMaybe<Scalars['Int']['input']>;
1113
1128
  };
@@ -1277,6 +1292,7 @@ export type PodcastTopper = Topper & TopperWithBrand & TopperWithHeadshot & Topp
1277
1292
 
1278
1293
  export type PodcastTopperHeadshotArgs = {
1279
1294
  dpr?: InputMaybe<Scalars['Int']['input']>;
1295
+ url?: InputMaybe<Scalars['String']['input']>;
1280
1296
  width?: InputMaybe<Scalars['Int']['input']>;
1281
1297
  };
1282
1298
 
@@ -1454,6 +1470,7 @@ export type TopperWithHeadshot = {
1454
1470
 
1455
1471
  export type TopperWithHeadshotHeadshotArgs = {
1456
1472
  dpr?: InputMaybe<Scalars['Int']['input']>;
1473
+ url?: InputMaybe<Scalars['String']['input']>;
1457
1474
  width?: InputMaybe<Scalars['Int']['input']>;
1458
1475
  };
1459
1476
 
@@ -1697,6 +1714,7 @@ export type ResolversTypes = ResolversObject<{
1697
1714
  Mutation: ResolverTypeWrapper<{}>;
1698
1715
  OpinionTopper: ResolverTypeWrapper<TopperModel>;
1699
1716
  PackageDesign: ResolverTypeWrapper<Scalars['PackageDesign']['output']>;
1717
+ Person: ResolverTypeWrapper<PersonModel>;
1700
1718
  Picture: ResolverTypeWrapper<PictureModel>;
1701
1719
  PictureFullBleed: ResolverTypeWrapper<PictureModel>;
1702
1720
  PictureInline: ResolverTypeWrapper<PictureModel>;
@@ -1783,6 +1801,7 @@ export type ResolversParentTypes = ResolversObject<{
1783
1801
  Mutation: {};
1784
1802
  OpinionTopper: TopperModel;
1785
1803
  PackageDesign: Scalars['PackageDesign']['output'];
1804
+ Person: PersonModel;
1786
1805
  Picture: PictureModel;
1787
1806
  PictureFullBleed: PictureModel;
1788
1807
  PictureInline: PictureModel;
@@ -2341,6 +2360,7 @@ export type LiveBlogPostResolvers<ContextType = QueryContext, ParentType extends
2341
2360
  altStandfirst: Resolver<Maybe<ResolversTypes['AltStandfirst']>, ParentType, ContextType>;
2342
2361
  altTitle: Resolver<Maybe<ResolversTypes['AltTitle']>, ParentType, ContextType>;
2343
2362
  annotations: Resolver<Maybe<ReadonlyArray<Maybe<ResolversTypes['Concept']>>>, ParentType, ContextType>;
2363
+ authors: Resolver<Maybe<ReadonlyArray<Maybe<ResolversTypes['Person']>>>, ParentType, ContextType>;
2344
2364
  body: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
2345
2365
  bodyXML: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2346
2366
  byline: Resolver<Maybe<ResolversTypes['StructuredContent']>, ParentType, ContextType, Partial<LiveBlogPostBylineArgs>>;
@@ -2409,6 +2429,13 @@ export interface PackageDesignScalarConfig extends GraphQLScalarTypeConfig<Resol
2409
2429
  name: 'PackageDesign';
2410
2430
  }
2411
2431
 
2432
+ export type PersonResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Person'] = ResolversParentTypes['Person']> = ResolversObject<{
2433
+ headshot: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, Partial<PersonHeadshotArgs>>;
2434
+ prefLabel: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2435
+ streamPage: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2436
+ __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2437
+ }>;
2438
+
2412
2439
  export type PictureResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Picture'] = ResolversParentTypes['Picture']> = ResolversObject<{
2413
2440
  __resolveType?: TypeResolveFn<'PictureFullBleed' | 'PictureInline' | 'PictureStandard', ParentType, ContextType>;
2414
2441
  alt: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -2725,6 +2752,7 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
2725
2752
  Mutation: MutationResolvers<ContextType>;
2726
2753
  OpinionTopper: OpinionTopperResolvers<ContextType>;
2727
2754
  PackageDesign: GraphQLScalarType;
2755
+ Person: PersonResolvers<ContextType>;
2728
2756
  Picture: PictureResolvers<ContextType>;
2729
2757
  PictureFullBleed: PictureFullBleedResolvers<ContextType>;
2730
2758
  PictureInline: PictureInlineResolvers<ContextType>;
@@ -14,6 +14,7 @@ import sortBy from 'lodash.sortby'
14
14
 
15
15
  import { CAPIImage } from './Image'
16
16
  import { Concept } from './Concept'
17
+ import { Person } from './Person'
17
18
  import isError from '../helpers/isError'
18
19
  import { uuidFromUrl } from '../helpers/metadata'
19
20
  import { schemas } from './schemas/capi'
@@ -302,6 +303,12 @@ export class CapiResponse {
302
303
  if ('topper' in this.capiData) return this.capiData.topper
303
304
  return null
304
305
  }
306
+ authors(): Person[] | null {
307
+ const authors = this.getAuthors()
308
+ return authors.length === 0
309
+ ? null
310
+ : authors.map((author: Concept) => new Person(author, this, this.context))
311
+ }
305
312
  accessLevel(): LiteralUnionScalarValues<typeof AccessLevel> {
306
313
  return this.capiData.accessLevel ?? 'subscribed'
307
314
  }
@@ -1,7 +1,6 @@
1
1
  import { Concept } from './Concept'
2
2
  import { jest } from '@jest/globals'
3
3
  import type { QueryContext } from '..'
4
- import { capiPerson } from '../fixtures/capiPerson'
5
4
  import { URLManagementDataSource } from '../datasources/url-management'
6
5
  import { CapiDataSource } from '../datasources/capi'
7
6
  import { Logger } from '@dotcom-reliability-kit/logger'
@@ -32,19 +31,6 @@ const topic = {
32
31
  ],
33
32
  }
34
33
 
35
- const author = {
36
- apiUrl: 'http://api.ft.com/people/4076f4fd-723b-4ce5-9934-fb29416554fa',
37
- directType: 'http://www.ft.com/ontology/person/Person',
38
- id: 'http://api.ft.com/things/4076f4fd-723b-4ce5-9934-fb29416554fa',
39
- predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
40
- prefLabel: 'Robert Shrimsley',
41
- type: 'PERSON',
42
- types: [
43
- 'http://www.ft.com/ontology/core/Thing',
44
- 'http://www.ft.com/ontology/concept/Concept',
45
- 'http://www.ft.com/ontology/person/Person',
46
- ],
47
- }
48
34
  describe('Concept model', () => {
49
35
  beforeEach(() => {
50
36
  jest.resetAllMocks()
@@ -88,39 +74,4 @@ describe('Concept model', () => {
88
74
  )
89
75
  })
90
76
  })
91
-
92
- describe('headshot()', () => {
93
- it('returns null if the concept is not for an author', async () => {
94
- const model = new Concept(topic, context)
95
- expect(await model.headshot()).toEqual(null)
96
- })
97
-
98
- it('returns the headshot for an author', async () => {
99
- getPersonMock.mockResolvedValue(capiPerson)
100
- const model = new Concept(author, context)
101
- expect(await model.headshot()).toEqual(
102
- 'https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2Ffthead-v1_robert-shrimsley-cc467908-15d6-474d-94d8-171594ceabb9.png?source=image-test&fit=scale-down&quality=highest&width=150&dpr=1'
103
- )
104
- })
105
-
106
- it('can resize headshot and provide high resolution version', async () => {
107
- getPersonMock.mockResolvedValue(capiPerson)
108
- const model = new Concept(author, context)
109
- expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(
110
- 'https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2Ffthead-v1_robert-shrimsley-cc467908-15d6-474d-94d8-171594ceabb9.png?source=image-test&fit=scale-down&quality=highest&width=300&dpr=2'
111
- )
112
- })
113
-
114
- it('returns null if there is an error getting the person data', async () => {
115
- getPersonMock.mockRejectedValue(new Error('error getting person'))
116
- const model = new Concept(author, context)
117
- expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(null)
118
- })
119
-
120
- it("returns null if the author doesn't have a headshot", async () => {
121
- getPersonMock.mockResolvedValue({ ...capiPerson, _imageUrl: undefined })
122
- const model = new Concept(author, context)
123
- expect(await model.headshot()).toEqual(null)
124
- })
125
- })
126
77
  })
@@ -1,11 +1,8 @@
1
1
  import type { Annotation } from '../types/internal-content'
2
2
  import type { QueryContext } from '..'
3
3
  import { uuidFromUrl } from '../helpers/metadata'
4
- import imageServiceUrl from '../helpers/imageService'
5
4
  import isError from '../helpers/isError'
6
5
  import conceptIds from '@financial-times/n-concept-ids'
7
- import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
8
- import type { TopperWithHeadshotHeadshotArgs } from '../generated'
9
6
 
10
7
  const CAPI_ID_PREFIX = /^https?:\/\/(?:www|api)\.ft\.com\/things?\//
11
8
  const BASE_URL = 'https://www.ft.com/stream/'
@@ -170,32 +167,4 @@ export class Concept {
170
167
  }
171
168
  return url
172
169
  }
173
-
174
- async headshot(args?: TopperWithHeadshotHeadshotArgs) {
175
- const uuid = this.apiUrl().match(UUID_REGEX)?.[0]
176
-
177
- if (!this.isAuthor() || !uuid) return null
178
-
179
- try {
180
- const person = await this.context.dataSources.capi.getPerson(uuid)
181
- const url = decorateHeadshotUrl(person)
182
-
183
- return url
184
- ? imageServiceUrl({
185
- url,
186
- systemCode: this.#systemCode,
187
- width: args?.width || 150,
188
- dpr: args?.dpr ?? undefined,
189
- })
190
- : null
191
- } catch (error) {
192
- if (isError(error)) {
193
- this.context.logger.error({
194
- event: 'RECOVERABLE_ERROR',
195
- error,
196
- })
197
- }
198
- return null
199
- }
200
- }
201
170
  }
@@ -0,0 +1,110 @@
1
+ import { Person } from './Person'
2
+ import { jest } from '@jest/globals'
3
+ import type { QueryContext } from '..'
4
+ import { Concept } from './Concept'
5
+ import { CapiResponse } from './CapiResponse'
6
+ import { baseCapiObject } from '../fixtures/capiObject'
7
+ import { capiPerson } from '../fixtures/capiPerson'
8
+ import cloneDeep from 'clone-deep'
9
+ import { URLManagementDataSource } from '../datasources/url-management'
10
+ import { CapiDataSource } from '../datasources/capi'
11
+ import { Logger } from '@dotcom-reliability-kit/logger'
12
+
13
+ const vanityMock = jest.fn<URLManagementDataSource['get']>()
14
+ const getPersonMock = jest.fn<CapiDataSource['getPerson']>()
15
+ const context = {
16
+ dataSources: {
17
+ origami: {},
18
+ capi: { getPerson: getPersonMock },
19
+ vanityUrls: { get: vanityMock },
20
+ },
21
+ systemCode: 'image-test',
22
+ logger: new Logger(),
23
+ } as unknown as QueryContext
24
+
25
+ const topic = {
26
+ apiUrl: 'http://api.ft.com/things/6b32f2c1-da43-4e19-80b9-8aef4ab640d7',
27
+ directType: 'http://www.ft.com/ontology/Topic',
28
+ id: 'http://api.ft.com/things/6b32f2c1-da43-4e19-80b9-8aef4ab640d7',
29
+ predicate: 'http://www.ft.com/ontology/annotation/about',
30
+ prefLabel: 'Technology sector',
31
+ type: 'TOPIC',
32
+ types: [
33
+ 'http://www.ft.com/ontology/core/Thing',
34
+ 'http://www.ft.com/ontology/concept/Concept',
35
+ 'http://www.ft.com/ontology/Topic',
36
+ ],
37
+ }
38
+
39
+ const author = {
40
+ apiUrl: 'http://api.ft.com/people/4076f4fd-723b-4ce5-9934-fb29416554fa',
41
+ directType: 'http://www.ft.com/ontology/person/Person',
42
+ id: 'http://api.ft.com/things/4076f4fd-723b-4ce5-9934-fb29416554fa',
43
+ predicate: 'http://www.ft.com/ontology/annotation/hasAuthor',
44
+ prefLabel: 'Robert Shrimsley',
45
+ type: 'PERSON',
46
+ types: [
47
+ 'http://www.ft.com/ontology/core/Thing',
48
+ 'http://www.ft.com/ontology/concept/Concept',
49
+ 'http://www.ft.com/ontology/person/Person',
50
+ ],
51
+ }
52
+ describe('Person model', () => {
53
+ let clonedBase: typeof baseCapiObject
54
+ let capiResponse: CapiResponse
55
+ let topicConcept: Concept
56
+ let authorConcept: Concept
57
+
58
+ beforeEach(() => {
59
+ jest.resetAllMocks()
60
+
61
+ clonedBase = cloneDeep(baseCapiObject)
62
+ capiResponse = new CapiResponse(clonedBase, context)
63
+ topicConcept = new Concept(topic, context)
64
+ authorConcept = new Concept(author, context)
65
+ })
66
+
67
+ describe('headshot()', () => {
68
+ it('returns null if the concept is not for an author', async () => {
69
+ const model = new Person(topicConcept, capiResponse, context)
70
+ expect(await model.headshot()).toEqual(null)
71
+ })
72
+
73
+ it('returns the headshot for an author', async () => {
74
+ getPersonMock.mockResolvedValue(capiPerson)
75
+ const model = new Person(authorConcept, capiResponse, context)
76
+ expect(await model.headshot()).toEqual(
77
+ 'https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2Ffthead-v1_robert-shrimsley-cc467908-15d6-474d-94d8-171594ceabb9.png?source=image-test&fit=scale-down&quality=highest&width=150&dpr=1'
78
+ )
79
+ })
80
+
81
+ it('can resize headshot and provide high resolution version', async () => {
82
+ getPersonMock.mockResolvedValue(capiPerson)
83
+ const model = new Person(authorConcept, capiResponse, context)
84
+ expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(
85
+ 'https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2Ffthead-v1_robert-shrimsley-cc467908-15d6-474d-94d8-171594ceabb9.png?source=image-test&fit=scale-down&quality=highest&width=300&dpr=2'
86
+ )
87
+ })
88
+
89
+ it('returns null if there is an error getting the person data', async () => {
90
+ getPersonMock.mockRejectedValue(new Error('error getting person'))
91
+ const model = new Person(authorConcept, capiResponse, context)
92
+ expect(await model.headshot({ width: 300, dpr: 2 })).toEqual(null)
93
+ })
94
+
95
+ it("returns null if the author doesn't have a headshot", async () => {
96
+ getPersonMock.mockResolvedValue({ ...capiPerson, _imageUrl: undefined })
97
+ const model = new Person(authorConcept, capiResponse, context)
98
+ expect(await model.headshot()).toEqual(null)
99
+ })
100
+ })
101
+
102
+ describe('streamPage()', () => {
103
+ it('returns the stream URL', () => {
104
+ const model = new Person(authorConcept, capiResponse, context)
105
+ expect(model.streamPage()).toEqual(
106
+ 'https://www.ft.com/stream/4076f4fd-723b-4ce5-9934-fb29416554fa'
107
+ )
108
+ })
109
+ })
110
+ })
@@ -0,0 +1,74 @@
1
+ import type { QueryContext } from '..'
2
+ import { Concept } from './Concept'
3
+ import { CapiResponse } from './CapiResponse'
4
+ import imageServiceUrl from '../helpers/imageService'
5
+ import isError from '../helpers/isError'
6
+ import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
7
+
8
+ type HeadshotArguments = {
9
+ width?: number | null
10
+ dpr?: number | null
11
+ }
12
+
13
+ const predicates = {
14
+ hasAuthor: 'http://www.ft.com/ontology/annotation/hasAuthor',
15
+ } as const
16
+
17
+ export class Person {
18
+ #systemCode: string
19
+
20
+ constructor(
21
+ private concept: Concept,
22
+ private capiResponse: CapiResponse,
23
+ private context: QueryContext
24
+ ) {
25
+ this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
26
+ }
27
+
28
+ prefLabel() {
29
+ return this.concept.prefLabel()
30
+ }
31
+
32
+ uuid() {
33
+ return this.concept.uuid()
34
+ }
35
+
36
+ streamPage() {
37
+ const uuid = this.uuid()
38
+ return `https://www.ft.com/stream/${uuid}`
39
+ }
40
+
41
+ apiUrl() {
42
+ return this.concept.apiUrl()
43
+ }
44
+
45
+ isAuthor() {
46
+ return this.concept.predicate() === predicates.hasAuthor
47
+ }
48
+
49
+ async headshot(args?: HeadshotArguments) {
50
+ try {
51
+ const uuid = this.apiUrl().match(UUID_REGEX)?.[0]
52
+ if (!this.isAuthor() || !uuid || !this.capiResponse) return null
53
+ const person = await this.context.dataSources.capi.getPerson(uuid)
54
+ const url = decorateHeadshotUrl(person)
55
+
56
+ return url
57
+ ? imageServiceUrl({
58
+ url,
59
+ systemCode: this.#systemCode,
60
+ width: args?.width || 150,
61
+ dpr: args?.dpr ?? undefined,
62
+ })
63
+ : null
64
+ } catch (error) {
65
+ if (isError(error)) {
66
+ this.context.logger.error({
67
+ event: 'RECOVERABLE_ERROR',
68
+ error,
69
+ })
70
+ }
71
+ return null
72
+ }
73
+ }
74
+ }
@@ -1,4 +1,5 @@
1
1
  import { Topper } from './Topper'
2
+ import { Person } from './Person'
2
3
  import { baseCapiObject } from '../fixtures/capiObject'
3
4
  import { CapiResponse } from './CapiResponse'
4
5
  import { predicates } from './Concept'
@@ -426,3 +427,39 @@ describe('produces the correct metadata tags', () => {
426
427
  expect(brandConcept?.prefLabel()).toEqual('Lex')
427
428
  })
428
429
  })
430
+
431
+ describe('headshot method', () => {
432
+ let topper: Topper
433
+ let capiResponse: CapiResponse
434
+
435
+ beforeEach(() => {
436
+ const clonedBase = cloneDeep(baseCapiObject)
437
+ capiResponse = new CapiResponse(clonedBase, context)
438
+ topper = new Topper(capiResponse, context)
439
+ })
440
+
441
+ afterEach(() => {
442
+ jest.clearAllMocks()
443
+ })
444
+
445
+ it('returns null if the content is not opinion genre', () => {
446
+ jest.spyOn(capiResponse, 'isPodcast').mockReturnValue(false)
447
+ jest.spyOn(capiResponse, 'isOpinion').mockReturnValue(false)
448
+
449
+ const result = topper.headshot({})
450
+
451
+ expect(result).toBeNull()
452
+ })
453
+
454
+ it('attempts to return the author headshot if the content is opinion genre', async () => {
455
+ jest.spyOn(capiResponse, 'isPodcast').mockReturnValue(false)
456
+ jest.spyOn(capiResponse, 'isOpinion').mockReturnValue(true)
457
+ jest.spyOn(capiResponse, 'getAuthors').mockReturnValue([])
458
+ jest
459
+ .spyOn(Person.prototype, 'headshot')
460
+ .mockReturnValue(Promise.resolve(null))
461
+
462
+ await topper.headshot({})
463
+ expect(Person.prototype.headshot).toHaveBeenCalledTimes(1)
464
+ })
465
+ })
@@ -1,4 +1,5 @@
1
1
  import { CapiResponse } from './CapiResponse'
2
+ import { Person } from './Person'
2
3
  import { CAPIImage } from './Image'
3
4
  import type { QueryContext } from '..'
4
5
  import {
@@ -299,31 +300,25 @@ export class Topper {
299
300
  return this.capiResponse.design()
300
301
  }
301
302
 
302
- async headshot(args: TopperWithHeadshotHeadshotArgs) {
303
- let headshotUrl: string | undefined
303
+ imageService(args: TopperWithHeadshotHeadshotArgs) {
304
+ if (args.url)
305
+ return imageServiceUrl({
306
+ url: args.url,
307
+ systemCode: this.#systemCode,
308
+ width: args.width ?? undefined,
309
+ dpr: args.dpr ?? undefined,
310
+ })
311
+ return null
312
+ }
304
313
 
314
+ headshot(args: TopperWithHeadshotHeadshotArgs) {
305
315
  if (this.capiResponse.isPodcast()) {
306
- headshotUrl = this.capiResponse.mainImage()?.url()
307
- }
308
-
309
- if (this.capiResponse.isOpinion()) {
310
- const authors = this.capiResponse.getAuthors()
311
- const headshotUrls: (string | null)[] = await Promise.all(
312
- authors.map(async (author) => author.headshot(args))
313
- ).then((res) => res.filter(Boolean))
314
-
315
- headshotUrl = headshotUrls[0] ?? undefined
316
+ args.url = this.capiResponse.mainImage()?.url()
317
+ return this.imageService(args)
316
318
  }
317
-
318
- if (!headshotUrl) {
319
- return null
320
- }
321
-
322
- return imageServiceUrl({
323
- url: headshotUrl,
324
- systemCode: this.#systemCode,
325
- width: args.width ?? undefined,
326
- dpr: args.dpr ?? undefined,
327
- })
319
+ if (!this.capiResponse.isOpinion()) return null
320
+ const author = this.capiResponse.getAuthors()[0]
321
+ const person = new Person(author, this.capiResponse, this.context)
322
+ return person.headshot(args)
328
323
  }
329
324
  }
@@ -162,6 +162,7 @@ export const CapiPerson = z.object({
162
162
  emailAddress: z.string().optional(),
163
163
  twitterHandle: z.string().optional(),
164
164
  descriptionXML: z.string().optional(),
165
+ headshot: z.string().optional(),
165
166
  _imageUrl: z.string().optional(),
166
167
  })
167
168
 
@@ -80,6 +80,7 @@ const resolvers = {
80
80
  }
81
81
  },
82
82
  isPinned: (parent) => parent.isPinned(),
83
+ authors: (parent) => parent.authors(),
83
84
  },
84
85
 
85
86
  LiveBlogPackage: {
@@ -9,6 +9,7 @@ import { default as richText } from './richText'
9
9
  import { default as scalars } from './scalars'
10
10
  import { default as teaser } from './teaser'
11
11
  import { default as topper } from './topper'
12
+ import { default as person } from './person'
12
13
  import { resolvers as references } from './content-tree/references'
13
14
  import { Resolvers } from '../generated'
14
15
 
@@ -25,6 +26,7 @@ const resolvers = {
25
26
  ...scalars,
26
27
  ...teaser,
27
28
  ...topper,
29
+ ...person,
28
30
  } satisfies Resolvers
29
31
 
30
32
  export default resolvers
@@ -0,0 +1,12 @@
1
+ import { PersonResolvers } from '../generated'
2
+ const resolvers = {
3
+ Person: {
4
+ headshot: (parent) => parent.headshot(),
5
+ prefLabel: (parent) => parent.prefLabel(),
6
+ streamPage: (parent) => parent.streamPage(),
7
+ },
8
+ } satisfies {
9
+ Person: PersonResolvers
10
+ }
11
+
12
+ export default resolvers