diff --git a/client/client.go b/client/client.go index 2263024..fca676c 100644 --- a/client/client.go +++ b/client/client.go @@ -1,17 +1,22 @@ package client -import "git.andreafazzi.eu/andrea/testhub/models" +import "git.andreafazzi.eu/andrea/probo/models" type Response struct { Status string `json:"status"` Content interface{} `json:"content"` } -type QuizReadAllResponse struct { +type ReadAllQuizResponse struct { Status string `json:"status"` Content []*models.Quiz `json:"content"` } +type CreateQuizResponse struct { + Status string `json:"status"` + Content *models.Quiz `json:"content"` +} + type CreateQuestionRequest struct { Text string `json:"text"` } diff --git a/go.mod b/go.mod index 50eb730..f3c54e4 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ -module git.andreafazzi.eu/andrea/testhub +module git.andreafazzi.eu/andrea/probo go 1.17 +require github.com/sirupsen/logrus v1.8.1 + require ( - github.com/google/uuid v1.3.0 // indirect github.com/kr/pretty v0.2.1 // indirect github.com/kr/text v0.1.0 // indirect github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 // indirect - github.com/sirupsen/logrus v1.8.1 // indirect golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index fe2dcf6..b0788f1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,4 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/hasher/hasher.go b/hasher/hasher.go new file mode 100644 index 0000000..9dbaba3 --- /dev/null +++ b/hasher/hasher.go @@ -0,0 +1,26 @@ +package hasher + +import ( + "git.andreafazzi.eu/andrea/probo/client" +) + +type HashFunc func(string) string + +type Hasher interface { + // Hash returns a slice of hashes. The first one is an + // hash calculated from the question using + // QuestionHash. Following hashes are calculated from the + // answers using AnswerHash. + QuizHashes(quiz *client.CreateQuizRequest) []string + + // QuestionHash returns an hash calculated from a field of + // Question struct. + QuestionHash(question *client.CreateQuestionRequest) string + + // AnswerHash returns an hash calculated from a field of + // Answer struct. + AnswerHash(answer *client.CreateAnswerRequest) string + + // Calculate calculates a checksum from all the given hashes. + Calculate(hashes []string) string +} diff --git a/hasher/hasher_test.go b/hasher/hasher_test.go new file mode 100644 index 0000000..6662224 --- /dev/null +++ b/hasher/hasher_test.go @@ -0,0 +1,64 @@ +package hasher + +import ( + "testing" + + "git.andreafazzi.eu/andrea/probo/client" + "github.com/remogatto/prettytest" +) + +type testSuite struct { + prettytest.Suite +} + +func TestRunner(t *testing.T) { + prettytest.Run( + t, + new(testSuite), + ) +} + +func (t *testSuite) TestQuizHashes() { + h := NewDefaultHash(DefaultSHA256HashingFn) + + firstQuizRequest := &client.CreateQuizRequest{ + Question: &client.CreateQuestionRequest{ + Text: "Question 1"}, + Answers: []*client.CreateAnswerRequest{ + {Text: "Answer 2", Correct: false}, + {Text: "Answer 3", Correct: false}, + {Text: "Answer 1", Correct: true}, + }, + } + + secondQuizRequest := &client.CreateQuizRequest{ + Question: &client.CreateQuestionRequest{ + Text: "Question 1"}, + Answers: []*client.CreateAnswerRequest{ + {Text: "Answer 1", Correct: false}, + {Text: "Answer 2", Correct: false}, + {Text: "Answer 3", Correct: true}, + }, + } + + thirdQuizRequest := &client.CreateQuizRequest{ + Question: &client.CreateQuestionRequest{ + Text: "Question 2"}, + Answers: []*client.CreateAnswerRequest{ + {Text: "Answer 1", Correct: false}, + {Text: "Answer 2", Correct: false}, + {Text: "Answer 3", Correct: true}, + }, + } + + hashesFromFirstRequest := h.QuizHashes(firstQuizRequest) + hashesFromSecondRequest := h.QuizHashes(secondQuizRequest) + hashesFromThirdRequest := h.QuizHashes(thirdQuizRequest) + + t.Equal(5, len(hashesFromFirstRequest)) + + t.True(hashesFromFirstRequest[1] == hashesFromSecondRequest[2], "Answers' hashes should maintain original request's order") + t.True(hashesFromFirstRequest[4] == hashesFromSecondRequest[4], "Quiz hash should be the same because quizzes are duplicated") + t.True(hashesFromFirstRequest[1] != hashesFromThirdRequest[1], "Questions' hashes should not be the same because texts are different") + t.True(hashesFromFirstRequest[4] != hashesFromThirdRequest[4], "Quiz hash should not be the same because quizzes are not duplicated") +} diff --git a/hasher/sha256/sha256.go b/hasher/sha256/sha256.go new file mode 100644 index 0000000..8a9310f --- /dev/null +++ b/hasher/sha256/sha256.go @@ -0,0 +1,54 @@ +package sha256 + +import ( + "crypto/sha256" + "fmt" + "sort" + "strings" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher" +) + +var DefaultSHA256HashingFn = func(text string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(text))) +} + +type Default256Hasher struct { + hashFn hasher.HashFunc +} + +func NewDefault256Hasher(hashFn hasher.HashFunc) *Default256Hasher { + return &Default256Hasher{hashFn} +} + +func (h *Default256Hasher) QuizHashes(quiz *client.CreateQuizRequest) []string { + result := make([]string, 0) + + result = append(result, h.QuestionHash(quiz.Question)) + + for _, a := range quiz.Answers { + result = append(result, h.AnswerHash(a)) + } + + result = append(result, h.Calculate(result)) + + return result +} + +func (h *Default256Hasher) QuestionHash(question *client.CreateQuestionRequest) string { + return h.hashFn(question.Text) +} + +func (h *Default256Hasher) AnswerHash(answer *client.CreateAnswerRequest) string { + return h.hashFn(answer.Text) +} + +func (h *Default256Hasher) Calculate(hashes []string) string { + orderedHashes := make([]string, len(hashes)) + + copy(orderedHashes, hashes) + sort.Strings(orderedHashes) + + return h.hashFn(strings.Join(orderedHashes, "")) +} diff --git a/main.go b/main.go index e92f090..b5822dc 100644 --- a/main.go +++ b/main.go @@ -4,8 +4,9 @@ import ( "log" "net/http" - "git.andreafazzi.eu/andrea/testhub/logger" - "git.andreafazzi.eu/andrea/testhub/store" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" + "git.andreafazzi.eu/andrea/probo/logger" + "git.andreafazzi.eu/andrea/probo/store/memory" "github.com/sirupsen/logrus" ) @@ -14,10 +15,14 @@ const port = "3000" func main() { logger.SetLevel(logger.DebugLevel) - server := NewQuizHubCollectorServer(store.NewMemoryQuizHubCollectorStore()) + server := NewQuizHubCollectorServer( + memory.NewMemoryQuizHubCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ), + ) - addr := "localhost:" + port - logrus.WithField("addr", addr).Info("TestHub Collector server is listening.") + addr := "http://localhost:" + port + logrus.WithField("address", addr).Info("Probo Collector is up&running...") log.Fatal(http.ListenAndServe(":"+port, server)) } diff --git a/models/test.go b/models/test.go index 077374f..f811006 100644 --- a/models/test.go +++ b/models/test.go @@ -1,7 +1,7 @@ package models type Quiz struct { - ID uint + ID string Question *Question Answers []*Answer diff --git a/server.go b/server.go index ee4b83e..c68645e 100644 --- a/server.go +++ b/server.go @@ -5,10 +5,10 @@ import ( "io/ioutil" "net/http" - "git.andreafazzi.eu/andrea/testhub/client" - "git.andreafazzi.eu/andrea/testhub/logger" - "git.andreafazzi.eu/andrea/testhub/models" - "git.andreafazzi.eu/andrea/testhub/store" + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/logger" + "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" ) const jsonContentType = "application/json" @@ -38,8 +38,17 @@ func (ps *QuizHubCollectorServer) testHandler(w http.ResponseWriter, r *http.Req json.NewEncoder(w).Encode(ps.readAllQuizzes(w, r)) case http.MethodPost: + response := new(client.Response) + + quiz, err := ps.createQuiz(w, r) + if err != nil { + response = &client.Response{Status: "error", Content: err.Error()} + } + + response = &client.Response{Status: "success", Content: quiz} + w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(ps.createQuiz(w, r)) + json.NewEncoder(w).Encode(response) } } @@ -51,18 +60,23 @@ func (ps *QuizHubCollectorServer) readAllQuizzes(w http.ResponseWriter, r *http. return &client.Response{Status: "success", Content: tests} } -func (ps *QuizHubCollectorServer) createQuiz(w http.ResponseWriter, r *http.Request) *models.Quiz { +func (ps *QuizHubCollectorServer) createQuiz(w http.ResponseWriter, r *http.Request) (*models.Quiz, error) { body, err := ioutil.ReadAll(r.Body) if err != nil { - panic(err) + return nil, err } + createQuizReq := new(client.CreateQuizRequest) + err = json.Unmarshal(body, &createQuizReq) if err != nil { - panic(err) + return nil, err } - createdQuiz := ps.store.CreateQuiz(createQuizReq) + createdQuiz, err := ps.store.CreateQuiz(createQuizReq) + if err != nil { + return nil, err + } - return createdQuiz + return createdQuiz, nil } diff --git a/server_integration_test.go b/server_integration_test.go index 102d92e..df1d00e 100644 --- a/server_integration_test.go +++ b/server_integration_test.go @@ -4,11 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" "strings" - "git.andreafazzi.eu/andrea/testhub/client" - "git.andreafazzi.eu/andrea/testhub/models" - "git.andreafazzi.eu/andrea/testhub/store" + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" + "git.andreafazzi.eu/andrea/probo/store/memory" "github.com/remogatto/prettytest" ) @@ -17,7 +18,11 @@ type integrationTestSuite struct { } func (t *integrationTestSuite) TestQuizCreateAndReadAll() { - server := NewQuizHubCollectorServer(store.NewMemoryQuizHubCollectorStore()) + server := NewQuizHubCollectorServer( + memory.NewMemoryQuizHubCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ), + ) // POST a new question using a JSON payload @@ -32,38 +37,80 @@ func (t *integrationTestSuite) TestQuizCreateAndReadAll() { ] } ` + createQuizResponse, err := t.createQuiz(server, payload) + t.True(err == nil) + + t.Equal("success", createQuizResponse.Status) + t.Equal("Question 1", createQuizResponse.Content.Question.Text) + t.Equal("Text of the answer 1", createQuizResponse.Content.Answers[0].Text) + t.Equal("Text of the answer 1", createQuizResponse.Content.Correct.Text) + + t.True(createQuizResponse.Content.ID != "", "Test ID should not be empty") + t.True(createQuizResponse.Content.Question.ID != "", "Question ID should not be empty") + t.True(createQuizResponse.Content.Answers[0].ID != "", "Answer ID should not be empty") + + readAllQuizResponse, err := t.readAllQuiz(server) + t.True(err == nil) + + t.True(len(readAllQuizResponse.Content) == 1, "Length of returned tests should be 1") + t.Equal("Question 1", readAllQuizResponse.Content[0].Question.Text) +} + +func (t *integrationTestSuite) TestCatchDuplicateQuiz() { + server := NewQuizHubCollectorServer( + memory.NewMemoryQuizHubCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ), + ) + + // POST a new question using a JSON payload + + payload := ` +{ + "question": {"text": "Question 1"}, + "answers": [ + {"text": "Text of the answer 1", "correct": true}, + {"text": "Text of the answer 2"}, + {"text": "Text of the answer 3"}, + {"text": "Text of the answer 4"} + ] +} +` + quiz1, err := t.createQuiz(server, payload) + t.True(err == nil) + quiz2, err := t.createQuiz(server, payload) + t.True(err == nil, "Quiz are duplicated, but the API should not return an error") + t.True(reflect.DeepEqual(quiz1, quiz2)) +} + +func (t *integrationTestSuite) createQuiz(server *QuizHubCollectorServer, payload string) (*client.CreateQuizResponse, error) { request, _ := http.NewRequest(http.MethodPost, "/quizzes", strings.NewReader(payload)) response := httptest.NewRecorder() server.ServeHTTP(response, request) - returnedTest := new(models.Quiz) + decodedResponse := new(client.CreateQuizResponse) - err := json.Unmarshal(response.Body.Bytes(), returnedTest) + err := json.Unmarshal(response.Body.Bytes(), decodedResponse) if err != nil { - t.True(err == nil, err.Error()) + return nil, err } - t.Equal("Question 1", returnedTest.Question.Text) - t.Equal("Text of the answer 1", returnedTest.Answers[0].Text) - t.Equal("Text of the answer 1", returnedTest.Correct.Text) + return decodedResponse, err +} - t.True(returnedTest.ID != 0, "Test ID should not be 0") - t.True(returnedTest.Question.ID != "", "Question ID should not be empty") - t.True(returnedTest.Answers[0].ID != "", "Answer ID should not be empty") - - request, _ = http.NewRequest(http.MethodGet, "/quizzes", nil) - response = httptest.NewRecorder() +func (t *integrationTestSuite) readAllQuiz(server *QuizHubCollectorServer) (*client.ReadAllQuizResponse, error) { + request, _ := http.NewRequest(http.MethodGet, "/quizzes", nil) + response := httptest.NewRecorder() server.ServeHTTP(response, request) - decodedResponse := new(client.QuizReadAllResponse) + decodedResponse := new(client.ReadAllQuizResponse) - err = json.Unmarshal(response.Body.Bytes(), &decodedResponse) + err := json.Unmarshal(response.Body.Bytes(), decodedResponse) if err != nil { - t.True(err == nil, err.Error()) + return nil, err } - t.True(len(decodedResponse.Content) == 1, "Length of returned tests should be 1") - t.Equal("Question 1", decodedResponse.Content[0].Question.Text) + return decodedResponse, err } diff --git a/server_test.go b/server_test.go index d2d0a39..ac838de 100644 --- a/server_test.go +++ b/server_test.go @@ -9,9 +9,9 @@ import ( "reflect" "testing" - "git.andreafazzi.eu/andrea/testhub/client" - "git.andreafazzi.eu/andrea/testhub/logger" - "git.andreafazzi.eu/andrea/testhub/models" + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/logger" + "git.andreafazzi.eu/andrea/probo/models" "github.com/remogatto/prettytest" ) @@ -23,8 +23,8 @@ type StubTestHubCollectorStore struct { tests []*models.Quiz } -func (store *StubTestHubCollectorStore) CreateQuiz(test *client.CreateQuizRequest) *models.Quiz { - return nil +func (store *StubTestHubCollectorStore) CreateQuiz(test *client.CreateQuizRequest) (*models.Quiz, error) { + return nil, nil } func (store *StubTestHubCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { @@ -44,7 +44,7 @@ func (t *testSuite) BeforeAll() { } func (t *testSuite) TestGETQuestions() { - expectedResult := &client.QuizReadAllResponse{ + expectedResult := &client.ReadAllQuizResponse{ Status: "success", Content: []*models.Quiz{ { @@ -76,7 +76,7 @@ func (t *testSuite) TestGETQuestions() { t.Equal(http.StatusOK, response.Code) } -func getResponse(body io.Reader) (response *client.QuizReadAllResponse) { +func getResponse(body io.Reader) (response *client.ReadAllQuizResponse) { err := json.NewDecoder(body).Decode(&response) if err != nil { panic(fmt.Errorf("Unable to parse response from server %q into slice of Test, '%v'", body, err)) @@ -85,6 +85,6 @@ func getResponse(body io.Reader) (response *client.QuizReadAllResponse) { return } -func testsAreEqual(got, want *client.QuizReadAllResponse) bool { +func testsAreEqual(got, want *client.ReadAllQuizResponse) bool { return reflect.DeepEqual(got, want) } diff --git a/store/memory.go b/store/memory.go deleted file mode 100644 index 5509898..0000000 --- a/store/memory.go +++ /dev/null @@ -1,81 +0,0 @@ -package store - -import ( - "crypto/sha256" - "fmt" - "sync" - - "git.andreafazzi.eu/andrea/testhub/client" - "git.andreafazzi.eu/andrea/testhub/models" -) - -type MemoryQuizHubCollectorStore struct { - questions map[string]*models.Question - answers map[string]*models.Answer - tests map[uint]*models.Quiz - lastQuizID uint - - questionAnswer map[string][]string - testQuestion map[string]uint - - // A mutex is used to synchronize read/write access to the map - lock sync.RWMutex -} - -func NewMemoryQuizHubCollectorStore() *MemoryQuizHubCollectorStore { - s := new(MemoryQuizHubCollectorStore) - - s.questions = make(map[string]*models.Question) - s.answers = make(map[string]*models.Answer) - s.tests = make(map[uint]*models.Quiz) - - return s -} - -func (s *MemoryQuizHubCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { - result := make([]*models.Quiz, 0) - for _, t := range s.tests { - result = append(result, t) - } - return result, nil -} - -func (s *MemoryQuizHubCollectorStore) CreateQuiz(r *client.CreateQuizRequest) *models.Quiz { - questionID := hash(r.Question.Text) - test := new(models.Quiz) - q, ok := s.questions[questionID] - if !ok { // if the question is not in the store add it - s.questions[questionID] = &models.Question{ - ID: questionID, - Text: r.Question.Text, - } - q = s.questions[questionID] - } - // Populate Question field - test.Question = q - for _, answer := range r.Answers { - // Calculate the hash from text - answerID := hash(answer.Text) - _, ok := s.answers[answerID] - if !ok { // if the answer is not in the store add it - s.answers[answerID] = &models.Answer{ - ID: answerID, - Text: answer.Text, - } - } - if answer.Correct { - test.Correct = s.answers[answerID] - } - test.Answers = append(test.Answers, s.answers[answerID]) - } - - s.lastQuizID++ - test.ID = s.lastQuizID - s.tests[s.lastQuizID] = test - - return test -} - -func hash(text string) string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(text))) -} diff --git a/store/memory/memory.go b/store/memory/memory.go new file mode 100644 index 0000000..166ff5d --- /dev/null +++ b/store/memory/memory.go @@ -0,0 +1,147 @@ +package memory + +import ( + "sync" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher" + "git.andreafazzi.eu/andrea/probo/models" +) + +type MemoryQuizHubCollectorStore struct { + questions map[string]*models.Question + answers map[string]*models.Answer + quizzes map[string]*models.Quiz + + questionAnswer map[string][]string + testQuestion map[string]uint + + // A mutex is used to synchronize read/write access to the map + lock sync.RWMutex + + hasher hasher.Hasher +} + +func NewMemoryQuizHubCollectorStore(hasher hasher.Hasher) *MemoryQuizHubCollectorStore { + s := new(MemoryQuizHubCollectorStore) + + s.hasher = hasher + s.questions = make(map[string]*models.Question) + s.answers = make(map[string]*models.Answer) + s.quizzes = make(map[string]*models.Quiz) + + return s +} + +func (s *MemoryQuizHubCollectorStore) readQuiz(id string) *models.Quiz { + s.lock.RLock() + defer s.lock.RUnlock() + + quiz, ok := s.quizzes[id] + if ok { + return quiz + } + + return nil +} + +func (s *MemoryQuizHubCollectorStore) readQuestion(id string) *models.Question { + s.lock.RLock() + defer s.lock.RUnlock() + + question, ok := s.questions[id] + if ok { + return question + } + + return nil +} + +func (s *MemoryQuizHubCollectorStore) readAnswer(id string) *models.Answer { + s.lock.RLock() + defer s.lock.RUnlock() + + answer, ok := s.answers[id] + if ok { + return answer + } + + return nil +} + +func (s *MemoryQuizHubCollectorStore) createQuiz(id string, quiz *models.Quiz) *models.Quiz { + s.lock.Lock() + defer s.lock.Unlock() + + quiz.ID = id + s.quizzes[id] = quiz + + return quiz +} + +func (s *MemoryQuizHubCollectorStore) createQuestion(id string, question *models.Question) *models.Question { + s.lock.Lock() + defer s.lock.Unlock() + + s.questions[id] = question + + return question +} + +func (s *MemoryQuizHubCollectorStore) createAnswer(id string, answer *models.Answer) *models.Answer { + s.lock.Lock() + defer s.lock.Unlock() + + s.answers[id] = answer + + return answer +} + +func (s *MemoryQuizHubCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { + result := make([]*models.Quiz, 0) + for id, _ := range s.quizzes { + result = append(result, s.readQuiz(id)) + } + return result, nil +} + +func (s *MemoryQuizHubCollectorStore) CreateQuiz(r *client.CreateQuizRequest) (*models.Quiz, error) { + hashes := s.hasher.QuizHashes(r) + + quizID := hashes[len(hashes)-1] + + quiz := s.readQuiz(quizID) + if quiz != nil { // Quiz is already present in the store + return quiz, nil + } + + quiz = new(models.Quiz) + + questionID := hashes[0] + q := s.readQuestion(questionID) + if q == nil { // if the question is not in the store add it + q = s.createQuestion(questionID, &models.Question{ + ID: questionID, + Text: r.Question.Text, + }) + } + + // Populate Question field + quiz.Question = q + for i, answer := range r.Answers { + answerID := hashes[i+1] + a := s.readAnswer(answerID) + if a == nil { // if the answer is not in the store add it + a = s.createAnswer(answerID, &models.Answer{ + ID: answerID, + Text: answer.Text, + }) + if answer.Correct { + quiz.Correct = a // s.readAnswer(answerID) + } + } + quiz.Answers = append(quiz.Answers, a) + } + + return s.createQuiz(quizID, quiz), nil +} diff --git a/store/store.go b/store/store.go index dfdbd96..1fc4c72 100644 --- a/store/store.go +++ b/store/store.go @@ -1,11 +1,11 @@ package store import ( - "git.andreafazzi.eu/andrea/testhub/client" - "git.andreafazzi.eu/andrea/testhub/models" + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" ) type QuizHubCollectorStore interface { ReadAllQuizzes() ([]*models.Quiz, error) - CreateQuiz(r *client.CreateQuizRequest) *models.Quiz + CreateQuiz(r *client.CreateQuizRequest) (*models.Quiz, error) }