Skip to content
quarry Artifact [40badfb5c2]
Login

Artifact [40badfb5c2]

Artifact 40badfb5c217be376bb654faa472a1acb8a0452c8bc0fa62017b472b8f3a76de:


package web

import (
	"regexp"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/oltp"
)

// The tab comment that held the strip at two set its own acceptance
// test: "a tab that opens onto an empty page teaches a reader the forge
// is broken rather than that the feature is unbuilt." So the Teams tab
// appears only when the forge has one.
func TestTheTeamsTabAppearsOnlyOnceATeamExists(t *testing.T) {
	var none, some strings.Builder
	if err := peoplePage.ExecuteTemplate(&none, "content",
		pageData{Explore: "people", FleetTotal: 20, PeopleTotal: 3}); err != nil {
		t.Fatal(err)
	}
	if strings.Contains(none.String(), `href="/teams"`) {
		t.Error("FAIL: a forge with no teams draws the Teams tab")
	}
	if err := peoplePage.ExecuteTemplate(&some, "content",
		pageData{Explore: "people", FleetTotal: 20, PeopleTotal: 3, TeamsTotal: 2}); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(some.String(), `href="/teams"`) {
		t.Error("FAIL: a forge with teams does not draw the Teams tab")
	}
	// And the other two are still there, in order.
	body := some.String()
	repos, people, teams := strings.Index(body, `href="/?view=explore"`),
		strings.Index(body, `href="/people"`), strings.Index(body, `href="/teams"`)
	if !(repos < people && people < teams) {
		t.Errorf("FAIL: tab order is repos %d, people %d, teams %d", repos, people, teams)
	}
}

// A team is shown by its title where it has one, and never by both —
// "Craft Design Group (craft-design-group)" is the same fact twice and
// the handle is already in the URL.
func TestATeamIsLabelledOnceNotTwice(t *testing.T) {
	named := teamRow{Name: "craft-design-group", Title: "Craft Design Group"}
	if got := named.Label(); got != "Craft Design Group" {
		t.Errorf("FAIL: label = %q", got)
	}
	if !named.Named() {
		t.Error("FAIL: a titled team does not read as named")
	}
	bare := teamRow{Name: "core"}
	if got := bare.Label(); got != "core" {
		t.Errorf("FAIL: an untitled team labels as %q", got)
	}
	if bare.Named() {
		t.Error("FAIL: an untitled team would print its handle twice")
	}
	// A title that merely repeats the handle is not a second fact.
	if (teamRow{Name: "core", Title: "core"}).Named() {
		t.Error("FAIL: a title equal to the handle reads as named")
	}
}

// The directory says where a team's substance is, and a team with no
// repositories is a real row rather than one that reads as broken.
func TestTheDirectoryNamesAnEmptyTeamHonestly(t *testing.T) {
	var buf strings.Builder
	if err := teamsPage.ExecuteTemplate(&buf, "content", pageData{
		Explore: "teams", TeamsTotal: 2,
		Teams: []teamRow{
			{Name: "craft-design-group", Title: "Craft Design Group",
				Descr: "quarry and zit", Members: 1, Repos: 0},
			{Name: "firestorm-dataworks", Title: "FireStorm DataWorks", Members: 4, Repos: 3},
		},
	}); err != nil {
		t.Fatal(err)
	}
	body := buf.String()
	for _, want := range []string{
		`href="/t/craft-design-group"`, "Craft Design Group", "quarry and zit",
		"no repositories yet", "3 repositories", "1 member", "4 members",
	} {
		if !strings.Contains(body, want) {
			t.Errorf("FAIL: the directory is missing %q", want)
		}
	}
	// A count of zero must not render as a bare "0 repositories".
	if strings.Contains(body, "0 repositor") {
		t.Error("FAIL: an empty team reads as a zero rather than as a sentence")
	}
}

// An empty forge says what a team is for, rather than showing an empty
// list — the same rule the tab comment states.
func TestAnEmptyDirectorySaysWhatATeamIs(t *testing.T) {
	var buf strings.Builder
	if err := teamsPage.ExecuteTemplate(&buf, "content", pageData{Explore: "teams"}); err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(buf.String(), "No teams yet") {
		t.Error("FAIL: an empty directory renders an empty list")
	}
}

// A member's standing is named in the words the People editor uses: the
// preset where the string matches one, the letters where it does not.
func TestAMembersRoleReadsAsARoleWhereItIsOne(t *testing.T) {
	for _, tc := range []struct {
		caps, want string
	}{
		{"", "by membership"}, // RFC 0018's third route, named
		{oltp.Maintain.Caps(), "Maintainer"},
		{"a", "Admin"},
		{"2i", "2i"}, // no preset covers it; the letters stand
	} {
		if got := (teamMember{Caps: tc.caps}).Role(); got != tc.want {
			t.Errorf("FAIL: Role(%q) = %q, want %q", tc.caps, got, tc.want)
		}
	}
}

