-
Notifications
You must be signed in to change notification settings - Fork 435
test(clerk-js): add tests verifying shouldIgnoreNullUpdate behavior #7753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jacekradko
wants to merge
3
commits into
main
Choose a base branch
from
jacek/user-4372-verify-null-resource-updates-when-previous-resource-is-not
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
887c5e7
test(clerk-js): add tests for shouldIgnoreNullUpdate behavior in Stat…
jacekradko 6fd3543
fix: restore SignUp.clerk and SignIn.clerk after tests to prevent glo…
jacekradko a658ac5
test: add concrete assertions to verify null-update handling in shoul…
jacekradko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { eventBus } from '../events'; | ||
| import { SignIn } from '../resources/SignIn'; | ||
| import { SignUp } from '../resources/SignUp'; | ||
| import { signInResourceSignal, signUpResourceSignal } from '../signals'; | ||
| import { State } from '../state'; | ||
|
|
||
| describe('State', () => { | ||
| let state: State; | ||
|
|
||
| // Capture original static clerk references to restore after tests | ||
| const originalSignUpClerk = SignUp.clerk; | ||
| const originalSignInClerk = SignIn.clerk; | ||
|
|
||
| beforeEach(() => { | ||
| // Reset signals to initial state | ||
| signUpResourceSignal({ resource: null }); | ||
| signInResourceSignal({ resource: null }); | ||
| // Create a new State instance which registers event handlers | ||
| state = new State(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.clearAllMocks(); | ||
| // Reset signals after each test | ||
| signUpResourceSignal({ resource: null }); | ||
| signInResourceSignal({ resource: null }); | ||
| // Restore original clerk references to prevent global state leakage | ||
| SignUp.clerk = originalSignUpClerk; | ||
| SignIn.clerk = originalSignInClerk; | ||
| }); | ||
|
|
||
| describe('shouldIgnoreNullUpdate behavior', () => { | ||
| describe('SignUp', () => { | ||
| it('should allow first resource update when previous resource is null', () => { | ||
| // Arrange: Signal starts with null | ||
| expect(signUpResourceSignal().resource).toBeNull(); | ||
|
|
||
| // Act: Emit a resource update with a SignUp that has an id | ||
| const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); | ||
|
|
||
| // Assert: Signal should be updated | ||
| expect(signUpResourceSignal().resource).toBe(signUp); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
| }); | ||
|
|
||
| it('should ignore null resource update when previous resource exists and canBeDiscarded is false', () => { | ||
| // Arrange: Set up a SignUp with id and canBeDiscarded = false (default) | ||
| const existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); | ||
| expect(signUpResourceSignal().resource).toBe(existingSignUp); | ||
| expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false); | ||
|
|
||
| // Act: Emit a resource update with a null SignUp (simulating client refresh with null sign_up) | ||
| const nullSignUp = new SignUp(null); | ||
|
|
||
| // Assert: Signal should NOT be updated - should still have the existing SignUp | ||
| expect(signUpResourceSignal().resource).toBe(existingSignUp); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
| }); | ||
|
|
||
| it('should allow null resource update when previous resource exists and canBeDiscarded is true', async () => { | ||
| // Arrange: Set up a SignUp with id and mock setActive | ||
| const mockSetActive = vi.fn().mockResolvedValue({}); | ||
| SignUp.clerk = { setActive: mockSetActive } as any; | ||
|
|
||
| const existingSignUp = new SignUp({ | ||
| id: 'signup_123', | ||
| status: 'complete', | ||
| created_session_id: 'session_123', | ||
| } as any); | ||
| expect(signUpResourceSignal().resource).toBe(existingSignUp); | ||
| expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false); | ||
|
|
||
| // Act: Call finalize() which sets canBeDiscarded to true | ||
| await existingSignUp.__internal_future.finalize(); | ||
|
|
||
| // Verify canBeDiscarded is now true | ||
| expect(existingSignUp.__internal_future.canBeDiscarded).toBe(true); | ||
| expect(mockSetActive).toHaveBeenCalledWith({ session: 'session_123', navigate: undefined }); | ||
|
|
||
| // Now emit a null resource update (simulating client refresh with null sign_up) | ||
| const nullSignUp = new SignUp(null); | ||
|
|
||
| // Assert: The null update SHOULD be allowed because canBeDiscarded is true | ||
| // shouldIgnoreNullUpdate returns false when canBeDiscarded is true | ||
| expect(signUpResourceSignal().resource).toBe(nullSignUp); | ||
| expect(signUpResourceSignal().resource?.id).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('should allow null resource update after reset() is called', async () => { | ||
| // Arrange: Set up mock client that tracks the new SignUp created during reset | ||
| let newSignUpFromReset: SignUp | null = null; | ||
| const mockClient = { | ||
| signUp: new SignUp(null), | ||
| resetSignUp: vi.fn().mockImplementation(function (this: typeof mockClient) { | ||
| newSignUpFromReset = new SignUp(null); | ||
| this.signUp = newSignUpFromReset; | ||
| // reset() emits resource:error to clear errors, but the signal update | ||
| // happens via resource:update when the new SignUp is created | ||
| eventBus.emit('resource:error', { resource: newSignUpFromReset, error: null }); | ||
| // Emit resource:update to update the signal (simulating what happens in real flow) | ||
| eventBus.emit('resource:update', { resource: newSignUpFromReset }); | ||
| }), | ||
| }; | ||
| SignUp.clerk = { client: mockClient } as any; | ||
|
|
||
| // Create a SignUp with id | ||
| const existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
| expect(existingSignUp.__internal_future.canBeDiscarded).toBe(false); | ||
|
|
||
| // Act: Call reset() - this sets canBeDiscarded to true before resetting | ||
| await existingSignUp.__internal_future.reset(); | ||
|
|
||
| // Assert: Verify reset was called | ||
| expect(mockClient.resetSignUp).toHaveBeenCalled(); | ||
|
|
||
| // Assert: Verify canBeDiscarded was set to true on the original SignUp | ||
| expect(existingSignUp.__internal_future.canBeDiscarded).toBe(true); | ||
|
|
||
| // Assert: Verify the signal was updated with a new SignUp that has no id | ||
| // The previous id 'signup_123' should be gone | ||
| expect(signUpResourceSignal().resource).toBe(newSignUpFromReset); | ||
| expect(signUpResourceSignal().resource?.id).toBeUndefined(); | ||
| expect(signUpResourceSignal().resource).not.toBe(existingSignUp); | ||
| }); | ||
|
|
||
| it('should allow resource update when new resource has an id (not a null update)', () => { | ||
| // Arrange: Set up a SignUp with id | ||
| const existingSignUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
|
|
||
| // Act: Emit a resource update with a different SignUp that also has an id | ||
| const newSignUp = new SignUp({ id: 'signup_456', status: 'complete' } as any); | ||
|
|
||
| // Assert: Signal should be updated with the new SignUp | ||
| expect(signUpResourceSignal().resource).toBe(newSignUp); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_456'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('SignIn', () => { | ||
| it('should allow first resource update when previous resource is null', () => { | ||
| // Arrange: Signal starts with null | ||
| expect(signInResourceSignal().resource).toBeNull(); | ||
|
|
||
| // Act: Emit a resource update with a SignIn that has an id | ||
| const signIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any); | ||
|
|
||
| // Assert: Signal should be updated | ||
| expect(signInResourceSignal().resource).toBe(signIn); | ||
| expect(signInResourceSignal().resource?.id).toBe('signin_123'); | ||
| }); | ||
|
|
||
| it('should ignore null resource update when previous resource exists and canBeDiscarded is false', () => { | ||
| // Arrange: Set up a SignIn with id and canBeDiscarded = false (default) | ||
| const existingSignIn = new SignIn({ id: 'signin_123', status: 'needs_identifier' } as any); | ||
| expect(signInResourceSignal().resource).toBe(existingSignIn); | ||
| expect(existingSignIn.__internal_future.canBeDiscarded).toBe(false); | ||
|
|
||
| // Act: Emit a resource update with a null SignIn (simulating client refresh with null sign_in) | ||
| const nullSignIn = new SignIn(null); | ||
|
|
||
| // Assert: Signal should NOT be updated - should still have the existing SignIn | ||
| expect(signInResourceSignal().resource).toBe(existingSignIn); | ||
| expect(signInResourceSignal().resource?.id).toBe('signin_123'); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Simulated client refresh scenarios', () => { | ||
| it('should protect against null update during email verification (before finalize)', () => { | ||
| // This test simulates the scenario described in USER-4372: | ||
| // 1. User is in the middle of sign up with verified email | ||
| // 2. Client refresh happens (e.g., router.refresh in Next.js) | ||
| // 3. Server returns sign_up: null because sign up is complete on server side | ||
| // 4. The null update should be IGNORED to prevent flash of default form | ||
|
|
||
| // Arrange: User has completed email verification but hasn't called finalize yet | ||
| const signUp = new SignUp({ | ||
| id: 'signup_123', | ||
| status: 'complete', | ||
| created_session_id: 'session_123', | ||
| verifications: { | ||
| email_address: { status: 'verified' }, | ||
| }, | ||
| } as any); | ||
|
|
||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
| expect(signUp.__internal_future.canBeDiscarded).toBe(false); | ||
|
|
||
| // Act: Simulate client refresh returning null sign_up | ||
| // This is what happens in Client.fromJSON when server returns sign_up: null | ||
| const nullSignUp = new SignUp(null); | ||
|
|
||
| // Assert: The null update should be IGNORED | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
| expect(signUpResourceSignal().resource).toBe(signUp); | ||
| }); | ||
|
|
||
| it('should allow null update after finalize sets canBeDiscarded to true', async () => { | ||
| // This test verifies that after finalize() is called, | ||
| // the null update IS allowed (because canBeDiscarded is true) | ||
|
|
||
| // Arrange | ||
| const mockSetActive = vi.fn().mockResolvedValue({}); | ||
| SignUp.clerk = { setActive: mockSetActive } as any; | ||
|
|
||
| const signUp = new SignUp({ | ||
| id: 'signup_123', | ||
| status: 'complete', | ||
| created_session_id: 'session_123', | ||
| } as any); | ||
|
|
||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
| expect(signUp.__internal_future.canBeDiscarded).toBe(false); | ||
|
|
||
| // Act: Call finalize - this sets canBeDiscarded to true | ||
| await signUp.__internal_future.finalize(); | ||
|
|
||
| // Verify canBeDiscarded is now true | ||
| expect(signUp.__internal_future.canBeDiscarded).toBe(true); | ||
|
|
||
| // Now simulate a null update | ||
| const nullSignUp = new SignUp(null); | ||
|
|
||
| // Assert: The null update SHOULD be allowed now | ||
| // Because canBeDiscarded is true, shouldIgnoreNullUpdate returns false | ||
| expect(signUpResourceSignal().resource).toBe(nullSignUp); | ||
| expect(signUpResourceSignal().resource?.id).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Edge cases', () => { | ||
| it('should handle rapid successive updates correctly', () => { | ||
| // First update with valid SignUp | ||
| const signUp1 = new SignUp({ id: 'signup_1', status: 'missing_requirements' } as any); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_1'); | ||
|
|
||
| // Second update with another valid SignUp | ||
| const signUp2 = new SignUp({ id: 'signup_2', status: 'missing_requirements' } as any); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_2'); | ||
|
|
||
| // Null update should be ignored | ||
| const nullSignUp = new SignUp(null); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_2'); | ||
|
|
||
| // Another valid update should work | ||
| const signUp3 = new SignUp({ id: 'signup_3', status: 'complete' } as any); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_3'); | ||
| }); | ||
|
|
||
| it('should handle update with same instance correctly', () => { | ||
| // Create a SignUp | ||
| const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any); | ||
| expect(signUpResourceSignal().resource?.id).toBe('signup_123'); | ||
|
|
||
| // Manually emit update with the same instance (simulating fromJSON on same instance) | ||
| eventBus.emit('resource:update', { resource: signUp }); | ||
|
|
||
| // Signal should still have the same instance | ||
| expect(signUpResourceSignal().resource).toBe(signUp); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.