github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/jobs/jobs_test.go (about)

     1  package jobs
     2  
     3  import (
     4  	"net/http/httptest"
     5  	"strings"
     6  	"testing"
     7  	"time"
     8  
     9  	"github.com/cozy/cozy-stack/model/instance"
    10  	"github.com/cozy/cozy-stack/model/job"
    11  	"github.com/cozy/cozy-stack/pkg/config/config"
    12  	"github.com/cozy/cozy-stack/pkg/consts"
    13  	"github.com/cozy/cozy-stack/pkg/emailer"
    14  	"github.com/cozy/cozy-stack/pkg/mail"
    15  	"github.com/cozy/cozy-stack/tests/testutils"
    16  	"github.com/cozy/cozy-stack/web/errors"
    17  	"github.com/cozy/cozy-stack/web/middlewares"
    18  	"github.com/gavv/httpexpect/v2"
    19  	"github.com/labstack/echo/v4"
    20  	"github.com/stretchr/testify/assert"
    21  	"github.com/stretchr/testify/mock"
    22  	"github.com/stretchr/testify/require"
    23  )
    24  
    25  func setupRouter(t *testing.T, inst *instance.Instance, emailerSvc emailer.Emailer) *httptest.Server {
    26  	t.Helper()
    27  
    28  	handler := echo.New()
    29  	handler.HTTPErrorHandler = errors.ErrorHandler
    30  	group := handler.Group("/jobs", func(next echo.HandlerFunc) echo.HandlerFunc {
    31  		return func(context echo.Context) error {
    32  			context.Set("instance", inst)
    33  
    34  			tok := middlewares.GetRequestToken(context)
    35  			// Forcing the token parsing to have the "claims" parameter in the
    36  			// context (in production, it is done via
    37  			// middlewares.CheckInstanceBlocked)
    38  			_, err := middlewares.ParseJWT(context, inst, tok)
    39  			if err != nil {
    40  				return err
    41  			}
    42  
    43  			return next(context)
    44  		}
    45  	})
    46  
    47  	NewHTTPHandler(emailerSvc).Register(group)
    48  	ts := httptest.NewServer(handler)
    49  	t.Cleanup(ts.Close)
    50  
    51  	return ts
    52  }
    53  
    54  func TestJobs(t *testing.T) {
    55  	if testing.Short() {
    56  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    57  	}
    58  
    59  	config.UseTestFile(t)
    60  	testutils.NeedCouchdb(t)
    61  	setup := testutils.NewSetup(t, t.Name())
    62  
    63  	job.AddWorker(&job.WorkerConfig{
    64  		WorkerType:  "print",
    65  		Concurrency: 4,
    66  		WorkerFunc: func(ctx *job.TaskContext) error {
    67  			var msg string
    68  			if err := ctx.UnmarshalMessage(&msg); err != nil {
    69  				return err
    70  			}
    71  
    72  			t.Log(msg)
    73  
    74  			return nil
    75  		},
    76  	})
    77  
    78  	testInstance := setup.GetTestInstance()
    79  
    80  	scope := strings.Join([]string{
    81  		consts.Jobs + ":ALL:print:worker",
    82  		consts.Triggers + ":ALL:print:worker",
    83  	}, " ")
    84  	token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope,
    85  		"", time.Now())
    86  
    87  	emailerSvc := emailer.NewMock(t)
    88  	ts := setupRouter(t, testInstance, emailerSvc)
    89  	ts.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler
    90  	t.Cleanup(ts.Close)
    91  
    92  	t.Run("GetQueue", func(t *testing.T) {
    93  		e := testutils.CreateTestClient(t, ts.URL)
    94  
    95  		e.GET("/jobs/queue/print").
    96  			WithHeader("Authorization", "Bearer "+token).
    97  			Expect().Status(200).
    98  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
    99  			Object().Value("data").Array().
   100  			Length().IsEqual(0)
   101  	})
   102  
   103  	t.Run("CreateJob", func(t *testing.T) {
   104  		e := testutils.CreateTestClient(t, ts.URL)
   105  
   106  		obj := e.POST("/jobs/queue/print").
   107  			WithHeader("Authorization", "Bearer "+token).
   108  			WithHeader("Content-Type", "application/json").
   109  			WithBytes([]byte(`{
   110          "data": {
   111            "attributes": { "arguments": "foobar" }
   112          }
   113        }`)).
   114  			Expect().Status(202).
   115  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   116  			Object()
   117  
   118  		attrs := obj.Path("$.data.attributes").Object()
   119  		attrs.HasValue("worker", "print")
   120  		attrs.NotContainsKey("manual_execution")
   121  	})
   122  
   123  	t.Run("CreateJobWithTimeout", func(t *testing.T) {
   124  		e := testutils.CreateTestClient(t, ts.URL)
   125  
   126  		obj := e.POST("/jobs/queue/print").
   127  			WithHeader("Authorization", "Bearer "+token).
   128  			WithHeader("Content-Type", "application/json").
   129  			WithBytes([]byte(`{
   130          "data": {
   131            "attributes": {
   132  		    "arguments": "foobar",
   133  		    "options": { "timeout": 42 }
   134  		  }
   135          }
   136        }`)).
   137  			Expect().Status(202).
   138  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   139  			Object()
   140  
   141  		jobID := obj.Path("$.data.id").String().NotEmpty().Raw()
   142  		job, err := job.Get(testInstance, jobID)
   143  		require.NoError(t, err)
   144  		require.NotNil(t, job.Options)
   145  		assert.Equal(t, 42*time.Second, job.Options.Timeout)
   146  	})
   147  
   148  	t.Run("CreateManualJob", func(t *testing.T) {
   149  		e := testutils.CreateTestClient(t, ts.URL)
   150  
   151  		obj := e.POST("/jobs/queue/print").
   152  			WithHeader("Authorization", "Bearer "+token).
   153  			WithHeader("Content-Type", "application/json").
   154  			WithBytes([]byte(`{
   155          "data": {
   156            "attributes": { 
   157              "arguments": "foobar",
   158              "manual": true
   159            }
   160          }
   161        }`)).
   162  			Expect().Status(202).
   163  			JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   164  			Object()
   165  
   166  		attrs := obj.Path("$.data.attributes").Object()
   167  		attrs.HasValue("worker", "print")
   168  		attrs.HasValue("manual_execution", true)
   169  	})
   170  
   171  	t.Run("CreateJobForReservedWorker", func(t *testing.T) {
   172  		e := testutils.CreateTestClient(t, ts.URL)
   173  
   174  		e.POST("/jobs/queue/trash-files").
   175  			WithHeader("Authorization", "Bearer "+token).
   176  			WithHeader("Content-Type", "application/json").
   177  			WithBytes([]byte(`{"data": {"attributes": {"arguments": "foobar"}}}`)).
   178  			Expect().Status(403)
   179  	})
   180  
   181  	t.Run("CreateJobNotExist", func(t *testing.T) {
   182  		e := testutils.CreateTestClient(t, ts.URL)
   183  
   184  		tokenNone, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI",
   185  			consts.Jobs+":ALL:none:worker",
   186  			"", time.Now())
   187  
   188  		e.POST("/jobs/queue/none"). // invalid
   189  						WithHeader("Authorization", "Bearer "+tokenNone).
   190  						WithHeader("Content-Type", "application/json").
   191  						WithBytes([]byte(`{"data": {"attributes": {"arguments": "foobar"}}}`)).
   192  						Expect().Status(404)
   193  	})
   194  
   195  	t.Run("AddGetAndDeleteTriggerAt", func(t *testing.T) {
   196  		var triggerID string
   197  		at := time.Now().Add(1100 * time.Millisecond).Format(time.RFC3339)
   198  
   199  		t.Run("AddSuccess", func(t *testing.T) {
   200  			e := testutils.CreateTestClient(t, ts.URL)
   201  
   202  			obj := e.POST("/jobs/triggers").
   203  				WithHeader("Authorization", "Bearer "+token).
   204  				WithHeader("Content-Type", "application/json").
   205  				WithBytes([]byte(`{
   206          "data": {
   207            "attributes": { 
   208              "type": "@at",
   209              "arguments": "` + at + `",
   210              "worker": "print",
   211              "message": "foo"
   212            }
   213          }
   214        }`)).
   215  				Expect().Status(201).
   216  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   217  				Object()
   218  
   219  			data := obj.Value("data").Object()
   220  			triggerID = data.Value("id").String().NotEmpty().Raw()
   221  			data.HasValue("type", consts.Triggers)
   222  
   223  			attrs := data.Value("attributes").Object()
   224  			attrs.HasValue("arguments", at)
   225  			attrs.HasValue("worker", "print")
   226  		})
   227  
   228  		t.Run("AddFailure", func(t *testing.T) {
   229  			e := testutils.CreateTestClient(t, ts.URL)
   230  
   231  			e.POST("/jobs/triggers").
   232  				WithHeader("Authorization", "Bearer "+token).
   233  				WithHeader("Content-Type", "application/json").
   234  				WithBytes([]byte(`{
   235          "data": {
   236            "attributes": { 
   237              "type": "@at",
   238              "arguments": "garbage",
   239              "worker": "print",
   240              "message": "foo"
   241            }
   242          }
   243        }`)).
   244  				Expect().Status(400)
   245  		})
   246  
   247  		t.Run("GetSuccess", func(t *testing.T) {
   248  			e := testutils.CreateTestClient(t, ts.URL)
   249  
   250  			e.GET("/jobs/triggers/"+triggerID).
   251  				WithHeader("Authorization", "Bearer "+token).
   252  				Expect().Status(200)
   253  		})
   254  
   255  		t.Run("DeleteSuccess", func(t *testing.T) {
   256  			e := testutils.CreateTestClient(t, ts.URL)
   257  
   258  			e.DELETE("/jobs/triggers/"+triggerID).
   259  				WithHeader("Authorization", "Bearer "+token).
   260  				Expect().Status(204)
   261  		})
   262  
   263  		t.Run("GetNotFound", func(t *testing.T) {
   264  			e := testutils.CreateTestClient(t, ts.URL)
   265  
   266  			e.GET("/jobs/triggers/"+triggerID).
   267  				WithHeader("Authorization", "Bearer "+token).
   268  				Expect().Status(404)
   269  		})
   270  	})
   271  
   272  	t.Run("AddGetAndDeleteTriggerIn", func(t *testing.T) {
   273  		var triggerID string
   274  
   275  		t.Run("AddSuccess", func(t *testing.T) {
   276  			e := testutils.CreateTestClient(t, ts.URL)
   277  
   278  			obj := e.POST("/jobs/triggers").
   279  				WithHeader("Authorization", "Bearer "+token).
   280  				WithHeader("Content-Type", "application/json").
   281  				WithBytes([]byte(`{
   282          "data": {
   283            "attributes": { 
   284              "type": "@in",
   285              "arguments": "1s",
   286              "worker": "print",
   287              "message": "foo"
   288            }
   289          }
   290        }`)).
   291  				Expect().Status(201).
   292  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   293  				Object()
   294  
   295  			data := obj.Value("data").Object()
   296  			triggerID = data.Value("id").String().NotEmpty().Raw()
   297  			data.HasValue("type", consts.Triggers)
   298  
   299  			attrs := data.Value("attributes").Object()
   300  			attrs.HasValue("type", "@in")
   301  			attrs.HasValue("arguments", "1s")
   302  			attrs.HasValue("worker", "print")
   303  		})
   304  
   305  		t.Run("AddFailure", func(t *testing.T) {
   306  			e := testutils.CreateTestClient(t, ts.URL)
   307  
   308  			e.POST("/jobs/triggers").
   309  				WithHeader("Authorization", "Bearer "+token).
   310  				WithHeader("Content-Type", "application/json").
   311  				WithBytes([]byte(`{
   312          "data": {
   313            "attributes": { 
   314              "type": "@in",
   315              "arguments": "garbage",
   316              "worker": "print",
   317              "message": "foo"
   318            }
   319          }
   320        }`)).
   321  				Expect().Status(400)
   322  		})
   323  
   324  		t.Run("GetSuccess", func(t *testing.T) {
   325  			e := testutils.CreateTestClient(t, ts.URL)
   326  
   327  			e.GET("/jobs/triggers/"+triggerID).
   328  				WithHeader("Authorization", "Bearer "+token).
   329  				Expect().Status(200)
   330  		})
   331  
   332  		t.Run("DeleteSuccess", func(t *testing.T) {
   333  			e := testutils.CreateTestClient(t, ts.URL)
   334  
   335  			e.DELETE("/jobs/triggers/"+triggerID).
   336  				WithHeader("Authorization", "Bearer "+token).
   337  				Expect().Status(204)
   338  		})
   339  
   340  		t.Run("GetNotFound", func(t *testing.T) {
   341  			e := testutils.CreateTestClient(t, ts.URL)
   342  
   343  			e.GET("/jobs/triggers/"+triggerID).
   344  				WithHeader("Authorization", "Bearer "+token).
   345  				Expect().Status(404)
   346  		})
   347  	})
   348  
   349  	t.Run("AddGetUpdateAndDeleteTriggerCron", func(t *testing.T) {
   350  		var triggerID string
   351  
   352  		t.Run("AddSuccess", func(t *testing.T) {
   353  			e := testutils.CreateTestClient(t, ts.URL)
   354  
   355  			obj := e.POST("/jobs/triggers").
   356  				WithHeader("Authorization", "Bearer "+token).
   357  				WithHeader("Content-Type", "application/json").
   358  				WithBytes([]byte(`{
   359          "data": {
   360            "attributes": { 
   361              "type": "@cron",
   362              "arguments": "0 0 0 * * 0",
   363              "worker": "print",
   364              "message": "foo"
   365            }
   366          }
   367        }`)).
   368  				Expect().Status(201).
   369  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   370  				Object()
   371  
   372  			data := obj.Value("data").Object()
   373  			triggerID = data.Value("id").String().NotEmpty().Raw()
   374  			data.HasValue("type", consts.Triggers)
   375  
   376  			attrs := data.Value("attributes").Object()
   377  			attrs.HasValue("type", "@cron")
   378  			attrs.HasValue("arguments", "0 0 0 * * 0")
   379  			attrs.HasValue("worker", "print")
   380  		})
   381  
   382  		t.Run("PatchArgumentsSuccess", func(t *testing.T) {
   383  			e := testutils.CreateTestClient(t, ts.URL)
   384  
   385  			e.PATCH("/jobs/triggers/"+triggerID).
   386  				WithHeader("Authorization", "Bearer "+token).
   387  				WithHeader("Content-Type", "application/json").
   388  				WithBytes([]byte(`{
   389          "data": {
   390            "attributes": { 
   391              "arguments": "0 0 0 * * 1"
   392            }
   393          }
   394        }`)).
   395  				Expect().Status(200)
   396  		})
   397  
   398  		t.Run("PatchMessageSuccess", func(t *testing.T) {
   399  			e := testutils.CreateTestClient(t, ts.URL)
   400  
   401  			e.PATCH("/jobs/triggers/"+triggerID).
   402  				WithHeader("Authorization", "Bearer "+token).
   403  				WithHeader("Content-Type", "application/json").
   404  				WithBytes([]byte(`{
   405          "data": {
   406            "attributes": { 
   407  			"message": {
   408  			  "folder_to_save": "123"
   409  			}
   410            }
   411          }
   412        }`)).
   413  				Expect().Status(200)
   414  		})
   415  
   416  		t.Run("GetSuccess", func(t *testing.T) {
   417  			e := testutils.CreateTestClient(t, ts.URL)
   418  
   419  			obj := e.GET("/jobs/triggers/"+triggerID).
   420  				WithHeader("Authorization", "Bearer "+token).
   421  				Expect().Status(200).
   422  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   423  				Object()
   424  
   425  			data := obj.Value("data").Object()
   426  			triggerID = data.Value("id").String().NotEmpty().Raw()
   427  			data.HasValue("type", consts.Triggers)
   428  
   429  			attrs := data.Value("attributes").Object()
   430  			attrs.HasValue("type", "@cron")
   431  			attrs.HasValue("arguments", "0 0 0 * * 1")
   432  			attrs.HasValue("worker", "print")
   433  		})
   434  
   435  		t.Run("DeleteSuccess", func(t *testing.T) {
   436  			e := testutils.CreateTestClient(t, ts.URL)
   437  
   438  			e.DELETE("/jobs/triggers/"+triggerID).
   439  				WithHeader("Authorization", "Bearer "+token).
   440  				Expect().Status(204)
   441  		})
   442  	})
   443  
   444  	t.Run("AddTriggerWithMetadata", func(t *testing.T) {
   445  		var triggerID string
   446  
   447  		at := time.Now().Add(1100 * time.Millisecond).Format(time.RFC3339)
   448  
   449  		t.Run("AddSuccess", func(t *testing.T) {
   450  			e := testutils.CreateTestClient(t, ts.URL)
   451  
   452  			obj := e.POST("/jobs/triggers").
   453  				WithHeader("Authorization", "Bearer "+token).
   454  				WithHeader("Content-Type", "application/json").
   455  				WithBytes([]byte(`{
   456          "data": {
   457            "attributes": { 
   458              "type": "@webhook",
   459              "arguments": "` + at + `",
   460              "worker": "print",
   461              "message": "foo"
   462            }
   463          }
   464        }`)).
   465  				Expect().Status(201).
   466  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   467  				Object()
   468  
   469  			data := obj.Value("data").Object()
   470  			triggerID = data.Value("id").String().NotEmpty().Raw()
   471  			data.HasValue("type", consts.Triggers)
   472  			data.Path("$.links.webhook").IsEqual("https://" + testInstance.Domain + "/jobs/webhooks/" + triggerID)
   473  
   474  			attrs := data.Value("attributes").Object()
   475  			attrs.HasValue("type", "@webhook")
   476  			attrs.HasValue("arguments", at)
   477  			attrs.HasValue("worker", "print")
   478  
   479  			metas := attrs.Value("cozyMetadata").Object()
   480  			metas.HasValue("doctypeVersion", "1")
   481  			metas.HasValue("metadataVersion", 1)
   482  			metas.HasValue("createdByApp", "CLI")
   483  			metas.Value("createdAt").String().AsDateTime(time.RFC3339)
   484  			metas.Value("updatedAt").String().AsDateTime(time.RFC3339)
   485  		})
   486  
   487  		t.Run("GetSuccess", func(t *testing.T) {
   488  			e := testutils.CreateTestClient(t, ts.URL)
   489  
   490  			e.GET("/jobs/triggers/"+triggerID).
   491  				WithHeader("Authorization", "Bearer "+token).
   492  				Expect().Status(200)
   493  		})
   494  
   495  		t.Run("DeleteSuccess", func(t *testing.T) {
   496  			e := testutils.CreateTestClient(t, ts.URL)
   497  
   498  			e.DELETE("/jobs/triggers/"+triggerID).
   499  				WithHeader("Authorization", "Bearer "+token).
   500  				Expect().Status(204)
   501  		})
   502  	})
   503  
   504  	t.Run("GetAllJobs", func(t *testing.T) {
   505  		tokenTriggers, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", consts.Triggers, "", time.Now())
   506  
   507  		t.Run("GetNoJobs", func(t *testing.T) {
   508  			e := testutils.CreateTestClient(t, ts.URL)
   509  
   510  			e.GET("/jobs/triggers").
   511  				WithHeader("Authorization", "Bearer "+tokenTriggers).
   512  				Expect().Status(200).
   513  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   514  				Object().
   515  				Value("data").Array().IsEmpty()
   516  		})
   517  
   518  		t.Run("CreateAJob", func(t *testing.T) {
   519  			e := testutils.CreateTestClient(t, ts.URL)
   520  
   521  			e.POST("/jobs/triggers").
   522  				WithHeader("Authorization", "Bearer "+tokenTriggers).
   523  				WithHeader("Content-Type", "application/json").
   524  				// worker_arguments is deprecated but should still works
   525  				// we are using it here to check that it still works
   526  				WithBytes([]byte(`{
   527          "data": {
   528            "attributes": { 
   529              "type": "@in",
   530              "arguments": "10s",
   531              "worker": "print",
   532              "worker_arguments": "foo"
   533            }
   534          }
   535        }`)).
   536  				Expect().Status(201)
   537  		})
   538  
   539  		t.Run("GetAllJobs", func(t *testing.T) {
   540  			e := testutils.CreateTestClient(t, ts.URL)
   541  
   542  			obj := e.GET("/jobs/triggers").
   543  				WithHeader("Authorization", "Bearer "+tokenTriggers).
   544  				Expect().Status(200).
   545  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   546  				Object()
   547  
   548  			obj.Value("data").Array().Length().IsEqual(1)
   549  			elem := obj.Value("data").Array().Value(0).Object()
   550  			elem.HasValue("type", consts.Triggers)
   551  			attrs := elem.Value("attributes").Object()
   552  			attrs.HasValue("type", "@in")
   553  			attrs.HasValue("arguments", "10s")
   554  			attrs.HasValue("worker", "print")
   555  		})
   556  
   557  		t.Run("WithWorkerQueryAndResult", func(t *testing.T) {
   558  			e := testutils.CreateTestClient(t, ts.URL)
   559  
   560  			obj := e.GET("/jobs/triggers").
   561  				WithQuery("Worker", "print").
   562  				WithHeader("Authorization", "Bearer "+tokenTriggers).
   563  				Expect().Status(200).
   564  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   565  				Object()
   566  
   567  			obj.Value("data").Array().Length().IsEqual(1)
   568  			elem := obj.Value("data").Array().Value(0).Object()
   569  			elem.HasValue("type", consts.Triggers)
   570  			attrs := elem.Value("attributes").Object()
   571  			attrs.HasValue("type", "@in")
   572  			attrs.HasValue("arguments", "10s")
   573  			attrs.HasValue("worker", "print")
   574  		})
   575  
   576  		t.Run("WithWorkerQueryAndNoResults", func(t *testing.T) {
   577  			e := testutils.CreateTestClient(t, ts.URL)
   578  
   579  			e.GET("/jobs/triggers").
   580  				WithQuery("Worker", "nojobforme"). // no matching job
   581  				WithHeader("Authorization", "Bearer "+tokenTriggers).
   582  				Expect().Status(200).
   583  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   584  				Object().Value("data").
   585  				Array().IsEmpty()
   586  		})
   587  
   588  		t.Run("WithTypeQuery", func(t *testing.T) {
   589  			e := testutils.CreateTestClient(t, ts.URL)
   590  
   591  			obj := e.GET("/jobs/triggers").
   592  				WithQuery("Type", "@in").
   593  				WithHeader("Authorization", "Bearer "+tokenTriggers).
   594  				Expect().Status(200).
   595  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   596  				Object()
   597  
   598  			obj.Value("data").Array().Length().IsEqual(1)
   599  			elem := obj.Value("data").Array().Value(0).Object()
   600  			elem.HasValue("type", consts.Triggers)
   601  			attrs := elem.Value("attributes").Object()
   602  			attrs.HasValue("type", "@in")
   603  			attrs.HasValue("arguments", "10s")
   604  			attrs.HasValue("worker", "print")
   605  		})
   606  	})
   607  
   608  	t.Run("ClientJobs", func(t *testing.T) {
   609  		var triggerID string
   610  		var jobID string
   611  
   612  		scope := consts.Jobs + " " + consts.Triggers
   613  		token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope, "", time.Now())
   614  
   615  		t.Run("CreateAClientJob", func(t *testing.T) {
   616  			e := testutils.CreateTestClient(t, ts.URL)
   617  
   618  			obj := e.POST("/jobs/triggers").
   619  				WithHeader("Authorization", "Bearer "+token).
   620  				WithHeader("Content-Type", "application/json").
   621  				WithBytes([]byte(`{
   622  	       "data": {
   623  	         "attributes": {
   624  	           "type": "@client",
   625  	           "message": "foobar"
   626  	         }
   627  	       }
   628  	     }`)).
   629  				Expect().Status(201).
   630  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   631  				Object()
   632  
   633  			triggerID = obj.Path("$.data.id").String().NotEmpty().Raw()
   634  
   635  			attrs := obj.Path("$.data.attributes").Object()
   636  			attrs.HasValue("type", "@client")
   637  			attrs.HasValue("worker", "client")
   638  		})
   639  
   640  		t.Run("LaunchAClientJob", func(t *testing.T) {
   641  			e := testutils.CreateTestClient(t, ts.URL)
   642  
   643  			obj := e.POST("/jobs/triggers/"+triggerID+"/launch").
   644  				WithHeader("Authorization", "Bearer "+token).
   645  				Expect().Status(201).
   646  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   647  				Object()
   648  
   649  			jobID = obj.Path("$.data.id").String().NotEmpty().Raw()
   650  
   651  			obj.Path("$.data.type").IsEqual(consts.Jobs)
   652  			attrs := obj.Path("$.data.attributes").Object()
   653  			attrs.HasValue("worker", "client")
   654  			attrs.HasValue("state", job.Running)
   655  			attrs.Value("queued_at").String().AsDateTime(time.RFC3339)
   656  			attrs.Value("started_at").String().AsDateTime(time.RFC3339)
   657  		})
   658  
   659  		t.Run("PatchAClientJob", func(t *testing.T) {
   660  			e := testutils.CreateTestClient(t, ts.URL)
   661  
   662  			obj := e.PATCH("/jobs/"+jobID).
   663  				WithHeader("Authorization", "Bearer "+token).
   664  				WithHeader("Content-Type", "application/json").
   665  				WithBytes([]byte(`{
   666  	       "data": {
   667  	         "attributes": {
   668  	           "state": "errored",
   669  	           "error": "LOGIN_FAILED"
   670  	         }
   671  	       }
   672  	     }`)).
   673  				Expect().Status(200).
   674  				JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
   675  				Object()
   676  
   677  			obj.Path("$.data.type").IsEqual(consts.Jobs)
   678  			attrs := obj.Path("$.data.attributes").Object()
   679  			attrs.HasValue("worker", "client")
   680  			attrs.HasValue("state", job.Errored)
   681  			attrs.HasValue("error", "LOGIN_FAILED")
   682  			attrs.Value("queued_at").String().AsDateTime(time.RFC3339)
   683  			attrs.Value("started_at").String().AsDateTime(time.RFC3339)
   684  			attrs.Value("finished_at").String().AsDateTime(time.RFC3339)
   685  		})
   686  	})
   687  
   688  	t.Run("SendCampaignEmail", func(t *testing.T) {
   689  		e := testutils.CreateTestClient(t, ts.URL)
   690  
   691  		t.Run("WithoutPermissions", func(t *testing.T) {
   692  			e.POST("/jobs/campaign-emails").
   693  				WithHeader("Authorization", "Bearer "+token).
   694  				WithHeader("Content-Type", "application/json").
   695  				WithBytes([]byte(`{
   696          "data": {
   697            "attributes": { 
   698  			"arguments": {
   699  			  "subject": "Some subject",
   700  			  "parts": [
   701  				{ "body": "Some content", "type": "text/plain" }
   702  			  ]
   703  			}
   704  		  }
   705          }
   706        }`)).Expect().Status(403)
   707  
   708  			emailerSvc.AssertNumberOfCalls(t, "SendCampaignEmail", 0)
   709  		})
   710  
   711  		t.Run("WithProperArguments", func(t *testing.T) {
   712  			emailerSvc.
   713  				On("SendCampaignEmail", testInstance, mock.Anything).
   714  				Return(nil).
   715  				Once()
   716  
   717  			scope := strings.Join([]string{
   718  				consts.Jobs + ":ALL:sendmail:worker",
   719  			}, " ")
   720  			token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope,
   721  				"", time.Now())
   722  
   723  			e.POST("/jobs/campaign-emails").
   724  				WithHeader("Authorization", "Bearer "+token).
   725  				WithHeader("Content-Type", "application/json").
   726  				WithBytes([]byte(`{
   727          "data": {
   728            "attributes": { 
   729  			"arguments": {
   730  			  "subject": "Some subject",
   731  			  "parts": [
   732  				{ "body": "Some content", "type": "text/plain" }
   733  			  ]
   734  			}
   735  		  }
   736          }
   737        }`)).Expect().Status(204)
   738  
   739  			emailerSvc.AssertCalled(t, "SendCampaignEmail", testInstance, &emailer.CampaignEmailCmd{
   740  				Subject: "Some subject",
   741  				Parts: []mail.Part{
   742  					{Body: "Some content", Type: "text/plain"},
   743  				},
   744  			})
   745  		})
   746  
   747  		t.Run("WithMissingSubject", func(t *testing.T) {
   748  			emailerSvc.
   749  				On("SendCampaignEmail", testInstance, mock.Anything).
   750  				Return(emailer.ErrMissingSubject).
   751  				Once()
   752  
   753  			scope := strings.Join([]string{
   754  				consts.Jobs + ":ALL:sendmail:worker",
   755  			}, " ")
   756  			token, _ := testInstance.MakeJWT(consts.CLIAudience, "CLI", scope,
   757  				"", time.Now())
   758  
   759  			e.POST("/jobs/campaign-emails").
   760  				WithHeader("Authorization", "Bearer "+token).
   761  				WithHeader("Content-Type", "application/json").
   762  				WithBytes([]byte(`{
   763          "data": {
   764            "attributes": { 
   765  			"arguments": {
   766  			  "parts": [
   767  				{ "body": "Some content", "type": "text/plain" }
   768  			  ]
   769  			}
   770  		  }
   771          }
   772        }`)).Expect().Status(400)
   773  
   774  			emailerSvc.AssertCalled(t, "SendCampaignEmail", testInstance, &emailer.CampaignEmailCmd{
   775  				Parts: []mail.Part{
   776  					{Body: "Some content", Type: "text/plain"},
   777  				},
   778  			})
   779  		})
   780  	})
   781  }