// One team's page: the repositories under it, the members, and the
// owner marked. An unlisted member reaching a manager is marked as
// such, so the manager can see why the public page differs.
func TestTheTeamPageShowsReposMembersAndWhoOwnsIt(t *testing.T) {
	var buf strings.Builder
	td := teamDetail{
		Team: teamRow{Name: "craft-design-group", Title: "Craft Design Group",
			Descr: "quarry and zit", Members: 2, Repos: 2},
		Repos: []teamRepo{
			{Repo: "quarry", Added: "2026-09-12T00:00:00Z"},
			{Repo: "zit", Added: "2026-09-12T00:00:00Z"},
		},
		Members: []teamMember{
			{Account: "dmestas", Owner: true, Listed: true},
			{Account: "lou", Caps: oltp.Maintain.Caps(), Listed: false},
		},
		CanManage: true,
	}
	// The whole page, not just "content": the team's name is on the
	// bar, and the bar is now drawn by the layout beside <main> the
	// way a repository's is, so rendering the content block alone
	// would assert against a page nobody is served.
	if err := teamPage.Execute(&buf,
		pageData{Title: "Craft Design Group", OwnH1: true, Team: &td,
			TeamBar: &teamBarView{Team: td.Team, CanManage: td.CanManage}}); err != nil {
		t.Fatal(err)
	}
	body := buf.String()
	for _, want := range []string{
		"Craft Design Group", "quarry and zit",
		`href="/r/quarry"`, `href="/r/zit"`,
		`href="/people/dmestas"`, "owner", "Maintainer", "unlisted",
	} {
		if !strings.Contains(body, want) {
			t.Errorf("FAIL: the team page is missing %q", want)
		}
	}
}

// A team holding nothing says so on its own page too, in a sentence
// that tells a reader how a repository gets there.
func TestAnEmptyTeamPageExplainsItself(t *testing.T) {
	var buf strings.Builder
	td := teamDetail{Team: teamRow{Name: "core", Members: 1}}
	if err := teamPage.ExecuteTemplate(&buf, "content", pageData{Team: &td}); err != nil {
		t.Fatal(err)
	}
	body := buf.String()
	if !strings.Contains(body, "somebody who administers it") {
		t.Error("FAIL: an empty team does not say how a repository joins")
	}
	if regexp.MustCompile(`0 repositor`).MatchString(body) {
		t.Error("FAIL: renders a zero instead of a sentence")
	}
}

// The repository's own People section must name the route, not just the
// result. RFC 0018 calls this the riskiest change in the feature and
// not one of the new pages: the section answered from stored strings
// alone, so a floor-only team member held read access and did not
// appear at all.
func TestTheRepoRosterNamesTheTeamRoute(t *testing.T) {
	for _, tc := range []struct {
		name string
		row  rosterRow
		want []string
		not  []string
	}{{
		name: "floor only, no string of their own",
		row:  rosterRow{Name: "lou", Team: "core", Route: "by membership of core"},
		want: []string{"by membership via core", `href="/t/core"`, "All of it comes from"},
		// No role chip: they hold no string here, so there is no role
		// to name, and no controls either — the change is on the team.
		not: []string{`class="chip role"`, "row-act"},
	}, {
		name: "a team role",
		row:  rosterRow{Name: "lou", Team: "core", TeamCaps: "a", Route: "via core"},
		want: []string{"Admin via core"},
		not:  []string{"row-act"},
	}, {
		name: "both routes at once",
		row:  rosterRow{Name: "lou", Caps: "a", Role: "Admin", Team: "core", TeamCaps: "uv"},
		want: []string{`class="chip role"`, "via core"},
		// It has a string of its own, so the controls appear and the
		// "all of it comes from" line does not.
		not: []string{"All of it comes from"},
	}, {
		name: "no team at all, the ordinary case",
		row:  rosterRow{Name: "dan", Caps: "a", Role: "Admin"},
		want: []string{`class="chip role"`},
		not:  []string{"via-team", "All of it comes from"},
	}} {
		t.Run(tc.name, func(t *testing.T) {
			var buf strings.Builder
			err := settingsPage.ExecuteTemplate(&buf, "content", pageData{
				Repo:   "secret",
				Roster: &rosterPanel{Rows: []rosterRow{tc.row}, Presets: oltp.Presets},
			})
			if err != nil {
				t.Fatal(err)
			}
			body := buf.String()
			for _, w := range tc.want {
				if !strings.Contains(body, w) {
					t.Errorf("FAIL: missing %q", w)
				}
			}
			for _, n := range tc.not {
				if strings.Contains(body, n) {
					t.Errorf("FAIL: should not contain %q", n)
				}
			}
		})
	}
}

