diff --git a/src/lib/integrationTests/helpers/approvalTestHelpers.ts b/src/lib/integrationTests/helpers/approvalTestHelpers.ts index 4777a0e1..a878842e 100644 --- a/src/lib/integrationTests/helpers/approvalTestHelpers.ts +++ b/src/lib/integrationTests/helpers/approvalTestHelpers.ts @@ -601,3 +601,96 @@ export async function createRideDirectly( tracker.track('rides', ride.id); return ride.id; } + +/** + * Create test photo gallery submission + */ +export async function createTestPhotoGallerySubmission( + entityId: string, + entityType: 'park' | 'ride', + photoCount: number, + userId: string, + tracker: TestDataTracker +): Promise<{ submissionId: string; itemId: string }> { + // Create content submission first + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'photo_gallery', + status: 'pending', + is_test_data: true, + }) + .select() + .single(); + + if (submissionError || !submission) { + throw new Error(`Failed to create content submission: ${submissionError?.message}`); + } + + tracker.track('content_submissions', submission.id); + + // Create photo submission + const { data: photoSubmission, error: photoSubError } = await supabase + .from('photo_submissions') + .insert({ + entity_id: entityId, + entity_type: entityType, + submission_id: submission.id, + is_test_data: true, + }) + .select() + .single(); + + if (photoSubError || !photoSubmission) { + throw new Error(`Failed to create photo submission: ${photoSubError?.message}`); + } + + tracker.track('photo_submissions', photoSubmission.id); + + // Create submission item linking to photo submission + const { data: item, error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + photo_submission_id: photoSubmission.id, + item_type: 'photo_gallery', + status: 'pending', + is_test_data: true, + }) + .select() + .single(); + + if (itemError || !item) { + throw new Error(`Failed to create submission item: ${itemError?.message}`); + } + + tracker.track('submission_items', item.id); + + // Create photo submission items + for (let i = 0; i < photoCount; i++) { + const { data: photoItem, error: photoItemError } = await supabase + .from('photo_submission_items') + .insert({ + photo_submission_id: photoSubmission.id, + cloudflare_image_id: `test-image-${Date.now()}-${i}`, + cloudflare_image_url: `https://test.com/image-${i}.jpg`, + caption: `Test photo ${i + 1}`, + order_index: i, + is_test_data: true, + }) + .select() + .single(); + + if (photoItemError || !photoItem) { + throw new Error(`Failed to create photo item ${i}: ${photoItemError?.message}`); + } + + tracker.track('photo_submission_items', photoItem.id); + } + + return { + submissionId: submission.id, + itemId: item.id, + }; +} diff --git a/src/lib/integrationTests/suites/approvalPipelineTests.ts b/src/lib/integrationTests/suites/approvalPipelineTests.ts index 490880dc..2360c766 100644 --- a/src/lib/integrationTests/suites/approvalPipelineTests.ts +++ b/src/lib/integrationTests/suites/approvalPipelineTests.ts @@ -28,6 +28,7 @@ import { createTestCompanySubmission, createTestRideModelSubmission, createCompositeSubmission, + createTestPhotoGallerySubmission, approveSubmission, pollForEntity, pollForVersion, @@ -723,6 +724,1144 @@ const idempotencyTest: Test = { }, }; +/** + * APL-005: Park Update Through Pipeline + */ +const parkUpdateTest: Test = { + id: 'APL-005', + name: 'Park Update Through Pipeline', + description: 'Validates park updates create new versions', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park directly + const parkData = generateUniqueParkData('apl-005'); + const parkId = await createParkDirectly(parkData, tracker); + + // Wait for version 1 + const version1 = await pollForVersion('park', parkId, 1, 10000); + if (!version1) { + throw new Error('Version 1 not created'); + } + tracker.track('park_versions', version1.id); + + // Submit update + const updateData = { + ...parkData, + name: `${parkData.name} UPDATED`, + description: 'Updated description', + }; + + const { submissionId, itemId } = await createTestParkSubmission(updateData, userId, tracker); + + // Approve update + const approvalResult = await approveSubmission(submissionId, [itemId], authToken); + if (!approvalResult.success) { + throw new Error(`Update approval failed: ${approvalResult.error}`); + } + + // Wait for version 2 + const version2 = await pollForVersion('park', parkId, 2, 10000); + if (!version2) { + throw new Error('Version 2 not created after update'); + } + tracker.track('park_versions', version2.id); + + // Verify park updated + const { data: updatedPark } = await supabase + .from('parks') + .select('*') + .eq('id', parkId) + .single(); + + if (!updatedPark || !updatedPark.name.includes('UPDATED')) { + throw new Error('Park not updated correctly'); + } + + // Verify version 1 still exists with original data + const { data: v1Check } = await supabase + .from('park_versions') + .select('*') + .eq('id', version1.id) + .single(); + + if (!v1Check || v1Check.version_number !== 1) { + throw new Error('Version 1 corrupted'); + } + + return { + id: 'APL-005', + name: 'Park Update Through Pipeline', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-005', + name: 'Park Update Through Pipeline', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-006: Ride Update Through Pipeline + */ +const rideUpdateTest: Test = { + id: 'APL-006', + name: 'Ride Update Through Pipeline', + description: 'Validates ride updates create new versions', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park first + const parkData = generateUniqueParkData('apl-006-park'); + const parkId = await createParkDirectly(parkData, tracker); + + // Create ride directly + const rideData = generateUniqueRideData(parkId, 'apl-006'); + const rideId = await createRideDirectly(rideData, tracker); + + // Wait for version 1 + const version1 = await pollForVersion('ride', rideId, 1, 10000); + if (!version1) { + throw new Error('Version 1 not created'); + } + tracker.track('ride_versions', version1.id); + + // Submit update + const updateData = { + ...rideData, + name: `${rideData.name} UPDATED`, + max_speed_kmh: 120, + }; + + const { submissionId, itemId } = await createTestRideSubmission(updateData, userId, tracker); + + // Approve update + const approvalResult = await approveSubmission(submissionId, [itemId], authToken); + if (!approvalResult.success) { + throw new Error(`Update approval failed: ${approvalResult.error}`); + } + + // Wait for version 2 + const version2 = await pollForVersion('ride', rideId, 2, 10000); + if (!version2) { + throw new Error('Version 2 not created after update'); + } + tracker.track('ride_versions', version2.id); + + // Verify ride updated + const { data: updatedRide } = await supabase + .from('rides') + .select('*') + .eq('id', rideId) + .single(); + + if (!updatedRide || !updatedRide.name.includes('UPDATED') || updatedRide.max_speed_kmh !== 120) { + throw new Error('Ride not updated correctly'); + } + + return { + id: 'APL-006', + name: 'Ride Update Through Pipeline', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-006', + name: 'Ride Update Through Pipeline', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-008: Ride + Manufacturer + Designer Composite + */ +const rideManufacturerDesignerCompositeTest: Test = { + id: 'APL-008', + name: 'Ride + Manufacturer + Designer Composite', + description: 'Validates composite with multiple company temp references', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park + const parkData = generateUniqueParkData('apl-008-park'); + const parkId = await createParkDirectly(parkData, tracker); + + // Create composite + const manufacturerData = generateUniqueCompanyData('manufacturer', 'apl-008-mfg'); + const designerData = generateUniqueCompanyData('designer', 'apl-008-des'); + const rideData = generateUniqueRideData(parkId, 'apl-008'); + + const { submissionId, itemIds } = await createCompositeSubmission( + { + type: 'ride', + data: rideData, + }, + [ + { + type: 'company', + data: manufacturerData, + tempId: 'temp-manufacturer', + companyType: 'manufacturer', + }, + { + type: 'company', + data: designerData, + tempId: 'temp-designer', + companyType: 'designer', + }, + ], + userId, + tracker + ); + + // Approve all + const approvalResult = await approveSubmission(submissionId, itemIds, authToken); + if (!approvalResult.success) { + throw new Error(`Approval failed: ${approvalResult.error}`); + } + + // Poll for companies + const manufacturer = await pollForEntity('companies', manufacturerData.slug, 10000); + const designer = await pollForEntity('companies', designerData.slug, 10000); + + if (!manufacturer || !designer) { + throw new Error('Companies not created'); + } + + tracker.track('companies', manufacturer.id); + tracker.track('companies', designer.id); + + // Poll for ride + const ride = await pollForEntity('rides', rideData.slug, 10000); + if (!ride) { + throw new Error('Ride not created'); + } + + tracker.track('rides', ride.id); + + // Verify both temp refs resolved + if (ride.manufacturer_id !== manufacturer.id || ride.designer_id !== designer.id) { + throw new Error('Temp refs not resolved correctly'); + } + + return { + id: 'APL-008', + name: 'Ride + Manufacturer + Designer Composite', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-008', + name: 'Ride + Manufacturer + Designer Composite', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-009: Park + Operator + Property Owner Composite + */ +const parkOperatorOwnerCompositeTest: Test = { + id: 'APL-009', + name: 'Park + Operator + Property Owner Composite', + description: 'Validates park composite with operator and owner temp references', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create composite + const operatorData = generateUniqueCompanyData('operator', 'apl-009-op'); + const ownerData = generateUniqueCompanyData('property_owner', 'apl-009-owner'); + const parkData = generateUniqueParkData('apl-009'); + + const { submissionId, itemIds } = await createCompositeSubmission( + { + type: 'park', + data: parkData, + }, + [ + { + type: 'company', + data: operatorData, + tempId: 'temp-operator', + companyType: 'operator', + }, + { + type: 'company', + data: ownerData, + tempId: 'temp-owner', + companyType: 'property_owner', + }, + ], + userId, + tracker + ); + + // Approve all + const approvalResult = await approveSubmission(submissionId, itemIds, authToken); + if (!approvalResult.success) { + throw new Error(`Approval failed: ${approvalResult.error}`); + } + + // Poll for companies + const operator = await pollForEntity('companies', operatorData.slug, 10000); + const owner = await pollForEntity('companies', ownerData.slug, 10000); + + if (!operator || !owner) { + throw new Error('Companies not created'); + } + + tracker.track('companies', operator.id); + tracker.track('companies', owner.id); + + // Poll for park + const park = await pollForEntity('parks', parkData.slug, 10000); + if (!park) { + throw new Error('Park not created'); + } + + tracker.track('parks', park.id); + + // Verify temp refs resolved + if (park.operator_id !== operator.id || park.property_owner_id !== owner.id) { + throw new Error('Park company refs not resolved correctly'); + } + + return { + id: 'APL-009', + name: 'Park + Operator + Property Owner Composite', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-009', + name: 'Park + Operator + Property Owner Composite', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-014: Lock Conflict Prevention + */ +const lockConflictTest: Test = { + id: 'APL-014', + name: 'Lock Conflict Prevention', + description: 'Validates locked submissions prevent concurrent approval', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create submission 1 - same user lock (should succeed) + const parkData1 = generateUniqueParkData('apl-014-a'); + const { submissionId: subId1, itemId: itemId1 } = await createTestParkSubmission(parkData1, userId, tracker); + + // Lock to current user + await supabase + .from('content_submissions') + .update({ + assigned_to: userId, + locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + }) + .eq('id', subId1); + + // Approve (should succeed - same user) + const result1 = await approveSubmission(subId1, [itemId1], authToken); + if (!result1.success) { + throw new Error(`Same user approval should succeed: ${result1.error}`); + } + + const park1 = await pollForEntity('parks', parkData1.slug, 10000); + if (!park1) { + throw new Error('Park 1 not created'); + } + tracker.track('parks', park1.id); + + // Create submission 2 - different user lock (should fail) + const parkData2 = generateUniqueParkData('apl-014-b'); + const { submissionId: subId2, itemId: itemId2 } = await createTestParkSubmission(parkData2, userId, tracker); + + // Lock to fake user + const fakeUserId = '00000000-0000-0000-0000-000000000001'; + await supabase + .from('content_submissions') + .update({ + assigned_to: fakeUserId, + locked_until: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + }) + .eq('id', subId2); + + // Try to approve (should fail) + const result2 = await approveSubmission(subId2, [itemId2], authToken); + if (result2.success) { + throw new Error('Approval should fail when locked by different user'); + } + + // Verify park NOT created + const park2 = await pollForEntity('parks', parkData2.slug, 2000); + if (park2) { + throw new Error('Park 2 should not be created due to lock'); + } + + return { + id: 'APL-014', + name: 'Lock Conflict Prevention', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-014', + name: 'Lock Conflict Prevention', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-016: Banned User Rejection + */ +const bannedUserTest: Test = { + id: 'APL-016', + name: 'Banned User Rejection', + description: 'Validates banned users cannot have submissions approved', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create submission + const parkData = generateUniqueParkData('apl-016'); + const { submissionId, itemId } = await createTestParkSubmission(parkData, userId, tracker); + + // Ban user + await supabase + .from('profiles') + .update({ banned: true }) + .eq('user_id', userId); + + // Try to approve (should fail) + const result = await approveSubmission(submissionId, [itemId], authToken); + if (result.success) { + throw new Error('Approval should fail for banned user'); + } + + // Verify park NOT created + const park = await pollForEntity('parks', parkData.slug, 2000); + if (park) { + throw new Error('Park should not be created for banned user'); + } + + // Unban user + await supabase + .from('profiles') + .update({ banned: false }) + .eq('user_id', userId); + + // Try again (should succeed now) + const result2 = await approveSubmission(submissionId, [itemId], authToken); + if (!result2.success) { + throw new Error(`Approval should succeed after unban: ${result2.error}`); + } + + // Verify park created + const parkNow = await pollForEntity('parks', parkData.slug, 10000); + if (!parkNow) { + throw new Error('Park not created after unban'); + } + tracker.track('parks', parkNow.id); + + return { + id: 'APL-016', + name: 'Banned User Rejection', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + // Make sure to unban user even if test fails + try { + const userId = await getCurrentUserId(); + await supabase + .from('profiles') + .update({ banned: false }) + .eq('user_id', userId); + } catch (cleanupError) { + console.error('Failed to unban user in cleanup:', cleanupError); + } + + return { + id: 'APL-016', + name: 'Banned User Rejection', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-017: Multiple Edit Chain + */ +const multipleEditChainTest: Test = { + id: 'APL-017', + name: 'Multiple Edit Chain', + description: 'Validates multiple sequential edits create correct version chain', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park directly + const parkData = generateUniqueParkData('apl-017'); + const parkId = await createParkDirectly(parkData, tracker); + + // Wait for version 1 + await pollForVersion('park', parkId, 1, 10000); + + // Edit 1: Change name + const edit1Data = { ...parkData, name: `${parkData.name} V2` }; + const { submissionId: sub1, itemId: item1 } = await createTestParkSubmission(edit1Data, userId, tracker); + await approveSubmission(sub1, [item1], authToken); + const v2 = await pollForVersion('park', parkId, 2, 10000); + if (!v2) throw new Error('Version 2 not created'); + tracker.track('park_versions', v2.id); + + // Edit 2: Change description + const edit2Data = { ...edit1Data, description: 'Version 3 description' }; + const { submissionId: sub2, itemId: item2 } = await createTestParkSubmission(edit2Data, userId, tracker); + await approveSubmission(sub2, [item2], authToken); + const v3 = await pollForVersion('park', parkId, 3, 10000); + if (!v3) throw new Error('Version 3 not created'); + tracker.track('park_versions', v3.id); + + // Edit 3: Change status + const edit3Data = { ...edit2Data, status: 'closed' }; + const { submissionId: sub3, itemId: item3 } = await createTestParkSubmission(edit3Data, userId, tracker); + await approveSubmission(sub3, [item3], authToken); + const v4 = await pollForVersion('park', parkId, 4, 10000); + if (!v4) throw new Error('Version 4 not created'); + tracker.track('park_versions', v4.id); + + // Verify all 4 versions exist + const { count } = await supabase + .from('park_versions') + .select('*', { count: 'exact', head: true }) + .eq('park_id', parkId); + + if (count !== 4) { + throw new Error(`Expected 4 versions, found ${count}`); + } + + // Verify version numbers are correct + const { data: versions } = await supabase + .from('park_versions') + .select('version_number') + .eq('park_id', parkId) + .order('version_number', { ascending: true }); + + const versionNumbers = versions?.map(v => v.version_number) || []; + if (versionNumbers.join(',') !== '1,2,3,4') { + throw new Error(`Version sequence incorrect: ${versionNumbers.join(',')}`); + } + + return { + id: 'APL-017', + name: 'Multiple Edit Chain', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-017', + name: 'Multiple Edit Chain', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-019: Version Snapshot Integrity + */ +const versionSnapshotIntegrityTest: Test = { + id: 'APL-019', + name: 'Version Snapshot Integrity', + description: 'Validates version snapshots maintain data integrity', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park with all fields + const parkData = generateUniqueParkData('apl-019'); + const fullParkData = { + ...parkData, + description: 'Full description with all fields', + website_url: 'https://example.com', + phone: '+1234567890', + email: 'test@example.com', + }; + + const parkId = await createParkDirectly(fullParkData, tracker); + + // Wait for version 1 + const v1 = await pollForVersion('park', parkId, 1, 10000); + if (!v1) throw new Error('Version 1 not created'); + tracker.track('park_versions', v1.id); + + // Update only 2 fields + const updateData = { + ...fullParkData, + name: `${fullParkData.name} CHANGED`, + description: 'Updated description only', + }; + + const { submissionId, itemId } = await createTestParkSubmission(updateData, userId, tracker); + await approveSubmission(submissionId, [itemId], authToken); + + // Wait for version 2 + const v2 = await pollForVersion('park', parkId, 2, 10000); + if (!v2) throw new Error('Version 2 not created'); + tracker.track('park_versions', v2.id); + + // Verify version 1 has original values + const { data: version1 } = await supabase + .from('park_versions') + .select('*') + .eq('id', v1.id) + .single(); + + if (!version1 || version1.name !== fullParkData.name) { + throw new Error('Version 1 data corrupted'); + } + + if (version1.website_url !== fullParkData.website_url) { + throw new Error('Version 1 unchanged fields corrupted'); + } + + // Verify version 2 has updated values + const { data: version2 } = await supabase + .from('park_versions') + .select('*') + .eq('id', v2.id) + .single(); + + if (!version2 || !version2.name.includes('CHANGED')) { + throw new Error('Version 2 changes not captured'); + } + + // Verify unchanged fields identical in both versions + if (version1.website_url !== version2.website_url || + version1.phone !== version2.phone || + version1.email !== version2.email) { + throw new Error('Unchanged fields differ between versions'); + } + + return { + id: 'APL-019', + name: 'Version Snapshot Integrity', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-019', + name: 'Version Snapshot Integrity', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-010: Photo Gallery for Park + */ +const parkPhotoGalleryTest: Test = { + id: 'APL-010', + name: 'Photo Gallery for Park', + description: 'Validates photo gallery submissions for parks', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park + const parkData = generateUniqueParkData('apl-010'); + const parkId = await createParkDirectly(parkData, tracker); + + // Create photo gallery submission + const { submissionId, itemId } = await createTestPhotoGallerySubmission( + parkId, + 'park', + 3, + userId, + tracker + ); + + // Approve + const approvalResult = await approveSubmission(submissionId, [itemId], authToken); + if (!approvalResult.success) { + throw new Error(`Photo approval failed: ${approvalResult.error}`); + } + + // Wait and verify photos created + await new Promise(resolve => setTimeout(resolve, 2000)); + + const { data: photos, error } = await supabase + .from('photos') + .select('*') + .eq('entity_id', parkId) + .eq('entity_type', 'park'); + + if (error || !photos || photos.length !== 3) { + throw new Error(`Expected 3 photos, found ${photos?.length || 0}`); + } + + photos.forEach(photo => { + tracker.track('photos', photo.id); + if (photo.entity_id !== parkId || photo.entity_type !== 'park') { + throw new Error('Photo entity references incorrect'); + } + }); + + return { + id: 'APL-010', + name: 'Photo Gallery for Park', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-010', + name: 'Photo Gallery for Park', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-011: Photo Gallery for Ride + */ +const ridePhotoGalleryTest: Test = { + id: 'APL-011', + name: 'Photo Gallery for Ride', + description: 'Validates photo gallery submissions for rides', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park and ride + const parkData = generateUniqueParkData('apl-011-park'); + const parkId = await createParkDirectly(parkData, tracker); + + const rideData = generateUniqueRideData(parkId, 'apl-011'); + const rideId = await createRideDirectly(rideData, tracker); + + // Create photo gallery submission with 5 photos + const { submissionId, itemId } = await createTestPhotoGallerySubmission( + rideId, + 'ride', + 5, + userId, + tracker + ); + + // Approve + const approvalResult = await approveSubmission(submissionId, [itemId], authToken); + if (!approvalResult.success) { + throw new Error(`Photo approval failed: ${approvalResult.error}`); + } + + // Wait and verify photos created + await new Promise(resolve => setTimeout(resolve, 2000)); + + const { data: photos, error } = await supabase + .from('photos') + .select('*') + .eq('entity_id', rideId) + .eq('entity_type', 'ride'); + + if (error || !photos || photos.length !== 5) { + throw new Error(`Expected 5 photos, found ${photos?.length || 0}`); + } + + photos.forEach(photo => { + tracker.track('photos', photo.id); + if (photo.entity_id !== rideId || photo.entity_type !== 'ride') { + throw new Error('Photo entity references incorrect'); + } + }); + + return { + id: 'APL-011', + name: 'Photo Gallery for Ride', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-011', + name: 'Photo Gallery for Ride', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-015: Invalid Temp Reference Rejection + */ +const invalidTempRefTest: Test = { + id: 'APL-015', + name: 'Invalid Temp Reference Rejection', + description: 'Validates submissions with invalid temp references are rejected', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park + const parkData = generateUniqueParkData('apl-015-park'); + const parkId = await createParkDirectly(parkData, tracker); + + // Create ride submission manually with invalid temp ref + const rideData = generateUniqueRideData(parkId, 'apl-015'); + + // Create submission + const { data: submission, error: subError } = await supabase + .from('content_submissions') + .insert({ + user_id: userId, + submission_type: 'ride_create', + status: 'pending', + is_test_data: true, + }) + .select() + .single(); + + if (subError || !submission) { + throw new Error('Failed to create test submission'); + } + + tracker.track('content_submissions', submission.id); + + // Create ride submission with invalid temp ref (99 doesn't exist) + const { data: rideSubmission, error: rideSubError } = await supabase + .from('ride_submissions') + .insert({ + ...rideData, + _temp_manufacturer_ref: 99, // Invalid - no item with this order_index + is_test_data: true, + }) + .select() + .single(); + + if (rideSubError || !rideSubmission) { + throw new Error('Failed to create ride submission'); + } + + tracker.track('ride_submissions', rideSubmission.id); + + // Create submission item + const { data: item, error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + ride_submission_id: rideSubmission.id, + item_type: 'ride_create', + status: 'pending', + is_test_data: true, + }) + .select() + .single(); + + if (itemError || !item) { + throw new Error('Failed to create submission item'); + } + + tracker.track('submission_items', item.id); + + // Try to approve (should fail) + const approvalResult = await approveSubmission(submission.id, [item.id], authToken); + + if (approvalResult.success) { + throw new Error('Approval should fail with invalid temp ref'); + } + + // Verify ride NOT created + const ride = await pollForEntity('rides', rideData.slug, 2000); + if (ride) { + throw new Error('Ride should not be created with invalid temp ref'); + } + + return { + id: 'APL-015', + name: 'Invalid Temp Reference Rejection', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-015', + name: 'Invalid Temp Reference Rejection', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + +/** + * APL-018: Concurrent Edit Conflict + */ +const concurrentEditTest: Test = { + id: 'APL-018', + name: 'Concurrent Edit Conflict', + description: 'Validates concurrent edits are applied in order', + run: async (): Promise => { + const startTime = Date.now(); + const tracker = new TestDataTracker(); + + try { + const userId = await getCurrentUserId(); + const authToken = await getAuthToken(); + + // Create park + const parkData = generateUniqueParkData('apl-018'); + const parkId = await createParkDirectly(parkData, tracker); + + // Wait for version 1 + await pollForVersion('park', parkId, 1, 10000); + + // Create two concurrent edits + const editA = { ...parkData, name: `${parkData.name} NAME-A` }; + const editB = { ...parkData, name: `${parkData.name} NAME-B` }; + + const { submissionId: subA, itemId: itemA } = await createTestParkSubmission(editA, userId, tracker); + const { submissionId: subB, itemId: itemB } = await createTestParkSubmission(editB, userId, tracker); + + // Approve edit A first + await approveSubmission(subA, [itemA], authToken); + const v2 = await pollForVersion('park', parkId, 2, 10000); + if (!v2) throw new Error('Version 2 not created'); + tracker.track('park_versions', v2.id); + + // Then approve edit B + await approveSubmission(subB, [itemB], authToken); + const v3 = await pollForVersion('park', parkId, 3, 10000); + if (!v3) throw new Error('Version 3 not created'); + tracker.track('park_versions', v3.id); + + // Verify final park name is NAME-B (last edit wins) + const { data: finalPark } = await supabase + .from('parks') + .select('name') + .eq('id', parkId) + .single(); + + if (!finalPark || !finalPark.name.includes('NAME-B')) { + throw new Error(`Expected NAME-B, got ${finalPark?.name}`); + } + + // Verify version 2 has NAME-A + const { data: v2Check } = await supabase + .from('park_versions') + .select('name') + .eq('id', v2.id) + .single(); + + if (!v2Check || !v2Check.name.includes('NAME-A')) { + throw new Error('Version 2 should have NAME-A'); + } + + // Verify version 3 has NAME-B + const { data: v3Check } = await supabase + .from('park_versions') + .select('name') + .eq('id', v3.id) + .single(); + + if (!v3Check || !v3Check.name.includes('NAME-B')) { + throw new Error('Version 3 should have NAME-B'); + } + + return { + id: 'APL-018', + name: 'Concurrent Edit Conflict', + suite: 'Approval Pipeline', + status: 'pass', + duration: Date.now() - startTime, + timestamp: new Date().toISOString(), + }; + } catch (error) { + return { + id: 'APL-018', + name: 'Concurrent Edit Conflict', + suite: 'Approval Pipeline', + status: 'fail', + duration: Date.now() - startTime, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }; + } finally { + await tracker.cleanup(); + } + }, +}; + // ============================================ // TEST SUITE EXPORT // ============================================ @@ -736,8 +1875,20 @@ export const approvalPipelineTestSuite: TestSuite = { rideCreationTest, companyCreationTest, rideModelCreationTest, + parkUpdateTest, + rideUpdateTest, rideManufacturerCompositeTest, + rideManufacturerDesignerCompositeTest, + parkOperatorOwnerCompositeTest, + parkPhotoGalleryTest, + ridePhotoGalleryTest, partialApprovalTest, idempotencyTest, + lockConflictTest, + invalidTempRefTest, + bannedUserTest, + multipleEditChainTest, + concurrentEditTest, + versionSnapshotIntegrityTest, ], };