Skip to content
go-libfossil Artifact [f7409c41ee]
Login

Artifact [f7409c41ee]

Artifact f7409c41ee3ca4cbc4d1473db751301b09165b4569029344f73bb2e914ffaa4b:


package libfossil

import (
	"encoding/json"
	"path/filepath"
	"strings"
	"testing"
	"time"

	_ "github.com/danmestas/go-libfossil/internal/testdriver"
)

// ticketT0 is after the initial check-in Create makes at the current time, so
// every ticket in these tests is newer than every check-in.
var ticketT0 = time.Now().UTC().Truncate(time.Second).Add(time.Hour)

// sameMs compares times at the millisecond a D card records.
func sameMs(a, b time.Time) bool {
	d := a.Sub(b)
	return d > -time.Millisecond && d < time.Millisecond
}

func newTicketRepo(t *testing.T) *Repo {
	t.Helper()
	r, err := Create(filepath.Join(t.TempDir(), "t.fossil"), CreateOpts{User: "alice"})
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { r.Close() })
	return r
}

func mustTicketAdd(t *testing.T, r *Repo, opts TicketOpts) (string, string) {
	t.Helper()
	uuid, artifact, err := r.TicketAdd(opts)
	if err != nil {
		t.Fatalf("TicketAdd: %v", err)
	}
	if len(uuid) != 40 || strings.Trim(uuid, "0123456789abcdef") != "" {
		t.Fatalf("ticket uuid %q is not 40 lowercase hex digits", uuid)
	}
	if !isHex(artifact) || (len(artifact) != 40 && len(artifact) != 64) {
		t.Fatalf("artifact hash %q is not a full hash", artifact)
	}
	return uuid, artifact
}

// Issue #503's Go acceptance: two tickets added, the first amended; the
// ticket table and the change list must reflect all three artifacts at once,
// with no separate rebuild step.
func TestTicketAddSetCountStatusesOrder(t *testing.T) {
	r := newTicketRepo(t)
	first, art1 := mustTicketAdd(t, r, TicketOpts{
		Fields:  map[string]string{"title": "first", "status": "Open", "type": "Code_Defect"},
		User:    "alice",
		Comment: "first comment",
		Time:    ticketT0,
	})
	second, _ := mustTicketAdd(t, r, TicketOpts{
		Fields: map[string]string{"title": "second", "status": "Open"},
		User:   "bob",
		Time:   ticketT0.Add(time.Second),
	})
	art3, err := r.TicketSet(first, TicketOpts{
		Fields:  map[string]string{"status": "Closed", "resolution": "Fixed"},
		User:    "carol",
		Comment: "done",
		Time:    ticketT0.Add(2 * time.Second),
	})
	if err != nil {
		t.Fatalf("TicketSet: %v", err)
	}

	tickets, err := r.TicketList()
	if err != nil {
		t.Fatal(err)
	}
	if len(tickets) != 2 {
		t.Fatalf("TicketList returned %d tickets, want 2", len(tickets))
	}
	status := map[string]string{}
	for _, tk := range tickets {
		status[tk.UUID] = tk.Status
	}
	if status[first] != "Closed" || status[second] != "Open" {
		t.Fatalf("statuses = %v, want %s Closed and %s Open", status, first, second)
	}
	// Newest-changed first: the amended ticket changed last.
	if tickets[0].UUID != first || !sameMs(tickets[0].Changed, ticketT0.Add(2*time.Second)) {
		t.Fatalf("first listed ticket = %s changed %v, want %s changed %v",
			tickets[0].UUID, tickets[0].Changed, first, ticketT0.Add(2*time.Second))
	}
	if got := tickets[0].Fields["resolution"]; got != "Fixed" {
		t.Fatalf("resolution = %q, want Fixed", got)
	}

	changes, err := r.TicketChanges(first)
	if err != nil {
		t.Fatal(err)
	}
	if len(changes) != 2 {
		t.Fatalf("TicketChanges returned %d changes, want 2", len(changes))
	}
	if changes[0].Artifact != art1 || changes[1].Artifact != art3 {
		t.Fatalf("change order = %s, %s; want %s, %s",
			changes[0].Artifact, changes[1].Artifact, art1, art3)
	}
	if changes[0].User != "alice" || changes[1].User != "carol" {
		t.Fatalf("change users = %q, %q; want alice, carol", changes[0].User, changes[1].User)
	}
	if changes[0].Comment != "first comment" || changes[1].Comment != "done" {
		t.Fatalf("change comments = %q, %q", changes[0].Comment, changes[1].Comment)
	}
	if changes[0].Fields["status"] != "Open" || changes[1].Fields["status"] != "Closed" {
		t.Fatalf("change statuses = %v, %v", changes[0].Fields, changes[1].Fields)
	}
	if !sameMs(changes[1].Time, ticketT0.Add(2*time.Second)) {
		t.Fatalf("second change time = %v", changes[1].Time)
	}
}