// The team route reads in the same words on both surfaces, so one
// grant cannot be described two ways.
func TestTheRouteReadsTheSameOnBothSurfaces(t *testing.T) {
	caps := oltp.Maintain.Caps()
	onTeamPage := (teamMember{Caps: caps}).Role()
	onRepoPage := (rosterRow{Team: "core", TeamCaps: caps}).TeamRole()
	if onTeamPage != onRepoPage {
		t.Errorf("FAIL: team page says %q, repo page says %q", onTeamPage, onRepoPage)
	}
	if floor := (rosterRow{Team: "core"}).TeamRole(); floor != "by membership" {
		t.Errorf("FAIL: the floor reads as %q", floor)
	}
}

func peopleScreen(t *testing.T, tp teamPeople) string {
	t.Helper()
	tp.Presets = oltp.Presets
	var buf strings.Builder
	if err := teamPeoplePage.ExecuteTemplate(&buf, "content",
		pageData{TeamPeople: &tp}); err != nil {
		t.Fatal(err)
	}
	return buf.String()
}

// The screen RFC 0018 exists for. The heading counts what the list is
// ABOUT, because "everyone" means nothing without saying everyone who
// reaches what.
func TestThePeopleScreenSaysWhatItIsCounting(t *testing.T) {
	body := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core", Title: "Core"}, Repos: 3,
		People: []teamPersonRow{{Account: "dan", Member: true, Owner: true}},
	})
	if !strings.Contains(body, "reaches these 3 repositories") {
		t.Error("FAIL: the heading does not say what it counts")
	}
	// A team holding nothing says so rather than listing people
	// against no repositories.
	empty := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core"}, Repos: 0,
		People: []teamPersonRow{{Account: "dan", Member: true, Owner: true}},
	})
	if !strings.Contains(empty, "holds no repositories yet") {
		t.Error("FAIL: a team holding nothing does not say so")
	}
	if strings.Contains(empty, "reaches these 0") {
		t.Error("FAIL: renders a zero instead of a sentence")
	}
}

// All three routes on one screen, each distinguishable, and a direct
// grant linked to where it can actually be changed.
func TestThePeopleScreenNamesEveryRoute(t *testing.T) {
	body := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core"}, Repos: 2, CanManage: true,
		People: []teamPersonRow{
			{Account: "dan", Member: true, Owner: true},
			{Account: "lou", Member: true},
			{Account: "zeitgeist", Member: true, Caps: oltp.Maintain.Caps()},
			{Account: "ann", Direct: []teamDirectRow{{Repo: "zit", Caps: "a"}}},
		},
	})
	for _, want := range []string{
		"owner", "by membership", "Maintainer",
		`href="/r/zit/settings#people"`, "zit: a",
		"Not in this team", "Change that where it was granted",
	} {
		if !strings.Contains(body, want) {
			t.Errorf("FAIL: missing %q", want)
		}
	}
}

// A manager edits a team role in place — the whole point of one screen
// — and cannot edit a direct grant here, because it is not the team's.
func TestOnlyTheTeamsOwnGrantIsEditableHere(t *testing.T) {
	rows := []teamPersonRow{
		{Account: "lou", Member: true},
		{Account: "ann", Direct: []teamDirectRow{{Repo: "zit", Caps: "a"}}},
	}
	managed := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core"}, Repos: 1, CanManage: true, People: rows})
	if !strings.Contains(managed, `action="/t/core/member"`) {
		t.Error("FAIL: a manager gets no control at all")
	}
	if strings.Count(managed, "row-act") != 1 {
		t.Errorf("FAIL: %d editable rows, want only the member's", strings.Count(managed, "row-act"))
	}
	// A reader who cannot manage gets the list and no controls.
	read := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core"}, Repos: 1, People: rows})
	if strings.Contains(read, "row-act") || strings.Contains(read, "roster-add") {
		t.Error("FAIL: a reader is offered controls")
	}
	if !strings.Contains(read, "lou") {
		t.Error("FAIL: a reader cannot see the list")
	}
}

