@truedat/dd 8.13.5 → 8.13.7

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 (38) hide show
  1. package/package.json +3 -3
  2. package/src/components/GraphGenerationDialog.js +125 -0
  3. package/src/components/GraphGenerationNotices.js +67 -0
  4. package/src/components/GraphLoader.js +12 -17
  5. package/src/components/GraphRedirector.js +4 -4
  6. package/src/components/LineageGraph.js +38 -49
  7. package/src/components/LineageRoutes.js +20 -13
  8. package/src/components/StructureNotesEdit.js +8 -2
  9. package/src/components/SystemForm.js +10 -1
  10. package/src/components/__tests__/GraphGenerationDialog.spec.js +117 -0
  11. package/src/components/__tests__/GraphGenerationNotices.spec.js +60 -0
  12. package/src/components/__tests__/GraphGenerationTranslations.spec.js +74 -0
  13. package/src/components/__tests__/GraphLoader.spec.js +83 -0
  14. package/src/components/__tests__/LineageGraphInteraction.spec.js +78 -7
  15. package/src/components/__tests__/StructureNotesEdit.spec.js +105 -1
  16. package/src/components/__tests__/SystemForm.dateRestrictions.spec.js +102 -0
  17. package/src/components/__tests__/__snapshots__/LineageRoutes.spec.js.snap +12 -1
  18. package/src/components/__tests__/fixtures/graphGenerationMessages.json +28 -0
  19. package/src/messages/en.js +0 -29
  20. package/src/messages/es.js +0 -30
  21. package/src/reducers/__tests__/graphGenerationState.spec.js +113 -0
  22. package/src/reducers/__tests__/structureNoteErrorMessage.spec.js +78 -0
  23. package/src/reducers/ddMessage.js +16 -1
  24. package/src/reducers/graphGenerationState.js +113 -0
  25. package/src/reducers/graphLoading.js +2 -0
  26. package/src/reducers/index.js +2 -0
  27. package/src/routines.js +3 -0
  28. package/src/sagas/__tests__/createGraph.spec.js +57 -122
  29. package/src/sagas/__tests__/fetchGraph.spec.js +19 -0
  30. package/src/sagas/createGraph.js +13 -65
  31. package/src/sagas/fetchGraph.js +6 -38
  32. package/src/sagas/graph/__tests__/backgroundGraphs.spec.js +22 -0
  33. package/src/sagas/graph/__tests__/generation.spec.js +247 -0
  34. package/src/sagas/graph/__tests__/polling.spec.js +88 -0
  35. package/src/sagas/graph/generation.js +99 -0
  36. package/src/sagas/graph/polling.js +26 -35
  37. package/src/sagas/index.js +2 -0
  38. package/src/styles/LineageGraph.less +117 -0
@@ -1,20 +1,15 @@
1
1
  import { compile } from "path-to-regexp";
2
- import { takeLatest } from "redux-saga/effects";
2
+ import { takeEvery } from "redux-saga/effects";
3
3
  import { testSaga } from "redux-saga-test-plan";
4
4
  import { apiJson, apiJsonPost, JSON_OPTS } from "@truedat/core/services/api";
5
5
  import {
6
6
  createGraphRequestSaga,
7
7
  createGraphSaga,
8
- POLLING_CANCELLED,
9
8
  selectLineageQuery,
10
9
  } from "../createGraph";
10
+ import { generateGraph } from "../graph/generation";
11
11
  import { polling } from "../graph/polling";
12
- import {
13
- cancelPoll,
14
- createGraph,
15
- fetchGraph,
16
- excludeNode,
17
- } from "../../routines";
12
+ import { createGraph, excludeNode } from "../../routines";
18
13
  import { API_GRAPHS, API_GRAPH_HASH } from "../../api";
19
14
 
