Ctrl K
ComunidadGuías…

internal/handlers/branch_list_handler_test.go

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

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"xprem/internal/services"
	"xprem/internal/types"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const surfingTestAppID = "1a2b3c4d-0000-0000-0000-000000000001"

type stubChannelRepo struct {
	surfing map[string]*types.BranchSurfing
}

func (r *stubChannelRepo) InsertChannel(_ context.Context, _ string, _ *int64, _ string) (int64, error) {
	return 0, nil
}
func (r *stubChannelRepo) DeleteChannel(_ context.Context, _, _ string) error { return nil }
func (r *stubChannelRepo) GetChannelNameByBranchName(_ context.Context, _, _ string) ([]string, error) {
	return nil, nil
}
func (r *stubChannelRepo) GetChannels(_ context.Context, _ string) ([]types.ChannelMapping, error) {
	return nil, nil
}
func (r *stubChannelRepo) GetChannelBranchMapping(_ context.Context, _, _ string) (*types.ChannelResolution, error) {
	return nil, nil
}
func (r *stubChannelRepo) GetBranchSurfing(_ context.Context, _, channelName string) (*types.BranchSurfing, error) {
	return r.surfing[channelName], nil
}
func (r *stubChannelRepo) SetBranchSurfing(_ context.Context, _, _ string, _ types.BranchSurfing) error {
	return nil
}

type stubBranchRepo struct {
	surfable map[string][]types.SurfableBranch
}

func (r *stubBranchRepo) InsertBranch(_ context.Context, _, _ string) (int64, error) {
	return 0, nil
}
func (r *stubBranchRepo) UpsertBranchAndRuntimeVersion(_ context.Context, _, _, _ string) error {
	return nil
}
func (r *stubBranchRepo) GetUpdateRefsByBranchName(_ context.Context, _, _ string) ([]types.UpdateRef, error) {
	return nil, nil
}
func (r *stubBranchRepo) DeleteBranchByName(_ context.Context, _, _ string) error { return nil }
func (r *stubBranchRepo) GetBranches(_ context.Context, _ string) ([]types.BranchMapping, error) {
	return nil, nil
}
func (r *stubBranchRepo) GetSurfableBranches(_ context.Context, _, runtimeVersion string, _ types.Platform) ([]types.SurfableBranch, error) {
	return r.surfable[runtimeVersion], nil
}
func (r *stubBranchRepo) GetRuntimeVersionsWithUpdateStats(_ context.Context, _, _ string) ([]types.RuntimeVersionWithStats, error) {
	return nil, nil
}
func (r *stubBranchRepo) UpdateChannelBranchMapping(_ context.Context, _, _, _ string) error {
	return nil
}
func (r *stubBranchRepo) CreateRuntimeVersion(_ context.Context, _, _ string) (int64, error) {
	return 0, nil
}
func (r *stubBranchRepo) GetBranchByName(_ context.Context, _, _ string) (int64, error) {
	return 0, nil
}

// The delivery-path caches are process-wide singletons keyed on the app id, so a
// test that shares one with its neighbours would read their answers. Surfing also
// only exists on the control plane, so DB_URL has to look set.
func surfingHandler(t *testing.T) *BranchListHandler {
	t.Helper()
	t.Setenv("DB_URL", "postgres://stub")
	services.ForgetBranchSurfing(surfingTestAppID, "qa")
	services.ForgetBranchSurfing(surfingTestAppID, "production")
	services.ForgetSurfableBranches(surfingTestAppID, "3.0.0", "ios")
	services.ForgetSurfableBranches(surfingTestAppID, "9.9.9", "ios")
	services.ForgetSurfableBranches(surfingTestAppID, "2.0.0", "ios")
	return newSurfingHandler()
}

// Same isolation, with a branch set the test chooses.
func surfingHandlerWithBranches(t *testing.T, branches []types.SurfableBranch) *BranchListHandler {
	t.Helper()
	t.Setenv("DB_URL", "postgres://stub")
	services.ForgetBranchSurfing(surfingTestAppID, "qa")
	services.ForgetSurfableBranches(surfingTestAppID, "3.0.0", "ios")
	channelRepo := &stubChannelRepo{surfing: map[string]*types.BranchSurfing{
		"qa": {Enabled: true, Pattern: "pr-*"},
	}}
	branchRepo := &stubBranchRepo{surfable: map[string][]types.SurfableBranch{"3.0.0": branches}}
	return NewBranchListHandler(services.NewChannelService(branchRepo, channelRepo))
}

func newSurfingHandler() *BranchListHandler {
	channelRepo := &stubChannelRepo{surfing: map[string]*types.BranchSurfing{
		"qa":         {Enabled: true, Pattern: "pr-*"},
		"production": {Enabled: false, Pattern: "*"},
	}}
	branchRepo := &stubBranchRepo{surfable: map[string][]types.SurfableBranch{
		"3.0.0": {
			{Name: "pr-482", LastUpdateAt: "2026-08-01T10:00:00Z"},
			{Name: "production", LastUpdateAt: "2026-07-30T10:00:00Z"},
		},
	}}
	return NewBranchListHandler(services.NewChannelService(branchRepo, channelRepo))
}