// A team string no preset covers must not be silently rounded to the
// nearest role by an edit.
func TestACustomTeamStringSaysSoBeforeItIsOverwritten(t *testing.T) {
	body := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core"}, Repos: 1, CanManage: true,
		People: []teamPersonRow{{Account: "lou", Member: true, Caps: "2i"}},
	})
	if !strings.Contains(body, "which no role covers") {
		t.Error("FAIL: a custom string does not warn before being replaced")
	}
	row := teamPersonRow{Member: true, Caps: "2i"}
	if !row.Custom() || row.Preset() != "" {
		t.Errorf("FAIL: %q reads as a preset", row.Caps)
	}
	if (teamPersonRow{Member: true, Caps: "a"}).Custom() {
		t.Error("FAIL: Admin reads as custom")
	}
}

// Removal warns what it actually does, because on a private repository
// the membership floor is the whole of somebody's access.
func TestRemovalSaysWhatItCosts(t *testing.T) {
	body := peopleScreen(t, teamPeople{
		Team: teamRow{Name: "core"}, Repos: 2, CanManage: true,
		People: []teamPersonRow{{Account: "lou", Member: true}},
	})
	if !strings.Contains(body, "that is all of their access") {
		t.Error("FAIL: removal does not say what it costs on a private repository")
	}
}

// One grant, one set of words, on all three surfaces.
func TestTheRoleReadsTheSameOnAllThreeSurfaces(t *testing.T) {
	caps := oltp.Maintain.Caps()
	onTeam := (teamMember{Caps: caps}).Role()
	onRepo := (rosterRow{Team: "core", TeamCaps: caps}).TeamRole()
	onScreen := (teamPersonRow{Member: true, Caps: caps}).Role()
	if onTeam != onRepo || onRepo != onScreen {
		t.Errorf("FAIL: team page %q, repo roster %q, people screen %q", onTeam, onRepo, onScreen)
	}
	if got := (teamPersonRow{Member: true}).Role(); got != "by membership" {
		t.Errorf("FAIL: the floor reads as %q on the people screen", got)
	}
}

// A control that would fail is not offered — quarry's rule, and the
// one the repository roster already follows for the last admin. The
// guard refuses removing a team's last owner, so the button must not
// be live.
func TestTheLastOwnersRemoveIsNotOffered(t *testing.T) {
	sole := teamPeople{Team: teamRow{Name: "core"}, Repos: 1, CanManage: true, Owners: 1,
		People: []teamPersonRow{{Account: "dmestas", Member: true, Owner: true}}}
	body := peopleScreen(t, sole)
	if !strings.Contains(body, "the last owner; make somebody else an owner first") {
		t.Error("FAIL: the last owner is offered a Remove that would be refused")
	}
	if strings.Contains(body, `name="remove"`) {
		t.Error("FAIL: the live Remove button is still there for the last owner")
	}
	// With a second owner, both are removable.
	two := teamPeople{Team: teamRow{Name: "core"}, Repos: 1, CanManage: true, Owners: 2,
		People: []teamPersonRow{
			{Account: "dmestas", Member: true, Owner: true},
			{Account: "lou", Member: true, Owner: true},
		}}
	body = peopleScreen(t, two)
	if strings.Count(body, `name="remove"`) != 2 {
		t.Errorf("FAIL: %d live Remove buttons with two owners, want 2", strings.Count(body, `name="remove"`))
	}
	// And an ordinary member is always removable.
	if !sole.LastOwner(teamPersonRow{Member: true, Owner: true}) {
		t.Error("FAIL: LastOwner is false for the sole owner")
	}
	if sole.LastOwner(teamPersonRow{Member: true}) {
		t.Error("FAIL: an ordinary member reads as the last owner")
	}
}

// The room is the CHAT LOG, not a list of its own. It used to invent a
// message row, and lost everything the real one had learned; these
// assert the component is what draws it, by structure rather than by
// anything anybody reads.
func TestTheTeamRoomIsDrawnByTheChatComponents(t *testing.T) {
	tr := teamRoom{
		Team:  teamRow{Name: "core", Title: "Core"},
		Rooms: []string{"quarry"},
		Messages: []roomLine{
			{Seq: 9, Kind: "msg", User: "dmestas", Text: "to everyone",
				At: "2026-09-13T17:40:00Z"},
			{Seq: 4, Kind: "msg", User: "lou", Repo: "quarry", Text: "in quarry",
				At: "2026-09-13T17:12:00Z"},
		},
		Cursor: "abc",
	}
	var buf strings.Builder
	if err := teamRoomPage.Execute(&buf, pageData{Title: "Core · Room", OwnH1: true,
		TeamRoom: &tr, TeamBar: tr.bar(), ChatOn: "/t/core", ChatName: "core",
		ChatMsgs: tr.log(), ChatOlder: tr.older()}); err != nil {
		t.Fatal(err)
	}
	body := buf.String()
	for _, want := range []string{
		// the shared row, the shared log, the shared composer
		`class="chatmsg"`, `id="chatlog"`, `class="composer"`,
		// each line says where it was said — both kinds asserted
		// separately, or one branch covers for the other
		`data-hook="chat-origin" href="/r/quarry"`,
		`data-hook="chat-origin" data-to-team`,
		// every endpoint hangs off the TEAM, not a repository
		`action="/t/core/chat"`, `href="/t/core/chat?cursor=abc"`,
	} {
		if !strings.Contains(body, want) {
			t.Errorf("FAIL: the room is missing %q", want)
		}
	}
	// The composer's repository-only halves are absent rather than
	// pointed at routes a team does not have.
	for _, gone := range []string{"mention.frag", "preview.frag", "/chat/read"} {
		if strings.Contains(body, gone) {
			t.Errorf("FAIL: the room offers %q, which only a repository serves", gone)
		}
	}
}

