573 lines
19 KiB
Go
573 lines
19 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"git.ourworld.tf/herocode/heroagent/pkg/herojobs"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
)
|
|
|
|
// MockRedisClient is a mock implementation of the RedisClientInterface
|
|
type MockRedisClient struct {
|
|
mock.Mock
|
|
}
|
|
|
|
// StoreJob mocks the StoreJob method
|
|
func (m *MockRedisClient) StoreJob(job *herojobs.Job) error {
|
|
args := m.Called(job)
|
|
return args.Error(0)
|
|
}
|
|
|
|
// EnqueueJob mocks the EnqueueJob method
|
|
func (m *MockRedisClient) EnqueueJob(job *herojobs.Job) error {
|
|
args := m.Called(job)
|
|
return args.Error(0)
|
|
}
|
|
|
|
// GetJob mocks the GetJob method
|
|
func (m *MockRedisClient) GetJob(jobID interface{}) (*herojobs.Job, error) { // jobID is interface{}
|
|
args := m.Called(jobID)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).(*herojobs.Job), args.Error(1)
|
|
}
|
|
|
|
// ListJobs mocks the ListJobs method
|
|
func (m *MockRedisClient) ListJobs(circleID, topic string) ([]uint32, error) { // Returns []uint32
|
|
args := m.Called(circleID, topic)
|
|
if args.Get(0) == nil {
|
|
return nil, args.Error(1)
|
|
}
|
|
return args.Get(0).([]uint32), args.Error(1)
|
|
}
|
|
|
|
// QueueSize mocks the QueueSize method
|
|
func (m *MockRedisClient) QueueSize(circleID, topic string) (int64, error) {
|
|
args := m.Called(circleID, topic)
|
|
// Ensure Get(0) is not nil before type assertion if it can be nil in some error cases
|
|
if args.Get(0) == nil && args.Error(1) != nil { // If error is set, result might be nil
|
|
return 0, args.Error(1)
|
|
}
|
|
return args.Get(0).(int64), args.Error(1)
|
|
}
|
|
|
|
// QueueEmpty mocks the QueueEmpty method
|
|
func (m *MockRedisClient) QueueEmpty(circleID, topic string) error {
|
|
args := m.Called(circleID, topic)
|
|
return args.Error(0)
|
|
}
|
|
|
|
// setupTest initializes a test environment with a mock client
|
|
func setupTest() (*JobHandler, *MockRedisClient, *fiber.App) {
|
|
mockClient := new(MockRedisClient)
|
|
handler := &JobHandler{
|
|
client: mockClient, // Assign the mock that implements RedisClientInterface
|
|
}
|
|
|
|
app := fiber.New()
|
|
|
|
// Register routes (ensure these match the actual routes in job_handlers.go)
|
|
apiJobs := app.Group("/api/jobs") // Assuming routes are under /api/jobs
|
|
apiJobs.Post("/submit", handler.submitJob)
|
|
apiJobs.Get("/get/:id", handler.getJob) // :id as per job_handlers.go
|
|
apiJobs.Delete("/delete/:id", handler.deleteJob) // :id as per job_handlers.go
|
|
apiJobs.Get("/list", handler.listJobs)
|
|
apiJobs.Get("/queue/size", handler.queueSize)
|
|
apiJobs.Post("/queue/empty", handler.queueEmpty)
|
|
apiJobs.Get("/queue/get", handler.queueGet)
|
|
apiJobs.Post("/create", handler.createJob)
|
|
|
|
// If admin routes are also tested, they need to be registered here too
|
|
// adminJobs := app.Group("/admin/jobs")
|
|
// jobRoutes(adminJobs) // if using the same handler instance
|
|
|
|
return handler, mockClient, app
|
|
}
|
|
|
|
// createTestRequest creates a test request with the given method, path, and body
|
|
func createTestRequest(method, path string, body io.Reader) (*http.Request, error) {
|
|
req := httptest.NewRequest(method, path, body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req, nil
|
|
}
|
|
|
|
// TestQueueEmpty tests the queueEmpty handler
|
|
func TestQueueEmpty(t *testing.T) {
|
|
// Test cases
|
|
tests := []struct {
|
|
name string
|
|
circleID string
|
|
topic string
|
|
emptyError error
|
|
expectedStatus int
|
|
expectedBody string
|
|
}{
|
|
{
|
|
name: "Success",
|
|
circleID: "test-circle",
|
|
topic: "test-topic",
|
|
emptyError: nil,
|
|
expectedStatus: fiber.StatusOK,
|
|
expectedBody: `{"status":"success","message":"Queue for circle test-circle and topic test-topic emptied successfully"}`,
|
|
},
|
|
// Removed "Connection Error" test case as Connect is no longer directly called per op
|
|
{
|
|
name: "Empty Error",
|
|
circleID: "test-circle",
|
|
topic: "test-topic",
|
|
emptyError: errors.New("empty error"),
|
|
expectedStatus: fiber.StatusInternalServerError,
|
|
expectedBody: `{"error":"Failed to empty queue: empty error"}`,
|
|
},
|
|
{
|
|
name: "Empty Circle ID",
|
|
circleID: "",
|
|
topic: "test-topic",
|
|
emptyError: nil,
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Circle ID is required"}`,
|
|
},
|
|
{
|
|
name: "Empty Topic",
|
|
circleID: "test-circle",
|
|
topic: "",
|
|
emptyError: nil,
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Topic is required"}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create a new mock client for each test and setup app
|
|
_, mockClient, app := setupTest() // Use setupTest to get handler with mock
|
|
|
|
// Setup mock expectations
|
|
if tc.circleID != "" && tc.topic != "" { // Only expect call if params are valid
|
|
mockClient.On("QueueEmpty", tc.circleID, tc.topic).Return(tc.emptyError)
|
|
}
|
|
|
|
// Create request body
|
|
reqBody := map[string]string{
|
|
"circleid": tc.circleID,
|
|
"topic": tc.topic,
|
|
}
|
|
reqBodyBytes, err := json.Marshal(reqBody)
|
|
assert.NoError(t, err)
|
|
|
|
// Create test request
|
|
req, err := createTestRequest(http.MethodPost, "/api/jobs/queue/empty", bytes.NewReader(reqBodyBytes))
|
|
assert.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Perform the request
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
|
|
// Check status code
|
|
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
|
|
|
|
// Check response body
|
|
body, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
assert.JSONEq(t, tc.expectedBody, string(body))
|
|
|
|
// Verify that all expectations were met
|
|
mockClient.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQueueGet tests the queueGet handler
|
|
func TestQueueGet(t *testing.T) {
|
|
// Create a test job
|
|
testJob := herojobs.NewJob()
|
|
testJob.JobID = 10 // This will be a number in JSON
|
|
testJob.CircleID = "test-circle"
|
|
testJob.Topic = "test-topic"
|
|
testJob.Params = "some script"
|
|
testJob.ParamsType = herojobs.ParamsTypeHeroScript
|
|
testJob.Status = herojobs.JobStatusNew
|
|
|
|
// Test cases
|
|
tests := []struct {
|
|
name string
|
|
circleID string
|
|
topic string
|
|
listJobsError error
|
|
listJobsResp []uint32
|
|
getJobError error
|
|
getJobResp *herojobs.Job
|
|
expectedStatus int
|
|
expectedBody string // This will need to be updated to match the actual job structure
|
|
}{
|
|
{
|
|
name: "Success",
|
|
circleID: "test-circle",
|
|
topic: "test-topic",
|
|
listJobsError: nil,
|
|
listJobsResp: []uint32{10},
|
|
getJobError: nil,
|
|
getJobResp: testJob,
|
|
expectedStatus: fiber.StatusOK,
|
|
expectedBody: `{"jobid":10,"circleid":"test-circle","topic":"test-topic","params":"some script","paramstype":"HeroScript","status":"new","sessionkey":"","result":"","error":"","timeout":60,"log":false,"timescheduled":0,"timestart":0,"timeend":0}`,
|
|
},
|
|
// Removed "Connection Error"
|
|
{
|
|
name: "ListJobs Error",
|
|
circleID: "test-circle",
|
|
topic: "test-topic",
|
|
listJobsError: errors.New("list error"),
|
|
listJobsResp: nil,
|
|
getJobError: nil, // Not reached
|
|
getJobResp: nil, // Not reached
|
|
expectedStatus: fiber.StatusInternalServerError,
|
|
expectedBody: `{"error":"Failed to list jobs in queue: list error"}`,
|
|
},
|
|
{
|
|
name: "GetJob Error after ListJobs success",
|
|
circleID: "test-circle",
|
|
topic: "test-topic",
|
|
listJobsError: nil,
|
|
listJobsResp: []uint32{10},
|
|
getJobError: errors.New("get error"),
|
|
getJobResp: nil,
|
|
expectedStatus: fiber.StatusInternalServerError, // Or based on how GetJob error is handled (e.g. fallback to OurDB)
|
|
// The error message might be more complex if OurDB load is also attempted and fails
|
|
expectedBody: `{"error":"Failed to get job 10 from queue (Redis err: get error / OurDB err: record not found)"}`, // Adjusted expected error
|
|
},
|
|
{
|
|
name: "Queue Empty (ListJobs returns empty)",
|
|
circleID: "test-circle",
|
|
topic: "test-topic",
|
|
listJobsError: nil,
|
|
listJobsResp: []uint32{}, // Empty list
|
|
getJobError: nil,
|
|
getJobResp: nil,
|
|
expectedStatus: fiber.StatusNotFound,
|
|
expectedBody: `{"error":"Queue is empty or no jobs found"}`,
|
|
},
|
|
{
|
|
name: "Empty Circle ID",
|
|
circleID: "",
|
|
topic: "test-topic",
|
|
listJobsError: nil,
|
|
listJobsResp: nil,
|
|
getJobError: nil,
|
|
getJobResp: nil,
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Circle ID is required"}`,
|
|
},
|
|
{
|
|
name: "Empty Topic",
|
|
circleID: "test-circle",
|
|
topic: "",
|
|
listJobsError: nil,
|
|
listJobsResp: nil,
|
|
getJobError: nil,
|
|
getJobResp: nil,
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Topic is required"}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Create a new mock client for each test and setup app
|
|
_, mockClient, app := setupTest()
|
|
|
|
// Setup mock expectations
|
|
if tc.circleID != "" && tc.topic != "" {
|
|
mockClient.On("ListJobs", tc.circleID, tc.topic).Return(tc.listJobsResp, tc.listJobsError)
|
|
if tc.listJobsError == nil && len(tc.listJobsResp) > 0 {
|
|
// Expect GetJob to be called with the first ID from listJobsResp
|
|
// The handler passes uint32 to client.GetJob, which matches interface{}
|
|
mockClient.On("GetJob", tc.listJobsResp[0]).Return(tc.getJobResp, tc.getJobError).Maybe()
|
|
// If GetJob from Redis fails, a Load from OurDB is attempted.
|
|
// We are not mocking job.Load() here as it's on the job object.
|
|
// The error message in the test case reflects this potential dual failure.
|
|
}
|
|
}
|
|
|
|
// Create test request
|
|
path := fmt.Sprintf("/api/jobs/queue/get?circleid=%s&topic=%s", tc.circleID, tc.topic)
|
|
req, err := createTestRequest(http.MethodGet, path, nil)
|
|
assert.NoError(t, err)
|
|
|
|
// Perform the request
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
|
|
// Check status code
|
|
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
|
|
|
|
// Check response body
|
|
body, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
assert.JSONEq(t, tc.expectedBody, string(body))
|
|
|
|
// Verify that all expectations were met
|
|
mockClient.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateJob tests the createJob handler
|
|
func TestCreateJob(t *testing.T) {
|
|
// Test cases
|
|
createdJob := herojobs.NewJob()
|
|
createdJob.JobID = 10 // Assuming Save will populate this; for mock, we set it
|
|
createdJob.CircleID = "test-circle"
|
|
createdJob.Topic = "test-topic"
|
|
createdJob.SessionKey = "test-key"
|
|
createdJob.Params = "test-params"
|
|
createdJob.ParamsType = herojobs.ParamsTypeHeroScript // Match "HeroScript" string
|
|
createdJob.Status = herojobs.JobStatusNew // Default status after NewJob and Save
|
|
|
|
tests := []struct {
|
|
name string
|
|
reqBody map[string]interface{} // Use map for flexibility
|
|
storeError error
|
|
enqueueError error
|
|
expectedStatus int
|
|
expectedBody string // Will be the createdJob marshaled
|
|
}{
|
|
{
|
|
name: "Success",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "test-circle",
|
|
"topic": "test-topic",
|
|
"sessionkey": "test-key",
|
|
"params": "test-params",
|
|
"paramstype": "HeroScript",
|
|
"timeout": 30,
|
|
"log": true,
|
|
},
|
|
storeError: nil,
|
|
enqueueError: nil,
|
|
expectedStatus: fiber.StatusOK,
|
|
// Expected body should match the 'createdJob' structure after Save, Store, Enqueue
|
|
// JobID is assigned by Save(), which we are not mocking here.
|
|
// The handler returns the job object.
|
|
// For the test, we assume Save() works and populates JobID if it were a real DB.
|
|
// The mock will return the job passed to it.
|
|
expectedBody: `{"jobid":0,"circleid":"test-circle","topic":"test-topic","params":"test-params","paramstype":"HeroScript","status":"new","sessionkey":"test-key","result":"","error":"","timeout":30,"log":true,"timescheduled":0,"timestart":0,"timeend":0}`,
|
|
},
|
|
// Removed "Connection Error"
|
|
{
|
|
name: "StoreJob Error",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "test-circle", "topic": "test-topic", "params": "p", "paramstype": "HeroScript",
|
|
},
|
|
storeError: errors.New("store error"),
|
|
enqueueError: nil,
|
|
expectedStatus: fiber.StatusInternalServerError,
|
|
expectedBody: `{"error":"Failed to store new job in Redis: store error"}`,
|
|
},
|
|
{
|
|
name: "EnqueueJob Error",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "test-circle", "topic": "test-topic", "params": "p", "paramstype": "HeroScript",
|
|
},
|
|
storeError: nil,
|
|
enqueueError: errors.New("enqueue error"),
|
|
expectedStatus: fiber.StatusInternalServerError,
|
|
expectedBody: `{"error":"Failed to enqueue new job in Redis: enqueue error"}`,
|
|
},
|
|
{
|
|
name: "Empty Circle ID",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "", "topic": "test-topic", "params": "p", "paramstype": "HeroScript",
|
|
},
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Circle ID is required"}`,
|
|
},
|
|
{
|
|
name: "Empty Topic",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "c", "topic": "", "params": "p", "paramstype": "HeroScript",
|
|
},
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Topic is required"}`,
|
|
},
|
|
{
|
|
name: "Empty Params",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "c", "topic": "t", "params": "", "paramstype": "HeroScript",
|
|
},
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Params are required"}`,
|
|
},
|
|
{
|
|
name: "Empty ParamsType",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "c", "topic": "t", "params": "p", "paramstype": "",
|
|
},
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"ParamsType is required"}`,
|
|
},
|
|
{
|
|
name: "Invalid ParamsType",
|
|
reqBody: map[string]interface{}{
|
|
"circleid": "c", "topic": "t", "params": "p", "paramstype": "InvalidType",
|
|
},
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Invalid ParamsType: InvalidType"}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, mockClient, app := setupTest()
|
|
|
|
// Setup mock expectations
|
|
// job.Save() is called before client interactions. We assume it succeeds for these tests.
|
|
// The mock will be called with a job object. We use mock.AnythingOfType for the job
|
|
// because the JobID might be populated by Save() in a real scenario, making exact match hard.
|
|
if tc.reqBody["circleid"] != "" && tc.reqBody["topic"] != "" &&
|
|
tc.reqBody["params"] != "" && tc.reqBody["paramstype"] != "" &&
|
|
herojobs.ParamsType(tc.reqBody["paramstype"].(string)) != "" { // Basic validation check
|
|
|
|
// We expect StoreJob to be called with a *herojobs.Job.
|
|
// The actual JobID is set by job.Save() which is not mocked here.
|
|
// So we use mock.AnythingOfType to match the argument.
|
|
mockClient.On("StoreJob", mock.AnythingOfType("*herojobs.Job")).Return(tc.storeError).Once().Maybe()
|
|
|
|
if tc.storeError == nil {
|
|
mockClient.On("EnqueueJob", mock.AnythingOfType("*herojobs.Job")).Return(tc.enqueueError).Once().Maybe()
|
|
}
|
|
}
|
|
|
|
reqBodyBytes, err := json.Marshal(tc.reqBody)
|
|
assert.NoError(t, err)
|
|
|
|
req, err := createTestRequest(http.MethodPost, "/api/jobs/create", bytes.NewReader(reqBodyBytes)) // Use /api/jobs/create
|
|
assert.NoError(t, err)
|
|
// Content-Type is set by createTestRequest
|
|
|
|
// Perform the request
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
|
|
// Check status code
|
|
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
|
|
|
|
// Check response body
|
|
body, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
assert.JSONEq(t, tc.expectedBody, string(body))
|
|
|
|
// Verify that all expectations were met
|
|
mockClient.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSubmitJob tests the submitJob handler
|
|
func TestSubmitJob(t *testing.T) {
|
|
// Test cases
|
|
submittedJob := herojobs.NewJob()
|
|
submittedJob.JobID = 10 // Assume Save populates this
|
|
submittedJob.CircleID = "test-circle"
|
|
submittedJob.Topic = "test-topic"
|
|
submittedJob.Params = "submitted params"
|
|
submittedJob.ParamsType = herojobs.ParamsTypeHeroScript
|
|
submittedJob.Status = herojobs.JobStatusNew
|
|
|
|
tests := []struct {
|
|
name string
|
|
jobToSubmit *herojobs.Job // This is the job in the request body
|
|
storeError error
|
|
enqueueError error
|
|
expectedStatus int
|
|
expectedBody string // Will be the jobToSubmit marshaled (after potential Save)
|
|
}{
|
|
{
|
|
name: "Success",
|
|
jobToSubmit: submittedJob,
|
|
storeError: nil,
|
|
enqueueError: nil,
|
|
expectedStatus: fiber.StatusOK,
|
|
// The handler returns the job object from the request after Save(), Store(), Enqueue()
|
|
// For the mock, the JobID from jobToSubmit will be used.
|
|
expectedBody: `{"jobid":10,"circleid":"test-circle","topic":"test-topic","params":"submitted params","paramstype":"HeroScript","status":"new","sessionkey":"","result":"","error":"","timeout":60,"log":false,"timescheduled":0,"timestart":0,"timeend":0}`,
|
|
},
|
|
// Removed "Connection Error"
|
|
{
|
|
name: "StoreJob Error",
|
|
jobToSubmit: submittedJob,
|
|
storeError: errors.New("store error"),
|
|
enqueueError: nil,
|
|
expectedStatus: fiber.StatusInternalServerError,
|
|
expectedBody: `{"error":"Failed to store job in Redis: store error"}`,
|
|
},
|
|
{
|
|
name: "EnqueueJob Error",
|
|
jobToSubmit: submittedJob,
|
|
storeError: nil,
|
|
enqueueError: errors.New("enqueue error"),
|
|
expectedStatus: fiber.StatusInternalServerError,
|
|
expectedBody: `{"error":"Failed to enqueue job: enqueue error"}`,
|
|
},
|
|
{
|
|
name: "Empty Job in request (parsing error)",
|
|
jobToSubmit: nil, // Simulates empty or malformed request body
|
|
expectedStatus: fiber.StatusBadRequest,
|
|
expectedBody: `{"error":"Failed to parse job data: unexpected end of JSON input"}`, // Or similar based on actual parsing
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, mockClient, app := setupTest()
|
|
|
|
// Setup mock expectations
|
|
// job.Save() is called before client interactions.
|
|
if tc.jobToSubmit != nil { // If job is parsable from request
|
|
// We expect StoreJob to be called with the job from the request.
|
|
// The JobID might be modified by Save() in a real scenario.
|
|
mockClient.On("StoreJob", tc.jobToSubmit).Return(tc.storeError).Once().Maybe()
|
|
if tc.storeError == nil {
|
|
mockClient.On("EnqueueJob", tc.jobToSubmit).Return(tc.enqueueError).Once().Maybe()
|
|
}
|
|
}
|
|
|
|
var reqBodyBytes []byte
|
|
var err error
|
|
if tc.jobToSubmit != nil {
|
|
reqBodyBytes, err = json.Marshal(tc.jobToSubmit)
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
req, err := createTestRequest(http.MethodPost, "/api/jobs/submit", bytes.NewReader(reqBodyBytes)) // Use /api/jobs/submit
|
|
assert.NoError(t, err)
|
|
// Content-Type is set by createTestRequest
|
|
|
|
// Perform the request
|
|
resp, err := app.Test(req)
|
|
assert.NoError(t, err)
|
|
|
|
// Check status code
|
|
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
|
|
|
|
// Check response body
|
|
body, err := io.ReadAll(resp.Body)
|
|
assert.NoError(t, err)
|
|
assert.JSONEq(t, tc.expectedBody, string(body))
|
|
|
|
// Verify that all expectations were met
|
|
mockClient.AssertExpectations(t)
|
|
})
|
|
}
|
|
}
|