func branchListRequest(target string, headers map[string]string) *http.Request {
	r := httptest.NewRequest(http.MethodGet, target, nil)
	for key, value := range headers {
		r.Header.Set(key, value)
	}
	return r
}

func TestBranchListRejectsMissingHeaders(t *testing.T) {
	cases := []struct {
		name    string
		headers map[string]string
	}{
		{"no app id", map[string]string{"expo-channel-name": "qa", "expo-runtime-version": "3.0.0"}},
		{"no channel", map[string]string{"expo-app-id": surfingTestAppID, "expo-runtime-version": "3.0.0"}},
		{"no runtime version", map[string]string{"expo-app-id": surfingTestAppID, "expo-channel-name": "qa"}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			w := httptest.NewRecorder()
			surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", tc.headers))
			assert.Equal(t, http.StatusBadRequest, w.Code)
		})
	}
}

func TestBranchListServesBranchesMatchingThePattern(t *testing.T) {
	w := httptest.NewRecorder()
	surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
		"expo-app-id":          surfingTestAppID,
		"expo-channel-name":    "qa",
		"expo-runtime-version": "3.0.0",
		"expo-platform":        "ios",
	}))

	require.Equal(t, http.StatusOK, w.Code)
	assert.Equal(t, "private, max-age=0", w.Header().Get("cache-control"))
	var list types.SurfableBranchList
	require.NoError(t, json.NewDecoder(w.Body).Decode(&list))
	assert.Equal(t, []types.SurfableBranch{{Name: "pr-482", LastUpdateAt: "2026-08-01T10:00:00Z"}}, list.Branches)
	assert.Equal(t, 1, list.Total)
}

func TestBranchListRefusesChannelWithSurfingOff(t *testing.T) {
	w := httptest.NewRecorder()
	surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
		"expo-app-id":          surfingTestAppID,
		"expo-channel-name":    "production",
		"expo-runtime-version": "3.0.0",
		"expo-platform":        "ios",
	}))

	assert.Equal(t, http.StatusNotFound, w.Code)
}

// The runtime version falls back to the query string, as the manifest route
// does for clients that cannot set the header.
func TestBranchListAcceptsRuntimeVersionFromQuery(t *testing.T) {
	w := httptest.NewRecorder()
	surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists?runtimeVersion=3.0.0", map[string]string{
		"expo-app-id":       surfingTestAppID,
		"expo-channel-name": "qa",
		"expo-platform":     "ios",
	}))

	require.Equal(t, http.StatusOK, w.Code)
	var list types.SurfableBranchList
	require.NoError(t, json.NewDecoder(w.Body).Decode(&list))
	assert.Len(t, list.Branches, 1)
}

// An unknown runtime version is an empty list, not an error: the channel allows
// surfing, there is simply nothing this binary can be served.
func TestBranchListIsEmptyForUnknownRuntimeVersion(t *testing.T) {
	w := httptest.NewRecorder()
	surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
		"expo-app-id":          surfingTestAppID,
		"expo-channel-name":    "qa",
		"expo-runtime-version": "9.9.9",
		"expo-platform":        "ios",
	}))

	require.Equal(t, http.StatusOK, w.Code)
	var list types.SurfableBranchList
	require.NoError(t, json.NewDecoder(w.Body).Decode(&list))
	assert.Empty(t, list.Branches)
	assert.Equal(t, 0, list.Total)
}

// all=1 is the only way to get past the first page, so a device that does not
// ask for it can never be handed the wide answer by accident.
func TestBranchListPageWidensOnlyWithTheAllFlag(t *testing.T) {
	branches := make([]types.SurfableBranch, 0, 30)
	for i := 0; i < 30; i++ {
		branches = append(branches, types.SurfableBranch{
			Name:         fmt.Sprintf("pr-%d", i),
			LastUpdateAt: "2026-08-01T10:00:00Z",
		})
	}
	handler := surfingHandlerWithBranches(t, branches)

	read := func(url string) types.SurfableBranchList {
		w := httptest.NewRecorder()
		handler.HandleBranchList(w, branchListRequest(url, map[string]string{
			"expo-app-id":          surfingTestAppID,
			"expo-channel-name":    "qa",
			"expo-runtime-version": "3.0.0",
			"expo-platform":        "ios",
		}))
		require.Equal(t, http.StatusOK, w.Code)
		var list types.SurfableBranchList
		require.NoError(t, json.NewDecoder(w.Body).Decode(&list))
		return list
	}

	first := read("/branch_lists")
	assert.Len(t, first.Branches, 10)
	assert.Equal(t, 30, first.Total)

	everything := read("/branch_lists?all=1")
	assert.Len(t, everything.Branches, 30)
	assert.Equal(t, 30, everything.Total)
}

