package api
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/notify"
)
// Every account email names its kind, and the kind is in the table as the
// application's: the origin is decided once, in mailkinds.go, and not
// inferred from a template. A new accountMail without a classified Kind
// fails here, and ships as text only until it is classified.
func TestEveryAccountMailIsClassified(t *testing.T) {
fset := token.NewFileSet()
found := 0
filepath.WalkDir(".", func(path string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
t.Fatal(perr)
}
ast.Inspect(f, func(n ast.Node) bool {
cl, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
if id, ok := cl.Type.(*ast.Ident); !ok || id.Name != "accountMail" {
return true
}
found++
var kind string
for _, el := range cl.Elts {
if kv, ok := el.(*ast.KeyValueExpr); ok {
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Kind" {
if v, ok := kv.Value.(*ast.Ident); ok {
kind = v.Name
}
}
}
}
if kind == "" {
t.Errorf("%s: an accountMail with no Kind", fset.Position(cl.Pos()))
}
return true
})
return nil
})
if found != 9 {
t.Errorf("%d accountMail literals, want the nine call sites (the team kinds share teamNotice)", found)
}
for _, k := range []string{kindInvite, kindReset, kindPasswordChanged, kindEmailCode, kindEmailChanged, kindEmailSet, kindInviteRedeemed, kindAccessChanged} {
if mailKinds[k].From != fromApp || mailKinds[k].Reason == "" {
t.Errorf("%s is not classified as the application's with a reason", k)
}
}
for _, k := range []string{kindTeamAdded, kindTeamRemoved, kindTeamRoleChanged} {
if mailKinds[k].From != fromTeam || mailKinds[k].Reason == "" {
t.Errorf("%s is not classified as a team's with a reason", k)
}
}
if got := (Deps{Origin: "https://f.example"}).card(accountMail{Subject: "s", Body: "<p>x</p>"}); got != "" {
t.Error("an unclassified email got a card")
}
}
// Every email the forge sends is in the table: each notify workflow with an
// email step, each kind the repository cards draw, and each account kind.
func TestEveryEmailKindIsInTheTable(t *testing.T) {
for _, def := range notify.Workflows() {
for _, st := range def.Steps {
if st.Type == "email" {
if k, ok := mailKinds[def.TriggerIdentifier]; !ok || k.From != fromRepo {
t.Errorf("workflow %s has an email step and is not a repository kind in the table", def.TriggerIdentifier)
}
}
}
}
for wf := range mailCards {
if mailKinds[wf].From != fromRepo {
t.Errorf("%s draws a repository card and the table does not say repo", wf)
}
}
if mailKindFor("quarry-mention", "team chat") != "quarry-mention.team" || mailKinds["quarry-mention.team"].From != fromTeam ||
mailKindFor("quarry-mention", "chat") != "quarry-mention" {
t.Error("a mention in a team's chat is not the team's")
}
for k, v := range mailKinds {
if v.Reason == "" || (v.From != fromApp && v.From != fromRepo && v.From != fromTeam) {
t.Errorf("%s: incomplete entry", k)
}
}
}
// The classification decides the header: an application email carries the
// wordmark in its header, a repository email never does.
func TestHeaderFollowsTheClassification(t *testing.T) {
prev := AssetVersion
AssetVersion = nil
t.Cleanup(func() { AssetVersion = prev })
d := Deps{Origin: "https://forge.example"}
var kinds []string
for k := range mailKinds {
kinds = append(kinds, k)
}
sort.Strings(kinds)
for _, k := range kinds {
var doc, name string
switch {
case mailKinds[k].From == fromApp && strings.HasPrefix(k, "account."):
doc = d.card(accountMail{Kind: k, Subject: "s", Body: "<p>x</p>"})
case mailKinds[k].From == fromTeam && strings.HasPrefix(k, "team."):
name = "crew"
doc = d.card(accountMail{Kind: k, Team: name, Subject: "s", Body: "<p>x</p>", Open: "Open crew", URL: "https://forge.example/t/crew"})
case mailKinds[k].From == fromRepo || mailKinds[k].From == fromTeam:
name = "alpha"
surface := "chat"
if mailKinds[k].From == fromTeam {
name, surface = "crew", "team chat"
}
p := map[string]any{"repo": name, "from": "eve", "text": "hi", "title": "T", "summary": "eve proposed landing 3 x",
"surface": surface, "link": "https://forge.example/x"}
e := d.withMailHTML(NotifyEvent{Workflow: strings.TrimSuffix(k, ".team"), Payload: p}, time.Now())
var ok bool
if doc, ok = e.Payload["html"].(string); !ok && k != "quarry-activity" {
t.Errorf("%s: no card", k)
}
if k == "quarry-activity" {
continue // drawn by notify's template, from its own tests
}
default:
continue // the probe: held to the application's header by its own test
}
head := doc[strings.Index(doc, "<body"):strings.Index(doc, `<div class="q-text" style="font-size:15px;`)]
has := strings.Contains(head, ">QUARRY<") && strings.Contains(head, `class="q-wm-b"`)
if has != (mailKinds[k].From == fromApp) {
t.Errorf("%s (%s): header wordmark = %v", k, mailKinds[k].From, has)
}
// A repository's or a team's header names it, in its own case.
if mailKinds[k].From != fromApp && !strings.Contains(head, ">"+name+"<") {
t.Errorf("%s (%s): the header does not name %q", k, mailKinds[k].From, name)
}
if !strings.Contains(doc[strings.Index(doc, `class="q-rule q-muted"`):], ">QUARRY<") {
t.Errorf("%s: the footer lost the wordmark", k)
}
}
}