package web
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// --- fixtures ----------------------------------------------------------------
// beadsTables is a schema fingerprint that Applies should accept: issues (with
// id + status) + dependencies both present.
func beadsTables() []browse.TableInfo {
return []browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{
{Name: "id", PrimaryKey: true}, {Name: "status"},
}},
{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}},
{Name: "labels"},
}
}
// beadsFixture wires a fakeSession whose per-table Rows model a small parade:
// - i-open : open, ready → Lined Up
// - i-prog : in_progress → Rolling
// - i-done : closed → Past Stand
// - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target)
// and also carries is_blocked=1 → Stalled
//
// The issues page deliberately orders its columns id,title,status,priority,...
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
issues := &browse.RowPage{
// Column order chosen so nothing is at a "natural" index; is_blocked is last
// and close_reason sits mid-row so name (not positional) mapping is exercised.
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
Rows: [][]string{
{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},
{"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"},
{"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"},
{"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"},
},
Total: 4,
}
// i-blocked depends on i-open (blocks, target open → keeps it Stalled).
// i-open is depended on by i-blocked → i-open.Blocks == 1.
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"d1", "i-blocked", "i-open", "blocks"},
},
Total: 1,
}
labels := &browse.RowPage{
Columns: []string{"issue_id", "label"},
Rows: [][]string{
{"i-open", "backend"},
{"i-open", "urgent"},
},
Total: 2,
}
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
Rows: [][]string{
{"open", "open"},
{"in_progress", "in_progress"},
{"closed", "closed"},
},
Total: 3,
}
comments := &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: [][]string{
{"i-open", "alice", "first!", "2024-01-05"},
{"i-prog", "bob", "not this one", "2024-01-06"},
},
Total: 2,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": deps,
"labels": labels,
"custom_statuses": statuses,
"comments": comments,
},
}
}
// laneBySlug finds a lane in a built board by its slug.
func laneBySlug(d *BeadsData, slug string) *BeadsLane {
for i := range d.Lanes {
if d.Lanes[i].Slug == slug {
return &d.Lanes[i]
}
}
return nil
}
// cardIDs lists the ids of a lane's cards.
func cardIDs(l *BeadsLane) []string {
if l == nil {
return nil
}
out := make([]string, len(l.Issues))
for i, c := range l.Issues {
out[i] = c.ID
}
return out
}
// --- Applies -----------------------------------------------------------------
func TestBeadsApplies(t *testing.T) {
v := &beadsView{}
if !v.Applies(beadsTables()) {
t.Fatalf("Applies should be true when issues+dependencies (with id+status) present")
}
// Missing dependencies → not a beads DB.
if v.Applies([]browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}}},
}) {
t.Fatalf("Applies should be false without a dependencies table")
}
// issues present but lacking status column → guard rejects.
if v.Applies([]browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}}},
{Name: "dependencies"},
}) {
t.Fatalf("Applies should be false when issues lacks a status column")
}
// Unrelated schema.
if v.Applies([]browse.TableInfo{{Name: "widgets"}}) {
t.Fatalf("Applies should be false for an unrelated schema")
}
}
// --- board mode --------------------------------------------------------------
func TestBeadsBuildBoardLanes(t *testing.T) {
v := &beadsView{}
got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "alice", Name: "db"}, "main", url.Values{})
if err != nil {
t.Fatalf("Build: %v", err)
}
d, ok := got.(*BeadsData)
if !ok {
t.Fatalf("Build returned %T, want *BeadsData", got)
}
if d.Mode != "board" {
t.Fatalf("Mode = %q, want board", d.Mode)
}
checks := map[string][]string{
"rolling": {"i-prog"},
"lined-up": {"i-open"},
"stalled": {"i-blocked"},
"past-stand": {"i-done"},
}
for slug, want := range checks {
got := cardIDs(laneBySlug(d, slug))
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("lane %s = %v, want %v", slug, got, want)
}
}
if d.Counts.Rolling != 1 || d.Counts.LinedUp != 1 || d.Counts.Stalled != 1 || d.Counts.PastStand != 1 {
t.Errorf("counts = %+v, want 1 each", d.Counts)
}
if d.Counts.Total != 4 || d.Total != 4 {
t.Errorf("total = %d/%d, want 4", d.Counts.Total, d.Total)
}
}
func TestBeadsBuildBoardCounts(t *testing.T) {
v := &beadsView{}
got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
d := got.(*BeadsData)
// i-blocked depends on i-open → i-blocked.BlockedBy==1, i-open.Blocks==1.
blocked := laneBySlug(d, "stalled").Issues[0]
if blocked.ID != "i-blocked" || blocked.BlockedBy != 1 || blocked.Blocks != 0 {
t.Errorf("i-blocked = %+v, want BlockedBy=1 Blocks=0", blocked)
}
open := laneBySlug(d, "lined-up").Issues[0]
if open.ID != "i-open" || open.Blocks != 1 || open.BlockedBy != 0 {
t.Errorf("i-open = %+v, want Blocks=1 BlockedBy=0", open)
}
// Labels attach by issue_id.
if strings.Join(open.Labels, ",") != "backend,urgent" {
t.Errorf("i-open labels = %v, want [backend urgent]", open.Labels)
}
}
// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.
func TestBeadsBlockedByDepOnly(t *testing.T) {
sess := &fakeSession{
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": {
Columns: []string{"id", "status", "is_blocked"},
Rows: [][]string{
{"a", "open", "0"}, // blocked by open b → Stalled
{"b", "open", "0"}, // ready → Lined Up
{"c", "open", "0"}, // "blocked" by closed d → NOT stalled → Lined Up
{"d", "closed", "0"}, // Past Stand
},
Total: 4,
},
"dependencies": {
Columns: []string{"issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"a", "b", "blocks"},
{"c", "d", "blocks"},
},
Total: 2,
},
},
}
v := &beadsView{}
got, err := v.Build(context.Background(), sess, &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
if err != nil {
t.Fatalf("Build: %v", err)
}
d := got.(*BeadsData)
if ids := cardIDs(laneBySlug(d, "stalled")); strings.Join(ids, ",") != "a" {
t.Errorf("stalled = %v, want [a] (blocked by open dep only)", ids)
}
if ids := cardIDs(laneBySlug(d, "lined-up")); strings.Join(ids, ",") != "b,c" {
t.Errorf("lined-up = %v, want [b c] (c's blocker is closed)", ids)
}
}
// --- detail mode -------------------------------------------------------------
func TestBeadsBuildDetail(t *testing.T) {
v := &beadsView{}
q := url.Values{}
q.Set("issue", "i-open")
got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
if err != nil {
t.Fatalf("Build: %v", err)
}
d := got.(*BeadsData)
if d.Mode != "detail" {
t.Fatalf("Mode = %q, want detail", d.Mode)
}
if d.Issue == nil || d.Issue.ID != "i-open" || d.Issue.Title != "Ready to roll" {
t.Fatalf("Issue = %+v, want i-open/Ready to roll", d.Issue)
}
if strings.Join(d.Issue.Labels, ",") != "backend,urgent" {
t.Errorf("labels = %v", d.Issue.Labels)
}
// i-open is depended on by i-blocked (incoming), and depends on nothing.
if len(d.DependsOn) != 0 {
t.Errorf("DependsOn = %v, want none", d.DependsOn)
}
if len(d.DependedOnBy) != 1 || d.DependedOnBy[0].IssueID != "i-blocked" {
t.Errorf("DependedOnBy = %+v, want [i-blocked]", d.DependedOnBy)
}
// Only i-open's comment shows in its thread.
if len(d.Comments) != 1 || d.Comments[0].Author != "alice" || d.Comments[0].Text != "first!" {
t.Errorf("Comments = %+v, want single alice comment", d.Comments)
}
}
func TestBeadsBuildDetailOutgoingEdge(t *testing.T) {
v := &beadsView{}
q := url.Values{}
q.Set("issue", "i-blocked")
got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
d := got.(*BeadsData)
if len(d.DependsOn) != 1 || d.DependsOn[0].IssueID != "i-open" || d.DependsOn[0].Title != "Ready to roll" {
t.Fatalf("DependsOn = %+v, want [i-open/Ready to roll]", d.DependsOn)
}
if d.DependsOn[0].Type != "blocks" || d.DependsOn[0].Closed {
t.Errorf("edge = %+v, want type=blocks not-closed", d.DependsOn[0])
}
}
// --- end to end --------------------------------------------------------------
func TestBeadsHandleViewBoard(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "beads-summary"} {
if !strings.Contains(body, want) {
t.Errorf("board body missing %q", want)
}
}
// The Tables tab must remain reachable from the view.
if !strings.Contains(body, "/~alice/db/tree/") {
t.Errorf("board missing Tables tab link; body=%s", body)
}
}
func TestBeadsHandleViewDetail(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-blocked", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Waiting") {
t.Errorf("detail missing issue title; body=%s", body)
}
if !strings.Contains(body, "Depends on") || !strings.Contains(body, "i-open") {
t.Errorf("detail missing dependency edge; body=%s", body)
}
if !strings.Contains(body, "Back to the parade") {
t.Errorf("detail missing back link; body=%s", body)
}
}
// A closed issue's detail pane must surface its close reason (and the closed
// timestamp), so the resolution recorded by `bd close -r` is not lost.
func TestBeadsDetailShowsCloseReason(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-done", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Close reason", "Fixed in commit abc123", "2024-01-04"} {
if !strings.Contains(body, want) {
t.Errorf("closed-issue detail missing %q; body=%s", want, body)
}
}
}