summaryrefslogtreecommitdiff
path: root/apps/web/src/__tests__/excessive-projects.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/__tests__/excessive-projects.test.ts')
-rw-r--r--apps/web/src/__tests__/excessive-projects.test.ts316
1 files changed, 316 insertions, 0 deletions
diff --git a/apps/web/src/__tests__/excessive-projects.test.ts b/apps/web/src/__tests__/excessive-projects.test.ts
new file mode 100644
index 00000000..54dfb6d6
--- /dev/null
+++ b/apps/web/src/__tests__/excessive-projects.test.ts
@@ -0,0 +1,316 @@
+/**
+ * Test Suite: Excessive Project Creation Bug
+ *
+ * This test suite reproduces and validates the bug where navigating to invalid
+ * project IDs creates excessive "Untitled Project" entries due to race conditions
+ * in the useEffect hook dependencies.
+ *
+ * Bug Report: https://github.com/OpenCut-app/OpenCut/issues/182
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { render, waitFor } from '@testing-library/react'
+import { act } from 'react'
+
+// Mock the project store
+const mockProjects: any[] = []
+const mockActiveProject = { id: null }
+
+const mockProjectStore = {
+ savedProjects: mockProjects,
+ activeProject: mockActiveProject,
+ loadProject: vi.fn(),
+ createNewProject: vi.fn(),
+ loadAllProjects: vi.fn(),
+}
+
+const mockLoadProject = vi.fn()
+const mockCreateNewProject = vi.fn()
+
+// Mock Zustand store
+vi.mock('@/stores/project-store', () => ({
+ useProjectStore: (selector: any) => {
+ if (typeof selector === 'function') {
+ return selector(mockProjectStore)
+ }
+ return mockProjectStore
+ },
+}))
+
+// Mock the FIXED editor page component logic (extracted for testing)
+class EditorPageLogic {
+ private handledProjectIds = new Set<string>()
+ private isInitializing = false
+ private projectStore: any
+ private router: any
+
+ constructor(projectStore: any, router: any) {
+ this.projectStore = projectStore
+ this.router = router
+ }
+
+ async initProject(projectId: string, activeProject: any) {
+ if (!projectId) {
+ return
+ }
+
+ // Prevent duplicate initialization (FIX for race condition)
+ if (this.isInitializing) {
+ return
+ }
+
+ // Check if project is already loaded
+ if (activeProject?.id === projectId) {
+ return
+ }
+
+ // Check if we've already handled this project ID
+ if (this.handledProjectIds.has(projectId)) {
+ return
+ }
+
+ // Mark as initializing to prevent race conditions
+ this.isInitializing = true
+ this.handledProjectIds.add(projectId)
+
+ try {
+ await this.projectStore.loadProject(projectId)
+ // Project loaded successfully
+ this.isInitializing = false
+ } catch (error) {
+ // More specific error handling - only create new project for actual "not found" errors
+ const isProjectNotFound = error instanceof Error &&
+ (error.message.includes('not found') ||
+ error.message.includes('does not exist') ||
+ error.message.includes('Project not found'))
+
+ if (isProjectNotFound) {
+ try {
+ const newProjectId = await this.projectStore.createNewProject("Untitled Project")
+ this.router.replace(`/editor/${newProjectId}`)
+ } catch (createError) {
+ console.error("Failed to create new project:", createError)
+ }
+ } else {
+ // For other errors (storage issues, corruption, etc.), don't create new project
+ console.error("Project loading failed with recoverable error:", error)
+ // Remove from handled set so user can retry
+ this.handledProjectIds.delete(projectId)
+ }
+
+ this.isInitializing = false
+ }
+ }
+
+ // Simulate the useEffect behavior with dependencies (FIXED - no activeProject dependency)
+ async simulateUseEffect(projectId: string, activeProject: any) {
+ // This simulates how React useEffect would trigger with FIXED dependencies
+ await this.initProject(projectId, activeProject)
+ }
+
+ reset() {
+ this.handledProjectIds.clear()
+ this.isInitializing = false
+ }
+}
+
+describe('Excessive Project Creation Bug', () => {
+ let editorLogic: EditorPageLogic
+ let projectCreationCount: number
+
+ beforeEach(() => {
+ // Reset state
+ mockProjects.length = 0
+ mockActiveProject.id = null
+ projectCreationCount = 0
+
+ // Setup mocks
+ mockLoadProject.mockRejectedValue(new Error('Project not found'))
+ mockCreateNewProject.mockImplementation(async (name: string) => {
+ projectCreationCount++
+ const newProject = {
+ id: `generated-id-${projectCreationCount}`,
+ name,
+ createdAt: new Date(),
+ }
+ mockProjects.push(newProject)
+ mockActiveProject.id = newProject.id
+ return newProject.id
+ })
+
+ mockProjectStore.loadProject = mockLoadProject
+ mockProjectStore.createNewProject = mockCreateNewProject
+
+ editorLogic = new EditorPageLogic(mockProjectStore, global.mockRouter)
+ })
+
+ afterEach(() => {
+ editorLogic.reset()
+ vi.clearAllMocks()
+ })
+
+ describe('Single Invalid Project ID', () => {
+ it('should create exactly one project for a single invalid ID', async () => {
+ const invalidProjectId = 'invalid-project-123'
+
+ await editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
+
+ expect(projectCreationCount).toBe(1)
+ expect(mockCreateNewProject).toHaveBeenCalledTimes(1)
+ expect(mockCreateNewProject).toHaveBeenCalledWith('Untitled Project')
+ expect(global.mockRouter.replace).toHaveBeenCalledWith('/editor/generated-id-1')
+ })
+
+ it('should not create duplicate projects for the same invalid ID', async () => {
+ const invalidProjectId = 'invalid-project-123'
+
+ // First call
+ await editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
+
+ // Second call with same ID (should be prevented by handledProjectIds)
+ await editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
+
+ expect(projectCreationCount).toBe(1)
+ expect(mockCreateNewProject).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('Race Condition Scenarios', () => {
+ it('should reproduce the race condition when activeProject changes', async () => {
+ const invalidProjectId = 'invalid-project-race'
+
+ // Simulate the race condition sequence:
+ // 1. useEffect triggers with invalid project ID
+ await editorLogic.simulateUseEffect(invalidProjectId, { id: null })
+
+ // 2. activeProject changes (this would trigger useEffect again in React)
+ const newActiveProject = { id: 'generated-id-1' }
+
+ // 3. useEffect triggers again with same projectId but different activeProject
+ await editorLogic.simulateUseEffect(invalidProjectId, newActiveProject)
+
+ // Should still only create one project due to handledProjectIds
+ expect(projectCreationCount).toBe(1)
+ })
+
+ it('should demonstrate the bug with rapid navigation', async () => {
+ const invalidIds = [
+ 'rapid-test-1',
+ 'rapid-test-2',
+ 'rapid-test-3',
+ 'rapid-test-4',
+ 'rapid-test-5'
+ ]
+
+ // Simulate rapid navigation to multiple invalid IDs
+ for (const invalidId of invalidIds) {
+ await editorLogic.simulateUseEffect(invalidId, mockActiveProject)
+ }
+
+ // Each invalid ID should create exactly one project
+ expect(projectCreationCount).toBe(invalidIds.length)
+ expect(mockCreateNewProject).toHaveBeenCalledTimes(invalidIds.length)
+ })
+
+ it('should simulate React Strict Mode double execution', async () => {
+ const invalidProjectId = 'strict-mode-test'
+
+ // Simulate React Strict Mode double execution
+ const promises = [
+ editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject),
+ editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
+ ]
+
+ await Promise.all(promises)
+
+ // Should only create one project despite double execution
+ // (This test validates that handledProjectIds prevents duplicates)
+ expect(projectCreationCount).toBe(1)
+ })
+ })
+
+ describe('Error Handling Scenarios', () => {
+ it('should only create projects for "not found" errors, not other failures', async () => {
+ const scenarios = [
+ { id: 'storage-error', error: new Error('Storage quota exceeded'), shouldCreateProject: false },
+ { id: 'network-error', error: new Error('Network timeout'), shouldCreateProject: false },
+ { id: 'corruption-error', error: new Error('File corrupted'), shouldCreateProject: false },
+ { id: 'permission-error', error: new Error('Permission denied'), shouldCreateProject: false },
+ { id: 'not-found-error', error: new Error('Project not found'), shouldCreateProject: true },
+ { id: 'does-not-exist-error', error: new Error('Project does not exist'), shouldCreateProject: true }
+ ]
+
+ let expectedProjectCount = 0
+ for (const scenario of scenarios) {
+ mockLoadProject.mockRejectedValueOnce(scenario.error)
+ await editorLogic.simulateUseEffect(scenario.id, mockActiveProject)
+ if (scenario.shouldCreateProject) {
+ expectedProjectCount++
+ }
+ }
+
+ // Only "not found" errors should create new projects
+ expect(projectCreationCount).toBe(expectedProjectCount)
+ expect(expectedProjectCount).toBe(2) // Only the 2 "not found" scenarios
+ })
+ })
+
+ describe('Stress Test - Excessive Project Creation', () => {
+ it('should demonstrate how 885 projects could be created', async () => {
+ const numberOfInvalidRequests = 50 // Simulate many invalid requests over time
+
+ // Simulate accumulated invalid project requests over development sessions
+ for (let i = 0; i < numberOfInvalidRequests; i++) {
+ const invalidId = `stress-test-${i}-${Date.now()}`
+ await editorLogic.simulateUseEffect(invalidId, mockActiveProject)
+
+ // Reset handledProjectIds occasionally to simulate component remounting
+ if (i % 10 === 0) {
+ editorLogic.reset()
+ }
+ }
+
+ expect(projectCreationCount).toBe(numberOfInvalidRequests)
+ expect(mockProjects).toHaveLength(numberOfInvalidRequests)
+
+ // Verify all projects are "Untitled Project"
+ const untitledProjects = mockProjects.filter(p => p.name === 'Untitled Project')
+ expect(untitledProjects).toHaveLength(numberOfInvalidRequests)
+ })
+ })
+
+ describe('Bug Validation', () => {
+ it('should confirm the exact bug reproduction steps', async () => {
+ // Step 1: Navigate to invalid project ID (like browser back button)
+ const invalidId = 'back-button-navigation-test'
+
+ // Step 2: Project load fails, creates new project
+ await editorLogic.simulateUseEffect(invalidId, mockActiveProject)
+
+ expect(projectCreationCount).toBe(1)
+ expect(mockProjects[0].name).toBe('Untitled Project')
+ expect(global.mockRouter.replace).toHaveBeenCalledWith('/editor/generated-id-1')
+
+ // Step 3: Verify the project was added to storage
+ expect(mockProjects).toHaveLength(1)
+ })
+
+ it('should measure the impact of the bug', async () => {
+ const startTime = Date.now()
+ const testIterations = 100
+
+ for (let i = 0; i < testIterations; i++) {
+ await editorLogic.simulateUseEffect(`test-${i}`, mockActiveProject)
+ }
+
+ const endTime = Date.now()
+ const duration = endTime - startTime
+
+ expect(projectCreationCount).toBe(testIterations)
+
+ // Log performance impact
+ console.log(`Created ${projectCreationCount} projects in ${duration}ms`)
+ console.log(`Average: ${duration / testIterations}ms per project`)
+ })
+ })
+})