Files
Containr/app/backend/internal/api/templategraph_test.go
T
Tomas DvorakandDevin 99a25cb10e feat(templates): catalog v2 — 5 new managed-DB stacks + seed validation test
Ghost+MySQL, Gitea+PostgreSQL, Nextcloud+PG+Redis, Paperless+PG+Redis,
Plausible+PG+ClickHouse. TestOfficialTemplatesAllParse parses every
seed, topo-sorts graphs, and dry-resolves with no unresolved refs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 00:33:27 +02:00

203 lines
6.4 KiB
Go

package api
import (
"encoding/json"
"strings"
"testing"
)
func TestTopoSortServicesOrdersByDependsOn(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "web", DependsOn: []string{"db"}},
{Key: "db", Type: "database"},
{Key: "worker", DependsOn: []string{"web", "db"}},
})
if err != nil {
t.Fatalf("topoSortServices: %v", err)
}
if sorted[0].Key != "db" || sorted[1].Key != "web" || sorted[2].Key != "worker" {
t.Fatalf("unexpected order: %v", []string{sorted[0].Key, sorted[1].Key, sorted[2].Key})
}
}
func TestTopoSortServicesRejectsCycle(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"b"}},
{Key: "b", DependsOn: []string{"a"}},
})
if err == nil || !strings.Contains(err.Error(), "cycle") {
t.Fatalf("expected cycle error, got %v", err)
}
}
func TestTopoSortServicesRejectsUnknownDep(t *testing.T) {
_, err := topoSortServices([]TemplateServiceSpec{
{Key: "a", DependsOn: []string{"ghost"}},
})
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}
func TestResolveStaticExpr(t *testing.T) {
unresolved := []string{}
var generated bool
v := resolveStaticExpr("{{secret}}", nil, &unresolved, &generated)
if len(v) != 32 || !generated {
t.Fatalf("secret: got %q generated=%v", v, generated)
}
v = resolveStaticExpr("{{secret(8)}}", nil, &unresolved, &generated)
if len(v) != 16 {
t.Fatalf("secret(8): got %q", v)
}
v = resolveStaticExpr("port-{{random:10}}", nil, &unresolved, &generated)
if !strings.HasPrefix(v, "port-") {
t.Fatalf("random: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN:-example.com}}", nil, &unresolved, &generated)
if v != "example.com" {
t.Fatalf("var default: got %q", v)
}
v = resolveStaticExpr("{{DOMAIN}}", map[string]string{"DOMAIN": "x.io"}, &unresolved, &generated)
if v != "x.io" {
t.Fatalf("var override: got %q", v)
}
v = resolveStaticExpr("{{MISSING}}", nil, &unresolved, &generated)
if v != "" || len(unresolved) == 0 || unresolved[0] != "MISSING" {
t.Fatalf("missing var: got %q unresolved=%v", v, unresolved)
}
}
func TestResolveTemplateGraphDatabaseRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql", Environment: map[string]string{
"POSTGRES_DB": "shop",
}},
{Key: "web", Type: "web", Runtime: "nginx", DependsOn: []string{"db"}, Environment: map[string]string{
"DATABASE_URL": "{{service.db.url}}",
"DB_HOST": "{{service.db.host}}",
"DB_PORT": "{{service.db.port}}",
"SESSION_TOKEN": "{{secret}}",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) > 0 {
t.Fatalf("unresolved: %v", graph.unresolved)
}
web := graph.members[1]
if web.env["DB_HOST"] != "db" {
t.Fatalf("DB_HOST=%q", web.env["DB_HOST"])
}
if web.env["DB_PORT"] != "5432" {
t.Fatalf("DB_PORT=%q", web.env["DB_PORT"])
}
dbURL := web.env["DATABASE_URL"]
if !strings.HasPrefix(dbURL, "postgresql://") || !strings.Contains(dbURL, "@db:5432/shop") {
t.Fatalf("DATABASE_URL=%q", dbURL)
}
if len(web.env["SESSION_TOKEN"]) != 32 || !web.secretKeys["SESSION_TOKEN"] {
t.Fatalf("SESSION_TOKEN=%q secret=%v", web.env["SESSION_TOKEN"], web.secretKeys["SESSION_TOKEN"])
}
// Database member must carry the generated password so refs and the
// container agree.
db := graph.members[0]
if pw := db.env["POSTGRES_PASSWORD"]; len(pw) != 32 || !db.secretKeys["POSTGRES_PASSWORD"] {
t.Fatalf("POSTGRES_PASSWORD=%q secret=%v", pw, db.secretKeys["POSTGRES_PASSWORD"])
}
if !strings.Contains(dbURL, db.env["POSTGRES_PASSWORD"]) {
t.Fatalf("url %q does not carry the resolved password", dbURL)
}
}
func TestResolveTemplateGraphServiceRefs(t *testing.T) {
sorted, err := topoSortServices([]TemplateServiceSpec{
{Key: "api", Type: "web", Runtime: "ghcr.io/x/api:1", Port: 8080},
{Key: "front", Type: "web", Runtime: "ghcr.io/x/front:1", Port: 3000, DependsOn: []string{"api"}, Environment: map[string]string{
"API_URL": "{{service.api.url}}/v2",
}},
})
if err != nil {
t.Fatalf("topo: %v", err)
}
graph := resolveTemplateGraph(sorted, nil)
if got := graph.members[1].env["API_URL"]; got != "http://api:8080/v2" {
t.Fatalf("API_URL=%q", got)
}
}
func TestResolveTemplateGraphUnresolvedServiceProp(t *testing.T) {
sorted, _ := topoSortServices([]TemplateServiceSpec{
{Key: "web", Type: "web", Runtime: "nginx", Environment: map[string]string{
"X": "{{service.nope.host}}",
}},
})
graph := resolveTemplateGraph(sorted, nil)
if len(graph.unresolved) == 0 {
t.Fatal("expected unresolved ref")
}
}
func TestValidateTemplateGraph(t *testing.T) {
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "postgresql"},
}); err != nil {
t.Fatalf("valid db spec rejected: %v", err)
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "db", Type: "database", Runtime: "nonsense"},
}); err == nil {
t.Fatal("expected unsupported db engine error")
}
if err := validateTemplateGraph([]TemplateServiceSpec{
{Key: "web", Type: "web"},
}); err == nil {
t.Fatal("expected missing runtime error")
}
}
// TestOfficialTemplatesAllParse walks every seeded template and asserts the
// config parses, graph templates topo-sort + validate, and a dry-run resolve
// produces no unresolved refs (defaults cover all expressions).
func TestOfficialTemplatesAllParse(t *testing.T) {
for _, tpl := range SeedTemplates() {
var cfg TemplateConfig
if err := json.Unmarshal([]byte(tpl.Config), &cfg); err != nil {
t.Errorf("%s: config parse: %v", tpl.ID, err)
continue
}
if len(cfg.Services) == 0 {
continue
}
sorted, err := topoSortServices(cfg.Services)
if err != nil {
t.Errorf("%s: topo sort: %v", tpl.ID, err)
continue
}
if err := validateTemplateGraph(sorted); err != nil {
t.Errorf("%s: validate: %v", tpl.ID, err)
continue
}
var vars []TemplateVariable
if tpl.Variables != "" {
if err := json.Unmarshal([]byte(tpl.Variables), &vars); err != nil {
t.Errorf("%s: variables parse: %v", tpl.ID, err)
continue
}
}
userVars := map[string]string{}
for _, v := range vars {
if v.Default != "" {
userVars[v.Key] = v.Default
}
}
graph := resolveTemplateGraph(sorted, userVars)
if len(graph.unresolved) > 0 {
t.Errorf("%s: unresolved refs: %v", tpl.ID, graph.unresolved)
}
}
}