Ctrl K
ComunidadGuías…

internal/expoimport/history_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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, "[email protected]")

	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,
	}
}