Skip to content
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"github.com/guidewire/fern-reporter/pkg/datamigrations"
"strings"

"github.com/99designs/gqlgen/graphql/handler/transport"
Expand Down Expand Up @@ -48,6 +49,8 @@ func initConfig() {

func initDb() {
db.Initialize()
db.GetDb().Debug()
go datamigrations.BackfillTestRunStatus(db.GetDb())
}

func initServer() {
Expand Down
13 changes: 7 additions & 6 deletions pkg/api/handlers/handler_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,16 @@ func ParseTimeFromStringWithDefault(timeString string, defaultTime time.Time) (t

func GetProjectSpecStatistics(h *Handler, projectId string) []models.TestSummary {
var testSummaries []models.TestSummary

h.db.Table("test_runs").
Joins("INNER JOIN suite_runs ON test_runs.id = suite_runs.test_run_id").
Joins("INNER JOIN spec_runs ON suite_runs.id = spec_runs.suite_id").
Select(`suite_runs.id AS suite_run_id,
suite_runs.suite_name,
test_runs.start_time,
COUNT(spec_runs.id) FILTER (WHERE spec_runs.status = 'passed') AS total_passed_spec_runs,
COUNT(spec_runs.id) FILTER (WHERE spec_runs.status = 'skipped') AS total_skipped_spec_runs,
COUNT(spec_runs.id) AS total_spec_runs`).
Select(`suite_runs.id AS suite_run_id,
suite_runs.suite_name,
test_runs.start_time,
COUNT(spec_runs.id) FILTER (WHERE LOWER(spec_runs.status) = 'passed') AS total_passed_spec_runs,
COUNT(spec_runs.id) FILTER (WHERE LOWER(spec_runs.status) = 'skipped') AS total_skipped_spec_runs,
COUNT(spec_runs.id) AS total_spec_runs`).
Where("test_runs.project_id = ?", projectId).
Group("suite_runs.id, test_runs.start_time").
Order("test_runs.start_time").
Expand Down
68 changes: 67 additions & 1 deletion pkg/api/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers
import (
"errors"
"fmt"
"strings"

"github.com/guidewire/fern-reporter/config"
"github.com/guidewire/fern-reporter/pkg/models"
Expand Down Expand Up @@ -67,6 +68,8 @@ func (h *Handler) CreateTestRun(c *gin.Context) {
return // Stop further processing if tag processing fails
}

computeTestRunStatus(&testRun)

// Save or update the testRun record in the database
if err := gdb.Save(&testRun).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "error saving record"})
Expand All @@ -83,6 +86,24 @@ func getProjectIDByUUID(db *gorm.DB, uuid string) (uint64, error) {
return project.ID, nil
}

func computeTestRunStatus(testRun *models.TestRun) {
status := "PASSED"

for _, suite := range testRun.SuiteRuns {
for _, spec := range suite.SpecRuns {
if strings.EqualFold(spec.Status, "FAILED") {
testRun.Status = "FAILED"
return
}
if strings.EqualFold(spec.Status, "SKIPPED") {
status = "SKIPPED"
}
}
}

testRun.Status = status
}

func ProcessTags(db *gorm.DB, testRun *models.TestRun) error {
for i, suite := range testRun.SuiteRuns {
for j, spec := range suite.SpecRuns {
Expand Down Expand Up @@ -116,8 +137,53 @@ func ProcessTags(db *gorm.DB, testRun *models.TestRun) error {
}

func (h *Handler) GetTestRunAll(c *gin.Context) {

var testRuns []models.TestRun
h.db.Find(&testRuns)

projectUUID := c.Query("project_uuid")
sortBy := c.DefaultQuery("sort_by", "end_time")
order := c.DefaultQuery("order", "desc")
fields := strings.Split(c.DefaultQuery("fields", ""), ",")

allowedSortFields := map[string]bool{
"end_time": true,
"start_time": true,
"status": true,
}
if !allowedSortFields[sortBy] {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort_by field"})
return
}

if order != "asc" && order != "desc" {
c.JSON(http.StatusBadRequest, gin.H{"error": "order must be 'asc' or 'desc'"})
return
}

query := h.db.Model(&models.TestRun{})

for _, field := range fields {
switch strings.ToLower(strings.TrimSpace(field)) {
case "project":
query = query.Preload("Project")
case "suiteruns":
query = query.Preload("SuiteRuns.SpecRuns")
}
}

if projectUUID != "" {
log.Println()
query = query.Joins("JOIN project_details ON project_details.id = test_runs.project_id").
Where("project_details.uuid = ?", projectUUID)
}

query = query.Order(fmt.Sprintf("test_runs.%s %s", sortBy, order))

if err := query.Find(&testRuns).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, testRuns)
}

Expand Down
117 changes: 113 additions & 4 deletions pkg/api/handlers/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,113 @@ var _ = Describe("Handlers", func() {
Expect(testRuns[0].TestProjectName).To(Equal("project 1"))
Expect(testRuns[1].TestProjectName).To(Equal("project 2"))
})

It("should return 400 for invalid sort_by parameter", func() {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

c.Request, _ = http.NewRequest("GET", "/testruns?sort_by=invalid_field", nil)
handler := handlers.NewHandler(gormDb)
handler.GetTestRunAll(c)

Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("invalid sort_by field"))
})

It("should return 400 for invalid order parameter", func() {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

c.Request, _ = http.NewRequest("GET", "/testruns?order=invalid_order", nil)
handler := handlers.NewHandler(gormDb)
handler.GetTestRunAll(c)

Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("order must be 'asc' or 'desc'"))
})

It("should apply project_uuid filter", func() {
rows := sqlmock.NewRows([]string{
"id", "test_project_name", "project_id", "test_seed", "start_time", "end_time", "git_branch", "git_sha", "build_trigger_actor", "build_url", "status",
}).AddRow(1, "project 1", 101, 0, time.Now(), time.Now(), "main", "abc123", "ci", "url", "PASSED")

mock.ExpectQuery(`(?i)SELECT .* FROM "test_runs" JOIN project_details ON project_details\.id = test_runs\.project_id WHERE project_details\.uuid = \$1 ORDER BY test_runs\.end_time desc`).
WithArgs("abc-123").
WillReturnRows(rows)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

c.Request, _ = http.NewRequest("GET", "/testruns?project_uuid=abc-123", nil)
handler := handlers.NewHandler(gormDb)
handler.GetTestRunAll(c)

Expect(w.Code).To(Equal(http.StatusOK))
})

It("should apply valid sort_by and order parameters", func() {
rows := sqlmock.NewRows([]string{
"id", "test_project_name", "project_id", "test_seed", "start_time", "end_time", "git_branch", "git_sha", "build_trigger_actor", "build_url", "status",
}).AddRow(1, "project 1", 101, 0, time.Now(), time.Now(), "main", "abc123", "ci", "url", "PASSED")

mock.ExpectQuery(`(?i)SELECT .* FROM "test_runs" ORDER BY test_runs\.start_time asc`).
WillReturnRows(rows)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

c.Request, _ = http.NewRequest("GET", "/testruns?sort_by=start_time&order=asc", nil)
handler := handlers.NewHandler(gormDb)
handler.GetTestRunAll(c)

Expect(w.Code).To(Equal(http.StatusOK))
})

It("should preload fields when specified", func() {
testRunRows := sqlmock.NewRows([]string{
"id", "test_project_name", "project_id", "test_seed", "start_time", "end_time", "git_branch", "git_sha", "build_trigger_actor", "build_url", "status",
}).AddRow(1, "project 1", 101, 123, time.Now(), time.Now(), "main", "abc123", "dev", "url", "PASSED")

mock.ExpectQuery(regexp.QuoteMeta(`
SELECT * FROM "test_runs"
ORDER BY test_runs.end_time desc`)).
WillReturnRows(testRunRows)

projectRows := sqlmock.NewRows([]string{"id", "uuid", "name", "team_name", "comment", "created_at", "updated_at"}).
AddRow(101, "uuid-123", "project-name", "team-a", "", time.Now(), time.Now())

mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "project_details" WHERE "project_details"."id" = $1`)).
WithArgs(101).
WillReturnRows(projectRows)

suiteRunRows := sqlmock.NewRows([]string{"id", "test_run_id", "suite_name", "start_time", "end_time"}).
AddRow(201, 1, "suite-1", time.Now(), time.Now())

mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "suite_runs" WHERE "suite_runs"."test_run_id" = $1`)).
WithArgs(1).
WillReturnRows(suiteRunRows)

specRunRows := sqlmock.NewRows([]string{"id", "suite_id", "spec_description", "status", "message", "start_time", "end_time"}).
AddRow(301, 201, "spec-1", "PASSED", "", time.Now(), time.Now())

mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "spec_runs" WHERE "spec_runs"."suite_id" = $1`)).
WithArgs(201).
WillReturnRows(specRunRows)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request, _ = http.NewRequest("GET", "/testruns?fields=project,suiteruns", nil)

handler := handlers.NewHandler(gormDb)
handler.GetTestRunAll(c)

Expect(w.Code).To(Equal(http.StatusOK))
var testRuns []models.TestRun
Expect(json.NewDecoder(w.Body).Decode(&testRuns)).To(Succeed())
Expect(len(testRuns)).To(Equal(1))
Expect(testRuns[0].Project.Name).To(Equal("project-name"))
Expect(testRuns[0].SuiteRuns[0].SuiteName).To(Equal("suite-1"))
})
})

Context("When GetTestRunByID handler is invoked", func() {
Expand Down Expand Up @@ -125,6 +232,7 @@ var _ = Describe("Handlers", func() {
BuildTriggerActor: "Actor Name",
BuildUrl: "https://someurl.com",
TestSeed: 0,
Status: "PASSED",
SuiteRuns: []models.SuiteRun{
{
ID: 1,
Expand Down Expand Up @@ -161,8 +269,8 @@ var _ = Describe("Handlers", func() {
WillReturnRows(rows)

mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "test_runs" ("test_project_name","project_id","test_seed","start_time","end_time","git_branch","git_sha","build_trigger_actor","build_url") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)).
WithArgs(expectedTestRun.TestProjectName, expectedProject.ID, expectedTestRun.TestSeed, expectedTestRun.StartTime, expectedTestRun.EndTime, expectedTestRun.GitBranch, expectedTestRun.GitSha, expectedTestRun.BuildTriggerActor, expectedTestRun.BuildUrl).
mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "test_runs" ("test_project_name","project_id","test_seed","start_time","end_time","git_branch","git_sha","build_trigger_actor","build_url","status") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING "id"`)).
WithArgs(expectedTestRun.TestProjectName, expectedProject.ID, expectedTestRun.TestSeed, expectedTestRun.StartTime, expectedTestRun.EndTime, expectedTestRun.GitBranch, expectedTestRun.GitSha, expectedTestRun.BuildTriggerActor, expectedTestRun.BuildUrl, expectedTestRun.Status).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectCommit()

Expand Down Expand Up @@ -1061,11 +1169,12 @@ var _ = Describe("Handlers", func() {
AddRow(1, "TestSuite1", "TestProject", time.Date(2024, 4, 20, 12, 0, 0, 0, time.UTC), 5, 1, 10).
AddRow(2, "TestSuite2", "TestProject", time.Date(2024, 4, 21, 12, 0, 0, 0, time.UTC), 7, 2, 12)

//TODO: Update the expected query
mock.ExpectQuery(regexp.QuoteMeta(`SELECT suite_runs.id AS suite_run_id,
suite_runs.suite_name,
test_runs.start_time,
COUNT(spec_runs.id) FILTER (WHERE spec_runs.status = 'passed') AS total_passed_spec_runs,
COUNT(spec_runs.id) FILTER (WHERE spec_runs.status = 'skipped') AS total_skipped_spec_runs,
COUNT(spec_runs.id) FILTER (WHERE LOWER(spec_runs.status) = 'passed') AS total_passed_spec_runs,
COUNT(spec_runs.id) FILTER (WHERE LOWER(spec_runs.status) = 'skipped') AS total_skipped_spec_runs,
COUNT(spec_runs.id) AS total_spec_runs FROM "test_runs"
INNER JOIN suite_runs ON test_runs.id = suite_runs.test_run_id
INNER JOIN spec_runs ON suite_runs.id = spec_runs.suite_id
Expand Down
69 changes: 69 additions & 0 deletions pkg/datamigrations/status_backfill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package datamigrations

import (
"github.com/guidewire/fern-reporter/pkg/models"
"gorm.io/gorm"
"log"
"strings"
)

func BackfillTestRunStatus(db *gorm.DB) {

// Short-circuit if all test runs already have status
var missingStatusCount int64
db.Model(&models.TestRun{}).Where("status IS NULL OR status = ''").Count(&missingStatusCount)
if missingStatusCount == 0 {
log.Println("No test runs missing status. Skipping backfill.")
return
}

const batchSize = 100
offset := 0

log.Println("Initiating test run status update .")
for {
var testRuns []models.TestRun

err := db.Preload("SuiteRuns.SpecRuns").
Where("status IS NULL OR status = ''").
Limit(batchSize).
Offset(offset).
Find(&testRuns).Error

if err != nil {
log.Printf("Backfill failed at offset %d: %v\n", offset, err)
return
}

if len(testRuns) == 0 {
break
}

for _, testRun := range testRuns {
status := "PASSED"

for _, suite := range testRun.SuiteRuns {
for _, spec := range suite.SpecRuns {
if strings.EqualFold(spec.Status, "FAILED") {
status = "FAILED"
goto UpdateStatus
}
if strings.EqualFold(spec.Status, "SKIPPED") && status != "FAILED" {
status = "SKIPPED"
}
}
}

UpdateStatus:
if err := db.Model(&models.TestRun{}).
Where("id = ?", testRun.ID).
Update("status", status).Error; err != nil {
log.Printf("Failed to update test_run ID %d: %v\n", testRun.ID, err)
}
}

offset += batchSize
}

log.Println("Test run status update is complete.")
}
3 changes: 3 additions & 0 deletions pkg/db/migrations/000009_alter_test_runs_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE public.test_runs

ADD COLUMN status text
1 change: 1 addition & 0 deletions pkg/models/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type TestRun struct {
BuildTriggerActor string `json:"build_trigger_actor"`
BuildUrl string `json:"build_url"`
SuiteRuns []SuiteRun `json:"suite_runs" gorm:"foreignKey:TestRunID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Status string `json:"status"`

// Relationship with ProjectDetails
Project ProjectDetails `gorm:"foreignKey:ProjectID;references:ID"`
Expand Down
Loading