github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/ci/run_tests_test.go (about)

     1  package ci_test
     2  
     3  import (
     4  	"context"
     5  	"os"
     6  	"strings"
     7  	"sync"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/google/go-cmp/cmp"
    12  	"github.com/quickfeed/quickfeed/ci"
    13  	"github.com/quickfeed/quickfeed/internal/qlog"
    14  	"github.com/quickfeed/quickfeed/internal/qtest"
    15  	"github.com/quickfeed/quickfeed/internal/rand"
    16  	"github.com/quickfeed/quickfeed/kit/score"
    17  	"github.com/quickfeed/quickfeed/qf"
    18  	"github.com/quickfeed/quickfeed/scm"
    19  	"github.com/quickfeed/quickfeed/web/stream"
    20  	"google.golang.org/protobuf/testing/protocmp"
    21  )
    22  
    23  // To run this test, please see instructions in the developer guide (dev.md).
    24  
    25  // This test uses a test course for experimenting with run.sh behavior.
    26  // The tests below will run locally on the test machine, not on the QuickFeed machine.
    27  
    28  func loadDockerfile(t *testing.T) string {
    29  	t.Helper()
    30  	b, err := os.ReadFile("testdata/Dockerfile")
    31  	if err != nil {
    32  		t.Fatal(err)
    33  	}
    34  	return string(b)
    35  }
    36  
    37  func testRunData(t *testing.T, runner ci.Runner) *ci.RunData {
    38  	dockerfile := loadDockerfile(t)
    39  	qfTestOrg := scm.GetTestOrganization(t)
    40  	// Only used to fetch the user's GitHub login (user name)
    41  	_, userName := scm.GetTestSCM(t)
    42  
    43  	repo := qf.RepoURL{ProviderURL: "github.com", Organization: qfTestOrg}
    44  	course := &qf.Course{
    45  		ID:                  1,
    46  		Code:                "QF101",
    47  		ScmOrganizationName: qfTestOrg,
    48  	}
    49  	course.UpdateDockerfile(dockerfile)
    50  
    51  	// Emulate running UpdateFromTestsRepo to ensure the docker image is built before running tests.
    52  	t.Logf("Building %s's Dockerfile:\n%v", course.GetCode(), course.GetDockerfile())
    53  	out, err := runner.Run(context.Background(), &ci.Job{
    54  		Name:       course.JobName(),
    55  		Image:      course.DockerImage(),
    56  		Dockerfile: course.GetDockerfile(),
    57  		Commands:   []string{`echo -n "Hello from Dockerfile"`},
    58  	})
    59  	if err != nil {
    60  		t.Fatal(err)
    61  	}
    62  	t.Log(out)
    63  
    64  	return &ci.RunData{
    65  		Course: course,
    66  		Assignment: &qf.Assignment{
    67  			Name:             "lab1",
    68  			ContainerTimeout: 1, // minutes
    69  		},
    70  		Repo: &qf.Repository{
    71  			HTMLURL:  repo.StudentRepoURL(userName),
    72  			RepoType: qf.Repository_USER,
    73  		},
    74  		JobOwner: "muggles",
    75  		CommitID: rand.String()[:7],
    76  	}
    77  }
    78  
    79  func TestRunTests(t *testing.T) {
    80  	runner, closeFn := dockerClient(t)
    81  	defer closeFn()
    82  
    83  	runData := testRunData(t, runner)
    84  	ctx, cancel := runData.Assignment.WithTimeout(2 * time.Minute)
    85  	defer cancel()
    86  
    87  	scmClient, _ := scm.GetTestSCM(t)
    88  	results, err := runData.RunTests(ctx, qtest.Logger(t), scmClient, runner)
    89  	if err != nil {
    90  		t.Fatal(err)
    91  	}
    92  	// We don't actually test anything here since we don't know how many assignments are in QF_TEST_ORG
    93  	t.Logf("%+v", results.BuildInfo.BuildLog)
    94  	results.BuildInfo.BuildLog = "removed"
    95  	t.Logf("%+v\n", qlog.IndentJson(results))
    96  }
    97  
    98  func TestRunTestsTimeout(t *testing.T) {
    99  	runner, closeFn := dockerClient(t)
   100  	defer closeFn()
   101  
   102  	runData := testRunData(t, runner)
   103  	// Note that this timeout value is susceptible to variation
   104  	ctx, cancel := context.WithTimeout(context.Background(), 2000*time.Millisecond)
   105  	defer cancel()
   106  
   107  	scmClient, _ := scm.GetTestSCM(t)
   108  	results, err := runData.RunTests(ctx, qtest.Logger(t), scmClient, runner)
   109  	if err != nil {
   110  		t.Fatal(err)
   111  	}
   112  	const wantOut = `Container timeout. Please check for infinite loops or other slowness.`
   113  	if results.BuildInfo != nil && !strings.HasPrefix(results.BuildInfo.BuildLog, wantOut) {
   114  		t.Errorf("RunTests(1s timeout) = '%s', got '%s'", wantOut, results.BuildInfo.BuildLog)
   115  	}
   116  }
   117  
   118  func TestRecordResults(t *testing.T) {
   119  	db, cleanup := qtest.TestDB(t)
   120  	defer cleanup()
   121  
   122  	course := &qf.Course{
   123  		Name:              "Test",
   124  		Code:              "DAT320",
   125  		ScmOrganizationID: 1,
   126  		SlipDays:          5,
   127  	}
   128  	admin := qtest.CreateFakeUser(t, db)
   129  	qtest.CreateCourse(t, db, admin, course)
   130  
   131  	assignment := &qf.Assignment{
   132  		CourseID:         course.ID,
   133  		Name:             "lab1",
   134  		Deadline:         qtest.Timestamp(t, "2022-11-11T13:00:00"),
   135  		AutoApprove:      true,
   136  		ScoreLimit:       70,
   137  		Order:            1,
   138  		IsGroupLab:       false,
   139  		ContainerTimeout: 1,
   140  	}
   141  	if err := db.CreateAssignment(assignment); err != nil {
   142  		t.Fatal(err)
   143  	}
   144  
   145  	buildInfo := &score.BuildInfo{
   146  		SubmissionDate: qtest.Timestamp(t, "2022-11-10T13:00:00"),
   147  		BuildDate:      qtest.Timestamp(t, "2022-11-10T13:00:00"),
   148  		BuildLog:       "Testing",
   149  		ExecTime:       33333,
   150  	}
   151  	testScores := []*score.Score{
   152  		{
   153  			Secret:   "secret",
   154  			TestName: "Test",
   155  			Score:    10,
   156  			MaxScore: 15,
   157  			Weight:   1,
   158  		},
   159  	}
   160  	// Must create a new submission with correct scores and build info, not approved
   161  	results := &score.Results{
   162  		BuildInfo: buildInfo,
   163  		Scores:    testScores,
   164  	}
   165  	runData := &ci.RunData{
   166  		Course:     course,
   167  		Assignment: assignment,
   168  		Repo: &qf.Repository{
   169  			RepoType: qf.Repository_USER,
   170  			UserID:   1,
   171  		},
   172  		JobOwner: "test",
   173  		CommitID: "deadbeef",
   174  	}
   175  
   176  	// Check that submission is recorded correctly
   177  	submission, err := runData.RecordResults(qtest.Logger(t), db, results)
   178  	if err != nil {
   179  		t.Fatal(err)
   180  	}
   181  	if submission.IsApproved() {
   182  		t.Error("Submission must not be auto approved")
   183  	}
   184  	if diff := cmp.Diff(testScores, submission.Scores, protocmp.Transform(), protocmp.IgnoreFields(&score.Score{}, "Secret")); diff != "" {
   185  		t.Errorf("submission score mismatch: (-want +got):\n%s", diff)
   186  	}
   187  	if diff := cmp.Diff(buildInfo.BuildDate, submission.BuildInfo.BuildDate, protocmp.Transform()); diff != "" {
   188  		t.Errorf("build date mismatch: (-want +got):\n%s", diff)
   189  	}
   190  	if diff := cmp.Diff(buildInfo.SubmissionDate, submission.BuildInfo.SubmissionDate, protocmp.Transform()); diff != "" {
   191  		t.Errorf("submission date mismatch: (-want +got):\n%s", diff)
   192  	}
   193  
   194  	// When updating submission after deadline: build info (submission and build dates) and slip days must be updated
   195  	newSubmissionDate := qtest.Timestamp(t, "2022-11-12T13:00:00")
   196  	results.BuildInfo.BuildDate = newSubmissionDate
   197  	results.BuildInfo.SubmissionDate = newSubmissionDate
   198  	updatedSubmission, err := runData.RecordResults(qtest.Logger(t), db, results)
   199  	if err != nil {
   200  		t.Fatal(err)
   201  	}
   202  	enrollment, err := db.GetEnrollmentByCourseAndUser(course.ID, admin.ID)
   203  	if err != nil {
   204  		t.Fatal(err)
   205  	}
   206  	if enrollment.RemainingSlipDays(course) == int32(course.SlipDays) || len(enrollment.UsedSlipDays) < 1 {
   207  		t.Error("Student must have reduced slip days")
   208  	}
   209  	if diff := cmp.Diff(newSubmissionDate, updatedSubmission.BuildInfo.BuildDate, protocmp.Transform()); diff != "" {
   210  		t.Errorf("build date mismatch: (-want +got):\n%s", diff)
   211  	}
   212  	if diff := cmp.Diff(newSubmissionDate, updatedSubmission.BuildInfo.SubmissionDate, protocmp.Transform()); diff != "" {
   213  		t.Errorf("submission date mismatch: (-want +got):\n%s", diff)
   214  	}
   215  
   216  	// When rebuilding after deadline: delivery date and slip days must stay unchanged, build date must be updated
   217  	runData.Rebuild = true
   218  	wantSubmissionDate := newSubmissionDate
   219  	newDate := qtest.Timestamp(t, "2022-11-13T15:00:00")
   220  	results.BuildInfo.BuildDate = newDate
   221  	results.BuildInfo.SubmissionDate = newDate
   222  	slipDaysBeforeUpdate := enrollment.RemainingSlipDays(course)
   223  	rebuiltSubmission, err := runData.RecordResults(qtest.Logger(t), db, results)
   224  	if err != nil {
   225  		t.Fatal(err)
   226  	}
   227  	if diff := cmp.Diff(newDate, rebuiltSubmission.BuildInfo.BuildDate, protocmp.Transform()); diff != "" {
   228  		t.Errorf("build date mismatch: (-want +got):\n%s", diff)
   229  	}
   230  	if diff := cmp.Diff(wantSubmissionDate, rebuiltSubmission.BuildInfo.SubmissionDate, protocmp.Transform()); diff != "" {
   231  		t.Errorf("submission date mismatch: (-want +got):\n%s", diff)
   232  	}
   233  	updatedEnrollment, err := db.GetEnrollmentByCourseAndUser(course.ID, admin.ID)
   234  	if err != nil {
   235  		t.Fatal(err)
   236  	}
   237  	if diff := cmp.Diff(slipDaysBeforeUpdate, updatedEnrollment.RemainingSlipDays(course)); diff != "" {
   238  		t.Errorf("slip days mismatch: (-want +got):\n%s", diff)
   239  	}
   240  }
   241  
   242  func TestRecordResultsForManualReview(t *testing.T) {
   243  	db, cleanup := qtest.TestDB(t)
   244  	defer cleanup()
   245  
   246  	course := &qf.Course{
   247  		Name:              "Test",
   248  		ScmOrganizationID: 1,
   249  		SlipDays:          5,
   250  	}
   251  	admin := qtest.CreateFakeUser(t, db)
   252  	qtest.CreateCourse(t, db, admin, course)
   253  
   254  	assignment := &qf.Assignment{
   255  		Order:      1,
   256  		CourseID:   course.ID,
   257  		Name:       "assignment-1",
   258  		Deadline:   qtest.Timestamp(t, "2022-11-11T13:00:00"),
   259  		IsGroupLab: false,
   260  		Reviewers:  1,
   261  	}
   262  	if err := db.CreateAssignment(assignment); err != nil {
   263  		t.Fatal(err)
   264  	}
   265  
   266  	initialSubmission := &qf.Submission{
   267  		AssignmentID: assignment.ID,
   268  		UserID:       admin.ID,
   269  		Score:        80,
   270  		Status:       qf.Submission_APPROVED,
   271  		Released:     true,
   272  	}
   273  	if err := db.CreateSubmission(initialSubmission); err != nil {
   274  		t.Fatal(err)
   275  	}
   276  
   277  	runData := &ci.RunData{
   278  		Course:     course,
   279  		Assignment: assignment,
   280  		Repo: &qf.Repository{
   281  			RepoType: qf.Repository_USER,
   282  			UserID:   admin.ID,
   283  		},
   284  		JobOwner: "test",
   285  	}
   286  
   287  	submission, err := runData.RecordResults(qtest.Logger(t), db, nil)
   288  	if err != nil {
   289  		t.Fatal(err)
   290  	}
   291  
   292  	// make sure all fields were saved correctly in the database
   293  	query := &qf.Submission{
   294  		AssignmentID: assignment.ID,
   295  		UserID:       admin.ID,
   296  	}
   297  	updatedSubmission, err := db.GetSubmission(query)
   298  	if err != nil {
   299  		t.Fatal(err)
   300  	}
   301  
   302  	if diff := cmp.Diff(updatedSubmission, submission, protocmp.Transform()); diff != "" {
   303  		t.Errorf("Incorrect submission fields in the database. Want: %+v, got %+v", initialSubmission, updatedSubmission)
   304  	}
   305  
   306  	// submission must stay approved, released, with score = 80
   307  	if diff := cmp.Diff(initialSubmission, updatedSubmission, protocmp.Transform(), protocmp.IgnoreFields(&qf.Submission{}, "BuildInfo", "Scores")); diff != "" {
   308  		t.Errorf("Incorrect submission after update. Want: %+v, got %+v", initialSubmission, updatedSubmission)
   309  	}
   310  }
   311  
   312  func TestStreamRecordResults(t *testing.T) {
   313  	db, cleanup := qtest.TestDB(t)
   314  	defer cleanup()
   315  	streamService := stream.NewStreamServices()
   316  
   317  	course := &qf.Course{
   318  		Name:              "Test",
   319  		Code:              "DAT320",
   320  		ScmOrganizationID: 1,
   321  		SlipDays:          5,
   322  	}
   323  	admin := qtest.CreateFakeUser(t, db)
   324  	qtest.CreateCourse(t, db, admin, course)
   325  
   326  	groupMember1 := qtest.CreateFakeUser(t, db)
   327  	groupMember2 := qtest.CreateFakeUser(t, db)
   328  	groupMember3 := qtest.CreateFakeUser(t, db)
   329  	for _, user := range []*qf.User{groupMember1, groupMember2, groupMember3} {
   330  		qtest.EnrollStudent(t, db, user, course)
   331  	}
   332  	group := &qf.Group{
   333  		CourseID: course.ID,
   334  		Name:     "group-1",
   335  		Users: []*qf.User{
   336  			groupMember1,
   337  			groupMember2,
   338  			groupMember3,
   339  		},
   340  	}
   341  	if err := db.CreateGroup(group); err != nil {
   342  		t.Fatal(err)
   343  	}
   344  
   345  	assignment := &qf.Assignment{
   346  		CourseID:         course.ID,
   347  		Name:             "lab1",
   348  		Deadline:         qtest.Timestamp(t, "2022-11-11T13:00:00"),
   349  		AutoApprove:      true,
   350  		ScoreLimit:       70,
   351  		Order:            1,
   352  		IsGroupLab:       true,
   353  		ContainerTimeout: 1,
   354  	}
   355  	if err := db.CreateAssignment(assignment); err != nil {
   356  		t.Fatal(err)
   357  	}
   358  
   359  	buildInfo := &score.BuildInfo{
   360  		BuildDate:      qtest.Timestamp(t, "2022-11-10T13:00:00"),
   361  		SubmissionDate: qtest.Timestamp(t, "2022-11-10T13:00:00"),
   362  		BuildLog:       "Testing",
   363  		ExecTime:       33333,
   364  	}
   365  	testScores := []*score.Score{
   366  		{
   367  			Secret:   "secret",
   368  			TestName: "Test",
   369  			Score:    10,
   370  			MaxScore: 15,
   371  			Weight:   1,
   372  		},
   373  	}
   374  
   375  	results := &score.Results{
   376  		BuildInfo: buildInfo,
   377  		Scores:    testScores,
   378  	}
   379  	runData := &ci.RunData{
   380  		Course:     course,
   381  		Assignment: assignment,
   382  		Repo: &qf.Repository{
   383  			RepoType: qf.Repository_GROUP,
   384  			GroupID:  group.ID,
   385  		},
   386  		JobOwner: "test",
   387  		CommitID: "deadbeef",
   388  	}
   389  
   390  	var streams []*qtest.MockStream[qf.Submission]
   391  	for _, user := range group.Users {
   392  		stream := qtest.NewMockStream[qf.Submission](t)
   393  		streamService.Submission.Add(stream, user.ID)
   394  		streams = append(streams, stream)
   395  	}
   396  
   397  	// Add a stream for the admin user
   398  	adminStream := qtest.NewMockStream[qf.Submission](t)
   399  	streamService.Submission.Add(adminStream, admin.ID)
   400  
   401  	var wg sync.WaitGroup
   402  	for i := range streams {
   403  		runStream(streams[i], &wg)
   404  	}
   405  	runStream(adminStream, &wg)
   406  
   407  	// Check that submission is recorded correctly
   408  	submission, err := runData.RecordResults(qtest.Logger(t), db, results)
   409  	if err != nil {
   410  		t.Fatal(err)
   411  	}
   412  
   413  	owners, err := runData.GetOwners(db)
   414  	if err != nil {
   415  		t.Fatal(err)
   416  	}
   417  	streamService.Submission.SendTo(submission, owners...)
   418  	if submission.IsApproved() {
   419  		t.Error("Submission must not be auto approved")
   420  	}
   421  
   422  	newBuildDate := qtest.Timestamp(t, "2022-11-12T13:00:00")
   423  	results.BuildInfo.BuildDate = newBuildDate
   424  	updatedSubmission, err := runData.RecordResults(qtest.Logger(t), db, results)
   425  	if err != nil {
   426  		t.Fatal(err)
   427  	}
   428  	streamService.Submission.SendTo(updatedSubmission, owners...)
   429  
   430  	runData.Rebuild = true
   431  	results.BuildInfo.BuildDate = qtest.Timestamp(t, "2022-11-13T13:00:00")
   432  	rebuiltSubmission, err := runData.RecordResults(qtest.Logger(t), db, results)
   433  	if err != nil {
   434  		t.Fatal(err)
   435  	}
   436  	streamService.Submission.SendTo(rebuiltSubmission, owners...)
   437  
   438  	for _, stream := range streams {
   439  		stream.Close()
   440  	}
   441  	adminStream.Close()
   442  	// Wait for all streams to be closed
   443  	wg.Wait()
   444  
   445  	// Admin user should have received 0 submissions
   446  	if len(adminStream.Messages) != 0 {
   447  		t.Errorf("Admin user should not have received any submissions, got %d", len(adminStream.Messages))
   448  	}
   449  
   450  	// We should have received three submissions for each stream
   451  	numSubmissions := 0
   452  	for _, stream := range streams {
   453  		numSubmissions += len(stream.Messages)
   454  	}
   455  	if numSubmissions != 9 {
   456  		t.Errorf("Expected 9 messages, got %d", numSubmissions)
   457  	}
   458  
   459  	// Check that the messages are correct
   460  	submissions := []*qf.Submission{submission, updatedSubmission, rebuiltSubmission}
   461  	for _, stream := range streams {
   462  		for i, submission := range submissions {
   463  			if diff := cmp.Diff(stream.Messages[i], submission, protocmp.Transform()); diff != "" {
   464  				t.Errorf("Incorrect submission. Want: %+v, got %+v", submission, stream.Messages[i])
   465  			}
   466  		}
   467  	}
   468  }
   469  
   470  func runStream(stream *qtest.MockStream[qf.Submission], wg *sync.WaitGroup) {
   471  	wg.Add(1)
   472  	go func() {
   473  		defer wg.Done()
   474  		_ = stream.Run()
   475  	}()
   476  }