From e15d31015ba771d473deb6ad4228762d8609f877 Mon Sep 17 00:00:00 2001 From: Aaron S Date: Thu, 14 Dec 2023 16:49:49 -0600 Subject: [PATCH 1/7] refactor(datastore): Refactor conflict resolution tests towards readability --- .../conflictResolutionBehavior.test.ts | 1123 ++++------------- .../__tests__/connectivityHandling.test.ts | 1 - .../helpers/UpdateSequenceHarness.ts | 266 ++++ .../__tests__/helpers/datastoreFactory.ts | 4 + .../__tests__/helpers/fakes/graphqlService.ts | 17 + packages/datastore/__tests__/helpers/util.ts | 100 +- 6 files changed, 601 insertions(+), 910 deletions(-) create mode 100644 packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts diff --git a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts index 56496551733..c9da2f22301 100644 --- a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts +++ b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts @@ -1,76 +1,20 @@ -import { - warpTime, - unwarpTime, - pause, - getDataStore, - waitForEmptyOutbox, - graphqlServiceSettled, -} from './helpers'; +import { warpTime, unwarpTime, pause } from './helpers'; +import { UpdateSequenceHarness } from './helpers/UpdateSequenceHarness'; describe('DataStore sync engine', () => { - // establish types :) - let { - DataStore, - errorHandler, - schema, - connectivityMonitor, - Model, - ModelWithExplicitOwner, - ModelWithExplicitCustomOwner, - ModelWithMultipleCustomOwner, - BasicModel, - BasicModelWritableTS, - LegacyJSONPost, - LegacyJSONComment, - Post, - Comment, - HasOneParent, - HasOneChild, - CompositePKParent, - CompositePKChild, - graphqlService, - simulateConnect, - simulateDisconnect, - simulateDisruption, - simulateDisruptionEnd, - } = getDataStore({ online: true, isNode: false }); + let harness: UpdateSequenceHarness; beforeEach(async () => { // we don't need to see all the console warnings for these tests ... (console as any)._warn = console.warn; console.warn = () => {}; - ({ - DataStore, - errorHandler, - schema, - connectivityMonitor, - Model, - ModelWithExplicitOwner, - ModelWithExplicitCustomOwner, - ModelWithMultipleCustomOwner, - BasicModel, - BasicModelWritableTS, - LegacyJSONPost, - LegacyJSONComment, - Post, - Comment, - Model, - HasOneParent, - HasOneChild, - CompositePKParent, - CompositePKChild, - graphqlService, - simulateConnect, - simulateDisconnect, - simulateDisruption, - simulateDisruptionEnd, - } = getDataStore({ online: true, isNode: false })); - await DataStore.start(); + harness = new UpdateSequenceHarness(); + await harness.startDatastore(); }); afterEach(async () => { - await DataStore.clear(); + await harness.destroy(); console.warn = (console as any)._warn; }); describe('connection state change handling', () => { @@ -81,7 +25,6 @@ describe('DataStore sync engine', () => { afterEach(async () => { unwarpTime(); }); - /** * NOTE: * The following test assertions are based on *existing* behavior, not *correct* @@ -121,218 +64,24 @@ describe('DataStore sync engine', () => { * the outbox. */ describe('observed rapid single-field mutations with variable connection latencies', () => { - // Number of primary client updates: - const numberOfUpdates = 3; - - // Incremented after each update: - let expectedNumberOfUpdates = 0; - - // Tuple of updated `title` and `_version`: - type SubscriptionLogTuple = [string, number]; - - // updated `title`, `blogId`, and `_version`: - type SubscriptionLogMultiField = [string, string, number]; - - /** - * Since we're essentially testing race conditions, we want to test the outbox logic - * exactly the same each time the tests are run. Minor fluctuations in test runs can - * cause different outbox behavior, so we set jitter to `0`. - */ - const jitter: number = 0; - const latency: number = 1000; - - /** - * Simulate a second client updating the original post - * @param originalPostId id of the post to update - * @param updatedFields field(s) to update - * @param version version number to be sent with the request (what would have been - * returned from a query prior to update) - */ - type ExternalPostUpdateParams = { - originalPostId: string; - updatedFields: Partial; - version: number | undefined; - }; - - const externalPostUpdate = async ({ - originalPostId, - updatedFields, - version, - }: ExternalPostUpdateParams) => { - await graphqlService.externalGraphql( - { - query: ` - mutation operation($input: UpdatePostInput!, $condition: ModelPostConditionInput) { - updatePost(input: $input, condition: $condition) { - id - title - blogId - updatedAt - createdAt - _version - _lastChangedAt - _deleted - } - } - `, - variables: { - input: { - id: originalPostId, - ...updatedFields, - _version: version, - }, - condition: null, - }, - authMode: undefined, - authToken: undefined, - }, - // For now we always ignore latency for external mutations. This could be a param if needed. - true - ); - }; - - /** - * Query post, update, then increment counter. - * @param postId - id of the post to update - * @param updatedTitle - title to update the post with - */ - const revPost = async (postId: string, updatedTitle: string) => { - const retrieved = await DataStore.query(Post, postId); - - await DataStore.save( - // @ts-ignore - Post.copyOf(retrieved, updated => { - updated.title = updatedTitle; - }) - ); - - expectedNumberOfUpdates++; - }; - - /** - * @param postId `id` of the record that was updated - * @param version expected final `_version` of the record after all updates are complete - * @param title expected final `title` of the record after all updates are complete - * @param blogId expected final `blogId` of the record after all updates are complete - */ - type FinalAssertionParams = { - postId: string; - version: number; - title: string; - blogId?: string | null; - }; - - const expectFinalRecordsToMatch = async ({ - postId, - version, - title, - blogId = undefined, - }: FinalAssertionParams) => { - // Validate that the record was saved to the service: - const table = graphqlService.tables.get('Post')!; - expect(table.size).toEqual(1); - - // Validate that the title was updated successfully: - const savedItem = table.get(JSON.stringify([postId])) as any; - expect(savedItem.title).toEqual(title); - - if (blogId) expect(savedItem.blogId).toEqual(blogId); - - // Validate that the `_version` was incremented correctly: - expect(savedItem._version).toEqual(version); - - // Validate that `query` returns the latest `title` and `_version`: - const queryResult = await DataStore.query(Post, postId); - expect(queryResult?.title).toEqual(title); - // @ts-ignore - expect(queryResult?._version).toEqual(version); - - if (blogId) expect(queryResult?.blogId).toEqual(blogId); - }; - describe('single client updates', () => { - /** - * All observed updates. Also includes "updates" from initial record creation, - * since we start the subscription in the `beforeEach` block. - */ - let subscriptionLog: SubscriptionLogTuple[] = []; - - beforeEach(async () => { - await DataStore.observe(Post).subscribe(({ opType, element }) => { - if (opType === 'UPDATE') { - const response: SubscriptionLogTuple = [ - element.title, - // No, TypeScript, there is a version: - // @ts-ignore - element._version, - ]; - // Track sequence of versions and titles - subscriptionLog.push(response); - } - }); - }); - - afterEach(async () => { - expectedNumberOfUpdates = 0; - subscriptionLog = []; - }); - test('rapid mutations on poor connection when initial create is not pending', async () => { - // Record to update: - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - /** - * Make sure the save was successfully sent out. Here `_version` IS defined. - */ - await waitForEmptyOutbox(); - - /** - * Note: Running this test without increased latencies will still fail, - * however, the `expectedNumberOfUpdates` received by the fake service - * will be different (here they are merged in the outbox). See the - * tests following this one. - */ - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'slow'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', }); - + await harness.outboxSettled(); // Note: We do NOT wait for the outbox to be empty here, because // we want to test concurrent updates being processed by the outbox. + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); + await postHarness.revise('post title 2'); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(2); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await revPost(original.id, 'post title 1'); - await revPost(original.id, 'post title 2'); - //endregion - - // Now we wait for the outbox to do what it needs to do: - await waitForEmptyOutbox(); - - /** - * Because we have increased the latency, and don't wait for the outbox - * to clear on each mutation, the outbox will merge some of the mutations. - * In this example, we expect the number of requests received to be one less than - * the actual number of updates. If we were running this test without - * increased latency, we'd expect more requests to be received. - */ - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: expectedNumberOfUpdates - 1, - externalNumberOfUpdates: 0, - modelName: 'Post', - }); - - // Validate that `observe` returned the expected updates to - // `title` and `version`, in the expected order: - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 1', 1], @@ -340,60 +89,30 @@ describe('DataStore sync engine', () => { ['post title 0', 3], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 3, title: 'post title 0', }); }); test('rapid mutations on fast connection when initial create is not pending', async () => { - // Record to update: - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - /** - * Make sure the save was successfully sent out. Here, `_version` is defined. - */ - await waitForEmptyOutbox(); + harness.connectionSpeed = 'fast'; + harness.latency = 'low'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', + }); + await harness.outboxSettled(); // Note: We do NOT wait for the outbox to be empty here, because // we want to test concurrent updates being processed by the outbox. + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); + await postHarness.revise('post title 2'); - //region perform consecutive updates - await pause(200); - await revPost(original.id, 'post title 0'); - - await pause(200); - await revPost(original.id, 'post title 1'); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(3); - await pause(200); - await revPost(original.id, 'post title 2'); - //endregion - - // Now we wait for the outbox to do what it needs to do: - await waitForEmptyOutbox(); - - /** - * Because we have NOT increased the latency, the outbox will not merge - * the mutations. In this example, we expect the number of requests - * received to be the same as the number of updates. If we were - * running this test with increased latency, we'd expect less requests - * to be received. - */ - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 0, - modelName: 'Post', - }); - - // Validate that `observe` returned the expected updates to - // `title` and `version`, in the expected order: - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 1', 1], @@ -401,157 +120,77 @@ describe('DataStore sync engine', () => { ['post title 0', 4], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 4, title: 'post title 0', }); }); test('rapid mutations on poor connection when initial create is pending', async () => { - // Record to update: - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - /** - * NOTE: We do NOT wait for the outbox here - we are testing - * updates on a record that is still in the outbox. - */ - - /** - * Note: Running this test without increased latencies will still fail, - * however, the `expectedNumberOfUpdates` received by the fake service - * will be different (here they are merged in the outbox). See the - * tests following this one. - */ - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'slow'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', }); // Note: We do NOT wait for the outbox to be empty here, because // we want to test concurrent updates being processed by the outbox. + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); + await postHarness.revise('post title 2'); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await revPost(original.id, 'post title 1'); - await revPost(original.id, 'post title 2'); - //endregion - - // Now we wait for the outbox to do what it needs to do: - await waitForEmptyOutbox(); - - /** - * Currently, the service does not receive any requests. - */ - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: 0, - externalNumberOfUpdates: 0, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(0); - // Validate that `observe` returned the expected updates to - // `title` and `version`, in the expected order: - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['post title 0', undefined], ['post title 1', undefined], ['post title 2', undefined], ['post title 2', 1], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 1, title: 'post title 2', }); }); test('rapid mutations on fast connection when initial create is pending', async () => { - // Record to update: - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - /** - * NOTE: We do NOT wait for the outbox here - we are testing - * updates on a record that is still in the outbox. - */ - - //region perform consecutive updates - await pause(200); - await revPost(original.id, 'post title 0'); - - await pause(200); - await revPost(original.id, 'post title 1'); + harness.connectionSpeed = 'fast'; + harness.latency = 'low'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', + }); - await pause(200); - await revPost(original.id, 'post title 2'); - //endregion + // Note: We do NOT wait for the outbox to be empty here, because + // we want to test concurrent updates being processed by the outbox. + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); + await postHarness.revise('post title 2'); - // Now we wait for the outbox to do what it needs to do: - await waitForEmptyOutbox(); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(0); - /** - * Currently, the service does not receive any requests. - */ - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: 0, - externalNumberOfUpdates: 0, - modelName: 'Post', - }); - - // Validate that `observe` returned the expected updates to - // `title` and `version`, in the expected order: - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['post title 0', undefined], ['post title 1', undefined], ['post title 2', undefined], ['post title 2', 1], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 1, title: 'post title 2', }); }); test('observe on poor connection with awaited outbox', async () => { - // Record to update: - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - /** - * Make sure the save was successfully sent out. There is a separate test - * for testing an update when the save is still in the outbox (see above). - * Here, `_version` is defined. - */ - await waitForEmptyOutbox(); - - /** - * Note: Running this test without increased latencies will still fail, - * however, the `expectedNumberOfUpdates` received by the fake service - * will be different (here they are merged in the outbox). See the - * tests following this one. - */ - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'slow'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', }); + await harness.outboxSettled(); /** * We wait for the empty outbox on each mutation, because @@ -559,32 +198,16 @@ describe('DataStore sync engine', () => { * sure all the updates are going out and are being observed) */ - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await waitForEmptyOutbox(); - - await revPost(original.id, 'post title 1'); - await waitForEmptyOutbox(); - - await revPost(original.id, 'post title 2'); - await waitForEmptyOutbox(); - //endregion + await postHarness.revise('post title 0'); + await harness.outboxSettled(); + await postHarness.revise('post title 1'); + await harness.outboxSettled(); + await postHarness.revise('post title 2'); - /** - * Even though we have increased the latency, we are still waiting - * on the outbox after each mutation. Therefore, mutations will not - * be merged. - */ - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 0, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(3); - // Validate that `observe` returned the expected updates to - // `title` and `version`, in the expected order: - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 0', 2], @@ -594,60 +217,29 @@ describe('DataStore sync engine', () => { ['post title 2', 4], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 4, title: 'post title 2', }); }); test('observe on fast connection with awaited outbox', async () => { - // Record to update: - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - /** - * Make sure the save was successfully sent out. There is a separate test - * for testing an update when the save is still in the outbox (see above). - * Here, `_version` is defined. - */ - await waitForEmptyOutbox(); - - /** - * We wait for the empty outbox on each mutation, because - * we want to test non-concurrent updates (i.e. we want to make - * sure all the updates are going out and are being observed) - */ - - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await waitForEmptyOutbox(); + harness.latency = 'low'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', + }); + await harness.outboxSettled(); - await revPost(original.id, 'post title 1'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 0'); + await harness.outboxSettled(); + await postHarness.revise('post title 1'); + await harness.outboxSettled(); + await postHarness.revise('post title 2'); + await harness.outboxSettled(); - await revPost(original.id, 'post title 2'); - await waitForEmptyOutbox(); - //endregion + await harness.expectUpdateCallCount(3); - /** - * Even though we have increased the latency, we are still waiting - * on the outbox after each mutation. Therefore, mutations will not - * be merged. - */ - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 0, - modelName: 'Post', - }); - - // Validate that `observe` returned the expected updates to - // `title` and `version`, in the expected order: - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 0', 2], @@ -657,8 +249,7 @@ describe('DataStore sync engine', () => { ['post title 2', 4], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 4, title: 'post title 2', }); @@ -674,73 +265,27 @@ describe('DataStore sync engine', () => { */ describe('Multi-client updates', () => { describe('Updates to the same field', () => { - /** - * All observed updates. Also includes "updates" from initial record creation, - * since we start the subscription in the `beforeEach` block. - */ - let subscriptionLog: SubscriptionLogTuple[] = []; - - beforeEach(async () => { - await DataStore.observe(Post).subscribe(({ opType, element }) => { - if (opType === 'UPDATE') { - const response: SubscriptionLogTuple = [ - element.title, - // No, TypeScript, there is a version: - // @ts-ignore - element._version, - ]; - // Track sequence of versions and titles - subscriptionLog.push(response); - } - }); - }); - - afterEach(async () => { - expectedNumberOfUpdates = 0; - subscriptionLog = []; - }); - test('rapid mutations on poor connection when initial create is not pending', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - await waitForEmptyOutbox(); - - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'slow'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - - await revPost(original.id, 'post title 1'); - - await externalPostUpdate({ - originalPostId: original.id, + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, updatedFields: { title: 'update from second client' }, version: 1, }); + await postHarness.revise('post title 2'); - await revPost(original.id, 'post title 2'); - //endregion - - await waitForEmptyOutbox(); - - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: expectedNumberOfUpdates - 1, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); - - expect(subscriptionLog).toEqual([ + await harness.outboxSettled(); + await harness.expectUpdateCallCount(3); + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 1', 1], @@ -748,49 +293,33 @@ describe('DataStore sync engine', () => { ['update from second client', 4], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 4, title: 'update from second client', }); }); test('rapid mutations on fast connection when initial create is not pending', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - await waitForEmptyOutbox(); - - //region perform consecutive updates - await pause(200); - await revPost(original.id, 'post title 0'); - - await pause(200); - await revPost(original.id, 'post title 1'); + harness.connectionSpeed = 'fast'; + harness.latency = 'low'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', + }); + await harness.outboxSettled(); - await externalPostUpdate({ - originalPostId: original.id, + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, updatedFields: { title: 'update from second client' }, version: 1, }); + await postHarness.revise('post title 2'); - await pause(200); - await revPost(original.id, 'post title 2'); - //endregion - - await waitForEmptyOutbox(); - - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 1', 1], @@ -798,54 +327,38 @@ describe('DataStore sync engine', () => { ['post title 0', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 0', }); }); test('observe on poor connection with awaited outbox', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); - - await waitForEmptyOutbox(); - - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'fast'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 0'); + await harness.outboxSettled(); - await revPost(original.id, 'post title 1'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 1'); + await harness.outboxSettled(); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, updatedFields: { title: 'update from second client' }, version: 3, }); - await revPost(original.id, 'post title 2'); - await waitForEmptyOutbox(); - //endregion + await postHarness.revise('post title 2'); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 0', 2], @@ -856,47 +369,39 @@ describe('DataStore sync engine', () => { ['post title 2', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 2', }); }); test('observe on fast connection with awaited outbox', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'blog id', - }) - ); + harness.connectionSpeed = 'fast'; + harness.latency = 'low'; - await waitForEmptyOutbox(); + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'blog id', + }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 0'); + await harness.outboxSettled(); - await revPost(original.id, 'post title 1'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 1'); + await harness.outboxSettled(); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, updatedFields: { title: 'update from second client' }, version: 3, }); - await revPost(original.id, 'post title 2'); - await waitForEmptyOutbox(); - //endregion + await postHarness.revise('post title 2'); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ + expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], ['post title 0', 2], @@ -907,38 +412,13 @@ describe('DataStore sync engine', () => { ['post title 2', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 2', }); }); }); describe('Updates to different fields', () => { - /** - * All observed updates. Also includes "updates" from initial record creation, - * since we start the subscription in the `beforeEach` block. - */ - let subscriptionLog: SubscriptionLogMultiField[] = []; - - beforeEach(async () => { - await DataStore.observe(Post).subscribe(({ opType, element }) => { - const response: SubscriptionLogMultiField = [ - element.title, - // @ts-ignore - element.blogId, - // @ts-ignore - element._version, - ]; - subscriptionLog.push(response); - }); - }); - - afterEach(async () => { - expectedNumberOfUpdates = 0; - subscriptionLog = []; - }); - /** * NOTE: Even though the primary client is updating `title`, * the second client's update to `blogId` "reverts" the primary @@ -952,47 +432,31 @@ describe('DataStore sync engine', () => { * ultimately resulting in different final states. */ test('poor connection, initial create is not pending, external request is first received update', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - }) - ); - - await waitForEmptyOutbox(); - - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'fast'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - - await revPost(original.id, 'post title 1'); + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, // External client performs a mutation against a different field: updatedFields: { blogId: 'update from second client' }, version: 1, }); - await revPost(original.id, 'post title 2'); - //endregion + await postHarness.revise('post title 2'); - await waitForEmptyOutbox(); - - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: expectedNumberOfUpdates - 1, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(3); - expect(subscriptionLog).toEqual([ - ['original title', null, undefined], + expect( + harness.subscriptionLogs(['title', 'blogId', '_version']) + ).toEqual([ ['original title', null, 1], ['post title 0', null, 1], ['post title 1', null, 1], @@ -1000,61 +464,44 @@ describe('DataStore sync engine', () => { ['original title', 'update from second client', 4], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 4, title: 'original title', blogId: 'update from second client', }); }); test('poor connection, initial create is not pending, external request is second received update', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - }) - ); - - await waitForEmptyOutbox(); - - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'slow'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - - await revPost(original.id, 'post title 1'); + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); /** * Ensure that the external update is received after the - * primary client's first update. + * primary client's first update. TODO - Can we just settle the outbox? */ await pause(3000); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, // External client performs a mutation against a different field: updatedFields: { blogId: 'update from second client' }, version: 1, }); - await revPost(original.id, 'post title 2'); - //endregion - - await waitForEmptyOutbox(); + await postHarness.revise('post title 2'); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ - ['original title', null, undefined], + expect( + harness.subscriptionLogs(['title', 'blogId', '_version']) + ).toEqual([ ['original title', null, 1], ['post title 0', null, 1], ['post title 1', null, 1], @@ -1062,51 +509,39 @@ describe('DataStore sync engine', () => { ['post title 0', 'update from second client', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 0', blogId: 'update from second client', }); }); test('rapid mutations on fast connection when initial create is not pending (second field is `null`)', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - }) - ); - - await waitForEmptyOutbox(); + harness.connectionSpeed = 'fast'; + harness.latency = 'low'; - //region perform consecutive updates - await pause(200); - await revPost(original.id, 'post title 0'); + const postHarness = await harness.createPostHarness({ + title: 'original title', + }); + await harness.outboxSettled(); - await pause(200); - await revPost(original.id, 'post title 1'); + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, // External client performs a mutation against a different field: updatedFields: { blogId: 'update from second client' }, version: 1, }); - await pause(200); - await revPost(original.id, 'post title 2'); - //endregion - - await waitForEmptyOutbox(); + await postHarness.revise('post title 2'); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ - ['original title', null, undefined], + expect( + harness.subscriptionLogs(['title', 'blogId', '_version']) + ).toEqual([ ['original title', null, 1], ['post title 0', null, 1], ['post title 1', null, 1], @@ -1114,8 +549,7 @@ describe('DataStore sync engine', () => { ['post title 0', 'update from second client', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 0', blogId: 'update from second client', @@ -1128,44 +562,33 @@ describe('DataStore sync engine', () => { * in different behavior. */ test('rapid mutations on fast connection when initial create is not pending (second field has initial value)', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - blogId: 'original blogId', - }) - ); + harness.connectionSpeed = 'fast'; + harness.latency = 'low'; - await waitForEmptyOutbox(); - - //region perform consecutive updates - await pause(200); - await revPost(original.id, 'post title 0'); + const postHarness = await harness.createPostHarness({ + title: 'original title', + blogId: 'original blogId', + }); + await harness.outboxSettled(); - await pause(200); - await revPost(original.id, 'post title 1'); + await postHarness.revise('post title 0'); + await postHarness.revise('post title 1'); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, // External client performs a mutation against a different field: updatedFields: { blogId: 'update from second client' }, version: 1, }); - await pause(200); - await revPost(original.id, 'post title 2'); - //endregion + await postHarness.revise('post title 2'); - await waitForEmptyOutbox(); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); - - expect(subscriptionLog).toEqual([ - ['original title', 'original blogId', undefined], + expect( + harness.subscriptionLogs(['title', 'blogId', '_version' ?? null]) + ).toEqual([ ['original title', 'original blogId', 1], ['post title 0', 'original blogId', 1], ['post title 1', 'original blogId', 1], @@ -1173,56 +596,41 @@ describe('DataStore sync engine', () => { ['post title 0', 'original blogId', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 0', blogId: 'original blogId', }); }); test('observe on poor connection with awaited outbox', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - }) - ); - - await waitForEmptyOutbox(); - - graphqlService.setLatencies({ - request: latency, - response: latency, - subscriber: latency, - jitter, + harness.connectionSpeed = 'slow'; + harness.latency = 'high'; + const postHarness = await harness.createPostHarness({ + title: 'original title', }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 0'); + await harness.outboxSettled(); - await revPost(original.id, 'post title 1'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 1'); + await harness.outboxSettled(); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, // External client performs a mutation against a different field: updatedFields: { blogId: 'update from second client' }, version: undefined, }); - await revPost(original.id, 'post title 2'); - await waitForEmptyOutbox(); - //endregion + await postHarness.revise('post title 2'); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ - ['original title', null, undefined], + expect( + harness.subscriptionLogs(['title', 'blogId', '_version']) + ).toEqual([ ['original title', null, 1], ['post title 0', null, 1], ['post title 0', null, 2], @@ -1233,49 +641,41 @@ describe('DataStore sync engine', () => { ['post title 2', 'update from second client', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 2', blogId: 'update from second client', }); }); test('observe on fast connection with awaited outbox', async () => { - const original = await DataStore.save( - new Post({ - title: 'original title', - }) - ); - - await waitForEmptyOutbox(); + harness.connectionSpeed = 'slow'; + harness.latency = 'low'; + const postHarness = await harness.createPostHarness({ + title: 'original title', + }); + await harness.outboxSettled(); - //region perform consecutive updates - await revPost(original.id, 'post title 0'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 0'); + await harness.outboxSettled(); - await revPost(original.id, 'post title 1'); - await waitForEmptyOutbox(); + await postHarness.revise('post title 1'); + await harness.outboxSettled(); - await externalPostUpdate({ - originalPostId: original.id, + await harness.externalPostUpdate({ + originalPostId: postHarness.original.id, // External client performs a mutation against a different field: updatedFields: { blogId: 'update from second client' }, version: 3, }); - await revPost(original.id, 'post title 2'); - await waitForEmptyOutbox(); - //endregion + await postHarness.revise('post title 2'); - await graphqlServiceSettled({ - graphqlService, - expectedNumberOfUpdates: numberOfUpdates, - externalNumberOfUpdates: 1, - modelName: 'Post', - }); + await harness.outboxSettled(); + await harness.expectUpdateCallCount(4); - expect(subscriptionLog).toEqual([ - ['original title', null, undefined], + expect( + harness.subscriptionLogs(['title', 'blogId', '_version']) + ).toEqual([ ['original title', null, 1], ['post title 0', null, 1], ['post title 0', null, 2], @@ -1286,8 +686,7 @@ describe('DataStore sync engine', () => { ['post title 2', 'update from second client', 5], ]); - expectFinalRecordsToMatch({ - postId: original.id, + postHarness.expectCurrentToMatch({ version: 5, title: 'post title 2', blogId: 'update from second client', diff --git a/packages/datastore/__tests__/connectivityHandling.test.ts b/packages/datastore/__tests__/connectivityHandling.test.ts index c09483461a4..06568b326d0 100644 --- a/packages/datastore/__tests__/connectivityHandling.test.ts +++ b/packages/datastore/__tests__/connectivityHandling.test.ts @@ -2,7 +2,6 @@ import { Observable } from 'rxjs'; import { pause, getDataStore, - graphqlServiceSettled, waitForEmptyOutbox, waitForDataStoreReady, waitForSyncQueriesReady, diff --git a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts new file mode 100644 index 00000000000..4f6c37a79a1 --- /dev/null +++ b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts @@ -0,0 +1,266 @@ +import { Subscription } from 'rxjs'; +import { getDataStore } from './datastoreFactory'; +import { Post } from './schemas'; +import { + waitForExpectModelUpdateGraphqlCallCount, + pause, + waitForEmptyOutbox, +} from './util'; + +/** + * Simulate a second client updating the original post + * @param originalPostId id of the post to update + * @param updatedFields field(s) to update + * @param version version number to be sent with the request (what would have been + * returned from a query prior to update) + */ +type ExternalPostUpdateParams = { + originalPostId: string; + updatedFields: Partial; + version: number | undefined; +}; + +/** + * @param postId `id` of the record that was updated + * @param version expected final `_version` of the record after all updates are complete + * @param title expected final `title` of the record after all updates are complete + * @param blogId expected final `blogId` of the record after all updates are complete + */ +type FinalAssertionParams = { + postId: string; + version: number; + title: string; + blogId?: string | null; +}; + +/** + * Since we're essentially testing race conditions, we want to test the outbox logic + * exactly the same each time the tests are run. Minor fluctuations in test runs can + * cause different outbox behavior, so we set jitter to `0`. + */ +const jitter: number = 0; +const highLatency = 1000; +const lowLatency = 15; + +class PostHarness { + harness: UpdateSequenceHarness; + original: Post; + constructor(post: Post, harness: UpdateSequenceHarness) { + this.original = post; + this.harness = harness; + } + + async revise(title: string) { + await this.harness.revisePost(this.original.id, title); + } + + expectCurrentToMatch(values: { version; title; blogId? }) { + this.harness.expectFinalRecordsToMatch({ + postId: this.original.id, + ...values, + }); + } +} + +export class UpdateSequenceHarness { + datastoreFake: ReturnType; + expectedNumberOfInternalUpdates = 0; + expectedNumberOfExternalUpdates = 0; + private latencyValue: 'low' | 'high' = 'low'; + connectionSpeed: 'slow' | 'fast' = 'slow'; + settleOutboxAfterRevisions: boolean = false; + + /** + * All observed updates. Also includes "updates" from initial record creation, + * since we start the subscription in the `beforeEach` block. + */ + subscriptionLog: Post[] = []; + + subscriptionLogSubscription: Subscription; + + subscriptionLogs(attributes: string[] = ['title', '_version']) { + return this.subscriptionLog.map(post => + attributes.map(attribute => post[attribute]) + ); + } + + get latency(): 'low' | 'high' { + return this.latencyValue; + } + + set latency(value: 'low' | 'high') { + if (value === 'low') { + this.datastoreFake.graphqlService.setLatencies({ + request: lowLatency, + response: lowLatency, + subscriber: lowLatency, + jitter, + }); + } else { + this.datastoreFake.graphqlService.setLatencies({ + request: highLatency, + response: highLatency, + subscriber: highLatency, + jitter, + }); + } + this.latencyValue = value; + } + + constructor() { + this.datastoreFake = getDataStore({ + online: true, + isNode: false, + }); + + this.subscriptionLogSubscription = this.datastoreFake.DataStore.observe( + this.datastoreFake.Post + ).subscribe(({ opType, element }) => { + if (opType === 'UPDATE') { + // const response: SubscriptionLogTuple = [ + // element.title, + // // No, TypeScript, there is a version: + // // @ts-ignore + // element._version, + // ]; + // Track sequence of versions and titles + this.subscriptionLog.push(element); + } + }); + } + + async outboxSettled() { + await waitForEmptyOutbox(); + } + + async expectUpdateCallCount(expectedCallCount: number) { + /** + * Because we have increased the latency, and don't wait for the outbox + * to clear on each mutation, the outbox will merge some of the mutations. + * In this example, we expect the number of requests received to be one less than + * the actual number of updates. If we were running this test without + * increased latency, we'd expect more requests to be received. + */ + await waitForExpectModelUpdateGraphqlCallCount({ + graphqlService: this.datastoreFake.graphqlService, + expectedCallCount, + modelName: 'Post', + }); + } + + async createPostHarness(...args: ConstructorParameters) { + const original = await this.datastoreFake.DataStore.save( + new this.datastoreFake.Post(...args) + ); + return new PostHarness(original, this); + } + + async destroy() { + this.subscriptionLogSubscription.unsubscribe(); + await this.clearDatastore(); + } + + async startDatastore() { + await this.datastoreFake.DataStore.start(); + } + + private async clearDatastore() { + await this.datastoreFake.DataStore.clear(); + } + + async externalPostUpdate({ + originalPostId, + updatedFields, + version, + }: ExternalPostUpdateParams) { + await this.datastoreFake.graphqlService.externalGraphql( + { + query: ` + mutation operation($input: UpdatePostInput!, $condition: ModelPostConditionInput) { + updatePost(input: $input, condition: $condition) { + id + title + blogId + updatedAt + createdAt + _version + _lastChangedAt + _deleted + } + } + `, + variables: { + input: { + id: originalPostId, + ...updatedFields, + _version: version, + }, + condition: null, + }, + authMode: undefined, + authToken: undefined, + }, + // For now we always ignore latency for external mutations. This could be a param if needed. + true + ); + this.expectedNumberOfExternalUpdates += 1; + } + + /** + * Query post, update, then increment counter. + * @param postId - id of the post to update + * @param updatedTitle - title to update the post with + */ + async revisePost(postId: string, updatedTitle: string) { + if (this.connectionSpeed === 'fast') { + await pause(200); + } + const retrieved = await this.datastoreFake.DataStore.query( + this.datastoreFake.Post, + postId + ); + if (retrieved) { + const x = await this.datastoreFake.DataStore.save( + this.datastoreFake.Post.copyOf(retrieved, updated => { + updated.title = updatedTitle; + }) + ); + } + if (this.settleOutboxAfterRevisions) { + await this.outboxSettled(); + } + + this.expectedNumberOfInternalUpdates++; + } + + async expectFinalRecordsToMatch({ + postId, + version, + title, + blogId = undefined, + }: FinalAssertionParams) { + // Validate that the record was saved to the service: + const table = this.datastoreFake.graphqlService.tables.get('Post')!; + expect(table.size).toEqual(1); + + // Validate that the title was updated successfully: + const savedItem = table.get(JSON.stringify([postId])) as any; + expect(savedItem.title).toEqual(title); + + if (blogId) expect(savedItem.blogId).toEqual(blogId); + + // Validate that the `_version` was incremented correctly: + expect(savedItem._version).toEqual(version); + + // Validate that `query` returns the latest `title` and `_version`: + const queryResult = await this.datastoreFake.DataStore.query( + this.datastoreFake.Post, + postId + ); + expect(queryResult?.title).toEqual(title); + // @ts-ignore + expect(queryResult?._version).toEqual(version); + + if (blogId) expect(queryResult?.blogId).toEqual(blogId); + } +} diff --git a/packages/datastore/__tests__/helpers/datastoreFactory.ts b/packages/datastore/__tests__/helpers/datastoreFactory.ts index dd34e59f7f6..b808543ad67 100644 --- a/packages/datastore/__tests__/helpers/datastoreFactory.ts +++ b/packages/datastore/__tests__/helpers/datastoreFactory.ts @@ -62,6 +62,10 @@ export function getDataStore({ online = false, isNode = true, storageAdapterFactory = () => undefined as any, +}: { + online?: boolean; + isNode?: boolean; + storageAdapterFactory?: () => any; } = {}) { jest.clearAllMocks(); jest.resetModules(); diff --git a/packages/datastore/__tests__/helpers/fakes/graphqlService.ts b/packages/datastore/__tests__/helpers/fakes/graphqlService.ts index d70c8bcf609..ffc881a4d05 100644 --- a/packages/datastore/__tests__/helpers/fakes/graphqlService.ts +++ b/packages/datastore/__tests__/helpers/fakes/graphqlService.ts @@ -91,6 +91,7 @@ export class FakeGraphQLService { this.tables.set(model.name, new Map()); this.tableDefinitions.set(model.name, model); let CPKFound = false; + for (const attribute of model.attributes || []) { if (isModelAttributePrimaryKey(attribute)) { this.PKFields.set(model.name, attribute!.properties!.fields); @@ -260,6 +261,22 @@ export class FakeGraphQLService { }; } + private makeOCCConflictUnhandeled(existingObject, call) { + return { + data: existingObject, + errorType: 'ConflictUnhandled', + message: 'Conflict resolver rejects mutation.', + locations: [ + { + line: 2, + column: 3, + sourceName: null, + }, + ], + path: [call], + }; + } + private makeMissingUpdateTarget(selection) { // Response from AppSync console on non-existent model. return { diff --git a/packages/datastore/__tests__/helpers/util.ts b/packages/datastore/__tests__/helpers/util.ts index 67d3fe9742c..37ed1bce0f9 100644 --- a/packages/datastore/__tests__/helpers/util.ts +++ b/packages/datastore/__tests__/helpers/util.ts @@ -459,15 +459,13 @@ export async function waitForSyncQueriesReady(verbose = false) { */ type GraphQLServiceSettledParams = { graphqlService: any; - expectedNumberOfUpdates: number; - externalNumberOfUpdates: number; + expectedCallCount: number; modelName: string; }; -export async function graphqlServiceSettled({ +export async function waitForExpectModelUpdateGraphqlCallCount({ graphqlService, - expectedNumberOfUpdates, - externalNumberOfUpdates, + expectedCallCount, modelName, }: GraphQLServiceSettledParams) { /** @@ -477,54 +475,62 @@ export async function graphqlServiceSettled({ */ await pause(1); + let lastObservedCount: number | undefined = undefined; + /** * Due to the addition of artificial latencies, the service may not be * done, so we retry: */ - await jitteredExponentialRetry( - () => { - // The test should fail if we haven't ended the simulated disruption: - const subscriptionMessagesNotStopped = - !graphqlService.stopSubscriptionMessages; - - // Ensure the service has received all the requests: - const allUpdatesSent = - graphqlService.requests.filter( + try { + await jitteredExponentialRetry( + () => { + // The test should fail if we haven't ended the simulated disruption: + const subscriptionMessagesNotStopped = + !graphqlService.stopSubscriptionMessages; + + // Ensure the service has received all the requests: + lastObservedCount = graphqlService.requests.filter( ({ operation, type, tableName }) => operation === 'mutation' && type === 'update' && tableName === modelName - ).length === - expectedNumberOfUpdates + externalNumberOfUpdates; - - // Ensure all mutations are complete: - const allRunningMutationsComplete = - graphqlService.runningMutations.size === 0; - - // Ensure we've notified subscribers: - const allSubscriptionsSent = - graphqlService.subscriptionMessagesSent.filter( - ([observerMessageName, message]) => { - return observerMessageName === `onUpdate${modelName}`; - } - ).length === - expectedNumberOfUpdates + externalNumberOfUpdates; - - if ( - allUpdatesSent && - allRunningMutationsComplete && - allSubscriptionsSent && - subscriptionMessagesNotStopped - ) { - return true; - } else { - throw new Error( - 'Fake GraphQL Service did not receive and/or process all updates and/or subscriptions' - ); - } - }, - [null], - undefined, - undefined - ); + ).length; + const allUpdatesSent = lastObservedCount === expectedCallCount; + + // Ensure all mutations are complete: + const allRunningMutationsComplete = + graphqlService.runningMutations.size === 0; + + // Ensure we've notified subscribers: + const allSubscriptionsSent = + graphqlService.subscriptionMessagesSent.filter( + ([observerMessageName, message]) => { + return observerMessageName === `onUpdate${modelName}`; + } + ).length === expectedCallCount; + + if ( + allUpdatesSent && + allRunningMutationsComplete && + allSubscriptionsSent && + subscriptionMessagesNotStopped + ) { + return true; + } else { + throw new Error( + 'Fake GraphQL Service did not receive and/or process all updates and/or subscriptions' + ); + } + }, + [null], + 5_000, + undefined + ); + } catch { + throw new Error( + `Expected ${expectedCallCount} update calls for ${modelName}, but received ${ + lastObservedCount ?? 'unknown' + }` + ); + } } From fa1aef60812c4c1afd636fbfcb6eaa803f16dfc8 Mon Sep 17 00:00:00 2001 From: Aaron S Date: Wed, 20 Dec 2023 12:05:44 -0600 Subject: [PATCH 2/7] Improve comments and get rid of unused features --- .../conflictResolutionBehavior.test.ts | 94 ++++----- .../helpers/UpdateSequenceHarness.ts | 182 +++++++++++------- packages/datastore/__tests__/helpers/util.ts | 13 +- 3 files changed, 173 insertions(+), 116 deletions(-) diff --git a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts index c9da2f22301..27745b94948 100644 --- a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts +++ b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts @@ -66,7 +66,7 @@ describe('DataStore sync engine', () => { describe('observed rapid single-field mutations with variable connection latencies', () => { describe('single client updates', () => { test('rapid mutations on poor connection when initial create is not pending', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -89,13 +89,13 @@ describe('DataStore sync engine', () => { ['post title 0', 3], ]); - postHarness.expectCurrentToMatch({ - version: 3, + expect(await postHarness.currentContents).toMatchObject({ + _version: 3, title: 'post title 0', }); }); test('rapid mutations on fast connection when initial create is not pending', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -120,13 +120,13 @@ describe('DataStore sync engine', () => { ['post title 0', 4], ]); - postHarness.expectCurrentToMatch({ - version: 4, + expect(await postHarness.currentContents).toMatchObject({ + _version: 4, title: 'post title 0', }); }); test('rapid mutations on poor connection when initial create is pending', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -149,13 +149,13 @@ describe('DataStore sync engine', () => { ['post title 2', 1], ]); - postHarness.expectCurrentToMatch({ - version: 1, + expect(await postHarness.currentContents).toMatchObject({ + _version: 1, title: 'post title 2', }); }); test('rapid mutations on fast connection when initial create is pending', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -178,13 +178,13 @@ describe('DataStore sync engine', () => { ['post title 2', 1], ]); - postHarness.expectCurrentToMatch({ - version: 1, + expect(await postHarness.currentContents).toMatchObject({ + _version: 1, title: 'post title 2', }); }); test('observe on poor connection with awaited outbox', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -217,8 +217,8 @@ describe('DataStore sync engine', () => { ['post title 2', 4], ]); - postHarness.expectCurrentToMatch({ - version: 4, + expect(await postHarness.currentContents).toMatchObject({ + _version: 4, title: 'post title 2', }); }); @@ -249,8 +249,8 @@ describe('DataStore sync engine', () => { ['post title 2', 4], ]); - postHarness.expectCurrentToMatch({ - version: 4, + expect(await postHarness.currentContents).toMatchObject({ + _version: 4, title: 'post title 2', }); }); @@ -266,7 +266,7 @@ describe('DataStore sync engine', () => { describe('Multi-client updates', () => { describe('Updates to the same field', () => { test('rapid mutations on poor connection when initial create is not pending', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -293,13 +293,13 @@ describe('DataStore sync engine', () => { ['update from second client', 4], ]); - postHarness.expectCurrentToMatch({ - version: 4, + expect(await postHarness.currentContents).toMatchObject({ + _version: 4, title: 'update from second client', }); }); test('rapid mutations on fast connection when initial create is not pending', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -327,13 +327,13 @@ describe('DataStore sync engine', () => { ['post title 0', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 0', }); }); test('observe on poor connection with awaited outbox', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -369,13 +369,13 @@ describe('DataStore sync engine', () => { ['post title 2', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 2', }); }); test('observe on fast connection with awaited outbox', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ @@ -412,8 +412,8 @@ describe('DataStore sync engine', () => { ['post title 2', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 2', }); }); @@ -432,7 +432,7 @@ describe('DataStore sync engine', () => { * ultimately resulting in different final states. */ test('poor connection, initial create is not pending, external request is first received update', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -464,14 +464,14 @@ describe('DataStore sync engine', () => { ['original title', 'update from second client', 4], ]); - postHarness.expectCurrentToMatch({ - version: 4, + expect(await postHarness.currentContents).toMatchObject({ + _version: 4, title: 'original title', blogId: 'update from second client', }); }); test('poor connection, initial create is not pending, external request is second received update', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -509,14 +509,14 @@ describe('DataStore sync engine', () => { ['post title 0', 'update from second client', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 0', blogId: 'update from second client', }); }); test('rapid mutations on fast connection when initial create is not pending (second field is `null`)', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ @@ -549,8 +549,8 @@ describe('DataStore sync engine', () => { ['post title 0', 'update from second client', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 0', blogId: 'update from second client', }); @@ -562,7 +562,7 @@ describe('DataStore sync engine', () => { * in different behavior. */ test('rapid mutations on fast connection when initial create is not pending (second field has initial value)', async () => { - harness.connectionSpeed = 'fast'; + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ @@ -596,14 +596,14 @@ describe('DataStore sync engine', () => { ['post title 0', 'original blogId', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 0', blogId: 'original blogId', }); }); test('observe on poor connection with awaited outbox', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -641,14 +641,14 @@ describe('DataStore sync engine', () => { ['post title 2', 'update from second client', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 2', blogId: 'update from second client', }); }); test('observe on fast connection with awaited outbox', async () => { - harness.connectionSpeed = 'slow'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', @@ -686,8 +686,8 @@ describe('DataStore sync engine', () => { ['post title 2', 'update from second client', 5], ]); - postHarness.expectCurrentToMatch({ - version: 5, + expect(await postHarness.currentContents).toMatchObject({ + _version: 5, title: 'post title 2', blogId: 'update from second client', }); diff --git a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts index 4f6c37a79a1..573357e215f 100644 --- a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts +++ b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts @@ -33,6 +33,21 @@ type FinalAssertionParams = { blogId?: string | null; }; +const MUTATION_QUERY = ` +mutation operation($input: UpdatePostInput!, $condition: ModelPostConditionInput) { + updatePost(input: $input, condition: $condition) { + id + title + blogId + updatedAt + createdAt + _version + _lastChangedAt + _deleted + } +} +`; + /** * Since we're essentially testing race conditions, we want to test the outbox logic * exactly the same each time the tests are run. Minor fluctuations in test runs can @@ -42,6 +57,23 @@ const jitter: number = 0; const highLatency = 1000; const lowLatency = 15; +/** + * The PostHarness is a decorator that wraps a `Post` model and offers common operations + * such as `revise` and `expectCurrentToMatch` for the tester to interact with. + * + * Example use: + * ``` + * const postHarness = await harness.createPostHarness({ + * title: 'original title', + * blogId: 'blog id', + * }); + * await postHarness.revise('post title 0'); + * postHarness.expectCurrentToMatch({ + * version: 4, + * title: 'update from second client', + * }); + * ``` + */ class PostHarness { harness: UpdateSequenceHarness; original: Post; @@ -54,20 +86,44 @@ class PostHarness { await this.harness.revisePost(this.original.id, title); } - expectCurrentToMatch(values: { version; title; blogId? }) { - this.harness.expectFinalRecordsToMatch({ - postId: this.original.id, - ...values, - }); + get currentContents() { + return this.harness.getCurrentRecord(this.original.id); } } +/** + * The UpdateSequenceHarness is a decorator for a Datastore instance connected to a graphql fake + * that offers configuration options and convenience functions for clear testing. + * + * It is specific to the creation of `Post` models and subsequent updates. + * + * Example use: + * ``` + * harness = new UpdateSequenceHarness(); + * harness.userInputLatency = 'slowerThanOutbox'; + * harness.latency = 'low'; + * const postHarness = await harness.createPostHarness({ + * title: 'original title', + * }); + * await harness.outboxSettled(); + * await harness.expectUpdateCallCount(0); + * expect(harness.subscriptionLogs()).toEqual([]); + * ``` + */ export class UpdateSequenceHarness { - datastoreFake: ReturnType; - expectedNumberOfInternalUpdates = 0; - expectedNumberOfExternalUpdates = 0; - private latencyValue: 'low' | 'high' = 'low'; - connectionSpeed: 'slow' | 'fast' = 'slow'; + private datastoreFake: ReturnType; + + /** + * Should we inject latency before each standard client `Post` revision? + * + * `slowerThanOutbox` will add 200ms of latency before each revision against datastore + */ + userInputLatency: 'fasterThanOutbox' | 'slowerThanOutbox' = + 'fasterThanOutbox'; + + /** + * Do we want to settle the outbox after each `Post` revision call? + */ settleOutboxAfterRevisions: boolean = false; /** @@ -84,10 +140,17 @@ export class UpdateSequenceHarness { ); } - get latency(): 'low' | 'high' { - return this.latencyValue; - } - + /** + * Should there be latency in the datastore sync process? + * Latency will be added: + * - Before the request is made from datastore to graphql + * - Before the response is processed from graphql + * - Before the subscription update is sent to update subscriber + * + * Values: + * - `low` will add 15ms of latency for each of these events + * - `high` will add 1000ms of latency for each of these events + */ set latency(value: 'low' | 'high') { if (value === 'low') { this.datastoreFake.graphqlService.setLatencies({ @@ -104,7 +167,6 @@ export class UpdateSequenceHarness { jitter, }); } - this.latencyValue = value; } constructor() { @@ -113,26 +175,32 @@ export class UpdateSequenceHarness { isNode: false, }); + this.latency = 'low'; + this.subscriptionLogSubscription = this.datastoreFake.DataStore.observe( this.datastoreFake.Post ).subscribe(({ opType, element }) => { if (opType === 'UPDATE') { - // const response: SubscriptionLogTuple = [ - // element.title, - // // No, TypeScript, there is a version: - // // @ts-ignore - // element._version, - // ]; - // Track sequence of versions and titles this.subscriptionLog.push(element); } }); } + /** + * Wait for the Hub event to be fired indicating that the outbox is empty. + * + * NOTICE: If the outbox is *already* empty, this will not resolve. + */ async outboxSettled() { await waitForEmptyOutbox(); } + /** + * Watch the graphql fake for an expected number of calls to ensure we've + * seen all of the expected behavior resolve before proceeding + * + * @param expectedCallCount The number of graphql update Post calls expected. + */ async expectUpdateCallCount(expectedCallCount: number) { /** * Because we have increased the latency, and don't wait for the outbox @@ -148,6 +216,12 @@ export class UpdateSequenceHarness { }); } + /** + * Create a new Post and decorate it in the post harness, returning the decorated Post + * + * @param postInputs The input arguments to create the new Post with + * @returns + */ async createPostHarness(...args: ConstructorParameters) { const original = await this.datastoreFake.DataStore.save( new this.datastoreFake.Post(...args) @@ -155,6 +229,9 @@ export class UpdateSequenceHarness { return new PostHarness(original, this); } + /** + * Teardown this harness instance + */ async destroy() { this.subscriptionLogSubscription.unsubscribe(); await this.clearDatastore(); @@ -168,6 +245,11 @@ export class UpdateSequenceHarness { await this.datastoreFake.DataStore.clear(); } + /** + * Make a call directly to the graphl service fake which tries to update the given PostId with the given field changes. + * + * @param args The input args `{originalPostId, updatedFields, version}` with which to make the external update call + */ async externalPostUpdate({ originalPostId, updatedFields, @@ -175,20 +257,7 @@ export class UpdateSequenceHarness { }: ExternalPostUpdateParams) { await this.datastoreFake.graphqlService.externalGraphql( { - query: ` - mutation operation($input: UpdatePostInput!, $condition: ModelPostConditionInput) { - updatePost(input: $input, condition: $condition) { - id - title - blogId - updatedAt - createdAt - _version - _lastChangedAt - _deleted - } - } - `, + query: MUTATION_QUERY, variables: { input: { id: originalPostId, @@ -203,7 +272,6 @@ export class UpdateSequenceHarness { // For now we always ignore latency for external mutations. This could be a param if needed. true ); - this.expectedNumberOfExternalUpdates += 1; } /** @@ -212,7 +280,7 @@ export class UpdateSequenceHarness { * @param updatedTitle - title to update the post with */ async revisePost(postId: string, updatedTitle: string) { - if (this.connectionSpeed === 'fast') { + if (this.userInputLatency === 'slowerThanOutbox') { await pause(200); } const retrieved = await this.datastoreFake.DataStore.query( @@ -229,38 +297,16 @@ export class UpdateSequenceHarness { if (this.settleOutboxAfterRevisions) { await this.outboxSettled(); } - - this.expectedNumberOfInternalUpdates++; } - async expectFinalRecordsToMatch({ - postId, - version, - title, - blogId = undefined, - }: FinalAssertionParams) { - // Validate that the record was saved to the service: + /** + * Get the current stored Post content from the graphql fake + * + * @param postId The id of the post to fetch from the graphql fake + * @returns The fields stored in the graphql service fake for a given post + */ + async getCurrentRecord(postId: string) { const table = this.datastoreFake.graphqlService.tables.get('Post')!; - expect(table.size).toEqual(1); - - // Validate that the title was updated successfully: - const savedItem = table.get(JSON.stringify([postId])) as any; - expect(savedItem.title).toEqual(title); - - if (blogId) expect(savedItem.blogId).toEqual(blogId); - - // Validate that the `_version` was incremented correctly: - expect(savedItem._version).toEqual(version); - - // Validate that `query` returns the latest `title` and `_version`: - const queryResult = await this.datastoreFake.DataStore.query( - this.datastoreFake.Post, - postId - ); - expect(queryResult?.title).toEqual(title); - // @ts-ignore - expect(queryResult?._version).toEqual(version); - - if (blogId) expect(queryResult?.blogId).toEqual(blogId); + return table.get(JSON.stringify([postId])); } } diff --git a/packages/datastore/__tests__/helpers/util.ts b/packages/datastore/__tests__/helpers/util.ts index 37ed1bce0f9..dbc919ad487 100644 --- a/packages/datastore/__tests__/helpers/util.ts +++ b/packages/datastore/__tests__/helpers/util.ts @@ -463,6 +463,13 @@ type GraphQLServiceSettledParams = { modelName: string; }; +/** + * Given a service fake, an expected call count and a modelName, this function observes the number + * of calls against the fake service and raises an error if the expected result isn't observed after + * 5 seconds of trying. + * + * @param inputs: Tells us the service fake, expected call count and model to observe + */ export async function waitForExpectModelUpdateGraphqlCallCount({ graphqlService, expectedCallCount, @@ -475,6 +482,7 @@ export async function waitForExpectModelUpdateGraphqlCallCount({ */ await pause(1); + // Keep track of the observed count for each retry so we can tell the developer what was observed last let lastObservedCount: number | undefined = undefined; /** @@ -488,13 +496,14 @@ export async function waitForExpectModelUpdateGraphqlCallCount({ const subscriptionMessagesNotStopped = !graphqlService.stopSubscriptionMessages; - // Ensure the service has received all the requests: lastObservedCount = graphqlService.requests.filter( ({ operation, type, tableName }) => operation === 'mutation' && type === 'update' && tableName === modelName ).length; + + // Ensure the service has received all expected requests: const allUpdatesSent = lastObservedCount === expectedCallCount; // Ensure all mutations are complete: @@ -523,10 +532,12 @@ export async function waitForExpectModelUpdateGraphqlCallCount({ } }, [null], + // Only retry up to 5 seconds 5_000, undefined ); } catch { + // If the expected call count isn't observed after 5 seconds, raise an error describing the discrepency throw new Error( `Expected ${expectedCallCount} update calls for ${modelName}, but received ${ lastObservedCount ?? 'unknown' From 0bcbf76d64fcf9be24e15a2a68cdeafd4ffc7e4c Mon Sep 17 00:00:00 2001 From: Aaron S Date: Wed, 20 Dec 2023 13:00:13 -0600 Subject: [PATCH 3/7] Updates from comments --- .../conflictResolutionBehavior.test.ts | 179 ++++++++---------- .../helpers/UpdateSequenceHarness.ts | 14 +- 2 files changed, 91 insertions(+), 102 deletions(-) diff --git a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts index 27745b94948..c13b0faf9b6 100644 --- a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts +++ b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts @@ -66,20 +66,20 @@ describe('DataStore sync engine', () => { describe('observed rapid single-field mutations with variable connection latencies', () => { describe('single client updates', () => { test('rapid mutations on poor connection when initial create is not pending', async () => { - harness.userInputLatency = 'fasterThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); - // Note: We do NOT wait for the outbox to be empty here, because - // we want to test concurrent updates being processed by the outbox. + + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'high'; + await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); await postHarness.revise('post title 2'); + await harness.outboxSettled(); - await harness.expectUpdateCallCount(2); + await harness.expectGraphqlSettledWithUpdateCallCount(2); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -95,22 +95,20 @@ describe('DataStore sync engine', () => { }); }); test('rapid mutations on fast connection when initial create is not pending', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); - // Note: We do NOT wait for the outbox to be empty here, because - // we want to test concurrent updates being processed by the outbox. + harness.userInputLatency = 'slowerThanOutbox'; + harness.latency = 'low'; + await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(3); + await harness.expectGraphqlSettledWithUpdateCallCount(3); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -126,21 +124,23 @@ describe('DataStore sync engine', () => { }); }); test('rapid mutations on poor connection when initial create is pending', async () => { + const postHarness = await harness.createPostHarness( + { + title: 'original title', + blogId: 'blog id', + }, + false + ); + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; - const postHarness = await harness.createPostHarness({ - title: 'original title', - blogId: 'blog id', - }); - // Note: We do NOT wait for the outbox to be empty here, because - // we want to test concurrent updates being processed by the outbox. await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(0); + await harness.expectGraphqlSettledWithUpdateCallCount(0); expect(harness.subscriptionLogs()).toEqual([ ['post title 0', undefined], @@ -155,12 +155,16 @@ describe('DataStore sync engine', () => { }); }); test('rapid mutations on fast connection when initial create is pending', async () => { + const postHarness = await harness.createPostHarness( + { + title: 'original title', + blogId: 'blog id', + }, + false + ); + harness.userInputLatency = 'slowerThanOutbox'; harness.latency = 'low'; - const postHarness = await harness.createPostHarness({ - title: 'original title', - blogId: 'blog id', - }); // Note: We do NOT wait for the outbox to be empty here, because // we want to test concurrent updates being processed by the outbox. @@ -169,7 +173,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(0); + await harness.expectGraphqlSettledWithUpdateCallCount(0); expect(harness.subscriptionLogs()).toEqual([ ['post title 0', undefined], @@ -184,13 +188,14 @@ describe('DataStore sync engine', () => { }); }); test('observe on poor connection with awaited outbox', async () => { - harness.userInputLatency = 'fasterThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'high'; + harness.settleOutboxAfterRevisions = true; /** * We wait for the empty outbox on each mutation, because @@ -199,13 +204,10 @@ describe('DataStore sync engine', () => { */ await postHarness.revise('post title 0'); - await harness.outboxSettled(); await postHarness.revise('post title 1'); - await harness.outboxSettled(); await postHarness.revise('post title 2'); - await harness.outboxSettled(); - await harness.expectUpdateCallCount(3); + await harness.expectGraphqlSettledWithUpdateCallCount(3); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -223,21 +225,19 @@ describe('DataStore sync engine', () => { }); }); test('observe on fast connection with awaited outbox', async () => { - harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); + + harness.latency = 'low'; + harness.settleOutboxAfterRevisions = true; await postHarness.revise('post title 0'); - await harness.outboxSettled(); await postHarness.revise('post title 1'); - await harness.outboxSettled(); await postHarness.revise('post title 2'); - await harness.outboxSettled(); - await harness.expectUpdateCallCount(3); + await harness.expectGraphqlSettledWithUpdateCallCount(3); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -266,13 +266,13 @@ describe('DataStore sync engine', () => { describe('Multi-client updates', () => { describe('Updates to the same field', () => { test('rapid mutations on poor connection when initial create is not pending', async () => { - harness.userInputLatency = 'fasterThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'high'; await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -284,7 +284,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(3); + await harness.expectGraphqlSettledWithUpdateCallCount(3); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], ['post title 0', 1], @@ -299,13 +299,13 @@ describe('DataStore sync engine', () => { }); }); test('rapid mutations on fast connection when initial create is not pending', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'slowerThanOutbox'; + harness.latency = 'low'; await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -317,7 +317,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -333,19 +333,17 @@ describe('DataStore sync engine', () => { }); }); test('observe on poor connection with awaited outbox', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); - await postHarness.revise('post title 0'); - await harness.outboxSettled(); + harness.userInputLatency = 'slowerThanOutbox'; + harness.latency = 'high'; + harness.settleOutboxAfterRevisions = true; + await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); - await harness.outboxSettled(); await harness.externalPostUpdate({ originalPostId: postHarness.original.id, @@ -355,8 +353,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); - await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -375,20 +372,17 @@ describe('DataStore sync engine', () => { }); }); test('observe on fast connection with awaited outbox', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'low'; - const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'blog id', }); - await harness.outboxSettled(); - await postHarness.revise('post title 0'); - await harness.outboxSettled(); + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'low'; + harness.settleOutboxAfterRevisions = true; + await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); - await harness.outboxSettled(); await harness.externalPostUpdate({ originalPostId: postHarness.original.id, @@ -398,8 +392,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); - await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect(harness.subscriptionLogs()).toEqual([ ['original title', 1], @@ -432,12 +425,12 @@ describe('DataStore sync engine', () => { * ultimately resulting in different final states. */ test('poor connection, initial create is not pending, external request is first received update', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'high'; await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -452,7 +445,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(3); + await harness.expectGraphqlSettledWithUpdateCallCount(3); expect( harness.subscriptionLogs(['title', 'blogId', '_version']) @@ -471,19 +464,19 @@ describe('DataStore sync engine', () => { }); }); test('poor connection, initial create is not pending, external request is second received update', async () => { - harness.userInputLatency = 'fasterThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'high'; await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); /** * Ensure that the external update is received after the - * primary client's first update. TODO - Can we just settle the outbox? + * primary client's first update. */ await pause(3000); @@ -497,7 +490,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect( harness.subscriptionLogs(['title', 'blogId', '_version']) @@ -516,13 +509,12 @@ describe('DataStore sync engine', () => { }); }); test('rapid mutations on fast connection when initial create is not pending (second field is `null`)', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'low'; - const postHarness = await harness.createPostHarness({ title: 'original title', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'slowerThanOutbox'; + harness.latency = 'low'; await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -537,7 +529,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect( harness.subscriptionLogs(['title', 'blogId', '_version']) @@ -562,14 +554,13 @@ describe('DataStore sync engine', () => { * in different behavior. */ test('rapid mutations on fast connection when initial create is not pending (second field has initial value)', async () => { - harness.userInputLatency = 'slowerThanOutbox'; - harness.latency = 'low'; - const postHarness = await harness.createPostHarness({ title: 'original title', blogId: 'original blogId', }); - await harness.outboxSettled(); + + harness.userInputLatency = 'slowerThanOutbox'; + harness.latency = 'low'; await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -584,7 +575,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect( harness.subscriptionLogs(['title', 'blogId', '_version' ?? null]) @@ -603,18 +594,16 @@ describe('DataStore sync engine', () => { }); }); test('observe on poor connection with awaited outbox', async () => { - harness.userInputLatency = 'fasterThanOutbox'; - harness.latency = 'high'; const postHarness = await harness.createPostHarness({ title: 'original title', }); - await harness.outboxSettled(); - await postHarness.revise('post title 0'); - await harness.outboxSettled(); + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'high'; + harness.settleOutboxAfterRevisions = true; + await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); - await harness.outboxSettled(); await harness.externalPostUpdate({ originalPostId: postHarness.original.id, @@ -625,8 +614,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); - await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect( harness.subscriptionLogs(['title', 'blogId', '_version']) @@ -648,18 +636,16 @@ describe('DataStore sync engine', () => { }); }); test('observe on fast connection with awaited outbox', async () => { - harness.userInputLatency = 'fasterThanOutbox'; - harness.latency = 'low'; const postHarness = await harness.createPostHarness({ title: 'original title', }); - await harness.outboxSettled(); - await postHarness.revise('post title 0'); - await harness.outboxSettled(); + harness.userInputLatency = 'fasterThanOutbox'; + harness.latency = 'low'; + harness.settleOutboxAfterRevisions = true; + await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); - await harness.outboxSettled(); await harness.externalPostUpdate({ originalPostId: postHarness.original.id, @@ -670,8 +656,7 @@ describe('DataStore sync engine', () => { await postHarness.revise('post title 2'); - await harness.outboxSettled(); - await harness.expectUpdateCallCount(4); + await harness.expectGraphqlSettledWithUpdateCallCount(4); expect( harness.subscriptionLogs(['title', 'blogId', '_version']) diff --git a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts index 573357e215f..b4247886b95 100644 --- a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts +++ b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts @@ -175,8 +175,6 @@ export class UpdateSequenceHarness { isNode: false, }); - this.latency = 'low'; - this.subscriptionLogSubscription = this.datastoreFake.DataStore.observe( this.datastoreFake.Post ).subscribe(({ opType, element }) => { @@ -201,7 +199,7 @@ export class UpdateSequenceHarness { * * @param expectedCallCount The number of graphql update Post calls expected. */ - async expectUpdateCallCount(expectedCallCount: number) { + async expectGraphqlSettledWithUpdateCallCount(expectedCallCount: number) { /** * Because we have increased the latency, and don't wait for the outbox * to clear on each mutation, the outbox will merge some of the mutations. @@ -222,10 +220,16 @@ export class UpdateSequenceHarness { * @param postInputs The input arguments to create the new Post with * @returns */ - async createPostHarness(...args: ConstructorParameters) { + async createPostHarness( + args: ConstructorParameters[0], + settleOutbox: boolean = true + ) { const original = await this.datastoreFake.DataStore.save( - new this.datastoreFake.Post(...args) + new this.datastoreFake.Post(args) ); + if (settleOutbox) { + await this.outboxSettled(); + } return new PostHarness(original, this); } From 73e9bca6209750c0e449f33d4b52f9f74d6b1354 Mon Sep 17 00:00:00 2001 From: Aaron S Date: Wed, 20 Dec 2023 14:57:21 -0600 Subject: [PATCH 4/7] More updates from comments --- .../helpers/UpdateSequenceHarness.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts index b4247886b95..df126d74a58 100644 --- a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts +++ b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts @@ -82,6 +82,21 @@ class PostHarness { this.harness = harness; } + /** + * Revise this post with a title value. + * + * This revision will be delayed by 200ms when + * ``` + * harness.userInputLatency = 'slowerThanOutbox'; + * ``` + * + * This revision will wait for the outbox to settle when + * ``` + * harness.settleOutboxAfterRevisions = true; + * ``` + * + * @param title The title value to update this post to + */ async revise(title: string) { await this.harness.revisePost(this.original.id, title); } @@ -200,13 +215,6 @@ export class UpdateSequenceHarness { * @param expectedCallCount The number of graphql update Post calls expected. */ async expectGraphqlSettledWithUpdateCallCount(expectedCallCount: number) { - /** - * Because we have increased the latency, and don't wait for the outbox - * to clear on each mutation, the outbox will merge some of the mutations. - * In this example, we expect the number of requests received to be one less than - * the actual number of updates. If we were running this test without - * increased latency, we'd expect more requests to be received. - */ await waitForExpectModelUpdateGraphqlCallCount({ graphqlService: this.datastoreFake.graphqlService, expectedCallCount, @@ -216,9 +224,12 @@ export class UpdateSequenceHarness { /** * Create a new Post and decorate it in the post harness, returning the decorated Post + * By default, this will wait for the outbox to clear unle * * @param postInputs The input arguments to create the new Post with - * @returns + * @param settleOutbox Should the outbox be settled after create? Defaults to `true` + * + * @returns A post harness wrapped around a newly created post */ async createPostHarness( args: ConstructorParameters[0], From abd922ce2dc3ee06a17ea82316552bb8da4012e6 Mon Sep 17 00:00:00 2001 From: Aaron S <94858815+stocaaro@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:43:59 -0600 Subject: [PATCH 5/7] Update packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts Co-authored-by: David McAfee --- packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts index df126d74a58..c972e724395 100644 --- a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts +++ b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts @@ -238,6 +238,7 @@ export class UpdateSequenceHarness { const original = await this.datastoreFake.DataStore.save( new this.datastoreFake.Post(args) ); + // We set this to `false` when we want to test updating a record that is still in the outbox. if (settleOutbox) { await this.outboxSettled(); } From 067c1e6e1a8540e6e38eebb20fb6acff6743e7ac Mon Sep 17 00:00:00 2001 From: Aaron S <94858815+stocaaro@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:44:20 -0600 Subject: [PATCH 6/7] Update packages/datastore/__tests__/conflictResolutionBehavior.test.ts Co-authored-by: David McAfee --- packages/datastore/__tests__/conflictResolutionBehavior.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts index c13b0faf9b6..c472ddeebbe 100644 --- a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts +++ b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts @@ -338,7 +338,7 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'slowerThanOutbox'; + harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; harness.settleOutboxAfterRevisions = true; From a59ba5e23c7c5c7970909fe87a3802fae603190b Mon Sep 17 00:00:00 2001 From: Aaron S Date: Wed, 20 Dec 2023 17:39:30 -0600 Subject: [PATCH 7/7] Merge fix --- .../conflictResolutionBehavior.test.ts | 32 +++++++------------ .../helpers/UpdateSequenceHarness.ts | 17 +++++++--- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts index c472ddeebbe..c8aea0c6e41 100644 --- a/packages/datastore/__tests__/conflictResolutionBehavior.test.ts +++ b/packages/datastore/__tests__/conflictResolutionBehavior.test.ts @@ -71,7 +71,6 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; await postHarness.revise('post title 0'); @@ -100,7 +99,7 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'slowerThanOutbox'; + harness.userInputDelayed(); harness.latency = 'low'; await postHarness.revise('post title 0'); @@ -132,7 +131,6 @@ describe('DataStore sync engine', () => { false ); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; await postHarness.revise('post title 0'); @@ -163,7 +161,7 @@ describe('DataStore sync engine', () => { false ); - harness.userInputLatency = 'slowerThanOutbox'; + harness.userInputDelayed(); harness.latency = 'low'; // Note: We do NOT wait for the outbox to be empty here, because @@ -193,9 +191,8 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; - harness.settleOutboxAfterRevisions = true; + harness.settleOutboxAfterRevisions(); /** * We wait for the empty outbox on each mutation, because @@ -231,7 +228,7 @@ describe('DataStore sync engine', () => { }); harness.latency = 'low'; - harness.settleOutboxAfterRevisions = true; + harness.settleOutboxAfterRevisions(); await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -271,7 +268,6 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; await postHarness.revise('post title 0'); @@ -304,7 +300,7 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'slowerThanOutbox'; + harness.userInputDelayed(); harness.latency = 'low'; await postHarness.revise('post title 0'); @@ -338,9 +334,8 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; - harness.settleOutboxAfterRevisions = true; + harness.settleOutboxAfterRevisions(); await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -377,9 +372,8 @@ describe('DataStore sync engine', () => { blogId: 'blog id', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'low'; - harness.settleOutboxAfterRevisions = true; + harness.settleOutboxAfterRevisions(); await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -429,7 +423,6 @@ describe('DataStore sync engine', () => { title: 'original title', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; await postHarness.revise('post title 0'); @@ -468,7 +461,6 @@ describe('DataStore sync engine', () => { title: 'original title', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; await postHarness.revise('post title 0'); @@ -513,7 +505,7 @@ describe('DataStore sync engine', () => { title: 'original title', }); - harness.userInputLatency = 'slowerThanOutbox'; + harness.userInputDelayed(); harness.latency = 'low'; await postHarness.revise('post title 0'); @@ -559,7 +551,7 @@ describe('DataStore sync engine', () => { blogId: 'original blogId', }); - harness.userInputLatency = 'slowerThanOutbox'; + harness.userInputDelayed(); harness.latency = 'low'; await postHarness.revise('post title 0'); @@ -598,9 +590,8 @@ describe('DataStore sync engine', () => { title: 'original title', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'high'; - harness.settleOutboxAfterRevisions = true; + harness.settleOutboxAfterRevisions(); await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); @@ -640,9 +631,8 @@ describe('DataStore sync engine', () => { title: 'original title', }); - harness.userInputLatency = 'fasterThanOutbox'; harness.latency = 'low'; - harness.settleOutboxAfterRevisions = true; + harness.settleOutboxAfterRevisions(); await postHarness.revise('post title 0'); await postHarness.revise('post title 1'); diff --git a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts index c972e724395..6e6b40f0231 100644 --- a/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts +++ b/packages/datastore/__tests__/helpers/UpdateSequenceHarness.ts @@ -128,18 +128,25 @@ class PostHarness { export class UpdateSequenceHarness { private datastoreFake: ReturnType; + private isUserInputDelayed: boolean = false; + /** * Should we inject latency before each standard client `Post` revision? * * `slowerThanOutbox` will add 200ms of latency before each revision against datastore */ - userInputLatency: 'fasterThanOutbox' | 'slowerThanOutbox' = - 'fasterThanOutbox'; + userInputDelayed() { + this.isUserInputDelayed = true; + } + + private isOutboxSettledAfterRevisions: boolean = false; /** * Do we want to settle the outbox after each `Post` revision call? */ - settleOutboxAfterRevisions: boolean = false; + settleOutboxAfterRevisions() { + this.isOutboxSettledAfterRevisions = true; + } /** * All observed updates. Also includes "updates" from initial record creation, @@ -296,7 +303,7 @@ export class UpdateSequenceHarness { * @param updatedTitle - title to update the post with */ async revisePost(postId: string, updatedTitle: string) { - if (this.userInputLatency === 'slowerThanOutbox') { + if (this.isUserInputDelayed) { await pause(200); } const retrieved = await this.datastoreFake.DataStore.query( @@ -310,7 +317,7 @@ export class UpdateSequenceHarness { }) ); } - if (this.settleOutboxAfterRevisions) { + if (this.isOutboxSettledAfterRevisions) { await this.outboxSettled(); } }