ti2 1.0.124 → 1.0.125

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.
@@ -0,0 +1,67 @@
1
+ module.exports = {
2
+ up: async (queryInterface, Sequelize) => {
3
+ await queryInterface.createTable('IntegrationLifecycles', {
4
+ userId: {
5
+ type: Sequelize.STRING(64),
6
+ allowNull: false,
7
+ primaryKey: true,
8
+ },
9
+ integrationId: {
10
+ type: Sequelize.STRING(100),
11
+ allowNull: false,
12
+ primaryKey: true,
13
+ },
14
+ hint: {
15
+ type: Sequelize.STRING(256),
16
+ allowNull: false,
17
+ primaryKey: true,
18
+ },
19
+ generation: {
20
+ type: Sequelize.INTEGER.UNSIGNED,
21
+ allowNull: false,
22
+ defaultValue: 1,
23
+ },
24
+ status: {
25
+ type: Sequelize.STRING,
26
+ allowNull: false,
27
+ },
28
+ requestId: {
29
+ type: Sequelize.STRING(64),
30
+ allowNull: false,
31
+ },
32
+ retryCount: {
33
+ type: Sequelize.INTEGER.UNSIGNED,
34
+ allowNull: false,
35
+ defaultValue: 0,
36
+ },
37
+ requestedBy: {
38
+ type: Sequelize.STRING,
39
+ allowNull: true,
40
+ },
41
+ requestedAt: {
42
+ type: Sequelize.DATE,
43
+ allowNull: false,
44
+ },
45
+ artifacts: {
46
+ type: Sequelize.JSON,
47
+ allowNull: true,
48
+ },
49
+ lastError: {
50
+ type: Sequelize.TEXT,
51
+ allowNull: true,
52
+ },
53
+ createdAt: {
54
+ type: Sequelize.DATE,
55
+ allowNull: false,
56
+ },
57
+ updatedAt: {
58
+ type: Sequelize.DATE,
59
+ allowNull: false,
60
+ },
61
+ });
62
+ },
63
+
64
+ down: async queryInterface => {
65
+ await queryInterface.dropTable('IntegrationLifecycles');
66
+ },
67
+ };
@@ -0,0 +1,44 @@
1
+ const Sequelize = require('sequelize');
2
+ const db = require('./db');
3
+
4
+ const IntegrationLifecycle = db.define('IntegrationLifecycle', {
5
+ userId: {
6
+ type: Sequelize.STRING(64),
7
+ primaryKey: true,
8
+ },
9
+ integrationId: {
10
+ type: Sequelize.STRING(100),
11
+ primaryKey: true,
12
+ },
13
+ hint: {
14
+ type: Sequelize.STRING(256),
15
+ primaryKey: true,
16
+ },
17
+ generation: {
18
+ type: Sequelize.INTEGER.UNSIGNED,
19
+ allowNull: false,
20
+ defaultValue: 1,
21
+ },
22
+ status: {
23
+ type: Sequelize.STRING,
24
+ allowNull: false,
25
+ },
26
+ requestId: {
27
+ type: Sequelize.STRING(64),
28
+ allowNull: false,
29
+ },
30
+ retryCount: {
31
+ type: Sequelize.INTEGER.UNSIGNED,
32
+ allowNull: false,
33
+ defaultValue: 0,
34
+ },
35
+ requestedBy: Sequelize.STRING,
36
+ requestedAt: {
37
+ type: Sequelize.DATE,
38
+ allowNull: false,
39
+ },
40
+ artifacts: Sequelize.JSON,
41
+ lastError: Sequelize.TEXT,
42
+ }, {});
43
+
44
+ module.exports = IntegrationLifecycle;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2",
3
- "version": "1.0.124",
3
+ "version": "1.0.125",
4
4
  "description": "Tourist Industry Exchange (TI2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,251 @@
