internal/expoimport/service_test.go
Rama main · réplica de lectura del repositorio original.
package expoimport
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"testing"
"xprem/config"
"xprem/internal/bucket"
"xprem/internal/providers/expo"
"xprem/internal/repository"
"xprem/internal/services"
"xprem/internal/types"
"xprem/internal/validation"
"github.com/google/uuid"
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const importExpoAppID = "9f0e1d2c-0000-0000-0000-00000000abcd"
func expoAuth(token string) types.Auth {
return types.Auth{Token: &token}
}
type importFakeAppRepo struct {
inserted []repository.InsertAppParameters
deleted []string
insertErr error
// missing makes GetAppByID answer not-found.
missing bool
}
func (f *importFakeAppRepo) InsertApp(_ context.Context, app repository.InsertAppParameters) (string, error) {
if f.insertErr != nil {
return "", f.insertErr
}
f.inserted = append(f.inserted, app)
return app.ID, nil
}
func (f *importFakeAppRepo) DeleteAppByID(_ context.Context, id string) error {
f.deleted = append(f.deleted, id)
return nil
}
func (f *importFakeAppRepo) GetApps(_ context.Context) ([]config.AppDescriptor, error) {
panic("unused")
}
func (f *importFakeAppRepo) UpdateAppNameByID(_ context.Context, _ string, _ string) error {
panic("unused")
}
func (f *importFakeAppRepo) UpdateAppGitURLByID(_ context.Context, _ string, _ string) error {
panic("unused")
}
func (f *importFakeAppRepo) GetAppByID(_ context.Context, id string) (config.AppConfig, error) {
if f.missing {
return config.AppConfig{}, fmt.Errorf("app %s not found", id)
}
return config.AppConfig{Id: id, Name: "My Imported App"}, nil
}
type importFakeBranchRepo struct {
nextId int64
byName map[string]int64
inserted []string
upserted []string
}
func (f *importFakeBranchRepo) InsertBranch(_ context.Context, _ string, branchName string) (int64, error) {
f.nextId++
if f.byName == nil {
f.byName = map[string]int64{}
}
f.byName[branchName] = f.nextId
f.inserted = append(f.inserted, branchName)
return f.nextId, nil
}
func (f *importFakeBranchRepo) GetBranchByName(_ context.Context, _ string, branchName string) (int64, error) {
id, ok := f.byName[branchName]
if !ok {
return 0, fmt.Errorf("branch %q not found", branchName)
}
return id, nil
}
func (f *importFakeBranchRepo) UpsertBranchAndRuntimeVersion(_ context.Context, _ string, branchName string, runtimeVersion string) error {
f.upserted = append(f.upserted, branchName+"@"+runtimeVersion)
return nil
}
func (f *importFakeBranchRepo) GetUpdateRefsByBranchName(_ context.Context, _ string, _ string) ([]types.UpdateRef, error) {
panic("unused")
}
func (f *importFakeBranchRepo) DeleteBranchByName(_ context.Context, _ string, _ string) error {
panic("unused")
}
func (f *importFakeBranchRepo) GetBranches(_ context.Context, _ string) ([]types.BranchMapping, error) {
panic("unused")
}
func (f *importFakeBranchRepo) GetSurfableBranches(_ context.Context, _ string, _ string, _ types.Platform) ([]types.SurfableBranch, error) {
panic("unused")
}
func (f *importFakeBranchRepo) GetRuntimeVersionsWithUpdateStats(_ context.Context, _ string, _ string) ([]types.RuntimeVersionWithStats, error) {
panic("unused")
}
func (f *importFakeBranchRepo) UpdateChannelBranchMapping(_ context.Context, _ string, _ string, _ string) error {
panic("unused")
}
func (f *importFakeBranchRepo) CreateRuntimeVersion(_ context.Context, _ string, _ string) (int64, error) {
panic("unused")
}
type importedChannel struct {
name string
branchId *int64
}
type importFakeChannelRepo struct {
nextId int64
inserted []importedChannel
insertErr error
}
func (f *importFakeChannelRepo) InsertChannel(_ context.Context, _ string, branchId *int64, channelName string) (int64, error) {
if f.insertErr != nil {
return 0, f.insertErr
}
f.nextId++
f.inserted = append(f.inserted, importedChannel{name: channelName, branchId: branchId})
return f.nextId, nil
}
func (f *importFakeChannelRepo) DeleteChannel(_ context.Context, _ string, _ string) error {
panic("unused")
}
func (f *importFakeChannelRepo) GetChannelNameByBranchName(_ context.Context, _ string, _ string) ([]string, error) {
panic("unused")
}
func (f *importFakeChannelRepo) GetChannels(_ context.Context, _ string) ([]types.ChannelMapping, error) {
panic("unused")
}
func (f *importFakeChannelRepo) GetChannelBranchMapping(_ context.Context, _ string, _ string) (*types.ChannelResolution, error) {
panic("unused")
}
func (f *importFakeChannelRepo) GetBranchSurfing(_ context.Context, _ string, _ string) (*types.BranchSurfing, error) {
panic("unused")
}
func (f *importFakeChannelRepo) SetBranchSurfing(_ context.Context, _ string, _ string, _ types.BranchSurfing) error {
panic("unused")
}
func importService(t *testing.T, appRepo *importFakeAppRepo, branchRepo *importFakeBranchRepo, channelRepo *importFakeChannelRepo) *Service {
t.Helper()
t.Setenv("DB_URL", "postgres://stub")
appService := services.NewAppService(appRepo)
branchService := services.NewBranchService(branchRepo, channelRepo, nil, nil, nil, nil)
channelService := services.NewChannelService(branchRepo, channelRepo)
return NewService(appService, branchService, channelService, nil, nil, nil, nil)
}
// No jobs client: tests run the job body directly or stop before the enqueue.
func historyImportService(t *testing.T, branchRepo *importFakeBranchRepo, updateRepo services.UpdateRepository, historyBucket *bucket.Bucket) *Service {
t.Helper()
t.Setenv("DB_URL", "postgres://stub")
appService := services.NewAppService(&importFakeAppRepo{})
branchService := services.NewBranchService(branchRepo, &importFakeChannelRepo{}, nil, nil, nil, nil)
channelService := services.NewChannelService(branchRepo, &importFakeChannelRepo{})
return NewService(appService, branchService, channelService, updateRepo, nil, historyBucket.BlobStore, historyBucket.UpdateStore)
}
func awsKeysConfig() config.KeysConfig {
return config.KeysConfig{
Mode: config.KeysModeAWSSM,
PublicSecretId: "public-secret",
PrivateSecretId: "private-secret",
}
}
// The expo provider caches these lookups per credential; every test using the
// shared "token" credential starts from a cold cache.
func resetExpoProviderCaches(t *testing.T) {
t.Helper()
expo.InvalidateAccountApps(expoAuth("token"))
expo.InvalidateProjectStructure(expoAuth("token"), importExpoAppID)
}
func mockExpoProjectStructure(t *testing.T) {
t.Helper()
resetExpoProviderCaches(t)
httpmock.RegisterResponder("POST", "https://api.expo.dev/graphql",
func(req *http.Request) (*http.Response, error) {
switch req.Header.Get("operationName") {
case "FetchExpoProjectStructure":
return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
"data": map[string]interface{}{
"app": map[string]interface{}{
"byId": map[string]interface{}{
"id": importExpoAppID,
"name": "My Imported App",
"updateBranches": []map[string]interface{}{
{"id": "b1", "name": "production"},
{"id": "b2", "name": "preview"},
{"id": "b3", "name": "bad*branch"},
},
"updateChannels": []map[string]interface{}{
{"id": "c1", "name": "production", "branchMapping": `{"version":0,"data":[{"branchId":"b1","branchMappingLogic":"true"}]}`},
{"id": "c2", "name": "staging", "branchMapping": `{"version":0,"data":[{"branchId":"b3","branchMappingLogic":"true"}]}`},
{"id": "c3", "name": "orphan", "branchMapping": `{"version":0,"data":[]}`},
},
},
},
},
})
case "FetchExpoAccountApps":
return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
"data": map[string]interface{}{
"me": map[string]interface{}{
"id": "user-1",
"accounts": []map[string]interface{}{
{
"id": "account-1",
"name": "my-org",
"apps": []map[string]interface{}{
{"id": importExpoAppID, "name": "My Imported App", "fullName": "@my-org/my-imported-app"},
},
},
{
"id": "account-2",
"name": "empty-org",
"apps": []map[string]interface{}{},
},
},
},
},
})
}
return httpmock.NewStringResponse(http.StatusNotFound, "unknown operation"), nil
})
}
func TestImportAppCopiesStructure(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
appRepo := &importFakeAppRepo{}
branchRepo := &importFakeBranchRepo{}
channelRepo := &importFakeChannelRepo{}
service := importService(t, appRepo, branchRepo, channelRepo)
result, err := service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), 0)
require.NoError(t, err)
assert.Equal(t, importExpoAppID, result.AppId)
assert.Equal(t, "My Imported App", result.Name)
assert.Equal(t, 2, result.BranchCount)
assert.Equal(t, 3, result.ChannelCount)
// bad*branch is skipped (reserved wildcard); staging is created unmapped.
require.Len(t, result.Skipped, 1)
assert.Contains(t, result.Skipped[0], "bad*branch")
require.Len(t, result.Warnings, 1)
assert.Contains(t, result.Warnings[0], `channel "staging"`)
require.Len(t, appRepo.inserted, 1)
assert.Equal(t, importExpoAppID, appRepo.inserted[0].ID)
assert.Equal(t, "My Imported App", appRepo.inserted[0].Name)
assert.Equal(t, []string{"production", "preview"}, branchRepo.inserted)
require.Len(t, channelRepo.inserted, 3)
assert.Equal(t, "production", channelRepo.inserted[0].name)
require.NotNil(t, channelRepo.inserted[0].branchId)
assert.Equal(t, branchRepo.byName["production"], *channelRepo.inserted[0].branchId)
assert.Equal(t, "staging", channelRepo.inserted[1].name)
assert.Nil(t, channelRepo.inserted[1].branchId)
assert.Equal(t, "orphan", channelRepo.inserted[2].name)
assert.Nil(t, channelRepo.inserted[2].branchId)
}
// The Expo project id is reused as the local app id, so non-canonical forms
// must collapse to the lowercase UUID the row is keyed and sealed under.
func TestImportAppCanonicalizesUppercaseId(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
appRepo := &importFakeAppRepo{}
service := importService(t, appRepo, &importFakeBranchRepo{}, &importFakeChannelRepo{})
result, err := service.ImportApp(context.Background(), expoAuth("token"), "9F0E1D2C-0000-0000-0000-00000000ABCD", awsKeysConfig(), 0)
require.NoError(t, err)
assert.Equal(t, importExpoAppID, result.AppId)
}
func TestImportAppRejectsNonUUIDProjectId(t *testing.T) {
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
_, err := service.ImportApp(context.Background(), expoAuth("token"), "@my-org/my-app", awsKeysConfig(), 0)
require.Error(t, err)
assert.True(t, validation.IsValidationError(err))
}
func TestImportRequiresControlPlane(t *testing.T) {
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
t.Setenv("DB_URL", "")
_, err := service.ListImportableApps(context.Background(), expoAuth("token"))
require.ErrorIs(t, err, repository.ErrNotSupportedInStatelessMode)
_, err = service.PreviewImport(context.Background(), expoAuth("token"), importExpoAppID)
require.ErrorIs(t, err, repository.ErrNotSupportedInStatelessMode)
_, err = service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), 0)
require.ErrorIs(t, err, repository.ErrNotSupportedInStatelessMode)
}
func TestImportAppSurfacesExpoErrors(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
httpmock.RegisterResponder("POST", "https://api.expo.dev/graphql",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
"errors": []map[string]interface{}{{"message": "UNAUTHORIZED"}},
})
})
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
_, err := service.ImportApp(context.Background(), expoAuth("bad-token"), importExpoAppID, awsKeysConfig(), 0)
var apiErr *expo.APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusHint)
assert.Contains(t, apiErr.Message, "UNAUTHORIZED")
}
func TestImportAppReportsUnknownProject(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
resetExpoProviderCaches(t)
httpmock.RegisterResponder("POST", "https://api.expo.dev/graphql",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
"data": map[string]interface{}{"app": map[string]interface{}{"byId": nil}},
})
})
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
_, err := service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), 0)
var apiErr *expo.APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusNotFound, apiErr.StatusHint)
}
func TestImportAppPropagatesAlreadyExists(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
appRepo := &importFakeAppRepo{insertErr: &repository.ErrResourceAlreadyExists{Resource: "app", Identifier: importExpoAppID}}
service := importService(t, appRepo, &importFakeBranchRepo{}, &importFakeChannelRepo{})
_, err := service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), 0)
alreadyExists := (*repository.ErrResourceAlreadyExists)(nil)
require.ErrorAs(t, err, &alreadyExists)
assert.Empty(t, appRepo.deleted)
}
func TestImportAppRollsBackOnHardFailure(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
appRepo := &importFakeAppRepo{}
channelRepo := &importFakeChannelRepo{insertErr: errors.New("connection lost")}
service := importService(t, appRepo, &importFakeBranchRepo{}, channelRepo)
_, err := service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), 0)
require.Error(t, err)
require.Len(t, appRepo.inserted, 1)
assert.Equal(t, []string{importExpoAppID}, appRepo.deleted)
}
func TestPreviewImportBuildsPlan(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
appRepo := &importFakeAppRepo{missing: true}
service := importService(t, appRepo, &importFakeBranchRepo{}, &importFakeChannelRepo{})
plan, err := service.PreviewImport(context.Background(), expoAuth("token"), importExpoAppID)
require.NoError(t, err)
assert.Empty(t, plan.Conflict)
assert.Equal(t, importExpoAppID, plan.AppId)
assert.Equal(t, "My Imported App", plan.Name)
assert.Equal(t, "My Imported App", plan.ExpoName)
require.Len(t, plan.Branches, 3)
assert.Empty(t, plan.Branches[0].SkipReason)
assert.Empty(t, plan.Branches[1].SkipReason)
assert.NotEmpty(t, plan.Branches[2].SkipReason, "bad*branch must be flagged before the import runs")
require.Len(t, plan.Channels, 3)
assert.Equal(t, "production", plan.Channels[0].MappedBranch)
// staging maps to the skipped branch: still created, but unmapped.
assert.Empty(t, plan.Channels[1].SkipReason)
assert.Empty(t, plan.Channels[1].MappedBranch)
assert.Contains(t, plan.Channels[1].Warning, "bad*branch")
assert.Empty(t, plan.Channels[2].MappedBranch)
assert.Empty(t, appRepo.inserted)
}
func TestBuildImportPlanSkipsReservedBranchName(t *testing.T) {
plan := buildImportPlan(uuid.MustParse(importExpoAppID), &expo.ProjectStructure{Name: "App", Branches: []string{"cas"}})
require.Len(t, plan.Branches, 1)
assert.Contains(t, plan.Branches[0].SkipReason, "reserved")
}
func TestBuildImportPlanEmptySlices(t *testing.T) {
plan := buildImportPlan(uuid.MustParse(importExpoAppID), &expo.ProjectStructure{Name: "Empty"})
require.NotNil(t, plan.Branches)
require.NotNil(t, plan.Channels)
assert.Empty(t, plan.Branches)
assert.Empty(t, plan.Channels)
}
func TestPreviewImportReportsConflict(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
plan, err := service.PreviewImport(context.Background(), expoAuth("token"), importExpoAppID)
require.NoError(t, err)
assert.Contains(t, plan.Conflict, "already exists")
}
func TestImportAppValidatesHistoryLimit(t *testing.T) {
appRepo := &importFakeAppRepo{}
service := importService(t, appRepo, &importFakeBranchRepo{}, &importFakeChannelRepo{})
_, err := service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), MaxHistoryImportGroups+1)
require.Error(t, err)
assert.True(t, validation.IsValidationError(err))
assert.Empty(t, appRepo.inserted)
}
// When the history job cannot start, the whole import fails and the app is
// rolled back.
func TestImportAppRollsBackWhenHistoryJobCannotStart(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
appRepo := &importFakeAppRepo{}
service := importService(t, appRepo, &importFakeBranchRepo{}, &importFakeChannelRepo{})
_, err := service.ImportApp(context.Background(), expoAuth("token"), importExpoAppID, awsKeysConfig(), 10)
require.ErrorContains(t, err, "failed to start the update history import")
require.Len(t, appRepo.inserted, 1)
assert.Equal(t, []string{importExpoAppID}, appRepo.deleted)
}
func TestListImportableApps(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
mockExpoProjectStructure(t)
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
accounts, err := service.ListImportableApps(context.Background(), expoAuth("token"))
require.NoError(t, err)
require.Len(t, accounts, 2)
assert.Equal(t, "my-org", accounts[0].AccountName)
require.Len(t, accounts[0].Apps, 1)
assert.Equal(t, importExpoAppID, accounts[0].Apps[0].Id)
// Non-nil even when empty: the dashboard reads apps.length on every account.
require.NotNil(t, accounts[1].Apps)
assert.Empty(t, accounts[1].Apps)
blank := " "
_, err = service.ListImportableApps(context.Background(), types.Auth{Token: &blank})
assert.True(t, validation.IsValidationError(err))
_, err = service.ListImportableApps(context.Background(), types.Auth{})
assert.True(t, validation.IsValidationError(err))
}
// The Expo API caps the apps field at 50 per call; more apps are read page by page.
func TestListImportableAppsPaginatesPastFiftyApps(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
resetExpoProviderCaches(t)
httpmock.RegisterResponder("POST", "https://api.expo.dev/graphql",
func(req *http.Request) (*http.Response, error) {
var body struct {
Variables struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
} `json:"variables"`
}
require.NoError(t, json.NewDecoder(req.Body).Decode(&body))
require.LessOrEqual(t, body.Variables.Limit, 50)
pageSize := body.Variables.Limit
if body.Variables.Offset >= 50 {
pageSize = 10
}
apps := make([]map[string]interface{}, 0, pageSize)
for i := 0; i < pageSize; i++ {
id := fmt.Sprintf("app-%d", body.Variables.Offset+i)
apps = append(apps, map[string]interface{}{"id": id, "name": id, "fullName": "@my-org/" + id})
}
return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
"data": map[string]interface{}{
"me": map[string]interface{}{
"id": "user-1",
"accounts": []map[string]interface{}{
{"id": "account-1", "name": "my-org", "apps": apps},
},
},
},
})
})
service := importService(t, &importFakeAppRepo{}, &importFakeBranchRepo{}, &importFakeChannelRepo{})
accounts, err := service.ListImportableApps(context.Background(), expoAuth("token"))
require.NoError(t, err)
require.Len(t, accounts, 1)
require.Len(t, accounts[0].Apps, 60)
assert.Equal(t, "app-0", accounts[0].Apps[0].Id)
assert.Equal(t, "app-59", accounts[0].Apps[59].Id)
}