package web import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/master/model" "github.com/shcizo/package-updater/internal/protocol" ) func TestImageParts(t *testing.T) { cases := []struct { ref, repo, tag, digest string }{ {"postgres:17.10", "postgres", ":17.10", ""}, {"postgres", "postgres", "", ""}, {"gitea/gitea:1.27.3", "gitea/gitea", ":1.27.3", ""}, {"gitea.shcizo.se/shcizo/slusk:v0.8.1", "shcizo/slusk", ":v0.8.1", ""}, {"localhost:5000/foo:v1", "foo", ":v1", ""}, {"localhost/foo", "foo", "", ""}, {"lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", "linuxserver/jellyfin", "", "@sha256:0f42497a69fa…"}, {"r/x:1.0@sha256:0f42497a69fa0441", "r/x", ":1.0", ""}, // the digest is hidden when there is a tag {"r/x@sha256:abc", "r/x", "", "@sha256:abc"}, } for _, c := range cases { t.Run(c.ref, func(t *testing.T) { repo, tag, digest := imageParts(c.ref) assert.Equal(t, c.repo, repo) assert.Equal(t, c.tag, tag) assert.Equal(t, c.digest, digest) }) } } func TestLetterClass(t *testing.T) { assert.Equal(t, "lc-0", letterClass("")) assert.Equal(t, "lc-1", letterClass("a"), "97 mod 4") assert.Equal(t, letterClass("gitea"), letterClass("gitea"), "a name always gets the same colour") for _, name := range []string{"gitea", "authentik-server", "ø", "n8n"} { assert.Regexp(t, `^lc-[0-3]$`, letterClass(name)) } } func TestNewAgentRow(t *testing.T) { seen := ptr(time.Now()) cases := []struct { name string agent model.Agent online bool masterVersion string status, role string old bool }{ {"online manager", model.Agent{SwarmManager: true, AgentVersion: "v1"}, true, "v1", "Online", "Manager", false}, {"offline worker on another version", model.Agent{SwarmClusterID: "c", AgentVersion: "v0", LastSeenAt: seen}, false, "v1", "Offline", "Worker", true}, {"never connected", model.Agent{}, false, "v1", "Never connected", "—", false}, {"revoked wins over online", model.Agent{RevokedAt: seen, LastSeenAt: seen}, true, "v1", "Revoked", "—", false}, {"dev master marks nothing", model.Agent{AgentVersion: "v0"}, true, "dev", "Online", "—", false}, {"agent without version", model.Agent{}, true, "v1", "Online", "—", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { row := newAgentRow(c.agent, c.online, c.masterVersion) assert.Equal(t, c.status, row.Status) assert.Equal(t, c.role, row.Role) assert.Equal(t, c.old, row.VersionOld) }) } } func TestMiddle(t *testing.T) { assert.Equal(t, "short", middle("short")) assert.Equal(t, "pua_7Hq2…Ge5A", middle("pua_7Hq2mX9vKc4LwT8nRb3ZyF6dJp1sGe5A")) } func TestGroupServices(t *testing.T) { at := func(h, m int) *time.Time { return ptr(time.Date(2026, 10, 12, h, m, 0, 0, time.UTC)) } id := func(n int64) *int64 { return &n } compose := func(sid int64, agent *int64, name string) model.Service { return model.Service{ID: sid, Kind: protocol.KindCompose, AgentID: agent, ComposeProject: "p", ComposeService: name, ActualRef: "r/" + name + ":1"} } swarm := func(sid int64, cluster string, agent *int64) model.Service { return model.Service{ID: sid, Kind: protocol.KindSwarm, SwarmClusterID: cluster, AgentID: agent, SwarmService: "s" + cluster} } noBlock := func(model.Service) string { return "" } onlineSet := func(ids ...int64) func(int64) bool { return func(a int64) bool { for _, i := range ids { if i == a { return true } } return false } } titles := func(groups []serviceGroup) []string { var out []string for _, g := range groups { out = append(out, g.Title) } return out } t.Run("empty", func(t *testing.T) { assert.Empty(t, groupServices(nil, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(), noBlock)) }) t.Run("agents without services get no group", func(t *testing.T) { groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}, {ID: 2, Name: "idle"}}, onlineSet(1, 2), noBlock) assert.Equal(t, []string{"a"}, titles(groups)) assert.Equal(t, "agent-1", groups[0].Key) assert.Equal(t, "Machine", groups[0].Kind) assert.False(t, groups[0].Offline) }) t.Run("services without a known agent are offline and last", func(t *testing.T) { groups := groupServices( []model.Service{compose(1, nil, "orphan"), compose(2, id(7), "ghost"), compose(3, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(1), noBlock) assert.Equal(t, []string{"a", "Unknown agent"}, titles(groups)) unknown := groups[1] assert.Equal(t, "unknown", unknown.Key) assert.True(t, unknown.Offline) assert.Nil(t, unknown.OfflineSince) assert.Len(t, unknown.Services, 2) }) t.Run("an offline agent says since when", func(t *testing.T) { groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a", Hostname: "a.lan", LastSeenAt: at(13, 51)}}, onlineSet(), noBlock) require.Len(t, groups, 1) assert.True(t, groups[0].Offline) assert.Equal(t, "Agent offline", groups[0].OfflineLabel) assert.Equal(t, at(13, 51), groups[0].OfflineSince) assert.Equal(t, "a.lan", groups[0].Meta) }) t.Run("a cluster is online when any manager is", func(t *testing.T) { agents := []model.Agent{ {ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)}, {ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)}, } groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(2), noBlock) require.Len(t, groups, 1) assert.False(t, groups[0].Offline) assert.Equal(t, "Swarm", groups[0].Title) assert.Equal(t, "c1", groups[0].TitleHint) assert.Equal(t, "Swarm cluster", groups[0].Kind) assert.Equal(t, "managers m1, m2", groups[0].Meta) assert.Equal(t, "swarm-c1", groups[0].Key) }) t.Run("a cluster with no manager online has been offline since the latest manager was seen", func(t *testing.T) { agents := []model.Agent{ {ID: 1, Name: "m1", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(10, 0)}, {ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(11, 0)}, } groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(), noBlock) assert.True(t, groups[0].Offline) assert.Equal(t, "No manager online", groups[0].OfflineLabel) assert.Equal(t, at(11, 0), groups[0].OfflineSince) }) t.Run("a revoked manager does not count", func(t *testing.T) { agents := []model.Agent{ {ID: 1, Name: "gone", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(12, 0), RevokedAt: at(12, 5)}, {ID: 2, Name: "m2", SwarmManager: true, SwarmClusterID: "c1", LastSeenAt: at(9, 0)}, } groups := groupServices([]model.Service{swarm(1, "c1", nil)}, agents, onlineSet(1), noBlock) assert.True(t, groups[0].Offline, "the revoked manager is not connected for this purpose") assert.Equal(t, "managers m2", groups[0].Meta) assert.Equal(t, at(9, 0), groups[0].OfflineSince) }) t.Run("managers are found through the rows when no agent reports the cluster", func(t *testing.T) { agents := []model.Agent{{ID: 4, Name: "reporter", LastSeenAt: at(8, 0)}} groups := groupServices([]model.Service{swarm(1, "c1", id(4))}, agents, onlineSet(4), noBlock) assert.False(t, groups[0].Offline) assert.Equal(t, "managers reporter", groups[0].Meta) }) t.Run("order is cluster, its machines, other machines by name, unknown", func(t *testing.T) { agents := []model.Agent{ {ID: 1, Name: "zeta"}, {ID: 2, Name: "mgr", SwarmManager: true, SwarmClusterID: "c1"}, {ID: 3, Name: "alpha"}, {ID: 4, Name: "mgr2", SwarmManager: true, SwarmClusterID: "c1"}, } services := []model.Service{ compose(1, id(1), "a"), compose(2, id(2), "b"), compose(3, id(3), "c"), compose(4, id(4), "d"), compose(5, nil, "e"), swarm(6, "c1", nil), } groups := groupServices(services, agents, onlineSet(1, 2, 3, 4), noBlock) assert.Equal(t, []string{"Swarm", "mgr", "mgr2", "alpha", "zeta", "Unknown agent"}, titles(groups)) assert.Equal(t, []bool{false, true, true, false, false, false}, []bool{groups[0].Sub, groups[1].Sub, groups[2].Sub, groups[3].Sub, groups[4].Sub, groups[5].Sub}) }) t.Run("two clusters carry their ids in the titles", func(t *testing.T) { groups := groupServices([]model.Service{swarm(1, "abcdefghijkl", nil), swarm(2, "xyz", nil)}, nil, onlineSet(), noBlock) assert.Equal(t, []string{"Swarm abcdefgh", "Swarm xyz"}, titles(groups)) assert.Equal(t, "abcdefghijkl", groups[0].TitleHint) }) t.Run("the auto-blocked reason is kept on the row", func(t *testing.T) { groups := groupServices([]model.Service{compose(1, id(1), "web")}, []model.Agent{{ID: 1, Name: "a"}}, onlineSet(1), func(model.Service) string { return "because" }) assert.Equal(t, "because", groups[0].Services[0].AutoBlocked) }) } func TestNewerTag(t *testing.T) { removed := time.Now() tests := []struct { name string svc model.Service want string }{ {"newer", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0"}, "1.28.0"}, {"bare image follows latest", model.Service{ActualRef: "gitea/gitea", NewerRef: "gitea/gitea:1.28.0"}, ""}, {"stale after the tag changed", model.Service{ActualRef: "gitea/gitea:1.28.0", NewerRef: "gitea/gitea:1.28.0"}, ""}, {"stale after the tag moved past it", model.Service{ActualRef: "gitea/gitea:1.29.0", NewerRef: "gitea/gitea:1.28.0"}, ""}, {"removed", model.Service{ActualRef: "gitea/gitea:1.27.3", NewerRef: "gitea/gitea:1.28.0", RemovedAt: &removed}, ""}, {"empty", model.Service{ActualRef: "gitea/gitea:1.27.3"}, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, newerTag(tt.svc)) }) } }