package api
import (
"context"
"path/filepath"
"testing"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// activityIndex is the merge shape RFC 0013 ยง3 is about: dan commits,
// eve commits on a branch, dan merges her work in. The merge's three
// paths are eve's, and the op has to say so.
func activityIndex(t *testing.T) *index.Index {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
if err := ix.FoldNow([]index.Parsed{
{Hash: "a1", Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-06-01T09:00:00", User: "dan", Comment: "one"}},
{Hash: "b1", Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-06-02T10:00:00", User: "eve", Comment: "two",
Parents: []string{"a1"}}},
{Hash: "m1", Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-06-03T11:00:00", User: "dan", Comment: "merge",
Parents: []string{"a1", "b1"}}},
}); err != nil {
t.Fatal(err)
}
if err := ix.FoldChangesBulk([]index.BulkChanges{
{Hash: "a1", Art: &artifact.Artifact{Date: "2026-06-01T09:00:00"},
Changes: []string{"src/one.go"}},
{Hash: "b1", Art: &artifact.Artifact{Date: "2026-06-02T10:00:00"},
Changes: []string{"src/two.go"}},
{Hash: "m1", Art: &artifact.Artifact{Date: "2026-06-03T11:00:00"},
Changes: []string{"src/two.go", "src/three.go", "doc/four.md"}},
}); err != nil {
t.Fatal(err)
}
return ix
}
func activityRegistry(t *testing.T) *ops.Registry {
t.Helper()
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: testRepos(activityIndex(t))}); err != nil {
t.Fatal(err)
}
return r
}
// Both routes exist and are anonymous reads, because the activity page
// is a door a visitor walks through before they have an account.
func TestActivityOpsAreRegisteredAsAnonymousReads(t *testing.T) {
r := activityRegistry(t)
spec, err := ops.Spec(r, "quarry", "v")
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/v1/r/{repo}/activity", "/api/v1/r/{repo}/activity/days"} {
p, ok := spec.Paths[path]
if !ok || p.Get == nil {
t.Fatalf("%s missing from the generated spec", path)
}
}
}
func TestRepoActivityOpCarriesTheMergeRule(t *testing.T) {
r := activityRegistry(t)
got, err := r.Invoke(context.Background(), "repo.activity",
repoActivityIn{Repo: "test", Since: "2026-06-01", Until: "2026-06-30"})
if err != nil {
t.Fatal(err)
}
out := got.(repoActivityOut)
if out.Window != "given" || out.Since != "2026-06-01" || out.Until != "2026-06-30" {
t.Fatalf("window = %s %s..%s", out.Window, out.Since, out.Until)
}
if len(out.Rows) != 3 {
t.Fatalf("got %d rows, want 3: %+v", len(out.Rows), out.Rows)
}
var merge activityRow
for _, row := range out.Rows {
if row.Hash == "m1" {
merge = row
}
}
if merge.Merge != 1 || merge.Files != 0 {
t.Fatalf("merge row = %+v, want Merge 1 Files 0", merge)
}
inherited := 0
for _, tc := range out.Touches {
if tc.Inherited == 1 {
inherited++
}
}
if inherited != 3 {
t.Fatalf("%d inherited touches, want 3: %+v", inherited, out.Touches)
}
if len(out.Authors) != 2 || out.Authors[0].Name != "dan" || out.Authors[0].Files != 1 {
t.Fatalf("authors = %+v, want dan leading with one authored file", out.Authors)
}
for _, a := range out.Authors {
if a.Hue < 0 || a.Hue >= 360 {
t.Fatalf("%s has no usable hue: %+v", a.Name, a)
}
}
for _, p := range out.Paths {
if p == "src" || p == "doc" {
t.Fatalf("a directory reached Paths: %v", out.Paths)
}
}
}
func TestRepoActivityOpFiltersByBranchAndUser(t *testing.T) {
r := activityRegistry(t)
got, err := r.Invoke(context.Background(), "repo.activity",
repoActivityIn{Repo: "test", User: "eve", Since: "2026-06-01", Until: "2026-06-30"})
if err != nil {
t.Fatal(err)
}
out := got.(repoActivityOut)
if len(out.Rows) != 1 || out.Rows[0].Hash != "b1" {
t.Fatalf("user filter = %+v, want only b1", out.Rows)
}
}
func TestRepoActivityDaysOpFoldsTheHistory(t *testing.T) {
r := activityRegistry(t)
got, err := r.Invoke(context.Background(), "repo.activity.days",
repoActivityDaysIn{Repo: "test"})
if err != nil {
t.Fatal(err)
}
out := got.(repoActivityDaysOut)
if out.First != "2026-06-01" || out.Last != "2026-06-03" {
t.Fatalf("span = %s..%s", out.First, out.Last)
}
if len(out.Days) != 3 {
t.Fatalf("got %d days, want 3: %+v", len(out.Days), out.Days)
}
d, ok := out.Days["2026-06-03"]
if !ok {
t.Fatalf("merge day missing: %+v", out.Days)
}
if d.N != 1 || d.Merges != 1 {
t.Fatalf("merge day = %+v, want N 1 Merges 1", d)
}
// Its only file touches were inherited, so no directory led it.
if d.Path != -1 {
t.Fatalf("merge day claimed directory %d (%v)", d.Path, out.Paths)
}
if len(out.Authors) != 2 {
t.Fatalf("authors = %+v, want two", out.Authors)
}
}
// Unknown repository, not a panic: the op is anonymous and the name
// comes off a URL.
func TestActivityOpsRejectAnUnknownRepo(t *testing.T) {
r := activityRegistry(t)
if _, err := r.Invoke(context.Background(), "repo.activity",
repoActivityIn{Repo: "nope"}); err == nil {
t.Fatal("want an error for an unknown repository")
}
if _, err := r.Invoke(context.Background(), "repo.activity.days",
repoActivityDaysIn{Repo: "nope"}); err == nil {
t.Fatal("want an error for an unknown repository")
}
}