@financial-times/dotcom-middleware-navigation 7.3.1 → 7.3.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/dotcom-middleware-navigation",
3
- "version": "7.3.1",
3
+ "version": "7.3.3",
4
4
  "description": "",
5
5
  "main": "dist/node/index.js",
6
6
  "types": "src/index.ts",
@@ -18,8 +18,8 @@
18
18
  "author": "",
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "@financial-times/dotcom-server-navigation": "file:../../packages/dotcom-server-navigation",
22
- "@financial-times/dotcom-types-navigation": "file:../dotcom-types-navigation"
21
+ "@financial-times/dotcom-server-navigation": "^7.3.3",
22
+ "@financial-times/dotcom-types-navigation": "^7.3.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "check-engine": "^1.10.1",
@@ -30,7 +30,8 @@
30
30
  "npm": "7.x || 8.x"
31
31
  },
32
32
  "files": [
33
- "dist/"
33
+ "dist/",
34
+ "src/"
34
35
  ],
35
36
  "repository": {
36
37
  "type": "git",
@@ -41,4 +42,4 @@
41
42
  "volta": {
42
43
  "extends": "../../package.json"
43
44
  }
44
- }
45
+ }
@@ -0,0 +1,64 @@
1
+ import subject from '../handleEdition'
2
+ import httpMocks from 'node-mocks-http'
3
+
4
+ describe('dotcom-middleware-navigation/src/handleEdition', () => {
5
+ describe('with no query string parameter or cookie set', () => {
6
+ let request
7
+ let response
8
+ let result
9
+
10
+ beforeAll(() => {
11
+ request = httpMocks.createRequest()
12
+ response = httpMocks.createResponse()
13
+ result = subject(request, response)
14
+ })
15
+
16
+ it('returns the default edition', () => {
17
+ expect(result).toEqual('uk')
18
+ })
19
+
20
+ it('sets an edition vary header', () => {
21
+ expect(response.getHeader('vary')).toBe('FT-Edition')
22
+ })
23
+ })
24
+
25
+ describe('with an edition header set', () => {
26
+ let request
27
+ let response
28
+ let result
29
+
30
+ beforeAll(() => {
31
+ request = httpMocks.createRequest({ headers: { 'ft-edition': 'international' } })
32
+ response = httpMocks.createResponse()
33
+ result = subject(request, response)
34
+ })
35
+
36
+ it('returns the set edition', () => {
37
+ expect(result).toEqual('international')
38
+ })
39
+
40
+ it('sets an edition vary header', () => {
41
+ expect(response.getHeader('vary')).toBe('FT-Edition')
42
+ })
43
+ })
44
+
45
+ describe('with a query string parameter set', () => {
46
+ let request
47
+ let response
48
+ let result
49
+
50
+ beforeAll(() => {
51
+ request = httpMocks.createRequest({ query: { edition: 'international' } })
52
+ response = httpMocks.createResponse()
53
+ result = subject(request, response)
54
+ })
55
+
56
+ it('returns the selected edition', () => {
57
+ expect(result).toEqual('international')
58
+ })
59
+
60
+ it('sets a next-edition cookie with the selected edition', () => {
61
+ expect(response.cookies['next-edition'].value).toBe('international')
62
+ })
63
+ })
64
+ })
@@ -0,0 +1,184 @@
1
+ import * as subject from '../navigation'
2
+ import httpMocks from 'node-mocks-http'
3
+
4
+ const fakeMenusData = {
5
+ navbar: {
6
+ label: 'Navbar UK',
7
+ items: [{ label: 'Foo', url: '#' }]
8
+ },
9
+ drawer: {
10
+ label: 'Drawer UK',
11
+ items: [{ label: 'Foo', url: '#' }]
12
+ }
13
+ }
14
+
15
+ const fakeEditionsData = {
16
+ current: { id: 'uk', name: 'UK', url: '/' },
17
+ others: [{ id: 'international', name: 'International', url: '/' }]
18
+ }
19
+
20
+ const fakeActionsData = { id: 'subscribe', name: 'Subscribe for full access', url: '/products?' }
21
+
22
+ const fakeSubNavigationData = {
23
+ breadcrumb: 'some-breadcrumb',
24
+ subsections: 'some-subsections'
25
+ }
26
+
27
+ const fakeNavigationData = { ...fakeMenusData, editions: fakeEditionsData, currentPath: '' }
28
+
29
+ const FakeNavigation = {
30
+ getSubscribeAction: jest.fn().mockResolvedValue(fakeActionsData),
31
+ getMenusFor: jest.fn().mockResolvedValue(fakeMenusData),
32
+ getSubNavigationFor: jest.fn().mockResolvedValue(fakeSubNavigationData),
33
+ getEditionsFor: jest.fn().mockReturnValue(fakeEditionsData)
34
+ }
35
+
36
+ jest.mock(
37
+ '@financial-times/dotcom-server-navigation',
38
+ () => {
39
+ return {
40
+ Navigation: jest.fn().mockImplementation(() => FakeNavigation)
41
+ }
42
+ },
43
+ { virtual: true }
44
+ )
45
+
46
+ describe('dotcom-middleware-navigation', () => {
47
+ let request
48
+ let response
49
+ let next
50
+ let instance
51
+
52
+ beforeEach(() => {
53
+ request = httpMocks.createRequest()
54
+ response = httpMocks.createResponse({ locals: {} })
55
+ next = jest.fn()
56
+ instance = subject.init()
57
+ })
58
+
59
+ afterEach(() => {
60
+ jest.clearAllMocks()
61
+ })
62
+
63
+ it('returns a middleware function', () => {
64
+ expect(instance).toBeInstanceOf(Function)
65
+ expect(instance.length).toBe(3)
66
+ })
67
+
68
+ describe('when handling a request', () => {
69
+ it('appends the navigation properties to response.locals', async () => {
70
+ await instance(request, response, next)
71
+ expect(response.locals.navigation).toEqual(expect.objectContaining(fakeNavigationData))
72
+ })
73
+
74
+ it('prefers the vanity URL if set', async () => {
75
+ const request = httpMocks.createRequest({
76
+ path: '/path/to/app/1234',
77
+ headers: {
78
+ 'ft-vanity-url': '/vanity-url'
79
+ }
80
+ })
81
+
82
+ await instance(request, response, next)
83
+ expect(FakeNavigation.getMenusFor).toHaveBeenCalledWith('/vanity-url', 'uk')
84
+ })
85
+
86
+ it('calls the fallthrough function', async () => {
87
+ await instance(request, response, next)
88
+ expect(next).toHaveBeenCalled()
89
+ })
90
+ })
91
+
92
+ describe('when handling a request with sub-navigation enabled', () => {
93
+ let instance
94
+
95
+ beforeEach(() => {
96
+ instance = subject.init({ enableSubNavigation: true })
97
+ })
98
+
99
+ it('prefers the path provided by the vanity URL header when available', async () => {
100
+ request = httpMocks.createRequest({
101
+ path: '/path/to/page/123',
102
+ headers: {
103
+ 'ft-vanity-url': '/vanity-url'
104
+ }
105
+ })
106
+
107
+ await instance(request, response, next)
108
+
109
+ expect(FakeNavigation.getSubNavigationFor).toHaveBeenCalledWith('/vanity-url')
110
+ })
111
+
112
+ it('normalizes the current path', async () => {
113
+ request = httpMocks.createRequest({
114
+ path: '/path/to/page/123',
115
+ query: {
116
+ page: 2
117
+ },
118
+ headers: {
119
+ 'ft-vanity-url': '/vanity-url?page=2'
120
+ }
121
+ })
122
+
123
+ await instance(request, response, next)
124
+
125
+ expect(FakeNavigation.getSubNavigationFor).toHaveBeenCalledWith('/vanity-url')
126
+ })
127
+
128
+ it('appends the sub-navigation properties on response.locals for the current path', async () => {
129
+ await instance(request, response, next)
130
+ expect(response.locals.navigation).toEqual(expect.objectContaining(fakeSubNavigationData))
131
+ })
132
+
133
+ it('fails silently if the the sub-navigation request fails', async () => {
134
+ FakeNavigation.getSubNavigationFor.mockRejectedValueOnce(new Error('Fail'))
135
+
136
+ await instance(request, response, next)
137
+
138
+ expect(next).not.toHaveBeenCalledWith(expect.any(Error))
139
+ })
140
+ })
141
+
142
+ describe('when handling a request with a custom getCurrentPath', () => {
143
+ it('executes the provided getCurrentPath function', async () => {
144
+ request = httpMocks.createRequest({
145
+ path: '/path/to/page/123',
146
+ headers: {
147
+ 'ft-vanity-url': '/vanity-url?page=2'
148
+ }
149
+ })
150
+ const dummyPath = '/foo'
151
+ const instance = subject.init({ getCurrentPath: () => dummyPath })
152
+ await instance(request, response, next)
153
+
154
+ expect(FakeNavigation.getMenusFor).toHaveBeenCalledWith(dummyPath, 'uk')
155
+ })
156
+
157
+ it('allows overriding of how to calculate current path logic', async () => {
158
+ request = httpMocks.createRequest({
159
+ path: '/path/to/page/123',
160
+ headers: {
161
+ 'ft-blocked-url': '/ig-content-test'
162
+ }
163
+ })
164
+
165
+ const instance = subject.init({ getCurrentPath: (request) => request.get('ft-blocked-url') })
166
+ await instance(request, response, next)
167
+
168
+ expect(FakeNavigation.getMenusFor).toHaveBeenCalledWith('/ig-content-test', 'uk')
169
+ })
170
+ })
171
+
172
+ describe('when something goes wrong', () => {
173
+ beforeEach(() => {
174
+ FakeNavigation.getMenusFor = jest.fn(() => {
175
+ throw Error('Whoops')
176
+ })
177
+ })
178
+
179
+ it('catches the error safely', async () => {
180
+ await instance(request, response, next)
181
+ expect(next).toHaveBeenCalledWith(expect.any(Error))
182
+ })
183
+ })
184
+ })
@@ -0,0 +1,28 @@
1
+ import { isEdition } from '@financial-times/dotcom-server-navigation'
2
+ import { Request, Response } from 'express'
3
+
4
+ const defaultEdition = 'uk'
5
+
6
+ export default (request: Request, response: Response): string => {
7
+ // NOTE: The FT-Edition header is set by the CDN and/or next-router...
8
+ // If an edition is selected with a cookie or query string it will be set to that.
9
+ // Otherwise the router will choose the best setting based on GeoIP.
10
+ // <https://github.com/Financial-Times/ft.com-cdn/blob/HEAD/src/vcl/next-editions.vcl>
11
+ // <https://github.com/Financial-Times/next-router/blob/HEAD/server/middleware/editions.js>
12
+ let currentEdition = request.get('FT-Edition') || defaultEdition
13
+
14
+ if (typeof request.query.edition === 'string' && isEdition(request.query.edition)) {
15
+ currentEdition = request.query.edition
16
+
17
+ response.cookie('next-edition', currentEdition, {
18
+ domain: 'ft.com',
19
+ maxAge: 1000 * 60 * 60 * 24 * 365 // 1 year
20
+ })
21
+ }
22
+
23
+ // NOTE: n-express overrides res.set() and res.vary() in order to merge all vary headers together.
24
+ // <https://github.com/Financial-Times/n-express/blob/HEAD/src/middleware/vary.js>
25
+ response.vary('FT-Edition')
26
+
27
+ return currentEdition
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './navigation'
@@ -0,0 +1,57 @@
1
+ import { Request, Response, NextFunction } from 'express'
2
+ import { TNavigationData } from '@financial-times/dotcom-types-navigation'
3
+ import { Navigation, TNavOptions } from '@financial-times/dotcom-server-navigation'
4
+ import handleEdition from './handleEdition'
5
+ import normalizePath from './normalizePath'
6
+
7
+ type MiddlewareOptions = TNavOptions & {
8
+ enableSubNavigation?: boolean
9
+ getCurrentPath?: Function
10
+ }
11
+
12
+ const defaultOptions: MiddlewareOptions = {
13
+ enableSubNavigation: false,
14
+ getCurrentPath: (request) => normalizePath(request.get('ft-vanity-url') || request.path)
15
+ }
16
+
17
+ export const init = (userOptions: MiddlewareOptions = {}) => {
18
+ const options = { ...defaultOptions, ...userOptions }
19
+ const navigation = new Navigation(options)
20
+
21
+ // Not all pages appear in the navigation so this request will fail often.
22
+ // Because it's not critical, ignore the error and move on.
23
+ const getSubNavigationFor = (currentPath) => navigation.getSubNavigationFor(currentPath).catch(() => {})
24
+
25
+ return async (request: Request, response: Response, next: NextFunction) => {
26
+ try {
27
+ // The vanity URL will usually be referenced in the navigation data
28
+ // rather than the underlying path, so prefer that when available.
29
+ // <https://github.com/Financial-Times/ft.com-cdn/blob/4841fbf100e1c561a2f6729b9921ec12bb6b837c/src/vcl/next-preflight.vcl#L213-L219>
30
+ // NOTE: Next router sets the vanity header inc. any query string so it must be normalized.
31
+ const currentPath = options.getCurrentPath(request)
32
+ const currentEdition = handleEdition(request, response)
33
+
34
+ const [menusData, subNavigationData] = await Promise.all([
35
+ navigation.getMenusFor(currentPath, currentEdition),
36
+ options.enableSubNavigation ? getSubNavigationFor(currentPath) : null
37
+ ])
38
+
39
+ const editions = navigation.getEditionsFor(currentEdition)
40
+ const subscribeAction = navigation.getSubscribeAction()
41
+
42
+ const navigationData: TNavigationData = {
43
+ editions,
44
+ subscribeAction,
45
+ currentPath,
46
+ ...menusData,
47
+ ...subNavigationData
48
+ }
49
+
50
+ response.locals.navigation = navigationData
51
+
52
+ next()
53
+ } catch (error) {
54
+ next(error)
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,6 @@
1
+ import url from 'url'
2
+
3
+ export default function normalizePath(currentPath: string): string {
4
+ // NOTE: We're using Node's old URL API because it can handle partial URLs
5
+ return url.parse(currentPath).pathname || ''
6
+ }