github.com/kubeshop/testkube@v1.17.23/pkg/repository/testresult/mongo.go (about)

     1  package testresult
     2  
     3  import (
     4  	"context"
     5  	"strings"
     6  	"time"
     7  
     8  	"github.com/kubeshop/testkube/pkg/repository/common"
     9  
    10  	"go.mongodb.org/mongo-driver/bson"
    11  	"go.mongodb.org/mongo-driver/bson/primitive"
    12  	"go.mongodb.org/mongo-driver/mongo"
    13  	"go.mongodb.org/mongo-driver/mongo/options"
    14  
    15  	"github.com/kubeshop/testkube/pkg/api/v1/testkube"
    16  )
    17  
    18  var _ Repository = (*MongoRepository)(nil)
    19  
    20  const CollectionName = "testresults"
    21  
    22  func NewMongoRepository(db *mongo.Database, allowDiskUse, isDocDb bool, opts ...MongoRepositoryOpt) *MongoRepository {
    23  	r := &MongoRepository{
    24  		db:           db,
    25  		Coll:         db.Collection(CollectionName),
    26  		allowDiskUse: allowDiskUse,
    27  		isDocDb:      isDocDb,
    28  	}
    29  
    30  	for _, opt := range opts {
    31  		opt(r)
    32  	}
    33  
    34  	return r
    35  }
    36  
    37  type MongoRepository struct {
    38  	db           *mongo.Database
    39  	Coll         *mongo.Collection
    40  	allowDiskUse bool
    41  	isDocDb      bool
    42  }
    43  
    44  func WithMongoRepositoryCollection(collection *mongo.Collection) MongoRepositoryOpt {
    45  	return func(r *MongoRepository) {
    46  		r.Coll = collection
    47  	}
    48  }
    49  
    50  type MongoRepositoryOpt func(*MongoRepository)
    51  
    52  func (r *MongoRepository) Get(ctx context.Context, id string) (result testkube.TestSuiteExecution, err error) {
    53  	err = r.Coll.FindOne(ctx, bson.M{"$or": bson.A{bson.M{"id": id}, bson.M{"name": id}}}).Decode(&result)
    54  	return *result.UnscapeDots(), err
    55  }
    56  
    57  func (r *MongoRepository) GetByNameAndTestSuite(ctx context.Context, name, testSuiteName string) (result testkube.TestSuiteExecution, err error) {
    58  	err = r.Coll.FindOne(ctx, bson.M{"name": name, "testsuite.name": testSuiteName}).Decode(&result)
    59  	return *result.UnscapeDots(), err
    60  }
    61  
    62  func (r *MongoRepository) slowGetLatestByTestSuite(ctx context.Context, testSuiteName string) (*testkube.TestSuiteExecution, error) {
    63  	opts := options.Aggregate()
    64  	pipeline := []bson.M{
    65  		{"$project": bson.M{"testsuite.name": 1, "starttime": 1, "endtime": 1}},
    66  		{"$match": bson.M{"testsuite.name": testSuiteName}},
    67  
    68  		{"$addFields": bson.M{
    69  			"updatetime": bson.M{"$max": bson.A{"$starttime", "$endtime"}},
    70  		}},
    71  		{"$group": bson.D{
    72  			{Key: "_id", Value: "$testsuite.name"},
    73  			{Key: "doc", Value: bson.M{"$max": bson.D{
    74  				{Key: "updatetime", Value: "$updatetime"},
    75  				{Key: "content", Value: "$$ROOT"},
    76  			}}},
    77  		}},
    78  		{"$sort": bson.M{"doc.updatetime": -1}},
    79  		{"$limit": 1},
    80  
    81  		{"$lookup": bson.M{"from": r.Coll.Name(), "localField": "doc.content._id", "foreignField": "_id", "as": "execution"}},
    82  		{"$replaceRoot": bson.M{"newRoot": bson.M{"$arrayElemAt": bson.A{"$execution", 0}}}},
    83  	}
    84  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
    85  	if err != nil {
    86  		return nil, err
    87  	}
    88  	var items []testkube.TestSuiteExecution
    89  	err = cursor.All(ctx, &items)
    90  	if err != nil {
    91  		return nil, err
    92  	}
    93  	if len(items) == 0 {
    94  		return nil, mongo.ErrNoDocuments
    95  	}
    96  	return items[0].UnscapeDots(), err
    97  }
    98  
    99  func (r *MongoRepository) GetLatestByTestSuite(ctx context.Context, testSuiteName string) (*testkube.TestSuiteExecution, error) {
   100  	// Workaround, to use subset of MongoDB features available in AWS DocumentDB
   101  	if r.isDocDb {
   102  		return r.slowGetLatestByTestSuite(ctx, testSuiteName)
   103  	}
   104  
   105  	opts := options.Aggregate()
   106  	pipeline := []bson.M{
   107  		{"$group": bson.M{"_id": "$testsuite.name", "doc": bson.M{"$first": bson.M{}}}},
   108  		{"$project": bson.M{"_id": 0, "name": "$_id"}},
   109  		{"$match": bson.M{"name": testSuiteName}},
   110  
   111  		{"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{
   112  			{"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}},
   113  			{"$sort": bson.M{"starttime": -1}},
   114  			{"$limit": 1},
   115  		}, "as": "execution_by_start_time"}},
   116  		{"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{
   117  			{"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}},
   118  			{"$sort": bson.M{"endtime": -1}},
   119  			{"$limit": 1},
   120  		}, "as": "execution_by_end_time"}},
   121  		{"$project": bson.M{"executions": bson.M{"$concatArrays": bson.A{"$execution_by_start_time", "$execution_by_end_time"}}}},
   122  		{"$unwind": "$executions"},
   123  		{"$replaceRoot": bson.M{"newRoot": "$executions"}},
   124  
   125  		{"$group": bson.D{
   126  			{Key: "_id", Value: "$testsuite.name"},
   127  			{Key: "doc", Value: bson.M{"$max": bson.D{
   128  				{Key: "updatetime", Value: bson.M{"$max": bson.A{"$starttime", "$endtime"}}},
   129  				{Key: "content", Value: "$$ROOT"},
   130  			}}},
   131  		}},
   132  		{"$sort": bson.M{"doc.updatetime": -1}},
   133  		{"$replaceRoot": bson.M{"newRoot": "$doc.content"}},
   134  		{"$limit": 1},
   135  	}
   136  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   137  	if err != nil {
   138  		return nil, err
   139  	}
   140  	var items []testkube.TestSuiteExecution
   141  	err = cursor.All(ctx, &items)
   142  	if err != nil {
   143  		return nil, err
   144  	}
   145  	if len(items) == 0 {
   146  		return nil, mongo.ErrNoDocuments
   147  	}
   148  	return items[0].UnscapeDots(), err
   149  }
   150  
   151  func (r *MongoRepository) slowGetLatestByTestSuites(ctx context.Context, testSuiteNames []string) (executions []testkube.TestSuiteExecution, err error) {
   152  	documents := bson.A{}
   153  	for _, testSuiteName := range testSuiteNames {
   154  		documents = append(documents, bson.M{"testsuite.name": testSuiteName})
   155  	}
   156  
   157  	pipeline := []bson.M{
   158  		{"$project": bson.M{"testsuite.name": 1, "starttime": 1, "endtime": 1}},
   159  		{"$match": bson.M{"$or": documents}},
   160  
   161  		{"$addFields": bson.M{
   162  			"updatetime": bson.M{"$max": bson.A{"$starttime", "$endtime"}},
   163  		}},
   164  		{"$group": bson.D{
   165  			{Key: "_id", Value: "$testsuite.name"},
   166  			{Key: "doc", Value: bson.M{"$max": bson.D{
   167  				{Key: "updatetime", Value: "$updatetime"},
   168  				{Key: "content", Value: "$$ROOT"},
   169  			}}},
   170  		}},
   171  		{"$sort": bson.M{"doc.updatetime": -1}},
   172  
   173  		{"$lookup": bson.M{"from": r.Coll.Name(), "localField": "doc.content._id", "foreignField": "_id", "as": "execution"}},
   174  		{"$replaceRoot": bson.M{"newRoot": bson.M{"$arrayElemAt": bson.A{"$execution", 0}}}},
   175  	}
   176  
   177  	opts := options.Aggregate()
   178  	if r.allowDiskUse {
   179  		opts.SetAllowDiskUse(r.allowDiskUse)
   180  	}
   181  
   182  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   183  	if err != nil {
   184  		return nil, err
   185  	}
   186  	err = cursor.All(ctx, &executions)
   187  	if err != nil {
   188  		return nil, err
   189  	}
   190  
   191  	if len(executions) == 0 {
   192  		return executions, nil
   193  	}
   194  
   195  	for i := range executions {
   196  		executions[i].UnscapeDots()
   197  	}
   198  	return executions, nil
   199  }
   200  
   201  func (r *MongoRepository) GetLatestByTestSuites(ctx context.Context, testSuiteNames []string) (executions []testkube.TestSuiteExecution, err error) {
   202  	if len(testSuiteNames) == 0 {
   203  		return executions, nil
   204  	}
   205  
   206  	// Workaround, to use subset of MongoDB features available in AWS DocumentDB
   207  	if r.isDocDb {
   208  		return r.slowGetLatestByTestSuites(ctx, testSuiteNames)
   209  	}
   210  
   211  	documents := bson.A{}
   212  	for _, testSuiteName := range testSuiteNames {
   213  		documents = append(documents, bson.M{"name": testSuiteName})
   214  	}
   215  
   216  	pipeline := []bson.M{
   217  		{"$group": bson.M{"_id": "$testsuite.name", "doc": bson.M{"$first": bson.M{}}}},
   218  		{"$project": bson.M{"_id": 0, "name": "$_id"}},
   219  		{"$match": bson.M{"$or": documents}},
   220  
   221  		{"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{
   222  			{"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}},
   223  			{"$sort": bson.M{"starttime": -1}},
   224  			{"$limit": 1},
   225  		}, "as": "execution_by_start_time"}},
   226  		{"$lookup": bson.M{"from": r.Coll.Name(), "let": bson.M{"name": "$name"}, "pipeline": []bson.M{
   227  			{"$match": bson.M{"$expr": bson.M{"$eq": bson.A{"$testsuite.name", "$$name"}}}},
   228  			{"$sort": bson.M{"endtime": -1}},
   229  			{"$limit": 1},
   230  		}, "as": "execution_by_end_time"}},
   231  		{"$project": bson.M{"executions": bson.M{"$concatArrays": bson.A{"$execution_by_start_time", "$execution_by_end_time"}}}},
   232  		{"$unwind": "$executions"},
   233  		{"$replaceRoot": bson.M{"newRoot": "$executions"}},
   234  
   235  		{"$group": bson.D{
   236  			{Key: "_id", Value: "$testsuite.name"},
   237  			{Key: "doc", Value: bson.M{"$max": bson.D{
   238  				{Key: "updatetime", Value: bson.M{"$max": bson.A{"$starttime", "$endtime"}}},
   239  				{Key: "content", Value: "$$ROOT"},
   240  			}}},
   241  		}},
   242  		{"$sort": bson.M{"doc.updatetime": -1}},
   243  		{"$replaceRoot": bson.M{"newRoot": "$doc.content"}},
   244  	}
   245  
   246  	opts := options.Aggregate()
   247  	if r.allowDiskUse {
   248  		opts.SetAllowDiskUse(r.allowDiskUse)
   249  	}
   250  
   251  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   252  	if err != nil {
   253  		return nil, err
   254  	}
   255  	err = cursor.All(ctx, &executions)
   256  	if err != nil {
   257  		return nil, err
   258  	}
   259  
   260  	if len(executions) == 0 {
   261  		return executions, nil
   262  	}
   263  
   264  	for i := range executions {
   265  		executions[i].UnscapeDots()
   266  	}
   267  	return executions, nil
   268  }
   269  
   270  func (r *MongoRepository) GetNewestExecutions(ctx context.Context, limit int) (result []testkube.TestSuiteExecution, err error) {
   271  	result = make([]testkube.TestSuiteExecution, 0)
   272  	resultLimit := int64(limit)
   273  	opts := &options.FindOptions{Limit: &resultLimit}
   274  	opts.SetSort(bson.D{{Key: "_id", Value: -1}})
   275  	if r.allowDiskUse {
   276  		opts.SetAllowDiskUse(r.allowDiskUse)
   277  	}
   278  
   279  	cursor, err := r.Coll.Find(ctx, bson.M{}, opts)
   280  	if err != nil {
   281  		return result, err
   282  	}
   283  	err = cursor.All(ctx, &result)
   284  
   285  	for i := range result {
   286  		result[i].UnscapeDots()
   287  	}
   288  	return
   289  }
   290  
   291  func (r *MongoRepository) Count(ctx context.Context, filter Filter) (count int64, err error) {
   292  	query, _ := composeQueryAndOpts(filter)
   293  	return r.Coll.CountDocuments(ctx, query)
   294  }
   295  
   296  func (r *MongoRepository) GetExecutionsTotals(ctx context.Context, filter ...Filter) (totals testkube.ExecutionsTotals, err error) {
   297  	var result []struct {
   298  		Status string `bson:"_id"`
   299  		Count  int    `bson:"count"`
   300  	}
   301  
   302  	query := bson.M{}
   303  	if len(filter) > 0 {
   304  		query, _ = composeQueryAndOpts(filter[0])
   305  	}
   306  
   307  	pipeline := []bson.D{
   308  		{{Key: "$sort", Value: bson.M{"status": 1}}},
   309  		{{Key: "$match", Value: query}},
   310  	}
   311  	if len(filter) > 0 {
   312  		pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "starttime", Value: -1}}}})
   313  		pipeline = append(pipeline, bson.D{{Key: "$skip", Value: int64(filter[0].Page() * filter[0].PageSize())}})
   314  		pipeline = append(pipeline, bson.D{{Key: "$limit", Value: int64(filter[0].PageSize())}})
   315  	} else {
   316  		pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "status", Value: 1}}}})
   317  	}
   318  
   319  	pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{{Key: "_id", Value: "$status"},
   320  		{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}})
   321  
   322  	opts := options.Aggregate()
   323  	if r.allowDiskUse {
   324  		opts.SetAllowDiskUse(r.allowDiskUse)
   325  	}
   326  
   327  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   328  	if err != nil {
   329  		return totals, err
   330  	}
   331  	err = cursor.All(ctx, &result)
   332  	if err != nil {
   333  		return totals, err
   334  	}
   335  
   336  	var sum int32
   337  
   338  	for _, o := range result {
   339  		sum += int32(o.Count)
   340  		switch testkube.TestSuiteExecutionStatus(o.Status) {
   341  		case testkube.QUEUED_TestSuiteExecutionStatus:
   342  			totals.Queued = int32(o.Count)
   343  		case testkube.RUNNING_TestSuiteExecutionStatus:
   344  			totals.Running = int32(o.Count)
   345  		case testkube.PASSED_TestSuiteExecutionStatus:
   346  			totals.Passed = int32(o.Count)
   347  		case testkube.FAILED_TestSuiteExecutionStatus:
   348  			totals.Failed = int32(o.Count)
   349  		}
   350  	}
   351  	totals.Results = sum
   352  
   353  	return
   354  }
   355  
   356  func (r *MongoRepository) GetExecutions(ctx context.Context, filter Filter) (result []testkube.TestSuiteExecution, err error) {
   357  	result = make([]testkube.TestSuiteExecution, 0)
   358  	query, opts := composeQueryAndOpts(filter)
   359  	if r.allowDiskUse {
   360  		opts.SetAllowDiskUse(r.allowDiskUse)
   361  	}
   362  
   363  	cursor, err := r.Coll.Find(ctx, query, opts)
   364  	if err != nil {
   365  		return
   366  	}
   367  	err = cursor.All(ctx, &result)
   368  
   369  	for i := range result {
   370  		result[i].UnscapeDots()
   371  	}
   372  	return
   373  }
   374  
   375  func (r *MongoRepository) Insert(ctx context.Context, result testkube.TestSuiteExecution) (err error) {
   376  	result.EscapeDots()
   377  	result.CleanStepsOutput()
   378  	_, err = r.Coll.InsertOne(ctx, result)
   379  	return
   380  }
   381  
   382  func (r *MongoRepository) Update(ctx context.Context, result testkube.TestSuiteExecution) (err error) {
   383  	result.EscapeDots()
   384  	result.CleanStepsOutput()
   385  	_, err = r.Coll.ReplaceOne(ctx, bson.M{"id": result.Id}, result)
   386  	return
   387  }
   388  
   389  // StartExecution updates execution start time
   390  func (r *MongoRepository) StartExecution(ctx context.Context, id string, startTime time.Time) (err error) {
   391  	_, err = r.Coll.UpdateOne(ctx, bson.M{"id": id}, bson.M{"$set": bson.M{"starttime": startTime}})
   392  	return
   393  }
   394  
   395  // EndExecution updates execution end time
   396  func (r *MongoRepository) EndExecution(ctx context.Context, e testkube.TestSuiteExecution) (err error) {
   397  	_, err = r.Coll.UpdateOne(ctx, bson.M{"id": e.Id}, bson.M{"$set": bson.M{"endtime": e.EndTime, "duration": e.Duration, "durationms": e.DurationMs}})
   398  	return
   399  }
   400  
   401  func composeQueryAndOpts(filter Filter) (bson.M, *options.FindOptions) {
   402  
   403  	query := bson.M{}
   404  	opts := options.Find()
   405  	startTimeQuery := bson.M{}
   406  
   407  	if filter.NameDefined() {
   408  		query["testsuite.name"] = filter.Name()
   409  	}
   410  
   411  	if filter.TextSearchDefined() {
   412  		query["name"] = bson.M{"$regex": primitive.Regex{Pattern: filter.TextSearch(), Options: "i"}}
   413  	}
   414  
   415  	if filter.LastNDaysDefined() {
   416  		startTimeQuery["$gte"] = time.Now().Add(-time.Duration(filter.LastNDays()) * 24 * time.Hour)
   417  	}
   418  
   419  	if filter.StartDateDefined() {
   420  		startTimeQuery["$gte"] = filter.StartDate()
   421  	}
   422  
   423  	if filter.EndDateDefined() {
   424  		startTimeQuery["$lte"] = filter.EndDate()
   425  	}
   426  
   427  	if len(startTimeQuery) > 0 {
   428  		query["starttime"] = startTimeQuery
   429  	}
   430  
   431  	if filter.StatusesDefined() {
   432  		statuses := filter.Statuses()
   433  		if len(statuses) == 1 {
   434  			query["status"] = statuses[0]
   435  		} else {
   436  			var conditions bson.A
   437  			for _, status := range statuses {
   438  				conditions = append(conditions, bson.M{"status": status})
   439  			}
   440  
   441  			query["$or"] = conditions
   442  		}
   443  	}
   444  
   445  	if filter.Selector() != "" {
   446  		items := strings.Split(filter.Selector(), ",")
   447  		for _, item := range items {
   448  			elements := strings.Split(item, "=")
   449  			if len(elements) == 2 {
   450  				query["labels."+elements[0]] = elements[1]
   451  			} else if len(elements) == 1 {
   452  				query["labels."+elements[0]] = bson.M{"$exists": true}
   453  			}
   454  		}
   455  	}
   456  
   457  	opts.SetSkip(int64(filter.Page() * filter.PageSize()))
   458  	opts.SetLimit(int64(filter.PageSize()))
   459  	opts.SetSort(bson.D{{Key: "starttime", Value: -1}})
   460  
   461  	return query, opts
   462  }
   463  
   464  // DeleteByTestSuite deletes execution results by test suite
   465  func (r *MongoRepository) DeleteByTestSuite(ctx context.Context, testSuiteName string) (err error) {
   466  	_, err = r.Coll.DeleteMany(ctx, bson.M{"testsuite.name": testSuiteName})
   467  	return
   468  }
   469  
   470  // DeleteAll deletes all execution results
   471  func (r *MongoRepository) DeleteAll(ctx context.Context) (err error) {
   472  	_, err = r.Coll.DeleteMany(ctx, bson.M{})
   473  	return
   474  }
   475  
   476  // DeleteByTestSuites deletes execution results by test suites
   477  func (r *MongoRepository) DeleteByTestSuites(ctx context.Context, testSuiteNames []string) (err error) {
   478  	if len(testSuiteNames) == 0 {
   479  		return nil
   480  	}
   481  
   482  	var filter bson.M
   483  	if len(testSuiteNames) > 1 {
   484  		conditions := bson.A{}
   485  		for _, testSuiteName := range testSuiteNames {
   486  			conditions = append(conditions, bson.M{"testsuite.name": testSuiteName})
   487  		}
   488  
   489  		filter = bson.M{"$or": conditions}
   490  	} else {
   491  		filter = bson.M{"testsuite.name": testSuiteNames[0]}
   492  	}
   493  
   494  	_, err = r.Coll.DeleteMany(ctx, filter)
   495  	return
   496  }
   497  
   498  // GetTestSuiteMetrics returns test executions metrics
   499  func (r *MongoRepository) GetTestSuiteMetrics(ctx context.Context, name string, limit, last int) (metrics testkube.ExecutionsMetrics, err error) {
   500  	query := bson.M{"testsuite.name": name}
   501  
   502  	var pipeline []bson.D
   503  	if last > 0 {
   504  		query["starttime"] = bson.M{"$gte": time.Now().Add(-time.Duration(last) * 24 * time.Hour)}
   505  	}
   506  
   507  	pipeline = append(pipeline, bson.D{{Key: "$match", Value: query}})
   508  	pipeline = append(pipeline, bson.D{{Key: "$sort", Value: bson.D{{Key: "starttime", Value: -1}}}})
   509  	pipeline = append(pipeline, bson.D{
   510  		{
   511  			Key: "$project", Value: bson.D{
   512  				{Key: "status", Value: 1},
   513  				{Key: "duration", Value: 1},
   514  				{Key: "starttime", Value: 1},
   515  				{Key: "name", Value: 1},
   516  			},
   517  		},
   518  	})
   519  
   520  	opts := options.Aggregate()
   521  	if r.allowDiskUse {
   522  		opts.SetAllowDiskUse(r.allowDiskUse)
   523  	}
   524  
   525  	cursor, err := r.Coll.Aggregate(ctx, pipeline, opts)
   526  	if err != nil {
   527  		return metrics, err
   528  	}
   529  
   530  	var executions []testkube.ExecutionsMetricsExecutions
   531  	err = cursor.All(ctx, &executions)
   532  
   533  	if err != nil {
   534  		return metrics, err
   535  	}
   536  
   537  	metrics = common.CalculateMetrics(executions)
   538  	if limit > 0 && limit < len(metrics.Executions) {
   539  		metrics.Executions = metrics.Executions[:limit]
   540  	}
   541  
   542  	return metrics, nil
   543  }