// The comment is stored as the stock ticket forms store a remark: icomment
// with the author's login, username and a mimetype, in ticketchng.
func TestTicketCommentIsStockRemark(t *testing.T) {
	r := newTicketRepo(t)
	uuid, _ := mustTicketAdd(t, r, TicketOpts{
		Fields: map[string]string{"title": "x"}, User: "alice", Comment: "hi\nthere",
		Time: ticketT0,
	})
	changes, err := r.TicketChanges(uuid)
	if err != nil {
		t.Fatal(err)
	}
	want := map[string]string{
		"title": "x", "icomment": "hi\nthere", "login": "alice",
		"username": "alice", "mimetype": "text/plain",
	}
	got := changes[0].Fields
	if len(got) != len(want) {
		t.Fatalf("fields = %v, want %v", got, want)
	}
	for k, v := range want {
		if got[k] != v {
			t.Fatalf("field %s = %q, want %q (all: %v)", k, got[k], v, got)
		}
	}
	var icomment, login string
	if err := r.DB().QueryRow(
		"SELECT icomment, login FROM ticketchng").Scan(&icomment, &login); err != nil {
		t.Fatal(err)
	}
	if icomment != "hi\nthere" || login != "alice" {
		t.Fatalf("ticketchng row = %q, %q", icomment, login)
	}
}

// A "+name" field appends to the ticket column rather than replacing it.
func TestTicketSetAppendField(t *testing.T) {
	r := newTicketRepo(t)
	uuid, _ := mustTicketAdd(t, r, TicketOpts{
		Fields: map[string]string{"title": "x", "comment": "one"}, User: "alice", Time: ticketT0,
	})
	if _, err := r.TicketSet(uuid[:10], TicketOpts{
		Fields: map[string]string{"+comment": " two"}, User: "alice",
		Time: ticketT0.Add(time.Second),
	}); err != nil {
		t.Fatal(err)
	}
	tickets, err := r.TicketList()
	if err != nil {
		t.Fatal(err)
	}
	if got := tickets[0].Fields["comment"]; got != "one two" {
		t.Fatalf("comment = %q, want %q", got, "one two")
	}
	changes, err := r.TicketChanges(uuid)
	if err != nil {
		t.Fatal(err)
	}
	if got := changes[1].Fields["+comment"]; got != " two" {
		t.Fatalf("append card = %v", changes[1].Fields)
	}
}

func TestTicketWriteRejectsBadInput(t *testing.T) {
	r := newTicketRepo(t)
	uuid, _ := mustTicketAdd(t, r, TicketOpts{
		Fields: map[string]string{"title": "x"}, User: "alice", Time: ticketT0,
	})
	cases := map[string]TicketOpts{
		"unknown field":    {Fields: map[string]string{"nosuch": "v"}, User: "alice"},
		"no user":          {Fields: map[string]string{"title": "v"}},
		"nothing to write": {Fields: map[string]string{"title": ""}, User: "alice"},
		"set and append": {
			Fields: map[string]string{"comment": "a", "+comment": "b"}, User: "alice",
		},
		"comment collides": {
			Fields: map[string]string{"icomment": "a"}, User: "alice", Comment: "b",
		},
		"private field": {Fields: map[string]string{"private_contact": "a@b"}, User: "alice"},
	}
	for name, opts := range cases {
		if _, _, err := r.TicketAdd(opts); err == nil {
			t.Errorf("TicketAdd %s: no error", name)
		}
	}
	if _, err := r.TicketSet("0123456789", TicketOpts{
		Fields: map[string]string{"status": "Closed"}, User: "alice",
	}); err == nil {
		t.Error("TicketSet on an unknown ticket: no error")
	}
	if _, err := r.TicketSet(uuid, TicketOpts{User: "alice"}); err == nil {
		t.Error("TicketSet with nothing to write: no error")
	}
	if _, err := r.TicketChanges("0123456789"); err == nil {
		t.Error("TicketChanges on an unknown ticket: no error")
	}
}

// threeTickets commits a check-in, adds three tickets after it and replies
// to the second.
func threeTickets(t *testing.T, r *Repo) (checkin string, uuids, artifacts []string, reply string) {
	t.Helper()
	_, checkin, err := r.Commit(CommitOpts{
		Files:   []FileToCommit{{Name: "index.html", Content: []byte("<p>hi</p>")}},
		Comment: "publish", User: "alice", Time: ticketT0,
	})
	if err != nil {
		t.Fatal(err)
	}
	for i, title := range []string{"one", "two", "three"} {
		u, a := mustTicketAdd(t, r, TicketOpts{
			Fields:  map[string]string{"title": title, "status": "Open"},
			User:    "reader",
			Comment: "comment " + title,
			Time:    ticketT0.Add(time.Duration(i+1) * time.Minute),
		})
		uuids, artifacts = append(uuids, u), append(artifacts, a)
	}
	reply, err = r.TicketSet(uuids[1], TicketOpts{
		User: "editor", Comment: "reply @" + checkin[:10], Time: ticketT0.Add(5 * time.Minute),
	})
	if err != nil {
		t.Fatal(err)
	}
	return checkin, uuids, artifacts, reply
}