// A branch whose only update is for the other platform cannot be served here:
// the manifest route filters on platform too, so offering it would hand the
// tester a switch that silently drops them back on the channel's branch.
func TestBranchListIsScopedToThePlatform(t *testing.T) {
	t.Setenv("DB_URL", "postgres://stub")
	services.ForgetBranchSurfing(surfingTestAppID, "qa")
	services.ForgetSurfableBranches(surfingTestAppID, "3.0.0", "ios")
	services.ForgetSurfableBranches(surfingTestAppID, "3.0.0", "android")
	channelRepo := &stubChannelRepo{surfing: map[string]*types.BranchSurfing{
		"qa": {Enabled: true, Pattern: "pr-*"},
	}}
	branchRepo := &platformBranchRepo{}
	handler := NewBranchListHandler(services.NewChannelService(branchRepo, channelRepo))

	for _, platform := range []string{"ios", "android"} {
		w := httptest.NewRecorder()
		handler.HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
			"expo-app-id":          surfingTestAppID,
			"expo-channel-name":    "qa",
			"expo-runtime-version": "3.0.0",
			"expo-platform":        platform,
		}))
		require.Equal(t, http.StatusOK, w.Code)
		var list types.SurfableBranchList
		require.NoError(t, json.NewDecoder(w.Body).Decode(&list))
		require.Len(t, list.Branches, 1)
		assert.Equal(t, "pr-"+platform, list.Branches[0].Name,
			"%s must not be offered the other platform's branch", platform)
	}
}

// Answers a different branch per platform, so a list built without it returns
// the wrong one rather than merely a longer one.
type platformBranchRepo struct{ services.BranchRepository }

func (platformBranchRepo) GetSurfableBranches(_ context.Context, _, _ string, platform types.Platform) ([]types.SurfableBranch, error) {
	return []types.SurfableBranch{{Name: "pr-" + string(platform), LastUpdateAt: "2026-08-01T10:00:00Z"}}, nil
}

func TestBranchListRejectsAMissingOrUnknownPlatform(t *testing.T) {
	for name, platform := range map[string]string{"absent": "", "unknown": "blackberry"} {
		t.Run(name, func(t *testing.T) {
			w := httptest.NewRecorder()
			headers := map[string]string{
				"expo-app-id":          surfingTestAppID,
				"expo-channel-name":    "qa",
				"expo-runtime-version": "3.0.0",
			}
			if platform != "" {
				headers["expo-platform"] = platform
			}
			surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", headers))
			assert.Equal(t, http.StatusBadRequest, w.Code)
		})
	}
}

// The answer is cached under the runtime version, empty answers included, and
// the local cache has no size ceiling — so an unbounded header is one cache
// entry per request for anyone who can reach this route, which is everyone.
func TestBranchListRejectsAnOversizedRuntimeVersion(t *testing.T) {
	w := httptest.NewRecorder()
	surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
		"expo-app-id":          surfingTestAppID,
		"expo-channel-name":    "qa",
		"expo-runtime-version": strings.Repeat("9", maxRuntimeVersionLen+1),
		"expo-platform":        "ios",
	}))

	assert.Equal(t, http.StatusBadRequest, w.Code)
}

// A device already pinned to a branch only learns that surfing was switched off
// from this header: the panel hides itself on the 404, and after that there is
// no interface left to unpin from. It must be on BOTH refusals — a header only
// the disabled channel carried would tell an unauthenticated caller which
// channel names exist, which the identical bodies exist to prevent.
func TestTheRefusalTellsAPinnedDeviceToUnpin(t *testing.T) {
	for name, channelName := range map[string]string{
		"surfing disabled": "production",
		"unknown channel":  "ghost",
	} {
		t.Run(name, func(t *testing.T) {
			w := httptest.NewRecorder()
			surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
				"expo-app-id":          surfingTestAppID,
				"expo-channel-name":    channelName,
				"expo-runtime-version": "3.0.0",
				"expo-platform":        "ios",
			}))

			require.Equal(t, http.StatusNotFound, w.Code)
			assert.Equal(t, "off", w.Header().Get(SurfingDisabledHeader))
		})
	}
}

// The signal must not ride on refusals the client cannot act on: clearing a
// tester's branch because a header was malformed would be a silent surprise.
func TestABadRequestCarriesNoUnpinSignal(t *testing.T) {
	w := httptest.NewRecorder()
	surfingHandler(t).HandleBranchList(w, branchListRequest("/branch_lists", map[string]string{
		"expo-app-id":          surfingTestAppID,
		"expo-channel-name":    "qa",
		"expo-runtime-version": "3.0.0",
		"expo-platform":        "blackberry",
	}))

	require.Equal(t, http.StatusBadRequest, w.Code)
	assert.Empty(t, w.Header().Get(SurfingDisabledHeader))
}