summaryrefslogtreecommitdiff
path: root/apps/web/src/__tests__/excessive-projects.test.ts
blob: 54dfb6d6345daebe3d947d1f0fa3f41418b96ed5 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
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`)
    })
  })
})