// Issue #504's acceptance: a repository with a check-in, three tickets and
// one reply, read through all three queries.
func TestTicketReadsAndTimelineSince(t *testing.T) {
	r := newTicketRepo(t)
	checkin, uuids, artifacts, reply := threeTickets(t, r)

	tickets, err := r.TicketList()
	if err != nil {
		t.Fatal(err)
	}
	if len(tickets) != 3 || tickets[0].UUID != uuids[1] {
		t.Fatalf("TicketList = %d tickets, first %v; want 3, first %s",
			len(tickets), tickets, uuids[1])
	}
	changes, err := r.TicketChanges(uuids[1])
	if err != nil {
		t.Fatal(err)
	}
	if len(changes) != 2 || changes[1].Artifact != reply || changes[1].User != "editor" ||
		changes[1].Comment != "reply @"+checkin[:10] {
		t.Fatalf("TicketChanges = %+v", changes)
	}

	since, err := r.TimelineSince(checkin, EventKindTicket)
	if err != nil {
		t.Fatal(err)
	}
	want := []string{reply, artifacts[2], artifacts[1], artifacts[0]}
	if len(since) != len(want) {
		t.Fatalf("TimelineSince(checkin, t) = %d entries, want %d", len(since), len(want))
	}
	for i, e := range since {
		if e.UUID != want[i] || e.Kind != EventKindTicket {
			t.Fatalf("entry %d = %s %s, want %s t", i, e.UUID, e.Kind, want[i])
		}
	}
	all, err := r.TimelineSince(checkin)
	if err != nil || len(all) != 4 {
		t.Fatalf("TimelineSince(checkin) = %d entries, %v; want 4", len(all), err)
	}
	checkins, err := r.TimelineSince(checkin, EventKindCheckin, EventKindWiki, EventKindForum)
	if err != nil || len(checkins) != 0 {
		t.Fatalf("TimelineSince(checkin, ci w f) = %v, %v; want none", checkins, err)
	}
	atTip, err := r.TimelineSince(reply)
	if err != nil {
		t.Fatal(err)
	}
	if atTip == nil || len(atTip) != 0 {
		t.Fatalf("TimelineSince at the tip = %#v, want an empty, non-nil slice", atTip)
	}
	older, err := r.TimelineSince(artifacts[2], EventKindTicket)
	if err != nil || len(older) != 1 || older[0].UUID != reply {
		t.Fatalf("TimelineSince(third ticket) = %v, %v; want only the reply", older, err)
	}

	assertTicketJSON(t, tickets, changes, atTip)
}

// The results cross the wasm boundary as JSON unchanged, so their encoding is
// part of the contract: lower-case keys, and [] rather than null when empty.
func assertTicketJSON(
	t *testing.T, tickets []Ticket, changes []TicketChange, empty []TimelineEntry,
) {
	t.Helper()
	b, err := json.Marshal(tickets[0])
	if err != nil {
		t.Fatal(err)
	}
	for _, key := range []string{`"uuid":`, `"status":"Open"`, `"changed":`, `"fields":{`} {
		if !strings.Contains(string(b), key) {
			t.Fatalf("ticket JSON %s lacks %s", b, key)
		}
	}
	b, err = json.Marshal(changes[1])
	if err != nil {
		t.Fatal(err)
	}
	for _, key := range []string{
		`"artifact":`, `"user":"editor"`, `"time":`, `"fields":{`, `"comment":"reply @`,
	} {
		if !strings.Contains(string(b), key) {
			t.Fatalf("change JSON %s lacks %s", b, key)
		}
	}
	if b, _ := json.Marshal(empty); string(b) != "[]" {
		t.Fatalf("empty timeline JSON = %s, want []", b)
	}
}

func TestTimelineSinceRejectsBadInput(t *testing.T) {
	r := newTicketRepo(t)
	_, checkin, err := r.Commit(CommitOpts{
		Files:   []FileToCommit{{Name: "a", Content: []byte("a")}},
		Comment: "c", User: "alice", Time: ticketT0,
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := r.TimelineSince(strings.Repeat("0", 40)); err == nil {
		t.Error("TimelineSince of an unknown hash: no error")
	}
	if _, err := r.TimelineSince(checkin, EventKind("x")); err == nil {
		t.Error("TimelineSince with an unknown kind: no error")
	}
}