// A run of messages never spans two channels: in a merged room the
// origin lives in the run's header, so grouping across origins would
// hide the one thing the room exists to show.
func TestARunNeverSpansTwoChannels(t *testing.T) {
	at := "2026-09-13T17:40:00Z"
	tr := teamRoom{Messages: []roomLine{
		{Seq: 3, Kind: "msg", User: "lou", Repo: "zit", Text: "b", At: at},
		{Seq: 2, Kind: "msg", User: "lou", Repo: "quarry", Text: "a", At: at},
	}}
	log := tr.log()
	if len(log) != 2 {
		t.Fatalf("FAIL: want 2 rows, got %d", len(log))
	}
	if log[1].Grouped {
		t.Errorf("FAIL: the same author in two repositories grouped into one run")
	}
	// The same author, the same minute, the same channel still does.
	same := teamRoom{Messages: []roomLine{
		{Seq: 3, Kind: "msg", User: "lou", Repo: "zit", Text: "b", At: at},
		{Seq: 2, Kind: "msg", User: "lou", Repo: "zit", Text: "a", At: at},
	}}.log()
	if !same[1].Grouped {
		t.Errorf("FAIL: a run in one channel stopped grouping")
	}
}

// The offer section says what an offer IS — a question carrying no
// access — and gives the controls only to somebody who may answer.
func TestOfferedRepositoriesAreShownWithControlsOnlyForOwners(t *testing.T) {
	td := teamDetail{
		Team: teamRow{Name: "core", Title: "Core"},
		Offered: []teamOfferRow{{Repo: "orchard", Team: "core", OfferedBy: "cal",
			Offered: "2026-09-13T18:00:00Z", Expires: "2026-09-20T18:00:00Z"}},
		Sect: "settings",
	}
	render := func(d teamDetail) string {
		var buf strings.Builder
		if err := teamSettingsPage.Execute(&buf, pageData{Title: "Core · Settings",
			OwnH1: true, Team: &d, TeamBar: d.bar()}); err != nil {
			t.Fatal(err)
		}
		return buf.String()
	}
	// A member who cannot manage sees what is pending, and no buttons.
	plain := render(td)
	if !strings.Contains(plain, `href="/r/orchard"`) {
		t.Errorf("FAIL: a pending offer is hidden from a member")
	}
	if strings.Contains(plain, `/t/core/offer`) {
		t.Errorf("FAIL: somebody who cannot manage the team was given the controls")
	}
	// An owner gets both verbs against the right repository.
	td.CanManage = true
	owner := render(td)
	for _, want := range []string{
		`action="/t/core/offer"`, `name="accept"`, `name="decline"`,
		`name="repo" value="orchard"`,
	} {
		if !strings.Contains(owner, want) {
			t.Errorf("FAIL: the owner's controls are missing %q", want)
		}
	}
}

// The remaining span is said forwards. ago() renders a future stamp as
// a calendar date, which reads as a different kind of fact beside the
// relative time next to it.
func TestAnOfferSaysHowLongItHasLeft(t *testing.T) {
	soon := teamOfferRow{Expires: time.Now().Add(50 * time.Hour).UTC().Format(time.RFC3339)}
	if got := soon.Lapses(); got != "2 days" {
		t.Errorf("FAIL: lapses = %q, want %q", got, "2 days")
	}
	past := teamOfferRow{Expires: time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)}
	if got := past.Lapses(); got != "any moment" {
		t.Errorf("FAIL: a lapsed offer says %q", got)
	}
	if got := (teamOfferRow{}).Lapses(); got != "" {
		t.Errorf("FAIL: an unparseable stamp invented %q", got)
	}
}