20
15
  const graphAcceptedData = {
@@ -50,8 +45,8 @@ describe("sagas: createGraphRequestSaga", () => {
50
45
  testSaga(createGraphRequestSaga)
51
46
  .next()
52
47
  .all([
53
- takeLatest(createGraph.TRIGGER, createGraphSaga),
54
- takeLatest(excludeNode.TRIGGER, createGraphSaga),
48
+ takeEvery(createGraph.TRIGGER, createGraphSaga),
49
+ takeEvery(excludeNode.TRIGGER, createGraphSaga),
55
50
  ])
56
51
  .finish()
57
52
  .isDone();
@@ -68,7 +63,7 @@ describe("sagas: createGraphRequestSaga", () => {
68
63
  });
69
64
 
70
65
  describe("sagas: createGraphSaga", () => {
71
- const isRedirected = false;
66
+ const isRedirected = true;
72
67
 
73
68
  const query = {
74
69
  ids: ["BASILEAII_HIST_PROA_MOROSIDAD [ 2 ] [ 1 ].SOBREGIROCAPITAL"],
@@ -77,74 +72,19 @@ describe("sagas: createGraphSaga", () => {
77
72
  isRedirected,
78
73
  };
79
74
 
80
- const graphAccepted = {
81
- data: graphAcceptedData,
82
- status: 202,
83
- };
84
-
85
- const graphCreated = {
86
- data: { ...graphData, isRedirected },
87
- status: 201,
88
- };
89
-
90
- it("should put a success action when uuids are requested and a 201 (created) graph data response is returned", () => {
91
- expect(() => {
92
- testSaga(createGraphSaga)
93
- .next()
94
- .select(selectLineageQuery)
95
- .next(query)
96
- .put(createGraph.request(query))
97
- .next()
98
- .call(apiJsonPost, API_GRAPHS, query, JSON_OPTS)
99
- .next(graphCreated)
100
- .put(createGraph.success(graphCreated))
101
- .next()
102
- .put(createGraph.fulfill())
103
- .next()
104
- .isDone();
105
- }).not.toThrow();
106
- });
107
-
108
- it("should fork a polling task when uuids are requested and a 202 (accepted) graph hash response is returned. Then, wait for task cancellation.", () => {
109
- expect(() => {
110
- testSaga(createGraphSaga)
111
- .next()
112
- .select(selectLineageQuery)
113
- .next(query)
114
- .put(createGraph.request(query))
115
- .next()
116
- .call(apiJsonPost, API_GRAPHS, query, JSON_OPTS)
117
- .next(graphAccepted)
118
- .put(fetchGraph.request(graphAccepted))
119
- .next()
120
- .fork(polling, createGraph, graphAccepted, isRedirected)
121
- .next()
122
- .take(cancelPoll.TRIGGER)
123
- .next()
124
- .cancel()
125
- .next()
126
- .put(createGraph.fulfill())
127
- .next()
128
- .isDone();
129
- }).not.toThrow();
130
- });
131
-
132
- it("should put a failure action when the call returns an error", () => {
133
- const message = "Request failed";
134
- const error = { message };
135
-
75
+ it("should pass the query and redirect intent to the shared graph generation", () => {
136
76
  expect(() => {
137
77
  testSaga(createGraphSaga)
138
78
  .next()
139
79
  .select(selectLineageQuery)
140
80
  .next(query)
141
- .put(createGraph.request(query))
142
- .next()
143
- .call(apiJsonPost, API_GRAPHS, query, JSON_OPTS)
144
- .throw(error)
145
- .put(createGraph.failure(message))
146
- .next()
147
- .put(createGraph.fulfill())
81
+ .call(
82
+ generateGraph,
83
+ createGraph,
84
+ apiJsonPost,
85
+ [API_GRAPHS, query, JSON_OPTS],
86
+ isRedirected,
87
+ )
148
88
  .next()
149
89
  .isDone();
150
90
  }).not.toThrow();
@@ -152,8 +92,6 @@ describe("sagas: createGraphSaga", () => {
152
92
  });
153
93
 
154
94
  describe("generator: polling", () => {
155
- const isRedirected = false;
156
-
157
95
  const graphAccepted = {
158
96
  data: graphAcceptedData,
159
97
  status: 202,
@@ -165,67 +103,64 @@ describe("generator: polling", () => {
165
103
  };
166
104
 
167
105
  const graphCompleted = {
168
- data: { ...graphData, isRedirected },
106
+ data: graphData,
169
107
  status: 200,
170
108
  };
171
109
 
110
+ const url = compile(API_GRAPH_HASH)({ hash: graphAccepted.data.graph_hash });
111
+ const progressFor = (retry) => ({
112
+ ...graphPending,
113
+ data: {
114
+ ...graphPending.data,
115
+ graph_hash: graphAccepted.data.graph_hash,
116
+ retry,
117
+ },
118
+ });
119
+
172
120
  it("polling should keep requesting by graph hash until graph is received", () => {
121
+ const onProgress = jest.fn();
122
+
173
123
  expect(() => {
174
- testSaga(polling, createGraph, graphAccepted, isRedirected)
124
+ testSaga(polling, graphAccepted, onProgress)
175
125
  .next()
176
126
  .delay(1000)
177
127
  .next()
178
- .call(
179
- apiJson,
180
- compile(API_GRAPH_HASH)({ hash: graphAccepted.data.graph_hash }),
181
- JSON_OPTS,
182
- )
128
+ .call(apiJson, url, JSON_OPTS)
183
129
  .next(graphPending)
184
- .put(
185
- fetchGraph.failure({
186
- data: { graph_hash: graphAccepted.data.graph_hash, retry: 1 },
187
- }),
188
- )
130
+ .call(onProgress, progressFor(1))
189
131
  .next()
190
- .cancelled()
191
- .next(false)
192
132
  .delay(1200)
193
133
  .next()
194
- .call(
195
- apiJson,
196
- compile(API_GRAPH_HASH)({ hash: graphAccepted.data.graph_hash }),
197
- JSON_OPTS,
198
- )
199
- .next(graphCompleted)
200
- .cancelled()
201
- .next(false)
202
- .put(createGraph.success(graphCompleted))
134
+ .call(apiJson, url, JSON_OPTS)
135
+ .next(graphPending)
136
+ .call(onProgress, progressFor(2))
203
137
  .next()
204
- .isDone();
138
+ .delay(1440)
139
+ .next()
140
+ .call(apiJson, url, JSON_OPTS)
141
+ .next(graphCompleted)
142
+ .returns(graphCompleted);
205
143
  }).not.toThrow();
206
144
  });
207
145
 
208
- it("cancel should stop polling and return createGraph failure", () => {
209
- expect(() => {
210
- testSaga(polling, createGraph, graphAccepted, isRedirected)
211
- .next()
212
- .delay(1000)
213
- .next()
214
- .call(
215
- apiJson,
216
- compile(API_GRAPH_HASH)({ hash: graphAccepted.data.graph_hash }),
217
- JSON_OPTS,
218
- )
219
- .next(graphPending)
220
- .put(
221
- fetchGraph.failure({
222
- data: { graph_hash: graphAccepted.data.graph_hash, retry: 1 },
223
- }),
224
- )
225
- .next()
226
- .cancelled()
227
- .next(true)
228
- .put(createGraph.failure(POLLING_CANCELLED));
229
- }).not.toThrow();
146
+ it("retries a network error without reporting the previous response as new progress", () => {
147
+ const onProgress = jest.fn();
148
+ testSaga(polling, graphAccepted, onProgress)
149
+ .next()
150
+ .delay(1000)
151
+ .next()
152
+ .call(apiJson, url, JSON_OPTS)
153
+ .next(graphPending)
154
+ .call(onProgress, progressFor(1))
155
+ .next()
156
+ .delay(1200)
157
+ .next()
158
+ .call(apiJson, url, JSON_OPTS)
159
+ .throw(new Error("offline"))
160
+ .delay(1440)
161
+ .next()
162
+ .call(apiJson, url, JSON_OPTS)
163
+ .next(graphCompleted)
164
+ .returns(graphCompleted);
230
165
  });
231
166
  });
@@ -0,0 +1,19 @@
1
+ import { testSaga } from "redux-saga-test-plan";
2
+ import { apiJson, JSON_OPTS } from "@truedat/core/services/api";
3
+ import { compile } from "path-to-regexp";
4
+ import { fetchGraph } from "../../routines";
5
+ import { API_GRAPH } from "../../api";
6
+ import { fetchGraphSaga } from "../fetchGraph";
7
+ import { generateGraph } from "../graph/generation";
8
+
9
+ test("loading a saved graph uses the shared generation lifecycle", () => {
10
+ const payload = { id: "42" };
11
+ testSaga(fetchGraphSaga, { payload })
12
+ .next()
13
+ .call(generateGraph, fetchGraph, apiJson, [
14
+ compile(API_GRAPH)(payload),
15
+ JSON_OPTS,
16
+ ])
17
+ .next()
18
+ .isDone();
19
+ });
@@ -1,78 +1,26 @@
1
1
  import _ from "lodash/fp";
2
- import {
3
- all,
4
- call,
5
- cancel,
6
- fork,
7
- put,
8
- select,
9
- take,
10
- takeLatest,
11
- } from "redux-saga/effects";
2
+ import { all, call, select, takeEvery } from "redux-saga/effects";
12
3
  import { apiJsonPost, JSON_OPTS } from "@truedat/core/services/api";
13
- import { excludeNode, cancelPoll, createGraph, fetchGraph } from "../routines";
4
+ import { excludeNode, createGraph } from "../routines";
14
5
  import { API_GRAPHS } from "../api";
15
- import { polling } from "./graph/polling";
6
+ import { generateGraph } from "./graph/generation";
16
7
 
17
8
  export const selectLineageQuery = _.prop("lineageQuery");
18
9
 
19
- export const POLLING_CANCELLED = "Polling cancelled!";
20
-
21
10
  export function* createGraphSaga() {
22
- try {
23
- const body = yield select(selectLineageQuery);
24
- yield put(createGraph.request(body));
25
-
26
- /* graph is either new (accepted) or "created" (actually previously stored
27
- from an earlier accepted one) */
28
- const graphAcceptedOrCreated = yield call(
29
- apiJsonPost,
30
- API_GRAPHS,
31
- body,
32
- JSON_OPTS,
33
- );
34
-
35
- if (graphAcceptedOrCreated.status === 202) {
36
- yield put(fetchGraph.request(graphAcceptedOrCreated));
37
- // starts the task in the background
38
- const pollingTask = yield fork(
39
- polling,
40
- createGraph,
41
- graphAcceptedOrCreated,
42
- body.isRedirected,
43
- );
44
-
45
- // wait for the user stop action
46
- yield take(cancelPoll.TRIGGER);
47
- // user clicked stop. cancel the background task
48
- // this will cause the forked bgSync task to jump into its finally block
49
- yield cancel(pollingTask);
50
- } else if (graphAcceptedOrCreated.status === 201) {
51
- yield put(
52
- createGraph.success({
53
- ...graphAcceptedOrCreated,
54
- data: {
55
- ...graphAcceptedOrCreated.data,
56
- isRedirected: body.isRedirected,
57
- },
58
- }),
59
- );
60
- }
61
- } catch (error) {
62
- if (error.response) {
63
- const { status, data } = error.response;
64
- yield put(createGraph.failure({ status, data }));
65
- } else {
66
- yield put(createGraph.failure(error.message));
67
- }
68
- } finally {
69
- yield put(createGraph.fulfill());
70
- }
11
+ const body = yield select(selectLineageQuery);
12
+ yield call(
13
+ generateGraph,
14
+ createGraph,
15
+ apiJsonPost,
16
+ [API_GRAPHS, body, JSON_OPTS],
17
+ body.isRedirected,
18
+ );
71
19
  }
72
20
 
73
21
  export function* createGraphRequestSaga() {
74
22
  yield all([
75
- takeLatest(createGraph.TRIGGER, createGraphSaga),
76
- takeLatest(excludeNode.TRIGGER, createGraphSaga),
23
+ takeEvery(createGraph.TRIGGER, createGraphSaga),
24
+ takeEvery(excludeNode.TRIGGER, createGraphSaga),
77
25
  ]);
78
26
  }
@@ -1,52 +1,20 @@
1
1
  import _ from "lodash/fp";
2
2
  import { compile } from "path-to-regexp";
3
- import { call, cancel, fork, put, take, takeLatest } from "redux-saga/effects";
3
+ import { call, takeEvery } from "redux-saga/effects";
4
4
  import { apiJson, JSON_OPTS } from "@truedat/core/services/api";
5
5
  import { API_GRAPH } from "../api";
6
- import { cancelPoll, fetchGraph } from "../routines";
7
- import { polling } from "./graph/polling";
6
+ import { fetchGraph } from "../routines";
7
+ import { generateGraph } from "./graph/generation";
8
8
 
9
9
  const toApiPath = compile(API_GRAPH);
10
10
 
11
11
  export const selectLineageQuery = _.prop("lineageQuery");
12
12
 
13
13
  export function* fetchGraphSaga({ payload }) {
14
- try {
15
- const url = toApiPath(payload);
16
- yield put(fetchGraph.request(payload));
17
-
18
- /* graph is either new (202 accepted) or 200 OK*/
19
- const graphAcceptedOrFetched = yield call(apiJson, url, JSON_OPTS);
20
-
21
- if (graphAcceptedOrFetched.status === 202) {
22
- yield put(fetchGraph.request(graphAcceptedOrFetched));
23
- // starts the task in the background
24
- const pollingTask = yield fork(
25
- polling,
26
- fetchGraph,
27
- graphAcceptedOrFetched,
28
- );
29
-
30
- // wait for the user stop action
31
- yield take(cancelPoll.TRIGGER);
32
- // user clicked stop. cancel the background task
33
- // this will cause the forked bgSync task to jump into its finally block
34
- yield cancel(pollingTask);
35
- } else if (graphAcceptedOrFetched.status === 200) {
36
- yield put(yield put(fetchGraph.success(graphAcceptedOrFetched.data)));
37
- }
38
- } catch (error) {
39
- if (error.response) {
40
- const { status, data } = error.response;
41
- yield put(fetchGraph.failure({ status, data }));
42
- } else {
43
- yield put(fetchGraph.failure(error.message));
44
- }
45
- } finally {
46
- yield put(fetchGraph.fulfill());
47
- }
14
+ const url = toApiPath(payload);
15
+ yield call(generateGraph, fetchGraph, apiJson, [url, JSON_OPTS]);
48
16
  }
49
17
 
50
18
  export function* fetchGraphRequestSaga() {
51
- yield takeLatest(fetchGraph.TRIGGER, fetchGraphSaga);
19
+ yield takeEvery(fetchGraph.TRIGGER, fetchGraphSaga);
52
20
  }
@@ -0,0 +1,22 @@
1
+ import { testSaga } from "redux-saga-test-plan";
2
+ import { watchBackgroundGraphs, selectGeneration } from "../generation";
3
+ import {
4
+ backgroundGraph,
5
+ clearGraph,
6
+ graphGeneration,
7
+ } from "../../../routines";
8
+
9
+ test("does not keep a timer running when no background graph is pending", () => {
10
+ const actions = [
11
+ backgroundGraph.TRIGGER,
12
+ clearGraph.TRIGGER,
13
+ graphGeneration.REQUEST,
14
+ ];
15
+ testSaga(watchBackgroundGraphs)
16
+ .next()
17
+ .take(actions)
18
+ .next()
19
+ .select(selectGeneration)
20
+ .next({ jobs: {} })
21
+ .take(actions);
22
+ });
@@ -0,0 +1,247 @@
1
+ import { apiJson } from "@truedat/core/services/api";
2
+ import { runSaga, stdChannel } from "redux-saga";
3
+ import { graphLoading } from "../../../reducers/graphLoading";
4
+ import { graphGenerationState } from "../../../reducers/graphGenerationState";
5
+ import { generateGraph } from "../generation";
6
+ import {
7
+ backgroundGraph,
8
+ createGraph,
9
+ fetchGraph,
10
+ fetchLineageEvents,
11
+ } from "../../../routines";
12
+
13
+ const run = (api) => {
14
+ const actions = [];
15
+ let state = { graphGenerationState: undefined, graphLoading: false };
16
+ const dispatch = (action) => {
17
+ actions.push(action);
18
+ state = {
19
+ graphGenerationState: graphGenerationState(
20
+ state.graphGenerationState,
21
+ action,
22
+ ),
23
+ graphLoading: graphLoading(state.graphLoading, action),
24
+ };
25
+ };
26
+ const task = runSaga(
27
+ { dispatch, getState: () => state },
28
+ generateGraph,
29
+ createGraph,
30
+ api,
31
+ ["/graphs", {}],
32
+ true,
33
+ );
34
+ return { task, actions, dispatch, getState: () => state };
35
+ };
36
+
37
+ test("progress is loading, not failure", () => {
38
+ const progress = { data: { graph_hash: "a", retry: 2 } };
39
+ expect(graphLoading(true, fetchGraph.request(progress))).toEqual(progress);
40
+ });
41
+
42
+ test("close before the response keeps the graph in background", async () => {
43
+ let resolve;
44
+ const api = () =>
45
+ new Promise((done) => {
46
+ resolve = done;
47
+ });
48
+ const job = run(api);
49
+ job.dispatch(backgroundGraph());
50
+ resolve({ status: 201, data: { id: 42 } });
51
+ await job.task.toPromise();
52
+ expect(
53
+ job.actions.some((action) => action.type === createGraph.SUCCESS),
54
+ ).toBe(false);
55
+ expect(job.getState().graphLoading).toBe(false);
56
+ job.dispatch(
57
+ fetchLineageEvents.success({
58
+ data: [{ graph_id: 42, status: "COMPLETED" }],
59
+ }),
60
+ );
61
+ expect(
62
+ Object.values(job.getState().graphGenerationState.jobs)[0].status,
63
+ ).toBe("completed");
64
+ });
65
+
66
+ test("a foreground success publishes the graph", async () => {
67
+ const job = run(async () => ({ status: 201, data: { id: 42 } }));
68
+ await job.task.toPromise();
69
+ expect(
70
+ job.actions.find((action) => action.type === createGraph.SUCCESS).payload
71
+ .data,
72
+ ).toEqual({ id: 42, isRedirected: true });
73
+ expect(job.getState().graphLoading).toBe(false);
74
+ });
75
+
76
+ test("network failure is explicit and ends loading", async () => {
77
+ const job = run(async () => {
78
+ throw new Error("network");
79
+ });
80
+ await job.task.toPromise();
81
+ expect(
82
+ Object.values(job.getState().graphGenerationState.jobs)[0].status,
83
+ ).toBe("failed");
84
+ expect(
85
+ job.actions.filter((action) => action.type === createGraph.FAILURE),
86
+ ).toHaveLength(1);
87
+ expect(job.getState().graphLoading).toBe(false);
88
+ });
89
+
90
+ test("normalizes the GET graph envelope before publishing", async () => {
91
+ const job = run(async () => ({
92
+ status: 200,
93
+ data: { data: { id: 42, resources: [] } },
94
+ }));
95
+ await job.task.toPromise();
96
+ expect(
97
+ job.actions.find((action) => action.type === createGraph.SUCCESS).payload
98
+ .data.id,
99
+ ).toBe(42);
100
+ });
101
+
102
+ test("a late response cannot replace or finish the newer foreground request", async () => {
103
+ const actions = [];
104
+ let state = { graphGenerationState: undefined, graphLoading: false };
105
+ const dispatch = (action) => {
106
+ actions.push(action);
107
+ state = {
108
+ graphGenerationState: graphGenerationState(
109
+ state.graphGenerationState,
110
+ action,
111
+ ),
112
+ graphLoading: graphLoading(state.graphLoading, action),
113
+ };
114
+ };
115
+ let firstDone;
116
+ let secondDone;
117
+ const environment = { dispatch, getState: () => state };
118
+ const first = runSaga(
119
+ environment,
120
+ generateGraph,
121
+ createGraph,
122
+ () =>
123
+ new Promise((resolve) => {
124
+ firstDone = resolve;
125
+ }),
126
+ ["/graphs", {}],
127
+ );
128
+ const second = runSaga(
129
+ environment,
130
+ generateGraph,
131
+ createGraph,
132
+ () =>
133
+ new Promise((resolve) => {
134
+ secondDone = resolve;
135
+ }),
136
+ ["/graphs", {}],
137
+ );
138
+ firstDone({ status: 201, data: { id: 1 } });
139
+ await first.toPromise();
140
+ expect(state.graphLoading).toBeTruthy();
141
+ expect(actions.some((action) => action.type === createGraph.SUCCESS)).toBe(
142
+ false,
143
+ );
144
+ secondDone({ status: 201, data: { id: 2 } });
145
+ await second.toPromise();
146
+ expect(
147
+ actions
148
+ .filter((action) => action.type === createGraph.SUCCESS)
149
+ .map((action) => action.payload.data.id),
150
+ ).toEqual([2]);
151
+ expect(state.graphLoading).toBe(false);
152
+ });
153
+
154
+ test.each(["COMPLETED", "FAILED"])(
155
+ "stops polling on a %s event without publishing or failing the active graph",
156
+ async (status) => {
157
+ jest.useFakeTimers();
158
+ const channel = stdChannel();
159
+ const actions = [];
160
+ let state = { graphGenerationState: undefined, graphLoading: false };
161
+ const dispatch = (action) => {
162
+ actions.push(action);
163
+ state = {
164
+ graphGenerationState: graphGenerationState(
165
+ state.graphGenerationState,
166
+ action,
167
+ ),
168
+ graphLoading: graphLoading(state.graphLoading, action),
169
+ };
170
+ channel.put(action);
171
+ };
172
+ const api = jest.fn(async () => ({
173
+ status: 202,
174
+ data: { graph_hash: "a" },
175
+ }));
176
+ const task = runSaga(
177
+ { channel, dispatch, getState: () => state },
178
+ generateGraph,
179
+ createGraph,
180
+ api,
181
+ [],
182
+ );
183
+ try {
184
+ await Promise.resolve();
185
+ await Promise.resolve();
186
+ dispatch(backgroundGraph());
187
+ dispatch(
188
+ fetchLineageEvents.success({
189
+ data: [{ graph_hash: "a", graph_id: 5, status }],
190
+ }),
191
+ );
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ expect(task.isRunning()).toBe(false);
195
+ expect(jest.getTimerCount()).toBe(0);
196
+ expect(
197
+ actions.some(({ type }) =>
198
+ [createGraph.SUCCESS, createGraph.FAILURE].includes(type),
199
+ ),
200
+ ).toBe(false);
201
+ expect(Object.values(state.graphGenerationState.jobs)[0].status).toBe(
202
+ status === "COMPLETED" ? "completed" : "failed",
203
+ );
204
+ } finally {
205
+ task.cancel();
206
+ jest.useRealTimers();
207
+ }
208
+ },
209
+ );
210
+
211
+ jest.mock("@truedat/core/services/api", () => ({
212
+ apiJson: jest.fn(),
213
+ JSON_OPTS: {},
214
+ }));
215
+
216
+ test.each([false, true])(
217
+ "transient polling errors preserve the job (background=%s)",
218
+ async (background) => {
219
+ jest.useFakeTimers();
220
+ apiJson.mockReset();
221
+ apiJson
222
+ .mockRejectedValueOnce({ response: { status: 503 } })
223
+ .mockResolvedValueOnce({ status: 200, data: { id: 42 } });
224
+ const job = run(async () => ({ status: 202, data: { graph_hash: "a" } }));
225
+ try {
226
+ if (background) job.dispatch(backgroundGraph());
227
+ await jest.advanceTimersByTimeAsync(1000);
228
+ expect(
229
+ Object.values(job.getState().graphGenerationState.jobs)[0].status,
230
+ ).toBe("pending");
231
+ expect(job.actions.some(({ type }) => type === createGraph.FAILURE)).toBe(
232
+ false,
233
+ );
234
+ await jest.runAllTimersAsync();
235
+ await job.task.toPromise();
236
+ expect(
237
+ Object.values(job.getState().graphGenerationState.jobs)[0].status,
238
+ ).toBe(background ? "awaitingEvent" : "completed");
239
+ expect(job.actions.some(({ type }) => type === createGraph.SUCCESS)).toBe(
240
+ !background,
241
+ );
242
+ } finally {
243
+ job.task.cancel();
244
+ jest.useRealTimers();
245
+ }
246
+ },
247
+ );