package expoimport import ( "bytes" "context" "encoding/json" "errors" "io" "mime/multipart" "net/http" "strconv" "strings" "sync" "testing" "time" "xprem/internal/bucket" "xprem/internal/crypto" "xprem/internal/jobs" "xprem/internal/objectstore" "xprem/internal/providers/expo" "xprem/internal/repository" "xprem/internal/services" "xprem/internal/types" "github.com/jarcoal/httpmock" "github.com/riverqueue/river/rivertype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // The embedded interface panics if the import grows an unexpected repository call. type importFakeUpdateImporter struct { services.UpdateRepository mu sync.Mutex rows []repository.ImportUpdateParams err error duplicate bool // exists makes every timeline slot look occupied. exists bool // hook runs before each insert, letting a test hold the job mid-run. hook func() } func (f *importFakeUpdateImporter) UpdateExists(context.Context, string, string, int64) (bool, error) { return f.exists, nil } func (f *importFakeUpdateImporter) ImportUpdate(_ context.Context, params repository.ImportUpdateParams) (bool, error) { if f.hook != nil { f.hook() } f.mu.Lock() defer f.mu.Unlock() if f.err != nil { return false, f.err } f.rows = append(f.rows, params) return !f.duplicate, nil } func (f *importFakeUpdateImporter) importedRows() []repository.ImportUpdateParams { f.mu.Lock() defer f.mu.Unlock() return append([]repository.ImportUpdateParams(nil), f.rows...) } func newHistoryBucket(t *testing.T) *bucket.Bucket { t.Helper() return bucket.Open(objectstore.ModeLocal, t.TempDir(), "") } // historyFile reads a file the import wrote into an update folder of the // production branch, runtime version 1.0.0. func historyFile(t *testing.T, historyBucket *bucket.Bucket, updateId, name string) ([]byte, bool) { t.Helper() update := types.Update{AppId: importExpoAppID, Branch: "production", RuntimeVersion: "1.0.0", UpdateId: updateId} file, err := historyBucket.UpdateStore.GetFile(context.Background(), update, name) require.NoError(t, err) if file == nil { return nil, false } defer file.Reader.Close() content, err := io.ReadAll(file.Reader) require.NoError(t, err) return content, true } func historyBlob(t *testing.T, historyBucket *bucket.Bucket, hash string) ([]byte, bool) { t.Helper() file, err := historyBucket.BlobStore.Get(context.Background(), importExpoAppID, hash) require.NoError(t, err) if file == nil { return nil, false } defer file.Reader.Close() content, err := io.ReadAll(file.Reader) require.NoError(t, err) return content, true } // historyUpdateFiles lists the keys the import left in update folders, cas/ aside. func historyUpdateFiles(t *testing.T, historyBucket *bucket.Bucket) []string { t.Helper() keys, err := historyBucket.ObjectStore.List(context.Background(), importExpoAppID+"/") require.NoError(t, err) var files []string for _, key := range keys { if !strings.HasPrefix(key, bucket.BlobObjectKey(importExpoAppID, "")) { files = append(files, key) } } return files } const ( historyBundleURL = "https://assets.eascdn.net/bundle" historyAssetURL = "https://assets.eascdn.net/asset" historyPermalinkURL = "https://u.expo.dev/update/manifest" historyIOSPermalink = historyPermalinkURL + "/ios" historyAndroidPermalik = historyPermalinkURL + "/android" historyBundleAuth = "Bearer bundle-token" historyAssetAuth = "Bearer asset-token" ) var ( historyBundleBytes = []byte("history-bundle-bytes") historyAssetBytes = []byte("history-asset-bytes") ) func historyAssetHash(t *testing.T, data []byte) string { t.Helper() hash, err := crypto.CreateHash(data, "sha256", "base64") require.NoError(t, err) return crypto.GetBase64URLEncoding(hash) } func historyServedManifest(t *testing.T, bundleHash string, assetHash string) string { t.Helper() manifest, err := json.Marshal(map[string]interface{}{ "id": "11111111-1111-1111-1111-111111111111", "createdAt": "2026-01-03T10:20:30.400Z", "runtimeVersion": "1.0.0", "launchAsset": map[string]interface{}{ "key": "bundlekey123", "hash": bundleHash, "contentType": "application/javascript", "fileExtension": ".bundle", "url": historyBundleURL, }, "assets": []map[string]interface{}{ { "key": "assetkey456", "hash": assetHash, "contentType": "image/png", "fileExtension": ".png", "url": historyAssetURL, }, }, "metadata": map[string]interface{}{}, "extra": map[string]interface{}{ "expoClient": map[string]interface{}{"name": "My Imported App"}, }, }) require.NoError(t, err) return string(manifest) } // Serves a manifest the way EAS permalinks do: multipart/mixed with a // "manifest" part and an "extensions" part. func historyMultipartResponder(t *testing.T, manifestJSON string) httpmock.Responder { t.Helper() extensions, err := json.Marshal(map[string]interface{}{ "assetRequestHeaders": map[string]interface{}{ "bundlekey123": map[string]string{"authorization": historyBundleAuth}, "assetkey456": map[string]string{"authorization": historyAssetAuth}, }, }) require.NoError(t, err) var body bytes.Buffer writer := multipart.NewWriter(&body) for _, field := range []struct{ name, payload string }{ {"manifest", manifestJSON}, {"extensions", string(extensions)}, } { part, err := writer.CreateFormField(field.name) require.NoError(t, err) _, err = part.Write([]byte(field.payload)) require.NoError(t, err) } require.NoError(t, writer.Close()) contentType := "multipart/mixed; boundary=" + writer.Boundary() payload := body.Bytes() return func(*http.Request) (*http.Response, error) { resp := httpmock.NewBytesResponse(http.StatusOK, payload) resp.Header.Set("Content-Type", contentType) return resp, nil } } // Answers like the EAS CDN: 403 unless the request carries the authorization // header announced in the extensions part. func historyProtectedAssetResponder(auth string, data []byte) httpmock.Responder { return func(req *http.Request) (*http.Response, error) { if req.Header.Get("Authorization") != auth { return httpmock.NewStringResponse(http.StatusForbidden, "Unauthorized asset request"), nil } return httpmock.NewBytesResponse(http.StatusOK, data), nil } } // Three update groups, newest first: a two-platform normal group, a // code-signed one, and a rollback. func mockExpoUpdateGroups(t *testing.T) { t.Helper() manifest := historyServedManifest(t, historyAssetHash(t, historyBundleBytes), historyAssetHash(t, historyAssetBytes)) httpmock.RegisterResponder("GET", historyIOSPermalink, historyMultipartResponder(t, manifest)) httpmock.RegisterResponder("GET", historyAndroidPermalik, historyMultipartResponder(t, manifest)) makeUpdate := func(id, group, platform, createdAt string, rollback bool, signed bool) map[string]interface{} { update := map[string]interface{}{ "id": id, "group": group, "message": "publish message", "createdAt": createdAt, "platform": platform, "manifestPermalink": historyPermalinkURL + "/" + platform, "isRollBackToEmbedded": rollback, "gitCommitHash": "abc1234", "codeSigningInfo": nil, "runtime": map[string]interface{}{"id": "r1", "version": "1.0.0"}, "branch": map[string]interface{}{"id": "b1", "name": "production"}, } if signed { update["codeSigningInfo"] = map[string]interface{}{"keyid": "main"} } return update } httpmock.RegisterResponder("POST", "https://api.expo.dev/graphql", func(req *http.Request) (*http.Response, error) { if req.Header.Get("operationName") != "FetchExpoUpdateGroups" { return httpmock.NewStringResponse(http.StatusNotFound, "unknown operation"), nil } return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{ "data": map[string]interface{}{ "app": map[string]interface{}{ "byId": map[string]interface{}{ "id": importExpoAppID, "updateGroups": []interface{}{ []interface{}{ makeUpdate("11111111-1111-1111-1111-111111111111", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "ios", "2026-01-03T10:20:30.400Z", false, false), makeUpdate("22222222-2222-2222-2222-222222222222", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "android", "2026-01-03T10:20:30.400Z", false, false), }, []interface{}{ makeUpdate("44444444-4444-4444-4444-444444444444", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "android", "2026-01-02T00:00:02.000Z", false, true), }, []interface{}{ makeUpdate("33333333-3333-3333-3333-333333333333", "cccccccc-cccc-cccc-cccc-cccccccccccc", "ios", "2026-01-01T00:00:01.000Z", true, false), }, }, }, }, }, }) }) httpmock.RegisterResponder("GET", historyBundleURL, historyProtectedAssetResponder(historyBundleAuth, historyBundleBytes)) httpmock.RegisterResponder("GET", historyAssetURL, historyProtectedAssetResponder(historyAssetAuth, historyAssetBytes)) } func historyUpdateIdFor(t *testing.T, createdAt string, platform types.Platform) int64 { t.Helper() parsed, err := time.Parse(time.RFC3339, createdAt) require.NoError(t, err) return parsed.UnixMilli()*10 + historyPlatformDigit(platform) } func fetchHistoryGroups(t *testing.T) [][]expo.HistoryUpdate { t.Helper() token := "token" groups, err := expo.FetchUpdateGroups(context.Background(), types.Auth{Token: &token}, importExpoAppID, 10) require.NoError(t, err) return groups } func TestCopyHistoryCopiesUpdates(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() mockExpoUpdateGroups(t) branchRepo := &importFakeBranchRepo{} importer := &importFakeUpdateImporter{} historyBucket := newHistoryBucket(t) service := historyImportService(t, branchRepo, importer, historyBucket) tracker := jobs.NewTracker(1) require.NoError(t, service.copyHistory(context.Background(), importExpoAppID, tracker, fetchHistoryGroups(t))) progress := tracker.Output() assert.Equal(t, 4, progress.Processed) assert.Equal(t, 3, progress.Succeeded) require.Len(t, progress.Warnings, 1) assert.Contains(t, progress.Warnings[0], "code signing") rows := importer.importedRows() require.Len(t, rows, 3) // Oldest group lands first: the rollback, then the two-platform publish. rollback := rows[0] assert.Equal(t, types.Rollback, rollback.UpdateType) assert.Nil(t, rollback.UpdateUUID) assert.Equal(t, historyUpdateIdFor(t, "2026-01-01T00:00:01.000Z", types.PlatformIOS), rollback.UpdateId) assert.Equal(t, rollback.CreatedAt, rollback.CheckedAt) iosRow := rows[1] assert.Equal(t, types.NormalUpdate, iosRow.UpdateType) assert.Equal(t, types.PlatformIOS, iosRow.Platform) assert.Equal(t, historyUpdateIdFor(t, "2026-01-03T10:20:30.400Z", types.PlatformIOS), iosRow.UpdateId) require.NotNil(t, iosRow.UpdateUUID) assert.Equal(t, "11111111-1111-1111-1111-111111111111", *iosRow.UpdateUUID) require.NotNil(t, iosRow.PublishGroup) assert.Equal(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", *iosRow.PublishGroup) assert.Equal(t, "abc1234", iosRow.CommitHash) assert.Equal(t, "publish message", iosRow.Message) assert.Equal(t, "production", iosRow.BranchName) assert.Equal(t, "1.0.0", iosRow.RuntimeVersion) androidRow := rows[2] assert.Equal(t, types.PlatformAndroid, androidRow.Platform) assert.Equal(t, historyUpdateIdFor(t, "2026-01-03T10:20:30.400Z", types.PlatformAndroid), androidRow.UpdateId) assert.Contains(t, branchRepo.upserted, "production@1.0.0") iosUpdateId := strconv.FormatInt(iosRow.UpdateId, 10) metadataBytes, ok := historyFile(t, historyBucket, iosUpdateId, "metadata.json") require.True(t, ok) var metadata types.MetadataObject require.NoError(t, json.Unmarshal(metadataBytes, &metadata)) assert.Equal(t, "bundles/ios-bundlekey123.bundle", metadata.FileMetadata.IOS.Bundle) require.Len(t, metadata.FileMetadata.IOS.Assets, 1) assert.Equal(t, "assets/assetkey456", metadata.FileMetadata.IOS.Assets[0].Path) assert.Equal(t, "png", metadata.FileMetadata.IOS.Assets[0].Ext) assert.Empty(t, metadata.FileMetadata.Android.Bundle) // The assets live in cas/, keyed by their EAS manifest hash; the update // folder only holds config files. bundleBytes, ok := historyBlob(t, historyBucket, historyAssetHash(t, historyBundleBytes)) require.True(t, ok) assert.Equal(t, historyBundleBytes, bundleBytes) assetBytes, ok := historyBlob(t, historyBucket, historyAssetHash(t, historyAssetBytes)) require.True(t, ok) assert.Equal(t, historyAssetBytes, assetBytes) _, ok = historyFile(t, historyBucket, iosUpdateId, "bundles/ios-bundlekey123.bundle") assert.False(t, ok) require.NotNil(t, iosRow.AssetMapping) assert.Equal(t, historyAssetHash(t, historyBundleBytes), iosRow.AssetMapping.LaunchAsset.Hash) assert.Equal(t, "bundlekey123", iosRow.AssetMapping.LaunchAsset.Key) assert.Equal(t, ".bundle", iosRow.AssetMapping.LaunchAsset.FileExtension) require.Len(t, iosRow.AssetMapping.Assets, 1) assert.Equal(t, historyAssetHash(t, historyAssetBytes), iosRow.AssetMapping.Assets[0].Hash) assert.Equal(t, "assetkey456", iosRow.AssetMapping.Assets[0].Key) assert.Equal(t, ".png", iosRow.AssetMapping.Assets[0].FileExtension) assert.Equal(t, "image/png", iosRow.AssetMapping.Assets[0].ContentType) assert.Nil(t, rollback.AssetMapping) storedBytes, ok := historyFile(t, historyBucket, iosUpdateId, "update-metadata.json") require.True(t, ok) var stored types.UpdateStoredMetadata require.NoError(t, json.Unmarshal(storedBytes, &stored)) assert.Equal(t, "11111111-1111-1111-1111-111111111111", stored.UpdateUUID) assert.Equal(t, types.PlatformIOS, stored.Platform) expoConfigBytes, ok := historyFile(t, historyBucket, iosUpdateId, "expoConfig.json") require.True(t, ok) assert.JSONEq(t, `{"name":"My Imported App"}`, string(expoConfigBytes)) // The rollback is a row without files. _, ok = historyFile(t, historyBucket, strconv.FormatInt(rollback.UpdateId, 10), "metadata.json") assert.False(t, ok) // cas dedup: a blob already stored by a previous update is not re-downloaded, // so the bundle shared by the ios and android updates travels once. callCounts := httpmock.GetCallCountInfo() assert.Equal(t, 1, callCounts["GET "+historyAssetURL]) assert.Equal(t, 1, callCounts["GET "+historyBundleURL]) } func TestCopyHistoryStopsOnCanceledContext(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() mockExpoUpdateGroups(t) // River delivers a cancel by canceling the worker's context; the first insert triggers it mid-run. ctx, cancel := context.WithCancel(context.Background()) defer cancel() importer := &importFakeUpdateImporter{hook: cancel} service := historyImportService(t, &importFakeBranchRepo{}, importer, newHistoryBucket(t)) tracker := jobs.NewTracker(1) err := service.copyHistory(ctx, importExpoAppID, tracker, fetchHistoryGroups(t)) require.ErrorIs(t, err, context.Canceled) require.Len(t, importer.importedRows(), 1) assert.Equal(t, 0, tracker.Output().Processed) } func TestCopyHistoryFailsWhenStoreIsDown(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() mockExpoUpdateGroups(t) importer := &importFakeUpdateImporter{err: errors.New("connection lost")} service := historyImportService(t, &importFakeBranchRepo{}, importer, newHistoryBucket(t)) err := service.copyHistory(context.Background(), importExpoAppID, jobs.NewTracker(1), fetchHistoryGroups(t)) require.ErrorContains(t, err, "connection lost") } func TestCancelHistoryImportUnknownJob(t *testing.T) { service := historyImportService(t, &importFakeBranchRepo{}, &importFakeUpdateImporter{}, newHistoryBucket(t)) require.ErrorIs(t, service.CancelHistoryJob(context.Background(), "424242"), ErrHistoryJobNotFound) } func TestStartHistoryImportRequiresControlPlane(t *testing.T) { service := historyImportService(t, &importFakeBranchRepo{}, &importFakeUpdateImporter{}, newHistoryBucket(t)) t.Setenv("DB_URL", "") _, err := service.StartHistoryImport(context.Background(), expoAuth("token"), importExpoAppID, 10) require.ErrorIs(t, err, repository.ErrNotSupportedInStatelessMode) } func TestStartHistoryImportValidatesLimit(t *testing.T) { service := historyImportService(t, &importFakeBranchRepo{}, &importFakeUpdateImporter{}, newHistoryBucket(t)) _, err := service.StartHistoryImport(context.Background(), expoAuth("token"), importExpoAppID, 0) require.Error(t, err) _, err = service.StartHistoryImport(context.Background(), expoAuth("token"), importExpoAppID, MaxHistoryImportGroups+1) require.Error(t, err) } func TestHistoryJobStatusMapping(t *testing.T) { discarded := &rivertype.JobRow{ State: rivertype.JobStateDiscarded, EncodedArgs: []byte(`{"appId":"` + importExpoAppID + `","total":4}`), Metadata: []byte(`{"output":{"processed":3,"succeeded":2,"warnings":["update x (ios): skipped"]}}`), Errors: []rivertype.AttemptError{{Error: "transient"}, {Error: "connection lost"}}, } status := historyJobStatus(discarded) assert.Equal(t, jobs.StateFailed, status.State) assert.Equal(t, 4, status.Total) assert.Equal(t, 3, status.Processed) assert.Equal(t, 2, status.Imported) assert.Equal(t, []string{"update x (ios): skipped"}, status.Skipped) assert.Equal(t, "connection lost", status.Error) assert.False(t, status.CancelRequested) running := &rivertype.JobRow{ State: rivertype.JobStateRunning, EncodedArgs: []byte(`{"total":4}`), Metadata: []byte(`{"cancel_attempted_at":"2026-08-29T10:00:00Z"}`), } status = historyJobStatus(running) assert.Equal(t, jobs.StateRunning, status.State) assert.True(t, status.CancelRequested) assert.Empty(t, status.Error) retryable := &rivertype.JobRow{ State: rivertype.JobStateRetryable, EncodedArgs: []byte(`{"total":4}`), Metadata: []byte(`{}`), Errors: []rivertype.AttemptError{{Error: "transient"}}, } status = historyJobStatus(retryable) assert.Equal(t, jobs.StateRunning, status.State) assert.Empty(t, status.Error) completed := &rivertype.JobRow{ State: rivertype.JobStateCompleted, EncodedArgs: []byte(`{"total":4}`), Metadata: []byte(`{"output":{"processed":4,"succeeded":4}}`), } status = historyJobStatus(completed) assert.Equal(t, jobs.StateDone, status.State) assert.Equal(t, 4, status.Imported) cancelled := &rivertype.JobRow{ State: rivertype.JobStateCancelled, EncodedArgs: []byte(`{"total":4}`), Metadata: []byte(`{}`), } assert.Equal(t, jobs.StateCanceled, historyJobStatus(cancelled).State) } func TestImportHistoryUpdateSkipsHashMismatch(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() httpmock.RegisterResponder("GET", historyBundleURL, httpmock.NewBytesResponder(http.StatusOK, historyBundleBytes)) httpmock.RegisterResponder("GET", historyAssetURL, httpmock.NewBytesResponder(http.StatusOK, []byte("tampered"))) // Serves the manifest as bare JSON to cover the non-multipart fallback. manifest := historyServedManifest(t, historyAssetHash(t, historyBundleBytes), historyAssetHash(t, historyAssetBytes)) httpmock.RegisterResponder("GET", historyIOSPermalink, func(*http.Request) (*http.Response, error) { resp := httpmock.NewStringResponse(http.StatusOK, manifest) resp.Header.Set("Content-Type", "application/json") return resp, nil }) historyBucket := newHistoryBucket(t) service := historyImportService(t, &importFakeBranchRepo{}, &importFakeUpdateImporter{}, historyBucket) skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, expoHistoryUpdateFixture(historyIOSPermalink), map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "does not match its manifest hash") // The skip lands before any folder write, so there is nothing to sweep. assert.Empty(t, historyUpdateFiles(t, historyBucket)) } func TestImportHistoryUpdateSkipsUnsupportedPlatform(t *testing.T) { service := historyImportService(t, &importFakeBranchRepo{}, &importFakeUpdateImporter{}, newHistoryBucket(t)) update := expoHistoryUpdateFixture("") update.Platform = "web" skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, update, map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "not supported") } // EAS branch names land in bucket paths: one that CreateBranch would refuse // must not slip in through the history import either. func TestImportHistoryUpdateSkipsInvalidBranchName(t *testing.T) { branchRepo := &importFakeBranchRepo{} importer := &importFakeUpdateImporter{} service := historyImportService(t, branchRepo, importer, newHistoryBucket(t)) update := expoHistoryUpdateFixture("") update.BranchName = "bad*branch" skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, update, map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "bad*branch") assert.Empty(t, branchRepo.upserted) assert.Empty(t, importer.importedRows()) } // A branch named like the cas/ directory must skip the update, not fail the job. func TestImportHistoryUpdateSkipsReservedBranchName(t *testing.T) { branchRepo := &importFakeBranchRepo{} importer := &importFakeUpdateImporter{} service := historyImportService(t, branchRepo, importer, newHistoryBucket(t)) update := expoHistoryUpdateFixture("") update.BranchName = "cas" skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, update, map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "reserved") assert.Empty(t, branchRepo.upserted) assert.Empty(t, importer.importedRows()) } func TestImportHistoryUpdateSkipsInvalidRuntimeVersion(t *testing.T) { branchRepo := &importFakeBranchRepo{} service := historyImportService(t, branchRepo, &importFakeUpdateImporter{}, newHistoryBucket(t)) update := expoHistoryUpdateFixture("") update.RuntimeVersion = "../escape" skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, update, map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "runtime version") assert.Empty(t, branchRepo.upserted) } // An occupied timeline slot skips the update before any bucket write, so a // re-import never overwrites the files of an update that already exists. func TestImportHistoryUpdateSkipsOccupiedSlotBeforeWriting(t *testing.T) { branchRepo := &importFakeBranchRepo{} importer := &importFakeUpdateImporter{exists: true} historyBucket := newHistoryBucket(t) service := historyImportService(t, branchRepo, importer, historyBucket) skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, expoHistoryUpdateFixture(historyIOSPermalink), map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "already exists") assert.Empty(t, historyUpdateFiles(t, historyBucket)) assert.Empty(t, branchRepo.upserted) assert.Empty(t, importer.importedRows()) } // A failed insert must not leave the freshly written files behind as orphans: // the retry starts from an empty slot. func TestImportHistoryUpdateCleansUpFolderWhenInsertFails(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() mockExpoUpdateGroups(t) importer := &importFakeUpdateImporter{err: errors.New("insert failed")} historyBucket := newHistoryBucket(t) service := historyImportService(t, &importFakeBranchRepo{}, importer, historyBucket) _, err := service.importHistoryUpdate(context.Background(), importExpoAppID, expoHistoryUpdateFixture(historyIOSPermalink), map[branchRuntime]bool{}) require.ErrorContains(t, err, "insert failed") // The config files were swept with their folder. assert.Empty(t, historyUpdateFiles(t, historyBucket)) } func TestImportHistoryUpdateSkipsWhenInsertReportsDuplicate(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() mockExpoUpdateGroups(t) importer := &importFakeUpdateImporter{duplicate: true} historyBucket := newHistoryBucket(t) service := historyImportService(t, &importFakeBranchRepo{}, importer, historyBucket) skipReason, err := service.importHistoryUpdate(context.Background(), importExpoAppID, expoHistoryUpdateFixture(historyIOSPermalink), map[branchRuntime]bool{}) require.NoError(t, err) assert.Contains(t, skipReason, "already exists") assert.Contains(t, skipReason, "overwritten") assert.NotEmpty(t, historyUpdateFiles(t, historyBucket), "the existing update keeps its files") } func expoHistoryUpdateFixture(permalink string) expo.HistoryUpdate { return expo.HistoryUpdate{ Id: "11111111-1111-1111-1111-111111111111", Group: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", BranchName: "production", RuntimeVersion: "1.0.0", Platform: "ios", Message: "publish message", GitCommitHash: "abc1234", CreatedAt: "2026-01-03T10:20:30.400Z", ManifestPermalink: permalink, } }