1
+ /* globals afterAll afterEach beforeEach describe expect it jest */
2
+
3
+ jest.mock('axios', () => ({ post: jest.fn() }));
4
+ jest.mock('../../worker/queue', () => ({ removeJob: jest.fn() }));
5
+
6
+ const { v4: uuidv4 } = require('uuid');
7
+ const axios = require('axios');
8
+
9
+ const sqldb = require('../../models');
10
+ const { removeJob } = require('../../worker/queue');
11
+ const { deleteIntegration } = require('../integrationLifecycle');
12
+
13
+ const originalEventsUrl = process.env.ti2_events2url_eventsURL;
14
+ const originalEventsAuthorization = process.env.ti2_events2url_authorization;
15
+
16
+ const deferred = () => {
17
+ let resolve;
18
+ let reject;
19
+ const promise = new Promise((resolvePromise, rejectPromise) => {
20
+ resolve = resolvePromise;
21
+ reject = rejectPromise;
22
+ });
23
+ return { promise, reject, resolve };
24
+ };
25
+
26
+ describe('integrationLifecycle database transitions', () => {
27
+ let userId;
28
+ const hint = 'Shared desk';
29
+
30
+ beforeEach(async () => {
31
+ jest.clearAllMocks();
32
+ process.env.ti2_events2url_eventsURL = 'http://filematch.test/ti2events';
33
+ process.env.ti2_events2url_authorization = 'Bearer service-token';
34
+ userId = `tc-1460-${uuidv4()}`;
35
+ await sqldb.User.create({ userId });
36
+ });
37
+
38
+ afterEach(async () => {
39
+ await sqldb.CronJobs.destroy({ where: { userId } });
40
+ await sqldb.UserAppKey.destroy({ where: { userId } });
41
+ await sqldb.UserIntegrationSettings.destroy({ where: { userId } });
42
+ await sqldb.IntegrationLifecycle.destroy({ where: { userId } });
43
+ await sqldb.User.destroy({ where: { userId } });
44
+ });
45
+
46
+ afterAll(() => {
47
+ if (originalEventsUrl === undefined) {
48
+ delete process.env.ti2_events2url_eventsURL;
49
+ } else {
50
+ process.env.ti2_events2url_eventsURL = originalEventsUrl;
51
+ }
52
+ if (originalEventsAuthorization === undefined) {
53
+ delete process.env.ti2_events2url_authorization;
54
+ } else {
55
+ process.env.ti2_events2url_authorization = originalEventsAuthorization;
56
+ }
57
+ });
58
+
59
+ const createActiveLifecycle = () => sqldb.IntegrationLifecycle.create({
60
+ userId,
61
+ integrationId: 'tourplan',
62
+ hint,
63
+ generation: 1,
64
+ status: 'active',
65
+ requestId: uuidv4(),
66
+ retryCount: 0,
67
+ requestedAt: new Date(),
68
+ });
69
+
70
+ it('returns 404 without lifecycle or catalog work for an unknown hint', async () => {
71
+ await expect(deleteIntegration({
72
+ userId,
73
+ integrationId: 'tourplan',
74
+ hint,
75
+ requestedBy: 'user:test',
76
+ })).rejects.toMatchObject({ status: 404 });
77
+
78
+ expect(axios.post).not.toHaveBeenCalled();
79
+ expect(await sqldb.IntegrationLifecycle.count({ where: { userId } })).toBe(0);
80
+ });
81
+
82
+ it('removes only schedules owned by the deleted integration', async () => {
83
+ await createActiveLifecycle();
84
+ await sqldb.CronJobs.bulkCreate([
85
+ {
86
+ pluginName: 'tourplan',
87
+ pluginJobId: 'dailyReport',
88
+ userId,
89
+ hint,
90
+ bullJobId: 'repeat:tourplan:1',
91
+ cron: '0 9 * * *',
92
+ },
93
+ {
94
+ pluginName: 'sellouts',
95
+ pluginJobId: 'dailyReport',
96
+ userId,
97
+ hint,
98
+ bullJobId: 'repeat:sellouts:1',
99
+ cron: '0 9 * * *',
100
+ },
101
+ ]);
102
+ axios.post.mockResolvedValue({ data: { status: 'deleted' } });
103
+
104
+ await deleteIntegration({
105
+ userId,
106
+ integrationId: 'tourplan',
107
+ hint,
108
+ requestedBy: 'user:test',
109
+ });
110
+
111
+ expect(removeJob).toHaveBeenCalledTimes(1);
112
+ expect(removeJob).toHaveBeenCalledWith('repeat:tourplan:1');
113
+ expect(await sqldb.CronJobs.count({
114
+ where: { userId, hint, pluginName: 'tourplan' },
115
+ })).toBe(0);
116
+ expect(await sqldb.CronJobs.count({
117
+ where: { userId, hint, pluginName: 'sellouts' },
118
+ })).toBe(1);
119
+ });
120
+
121
+ it('does not let a late failure overwrite external cleanup', async () => {
122
+ await createActiveLifecycle();
123
+ const successfulCatalog = deferred();
124
+ const failedCatalog = deferred();
125
+ const successfulCatalogCalled = deferred();
126
+ const failedCatalogCalled = deferred();
127
+ axios.post
128
+ .mockImplementationOnce(() => {
129
+ successfulCatalogCalled.resolve();
130
+ return successfulCatalog.promise;
131
+ })
132
+ .mockImplementationOnce(() => {
133
+ failedCatalogCalled.resolve();
134
+ return failedCatalog.promise;
135
+ });
136
+
137
+ const successfulDeletion = deleteIntegration({
138
+ userId,
139
+ integrationId: 'tourplan',
140
+ hint,
141
+ requestedBy: 'user:first',
142
+ deferCompletion: true,
143
+ });
144
+ await successfulCatalogCalled.promise;
145
+ const failedDeletion = deleteIntegration({
146
+ userId,
147
+ integrationId: 'tourplan',
148
+ hint,
149
+ requestedBy: 'user:second',
150
+ deferCompletion: true,
151
+ });
152
+ await failedCatalogCalled.promise;
153
+
154
+ successfulCatalog.resolve({ data: { status: 'deleted' } });
155
+ await expect(successfulDeletion).resolves.toEqual(expect.objectContaining({
156
+ status: 'external_cleanup',
157
+ }));
158
+ failedCatalog.reject(new Error('catalog unavailable'));
159
+ await expect(failedDeletion).rejects.toMatchObject({ status: 503 });
160
+
161
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
162
+ where: { userId, integrationId: 'tourplan', hint },
163
+ });
164
+ expect(lifecycle.status).toBe('external_cleanup');
165
+ });
166
+
167
+ it('lets a successful concurrent attempt recover a failed attempt', async () => {
168
+ await createActiveLifecycle();
169
+ const failedCatalog = deferred();
170
+ const successfulCatalog = deferred();
171
+ const failedCatalogCalled = deferred();
172
+ const successfulCatalogCalled = deferred();
173
+ axios.post
174
+ .mockImplementationOnce(() => {
175
+ failedCatalogCalled.resolve();
176
+ return failedCatalog.promise;
177
+ })
178
+ .mockImplementationOnce(() => {
179
+ successfulCatalogCalled.resolve();
180
+ return successfulCatalog.promise;
181
+ });
182
+
183
+ const failedDeletion = deleteIntegration({
184
+ userId,
185
+ integrationId: 'tourplan',
186
+ hint,
187
+ requestedBy: 'user:first',
188
+ deferCompletion: true,
189
+ });
190
+ await failedCatalogCalled.promise;
191
+ const successfulDeletion = deleteIntegration({
192
+ userId,
193
+ integrationId: 'tourplan',
194
+ hint,
195
+ requestedBy: 'user:second',
196
+ deferCompletion: true,
197
+ });
198
+ await successfulCatalogCalled.promise;
199
+
200
+ failedCatalog.reject(new Error('catalog unavailable'));
201
+ await expect(failedDeletion).rejects.toMatchObject({ status: 503 });
202
+ successfulCatalog.resolve({ data: { status: 'deleted' } });
203
+ await expect(successfulDeletion).resolves.toEqual(expect.objectContaining({
204
+ status: 'external_cleanup',
205
+ }));
206
+
207
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
208
+ where: { userId, integrationId: 'tourplan', hint },
209
+ });
210
+ expect(lifecycle.status).toBe('external_cleanup');
211
+ });
212
+
213
+ it('reports the persisted winner when concurrent deletes disagree on finalization', async () => {
214
+ await createActiveLifecycle();
215
+ const deferredCatalog = deferred();
216
+ const immediateCatalogCalled = deferred();
217
+ axios.post
218
+ .mockImplementationOnce(() => deferredCatalog.promise)
219
+ .mockImplementationOnce(() => {
220
+ immediateCatalogCalled.resolve();
221
+ return Promise.resolve({ data: { status: 'deleted' } });
222
+ });
223
+
224
+ const deferredDeletion = deleteIntegration({
225
+ userId,
226
+ integrationId: 'tourplan',
227
+ hint,
228
+ requestedBy: 'user:first',
229
+ deferCompletion: true,
230
+ });
231
+ const immediateDeletion = deleteIntegration({
232
+ userId,
233
+ integrationId: 'tourplan',
234
+ hint,
235
+ requestedBy: 'user:second',
236
+ deferCompletion: false,
237
+ });
238
+ await immediateCatalogCalled.promise;
239
+
240
+ const immediateResult = await immediateDeletion;
241
+ deferredCatalog.resolve({ data: { status: 'deleted' } });
242
+ const deferredResult = await deferredDeletion;
243
+ const lifecycle = await sqldb.IntegrationLifecycle.findOne({
244
+ where: { userId, integrationId: 'tourplan', hint },
245
+ });
246
+
247
+ expect(immediateResult.status).toBe('complete');
248
+ expect(deferredResult.status).toBe('complete');
249
+ expect(lifecycle.status).toBe('complete');
250
+ });
251
+ });