@@ -2033,10 +2033,270 @@ public async Task Both_Archives_Download_Retry_On_403_Error()
20332033 _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( freshMetadataArchiveUrl , METADATA_ARCHIVE_FILE_PATH ) , Times . Once ) ;
20342034 }
20352035
2036+ [ Fact ]
2037+ public async Task Git_Archive_Download_Retries_On_404_Error ( )
2038+ {
2039+ // Arrange
2040+ var freshGitArchiveUrl = "https://example.com/1/fresh" ;
2041+
2042+ _mockTargetGithubApi . Setup ( x => x . GetOrganizationId ( TARGET_ORG ) . Result ) . Returns ( GITHUB_ORG_ID ) ;
2043+ _mockTargetGithubApi . Setup ( x => x . CreateGhecMigrationSource ( GITHUB_ORG_ID ) . Result ) . Returns ( MIGRATION_SOURCE_ID ) ;
2044+ _mockTargetGithubApi . Setup ( x => x . DoesOrgExist ( TARGET_ORG ) . Result ) . Returns ( true ) ;
2045+ _mockTargetGithubApi . Setup ( x => x . StartMigration (
2046+ MIGRATION_SOURCE_ID ,
2047+ GHES_REPO_URL ,
2048+ GITHUB_ORG_ID ,
2049+ TARGET_REPO ,
2050+ GITHUB_SOURCE_PAT ,
2051+ GITHUB_TARGET_PAT ,
2052+ AUTHENTICATED_GIT_ARCHIVE_URL ,
2053+ AUTHENTICATED_METADATA_ARCHIVE_URL ,
2054+ false ,
2055+ null ,
2056+ false ) . Result ) . Returns ( MIGRATION_ID ) ;
2057+ _mockTargetGithubApi . Setup ( x => x . GetMigration ( MIGRATION_ID ) . Result ) . Returns ( ( State : RepositoryMigrationStatus . Succeeded , TARGET_REPO , 0 , null , null ) ) ;
2058+
2059+ _mockSourceGithubApi . Setup ( x => x . StartGitArchiveGeneration ( SOURCE_ORG , SOURCE_REPO ) . Result ) . Returns ( GIT_ARCHIVE_ID ) ;
2060+ _mockSourceGithubApi . Setup ( x => x . StartMetadataArchiveGeneration ( SOURCE_ORG , SOURCE_REPO , false , false ) . Result ) . Returns ( METADATA_ARCHIVE_ID ) ;
2061+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationStatus ( SOURCE_ORG , GIT_ARCHIVE_ID ) . Result ) . Returns ( ArchiveMigrationStatus . Exported ) ;
2062+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationStatus ( SOURCE_ORG , METADATA_ARCHIVE_ID ) . Result ) . Returns ( ArchiveMigrationStatus . Exported ) ;
2063+
2064+ // Setup GetArchiveMigrationUrl to return original URL first, then fresh URL on retry
2065+ _mockSourceGithubApi . SetupSequence ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , GIT_ARCHIVE_ID ) . Result )
2066+ . Returns ( GIT_ARCHIVE_URL )
2067+ . Returns ( freshGitArchiveUrl ) ;
2068+
2069+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , METADATA_ARCHIVE_ID ) . Result ) . Returns ( METADATA_ARCHIVE_URL ) ;
2070+
2071+ // Setup HttpDownloadService to fail first time with 404, succeed on retry
2072+ _mockHttpDownloadService
2073+ . SetupSequence ( x => x . DownloadToFile ( It . IsAny < string > ( ) , GIT_ARCHIVE_FILE_PATH ) )
2074+ . ThrowsAsync ( CreateHttpNotFoundException ( ) )
2075+ . Returns ( Task . CompletedTask ) ;
2076+
2077+ _mockHttpDownloadService
2078+ . Setup ( x => x . DownloadToFile ( METADATA_ARCHIVE_URL , METADATA_ARCHIVE_FILE_PATH ) )
2079+ . Returns ( Task . CompletedTask ) ;
2080+
2081+ _mockAzureApi
2082+ . SetupSequence ( x => x . UploadToBlob ( It . IsAny < string > ( ) , It . IsAny < FileStream > ( ) ) . Result )
2083+ . Returns ( new Uri ( AUTHENTICATED_GIT_ARCHIVE_URL ) )
2084+ . Returns ( new Uri ( AUTHENTICATED_METADATA_ARCHIVE_URL ) ) ;
2085+
2086+ _mockFileSystemProvider
2087+ . SetupSequence ( m => m . GetTempFileName ( ) )
2088+ . Returns ( GIT_ARCHIVE_FILE_PATH )
2089+ . Returns ( METADATA_ARCHIVE_FILE_PATH ) ;
2090+
2091+ _mockEnvironmentVariableProvider . Setup ( m => m . SourceGithubPersonalAccessToken ( It . IsAny < bool > ( ) ) ) . Returns ( GITHUB_SOURCE_PAT ) ;
2092+ _mockEnvironmentVariableProvider . Setup ( m => m . TargetGithubPersonalAccessToken ( It . IsAny < bool > ( ) ) ) . Returns ( GITHUB_TARGET_PAT ) ;
2093+ _mockGhesVersionChecker . Setup ( m => m . AreBlobCredentialsRequired ( GHES_API_URL ) ) . ReturnsAsync ( true ) ;
2094+
2095+ // Act
2096+ var args = new MigrateRepoCommandArgs
2097+ {
2098+ GithubSourceOrg = SOURCE_ORG ,
2099+ SourceRepo = SOURCE_REPO ,
2100+ GithubTargetOrg = TARGET_ORG ,
2101+ TargetRepo = TARGET_REPO ,
2102+ TargetApiUrl = TARGET_API_URL ,
2103+ GhesApiUrl = GHES_API_URL ,
2104+ AzureStorageConnectionString = AZURE_CONNECTION_STRING ,
2105+ } ;
2106+ await _handler . Handle ( args ) ;
2107+
2108+ // Assert
2109+ // Verify that GetArchiveMigrationUrl was called twice for git archive (once during generation, once during retry)
2110+ _mockSourceGithubApi . Verify ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , GIT_ARCHIVE_ID ) , Times . Exactly ( 2 ) ) ;
2111+ _mockSourceGithubApi . Verify ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , METADATA_ARCHIVE_ID ) , Times . Once ) ;
2112+
2113+ // Verify that DownloadToFile was called twice for git archive (original URL failed, fresh URL succeeded)
2114+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( GIT_ARCHIVE_URL , GIT_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2115+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( freshGitArchiveUrl , GIT_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2116+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( METADATA_ARCHIVE_URL , METADATA_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2117+ }
2118+
2119+ [ Fact ]
2120+ public async Task Metadata_Archive_Download_Retries_On_404_Error ( )
2121+ {
2122+ // Arrange
2123+ var freshMetadataArchiveUrl = "https://example.com/2/fresh" ;
2124+
2125+ _mockTargetGithubApi . Setup ( x => x . GetOrganizationId ( TARGET_ORG ) . Result ) . Returns ( GITHUB_ORG_ID ) ;
2126+ _mockTargetGithubApi . Setup ( x => x . CreateGhecMigrationSource ( GITHUB_ORG_ID ) . Result ) . Returns ( MIGRATION_SOURCE_ID ) ;
2127+ _mockTargetGithubApi . Setup ( x => x . DoesOrgExist ( TARGET_ORG ) . Result ) . Returns ( true ) ;
2128+ _mockTargetGithubApi . Setup ( x => x . StartMigration (
2129+ MIGRATION_SOURCE_ID ,
2130+ GHES_REPO_URL ,
2131+ GITHUB_ORG_ID ,
2132+ TARGET_REPO ,
2133+ GITHUB_SOURCE_PAT ,
2134+ GITHUB_TARGET_PAT ,
2135+ AUTHENTICATED_GIT_ARCHIVE_URL ,
2136+ AUTHENTICATED_METADATA_ARCHIVE_URL ,
2137+ false ,
2138+ null ,
2139+ false ) . Result ) . Returns ( MIGRATION_ID ) ;
2140+ _mockTargetGithubApi . Setup ( x => x . GetMigration ( MIGRATION_ID ) . Result ) . Returns ( ( State : RepositoryMigrationStatus . Succeeded , TARGET_REPO , 0 , null , null ) ) ;
2141+
2142+ _mockSourceGithubApi . Setup ( x => x . StartGitArchiveGeneration ( SOURCE_ORG , SOURCE_REPO ) . Result ) . Returns ( GIT_ARCHIVE_ID ) ;
2143+ _mockSourceGithubApi . Setup ( x => x . StartMetadataArchiveGeneration ( SOURCE_ORG , SOURCE_REPO , false , false ) . Result ) . Returns ( METADATA_ARCHIVE_ID ) ;
2144+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationStatus ( SOURCE_ORG , GIT_ARCHIVE_ID ) . Result ) . Returns ( ArchiveMigrationStatus . Exported ) ;
2145+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationStatus ( SOURCE_ORG , METADATA_ARCHIVE_ID ) . Result ) . Returns ( ArchiveMigrationStatus . Exported ) ;
2146+
2147+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , GIT_ARCHIVE_ID ) . Result ) . Returns ( GIT_ARCHIVE_URL ) ;
2148+
2149+ // Setup GetArchiveMigrationUrl to return original URL first, then fresh URL on retry
2150+ _mockSourceGithubApi . SetupSequence ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , METADATA_ARCHIVE_ID ) . Result )
2151+ . Returns ( METADATA_ARCHIVE_URL )
2152+ . Returns ( freshMetadataArchiveUrl ) ;
2153+
2154+ _mockHttpDownloadService
2155+ . Setup ( x => x . DownloadToFile ( GIT_ARCHIVE_URL , GIT_ARCHIVE_FILE_PATH ) )
2156+ . Returns ( Task . CompletedTask ) ;
2157+
2158+ // Setup HttpDownloadService to fail first time with 404, succeed on retry
2159+ _mockHttpDownloadService
2160+ . SetupSequence ( x => x . DownloadToFile ( It . IsAny < string > ( ) , METADATA_ARCHIVE_FILE_PATH ) )
2161+ . ThrowsAsync ( CreateHttpNotFoundException ( ) )
2162+ . Returns ( Task . CompletedTask ) ;
2163+
2164+ _mockAzureApi
2165+ . SetupSequence ( x => x . UploadToBlob ( It . IsAny < string > ( ) , It . IsAny < FileStream > ( ) ) . Result )
2166+ . Returns ( new Uri ( AUTHENTICATED_GIT_ARCHIVE_URL ) )
2167+ . Returns ( new Uri ( AUTHENTICATED_METADATA_ARCHIVE_URL ) ) ;
2168+
2169+ _mockFileSystemProvider
2170+ . SetupSequence ( m => m . GetTempFileName ( ) )
2171+ . Returns ( GIT_ARCHIVE_FILE_PATH )
2172+ . Returns ( METADATA_ARCHIVE_FILE_PATH ) ;
2173+
2174+ _mockEnvironmentVariableProvider . Setup ( m => m . SourceGithubPersonalAccessToken ( It . IsAny < bool > ( ) ) ) . Returns ( GITHUB_SOURCE_PAT ) ;
2175+ _mockEnvironmentVariableProvider . Setup ( m => m . TargetGithubPersonalAccessToken ( It . IsAny < bool > ( ) ) ) . Returns ( GITHUB_TARGET_PAT ) ;
2176+ _mockGhesVersionChecker . Setup ( m => m . AreBlobCredentialsRequired ( GHES_API_URL ) ) . ReturnsAsync ( true ) ;
2177+
2178+ // Act
2179+ var args = new MigrateRepoCommandArgs
2180+ {
2181+ GithubSourceOrg = SOURCE_ORG ,
2182+ SourceRepo = SOURCE_REPO ,
2183+ GithubTargetOrg = TARGET_ORG ,
2184+ TargetRepo = TARGET_REPO ,
2185+ TargetApiUrl = TARGET_API_URL ,
2186+ GhesApiUrl = GHES_API_URL ,
2187+ AzureStorageConnectionString = AZURE_CONNECTION_STRING ,
2188+ } ;
2189+ await _handler . Handle ( args ) ;
2190+
2191+ // Assert
2192+ _mockSourceGithubApi . Verify ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , GIT_ARCHIVE_ID ) , Times . Once ) ;
2193+ // Verify that GetArchiveMigrationUrl was called twice for metadata archive (once during generation, once during retry)
2194+ _mockSourceGithubApi . Verify ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , METADATA_ARCHIVE_ID ) , Times . Exactly ( 2 ) ) ;
2195+
2196+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( GIT_ARCHIVE_URL , GIT_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2197+ // Verify that DownloadToFile was called twice for metadata archive (original URL failed, fresh URL succeeded)
2198+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( METADATA_ARCHIVE_URL , METADATA_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2199+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( freshMetadataArchiveUrl , METADATA_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2200+ }
2201+
2202+ [ Fact ]
2203+ public async Task Both_Archives_Download_Retry_On_404_Error ( )
2204+ {
2205+ // Arrange
2206+ var freshGitArchiveUrl = "https://example.com/1/fresh" ;
2207+ var freshMetadataArchiveUrl = "https://example.com/2/fresh" ;
2208+
2209+ _mockTargetGithubApi . Setup ( x => x . GetOrganizationId ( TARGET_ORG ) . Result ) . Returns ( GITHUB_ORG_ID ) ;
2210+ _mockTargetGithubApi . Setup ( x => x . CreateGhecMigrationSource ( GITHUB_ORG_ID ) . Result ) . Returns ( MIGRATION_SOURCE_ID ) ;
2211+ _mockTargetGithubApi . Setup ( x => x . DoesOrgExist ( TARGET_ORG ) . Result ) . Returns ( true ) ;
2212+ _mockTargetGithubApi . Setup ( x => x . StartMigration (
2213+ MIGRATION_SOURCE_ID ,
2214+ GHES_REPO_URL ,
2215+ GITHUB_ORG_ID ,
2216+ TARGET_REPO ,
2217+ GITHUB_SOURCE_PAT ,
2218+ GITHUB_TARGET_PAT ,
2219+ AUTHENTICATED_GIT_ARCHIVE_URL ,
2220+ AUTHENTICATED_METADATA_ARCHIVE_URL ,
2221+ false ,
2222+ null ,
2223+ false ) . Result ) . Returns ( MIGRATION_ID ) ;
2224+ _mockTargetGithubApi . Setup ( x => x . GetMigration ( MIGRATION_ID ) . Result ) . Returns ( ( State : RepositoryMigrationStatus . Succeeded , TARGET_REPO , 0 , null , null ) ) ;
2225+
2226+ _mockSourceGithubApi . Setup ( x => x . StartGitArchiveGeneration ( SOURCE_ORG , SOURCE_REPO ) . Result ) . Returns ( GIT_ARCHIVE_ID ) ;
2227+ _mockSourceGithubApi . Setup ( x => x . StartMetadataArchiveGeneration ( SOURCE_ORG , SOURCE_REPO , false , false ) . Result ) . Returns ( METADATA_ARCHIVE_ID ) ;
2228+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationStatus ( SOURCE_ORG , GIT_ARCHIVE_ID ) . Result ) . Returns ( ArchiveMigrationStatus . Exported ) ;
2229+ _mockSourceGithubApi . Setup ( x => x . GetArchiveMigrationStatus ( SOURCE_ORG , METADATA_ARCHIVE_ID ) . Result ) . Returns ( ArchiveMigrationStatus . Exported ) ;
2230+
2231+ // Setup GetArchiveMigrationUrl to return original URLs first, then fresh URLs on retry
2232+ _mockSourceGithubApi . SetupSequence ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , GIT_ARCHIVE_ID ) . Result )
2233+ . Returns ( GIT_ARCHIVE_URL )
2234+ . Returns ( freshGitArchiveUrl ) ;
2235+
2236+ _mockSourceGithubApi . SetupSequence ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , METADATA_ARCHIVE_ID ) . Result )
2237+ . Returns ( METADATA_ARCHIVE_URL )
2238+ . Returns ( freshMetadataArchiveUrl ) ;
2239+
2240+ // Setup HttpDownloadService to fail first time for both archives with 404, succeed on retry
2241+ _mockHttpDownloadService
2242+ . SetupSequence ( x => x . DownloadToFile ( It . IsAny < string > ( ) , GIT_ARCHIVE_FILE_PATH ) )
2243+ . ThrowsAsync ( CreateHttpNotFoundException ( ) )
2244+ . Returns ( Task . CompletedTask ) ;
2245+
2246+ _mockHttpDownloadService
2247+ . SetupSequence ( x => x . DownloadToFile ( It . IsAny < string > ( ) , METADATA_ARCHIVE_FILE_PATH ) )
2248+ . ThrowsAsync ( CreateHttpNotFoundException ( ) )
2249+ . Returns ( Task . CompletedTask ) ;
2250+
2251+ _mockAzureApi
2252+ . SetupSequence ( x => x . UploadToBlob ( It . IsAny < string > ( ) , It . IsAny < FileStream > ( ) ) . Result )
2253+ . Returns ( new Uri ( AUTHENTICATED_GIT_ARCHIVE_URL ) )
2254+ . Returns ( new Uri ( AUTHENTICATED_METADATA_ARCHIVE_URL ) ) ;
2255+
2256+ _mockFileSystemProvider
2257+ . SetupSequence ( m => m . GetTempFileName ( ) )
2258+ . Returns ( GIT_ARCHIVE_FILE_PATH )
2259+ . Returns ( METADATA_ARCHIVE_FILE_PATH ) ;
2260+
2261+ _mockEnvironmentVariableProvider . Setup ( m => m . SourceGithubPersonalAccessToken ( It . IsAny < bool > ( ) ) ) . Returns ( GITHUB_SOURCE_PAT ) ;
2262+ _mockEnvironmentVariableProvider . Setup ( m => m . TargetGithubPersonalAccessToken ( It . IsAny < bool > ( ) ) ) . Returns ( GITHUB_TARGET_PAT ) ;
2263+ _mockGhesVersionChecker . Setup ( m => m . AreBlobCredentialsRequired ( GHES_API_URL ) ) . ReturnsAsync ( true ) ;
2264+
2265+ // Act
2266+ var args = new MigrateRepoCommandArgs
2267+ {
2268+ GithubSourceOrg = SOURCE_ORG ,
2269+ SourceRepo = SOURCE_REPO ,
2270+ GithubTargetOrg = TARGET_ORG ,
2271+ TargetRepo = TARGET_REPO ,
2272+ TargetApiUrl = TARGET_API_URL ,
2273+ GhesApiUrl = GHES_API_URL ,
2274+ AzureStorageConnectionString = AZURE_CONNECTION_STRING ,
2275+ } ;
2276+ await _handler . Handle ( args ) ;
2277+
2278+ // Assert
2279+ // Verify that GetArchiveMigrationUrl was called twice for both archives (once during generation, once during retry)
2280+ _mockSourceGithubApi . Verify ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , GIT_ARCHIVE_ID ) , Times . Exactly ( 2 ) ) ;
2281+ _mockSourceGithubApi . Verify ( x => x . GetArchiveMigrationUrl ( SOURCE_ORG , METADATA_ARCHIVE_ID ) , Times . Exactly ( 2 ) ) ;
2282+
2283+ // Verify that DownloadToFile was called twice for each archive (original URL failed, fresh URL succeeded)
2284+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( GIT_ARCHIVE_URL , GIT_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2285+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( freshGitArchiveUrl , GIT_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2286+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( METADATA_ARCHIVE_URL , METADATA_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2287+ _mockHttpDownloadService . Verify ( x => x . DownloadToFile ( freshMetadataArchiveUrl , METADATA_ARCHIVE_FILE_PATH ) , Times . Once ) ;
2288+ }
2289+
20362290 private static HttpRequestException CreateHttpForbiddenException ( )
20372291 {
20382292 // Use the constructor that sets the StatusCode property (available in .NET 5+)
20392293 return new HttpRequestException ( "Response status code does not indicate success: 403 (Forbidden)." , null , HttpStatusCode . Forbidden ) ;
20402294 }
2295+
2296+ private static HttpRequestException CreateHttpNotFoundException ( )
2297+ {
2298+ // Use the constructor that sets the StatusCode property (available in .NET 5+)
2299+ return new HttpRequestException ( "Response status code does not indicate success: 404 (Not Found)." , null , HttpStatusCode . NotFound ) ;
2300+ }
20412301 }
20422302}
0 commit comments