Pārlūkot izejas kodu

Last commit before switching to generics

andrea 6 mēneši atpakaļ
vecāks
revīzija
ce9dd7fd63

+ 4 - 4
client/client.go

@@ -22,10 +22,10 @@ type Collection struct {
 }
 
 type Participant struct {
-	Firstname string `json:"firstname"`
-	Lastname  string `json:"lastname"`
-	Class     string `json:"class"`
-	Token     uint   `json:"token"`
+	Firstname  string            `json:"firstname"`
+	Lastname   string            `json:"lastname"`
+	Token      uint              `json:"token"`
+	Attributes map[string]string `json:"attributes"`
 }
 
 type Exam struct {

+ 7 - 5
go.mod

@@ -1,18 +1,21 @@
 module git.andreafazzi.eu/andrea/probo
 
-go 1.17
+go 1.21
 
-require github.com/sirupsen/logrus v1.8.1
+require (
+	github.com/google/uuid v1.3.1
+	github.com/julienschmidt/httprouter v1.3.0
+	github.com/sirupsen/logrus v1.8.1
+	gorm.io/gorm v1.25.5
+)
 
 require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/glebarez/go-sqlite v1.21.2 // indirect
 	github.com/glebarez/sqlite v1.9.0 // indirect
 	github.com/go-yaml/yaml v2.1.0+incompatible // indirect
-	github.com/google/uuid v1.3.1 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
-	github.com/julienschmidt/httprouter v1.3.0 // indirect
 	github.com/kr/pretty v0.2.1 // indirect
 	github.com/kr/text v0.1.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
@@ -20,7 +23,6 @@ require (
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	golang.org/x/sys v0.13.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
-	gorm.io/gorm v1.25.5 // indirect
 	modernc.org/libc v1.24.1 // indirect
 	modernc.org/mathutil v1.6.0 // indirect
 	modernc.org/memory v1.7.2 // indirect

+ 1 - 0
hasher/sha256/sha256.go

@@ -52,3 +52,4 @@ func (h *Default256Hasher) Calculate(hashes []string) string {
 
 	return h.hashFn(strings.Join(orderedHashes, ""))
 }
+-

+ 6 - 0
models/group.go

@@ -0,0 +1,6 @@
+package models
+
+type Group struct {
+	Name         string
+	Participants []*Participant
+}

+ 4 - 0
models/participant.go

@@ -5,4 +5,8 @@ type Participant struct {
 
 	Firstname string
 	Lastname  string
+
+	Token uint
+
+	Attributes map[string]string
 }

+ 160 - 0
store/file/collection.go

@@ -0,0 +1,160 @@
+package file
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/models"
+)
+
+func (s *FileProboCollectorStore) GetCollectionsDir() string {
+	return s.collectionsDir
+}
+
+func (s *FileProboCollectorStore) GetCollectionPath(collection *models.Collection) (string, error) {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	path, ok := s.collectionsPaths[collection.ID]
+	if !ok {
+		return "", errors.New(fmt.Sprintf("Path not found for collection ID %v", collection.ID))
+	}
+	return path, nil
+}
+
+func (s *FileProboCollectorStore) SetCollectionPath(collection *models.Collection, path string) string {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.collectionsPaths[collection.ID] = path
+
+	return path
+}
+
+func (s *FileProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) {
+	return s.memoryStore.ReadAllCollections()
+}
+
+func (s *FileProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
+	collection, err := s.memoryStore.CreateCollection(r)
+	if err != nil {
+		return nil, err
+	}
+
+	err = s.createOrUpdateCollectionFile(collection)
+	if err != nil {
+		return nil, err
+	}
+
+	return s.memoryStore.ReadCollectionByID(collection.ID)
+}
+
+func (s *FileProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, error) {
+	collection, _, err := s.memoryStore.UpdateCollection(r, id)
+	if err != nil {
+		return nil, err
+	}
+
+	err = s.createOrUpdateCollectionFile(collection)
+	if err != nil {
+		return nil, err
+	}
+
+	return s.memoryStore.ReadCollectionByID(collection.ID)
+}
+
+func (s *FileProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) {
+	collection, err := s.memoryStore.DeleteCollection(&client.DeleteCollectionRequest{ID: r.ID})
+	if err != nil {
+		return nil, err
+	}
+
+	path, err := s.GetCollectionPath(collection)
+	if err != nil {
+		return nil, err
+	}
+
+	err = os.Remove(path)
+	if err != nil {
+		return nil, err
+	}
+
+	return collection, nil
+}
+
+func (s *FileProboCollectorStore) reindexCollections() error {
+	files, err := os.ReadDir(s.collectionsDir)
+	if err != nil {
+		return err
+	}
+
+	jsonFiles := make([]fs.DirEntry, 0)
+
+	for _, file := range files {
+		filename := file.Name()
+		if !file.IsDir() && strings.HasSuffix(filename, ".json") {
+			jsonFiles = append(jsonFiles, file)
+		}
+	}
+
+	for _, file := range jsonFiles {
+		filename := file.Name()
+		fullPath := filepath.Join(s.collectionsDir, filename)
+
+		content, err := os.ReadFile(fullPath)
+		if err != nil {
+			return err
+		}
+
+		var clientCollection *client.Collection
+
+		err = json.Unmarshal(content, &clientCollection)
+		if err != nil {
+			return err
+		}
+
+		collection, err := s.memoryStore.CreateCollection(&client.CreateUpdateCollectionRequest{
+			Collection: clientCollection,
+		})
+		if err != nil {
+			return err
+		}
+
+		s.SetCollectionPath(collection, fullPath)
+	}
+
+	return nil
+}
+
+func (s *FileProboCollectorStore) createOrUpdateCollectionFile(collection *models.Collection) error {
+	json, err := json.Marshal(collection)
+	if err != nil {
+		return err
+	}
+
+	fn, _ := s.GetCollectionPath(collection)
+	if fn == "" {
+		fn = filepath.Join(s.collectionsDir, fmt.Sprintf("collection_%v.%s", collection.ID, "json"))
+	}
+
+	file, err := os.Create(fn)
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	_, err = file.Write([]byte(json))
+	if err != nil {
+		return err
+	}
+
+	s.SetCollectionPath(collection, fn)
+
+	return nil
+}

+ 494 - 0
store/file/quiz.go

@@ -0,0 +1,494 @@
+package file
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/models"
+	"github.com/go-yaml/yaml"
+)
+
+func (s *FileProboCollectorStore) GetQuizzesDir() string {
+	return s.quizzesDir
+}
+
+func (s *FileProboCollectorStore) SetQuizPath(quiz *models.Quiz, path string) string {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.quizzesPaths[quiz.ID] = path
+
+	return path
+}
+
+func (s *FileProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) {
+	return s.memoryStore.ReadAllQuizzes()
+}
+
+func (s *FileProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
+	quiz, err := s.memoryStore.CreateQuiz(r)
+	if err != nil {
+		return nil, err
+	}
+
+	err = s.createOrUpdateMarkdownFile(quiz)
+	if err != nil {
+		return nil, err
+	}
+
+	err = s.Reindex()
+	if err != nil {
+		return nil, err
+	}
+
+	return s.memoryStore.ReadQuizByHash(quiz.Hash)
+}
+
+func (s *FileProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) {
+	quiz, updated, err := s.memoryStore.UpdateQuiz(r, id)
+	if err != nil {
+		return nil, err
+	}
+
+	if updated { // Update and re-index only if quiz hash is changed
+		err = s.createOrUpdateMarkdownFile(quiz)
+		if err != nil {
+			return nil, err
+		}
+
+		err = s.Reindex()
+		if err != nil {
+			return nil, err
+		}
+	}
+	return s.memoryStore.ReadQuizByHash(quiz.Hash)
+}
+
+func (s *FileProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) {
+	quiz, err := s.memoryStore.DeleteQuiz(&client.DeleteQuizRequest{ID: r.ID})
+	if err != nil {
+		return nil, err
+	}
+
+	path, err := s.GetQuizPath(quiz)
+	if err != nil {
+		return nil, err
+	}
+
+	err = os.Remove(path)
+	if err != nil {
+		return nil, err
+	}
+
+	err = s.Reindex()
+	if err != nil {
+		return nil, err
+	}
+
+	return quiz, nil
+}
+
+func (s *FileProboCollectorStore) GetQuizPath(quiz *models.Quiz) (string, error) {
+	if quiz == nil {
+		return "", errors.New("Quiz object passed as argument is nil!")
+	}
+
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	path, ok := s.quizzesPaths[quiz.ID]
+	if !ok {
+		return "", errors.New(fmt.Sprintf("Path not found for quiz ID %v", quiz.ID))
+	}
+	return path, nil
+}
+
+func MarkdownFromQuiz(quiz *models.Quiz) (string, error) {
+	if quiz.Question == nil {
+		return "", errors.New("Quiz should contain a question but it wasn't provided.")
+	}
+
+	if len(quiz.Answers) < 2 {
+		return "", errors.New("Quiz should contain at least 2 answers but none was provided.")
+	}
+
+	if quiz.Correct == nil {
+		return "", errors.New("Quiz should contain a correct answer but not was provided.")
+	}
+
+	correctAnswer := "* " + quiz.Correct.Text
+	var otherAnswers string
+
+	for _, answer := range quiz.Answers {
+		if quiz.Correct.ID != answer.ID {
+			otherAnswers += "* " + answer.Text + "\n"
+		}
+	}
+
+	markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers
+
+	return markdown, nil
+}
+
+func QuizFromMarkdown(markdown string) (*client.Quiz, *models.Meta, error) {
+	meta, remainingMarkdown, err := parseMetaHeaderFromMarkdown(markdown)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	lines := strings.Split(remainingMarkdown, "\n")
+
+	questionText := ""
+	answers := []*client.Answer{}
+
+	for _, line := range lines {
+		if strings.HasPrefix(line, "*") {
+			answerText := strings.TrimPrefix(line, "* ")
+			correct := len(answers) == 0
+			answer := &client.Answer{Text: answerText, Correct: correct}
+			answers = append(answers, answer)
+		} else {
+			if questionText != "" {
+				questionText += "\n"
+			}
+			questionText += line
+		}
+	}
+
+	questionText = strings.TrimRight(questionText, "\n")
+
+	if questionText == "" {
+		return nil, nil, fmt.Errorf("Question text should not be empty.")
+	}
+
+	if len(answers) < 2 {
+		return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
+	}
+
+	question := &client.Question{Text: questionText}
+	quiz := &client.Quiz{Question: question, Answers: answers}
+
+	return quiz, meta, nil
+}
+
+func (s *FileProboCollectorStore) ReadMetaHeaderFromFile(filename string) (*models.Meta, error) {
+	data, err := os.ReadFile(path.Join(s.quizzesDir, filename))
+	if err != nil {
+		return nil, err
+	}
+	meta, _, err := parseMetaHeaderFromMarkdown(string(data))
+	if err != nil {
+		return nil, err
+	}
+	return meta, nil
+}
+
+func (s *FileProboCollectorStore) WriteMetaHeaderToFile(filename string, meta *models.Meta) (*models.Meta, error) {
+	readMeta, err := s.ReadMetaHeaderFromFile(filename)
+	if err != nil {
+		return nil, err
+	}
+	if readMeta == nil {
+		_, err := writeMetaHeader(path.Join(s.quizzesDir, filename), meta)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return meta, nil
+}
+
+func (s *FileProboCollectorStore) reindexQuizzes() error {
+	files, err := os.ReadDir(s.quizzesDir)
+	if err != nil {
+		return err
+	}
+
+	markdownFiles := make([]fs.DirEntry, 0)
+
+	for _, file := range files {
+		filename := file.Name()
+		if !file.IsDir() && strings.HasSuffix(filename, ".md") {
+			markdownFiles = append(markdownFiles, file)
+		}
+	}
+
+	if len(markdownFiles) == 0 {
+		return fmt.Errorf("The directory is empty.")
+	}
+
+	for _, file := range markdownFiles {
+		filename := file.Name()
+		fullPath := filepath.Join(s.quizzesDir, filename)
+
+		content, err := os.ReadFile(fullPath)
+		if err != nil {
+			return err
+		}
+		quiz, meta, err := QuizFromMarkdown(string(content))
+		if err != nil {
+			return err
+		}
+		q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{
+			Quiz: quiz,
+			Meta: meta,
+		})
+		if err != nil {
+			return err
+		}
+
+		if meta == nil {
+			s.WriteMetaHeaderToFile(filename, &models.Meta{
+				ID:        q.ID,
+				CreatedAt: time.Now(),
+			})
+		}
+		s.SetQuizPath(q, fullPath)
+	}
+
+	return nil
+}
+
+func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) {
+	file, err := os.Open(path.Join(s.quizzesDir, filename))
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	var buffer bytes.Buffer
+
+	reader := bufio.NewReader(file)
+
+	var meta models.Meta
+	var line string
+	var sb strings.Builder
+	for {
+		line, err = reader.ReadString('\n')
+		if err != nil {
+			if err == io.EOF {
+				break
+			}
+			return nil, err
+		}
+
+		if strings.TrimSpace(line) == "---" {
+			break
+		}
+	}
+
+	for {
+		line, err = reader.ReadString('\n')
+		if err != nil {
+			if err == io.EOF {
+				break
+			}
+			return nil, err
+		}
+
+		if strings.TrimSpace(line) == "---" {
+			break
+		}
+
+		sb.WriteString(line)
+	}
+
+	err = yaml.Unmarshal([]byte(sb.String()), &meta)
+	if err != nil {
+		return nil, err
+	}
+
+	_, err = io.Copy(&buffer, reader)
+	if err != nil {
+		return nil, err
+	}
+
+	file, err = os.Create(path.Join(s.quizzesDir, filename))
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	_, err = io.Copy(file, &buffer)
+	if err != nil {
+		return nil, err
+	}
+
+	return &meta, nil
+}
+
+func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error {
+	markdown, err := MarkdownFromQuiz(quiz)
+	if err != nil {
+		return err
+	}
+
+	fn, _ := s.GetQuizPath(quiz)
+	if fn == "" {
+		fn = filepath.Join(s.quizzesDir, fmt.Sprintf("quiz_%v.%s", quiz.ID, "md"))
+	}
+
+	file, err := os.Create(fn)
+	if err != nil {
+		return err
+	}
+
+	defer file.Close()
+
+	markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta)
+	if err != nil {
+		return err
+	}
+
+	_, err = file.Write([]byte(markdownWithMetaHeader))
+	if err != nil {
+		return err
+	}
+
+	s.SetQuizPath(quiz, fn)
+
+	return nil
+}
+
+func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) {
+	var buffer bytes.Buffer
+
+	header, err := yaml.Marshal(meta)
+	if err != nil {
+		return "", err
+	}
+	_, err = buffer.WriteString("---\n" + string(header) + "---\n")
+	if err != nil {
+		return "", err
+	}
+
+	_, err = buffer.WriteString(content)
+	if err != nil {
+		return "", err
+	}
+
+	return buffer.String(), nil
+}
+
+func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error) {
+	reader := strings.NewReader(markdown)
+	var sb strings.Builder
+	var line string
+	var err error
+	for {
+		line, err = readLine(reader)
+		if err != nil {
+			if err == io.EOF {
+				break
+			}
+			return nil, "", err
+		}
+
+		if strings.TrimSpace(line) == "---" {
+			break
+		}
+	}
+
+	for {
+		line, err = readLine(reader)
+		if err != nil {
+			if err == io.EOF {
+				break
+			}
+			return nil, "", err
+		}
+
+		if strings.TrimSpace(line) == "---" {
+			break
+		}
+
+		sb.WriteString(line)
+	}
+
+	if sb.String() == "" {
+		return nil, markdown, nil
+	}
+
+	var meta models.Meta
+	err = yaml.Unmarshal([]byte(sb.String()), &meta)
+	if err != nil {
+		return nil, markdown, err
+	}
+
+	remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):]
+
+	return &meta, remainingMarkdown, nil
+}
+
+func readLine(reader *strings.Reader) (string, error) {
+	var sb strings.Builder
+	for {
+		r, _, err := reader.ReadRune()
+		if err != nil {
+			if err == io.EOF {
+				return sb.String(), io.EOF
+			}
+			return "", err
+		}
+
+		sb.WriteRune(r)
+		if r == '\n' {
+			break
+		}
+	}
+
+	return sb.String(), nil
+}
+
+func writeMetaHeader(filename string, meta *models.Meta) (*models.Meta, error) {
+	// Apri il file in lettura
+	file, err := os.Open(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	// Crea un buffer in memoria
+	var buffer bytes.Buffer
+
+	// Scrivi l'intestazione YAML nel buffer
+	header, err := yaml.Marshal(meta)
+	if err != nil {
+		return nil, err
+	}
+	_, err = buffer.WriteString("---\n" + string(header) + "---\n")
+	if err != nil {
+		return nil, err
+	}
+
+	// Copia il contenuto del file originale nel buffer
+	_, err = io.Copy(&buffer, file)
+	if err != nil {
+		return nil, err
+	}
+
+	// Riapri il file in scrittura
+	file, err = os.Create(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close()
+
+	// Scrivi il contenuto del buffer nel file
+	_, err = io.Copy(file, &buffer)
+	if err != nil {
+		return nil, err
+	}
+
+	return meta, nil
+}

+ 2 - 2
store/file/testdata/quizzes/quiz_5.md

@@ -1,6 +1,6 @@
 ---
-id: 9243c924-5a91-4fa1-9cf3-db9ce1e19ca4
-created_at: !!timestamp 2023-10-28T20:49:22.688075744+02:00
+id: b7ec3eb9-55e1-47c6-8652-3a27fe90bc0f
+created_at: !!timestamp 2023-10-31T10:02:02.869395215+01:00
 updated_at: !!timestamp 0001-01-01T00:00:00Z
 ---
 This quiz is initially without metadata.

+ 124 - 0
store/memory/collection.go

@@ -0,0 +1,124 @@
+package memory
+
+import (
+	"errors"
+	"fmt"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/models"
+	"github.com/google/uuid"
+)
+
+func (s *MemoryProboCollectorStore) getCollectionFromID(id string) *models.Collection {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	collection, ok := s.collections[id]
+	if ok {
+		return collection
+	}
+
+	return nil
+}
+
+func (s *MemoryProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) {
+	result := make([]*models.Collection, 0)
+	for id := range s.collections {
+		if collection := s.getCollectionFromID(id); collection != nil {
+			result = append(result, collection)
+		}
+	}
+	return result, nil
+}
+
+func (s *MemoryProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
+	q, _, err := s.createOrUpdateCollection(r, "")
+	return q, err
+}
+
+func (s *MemoryProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) {
+	return s.createOrUpdateCollection(r, id)
+}
+
+func (s *MemoryProboCollectorStore) ReadCollectionByID(id string) (*models.Collection, error) {
+	if id == "" {
+		return nil, errors.New("ID should not be an empty string!")
+	}
+	collection := s.getCollectionFromID(id)
+	if collection == nil {
+		return nil, fmt.Errorf("Collection ID %v not found in the store", id)
+	}
+	return collection, nil
+}
+
+func (s *MemoryProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) {
+	return s.deleteCollection(r.ID)
+}
+
+func (s *MemoryProboCollectorStore) deleteCollection(id string) (*models.Collection, error) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	collection := s.collections[id]
+	if collection == nil {
+		return nil, fmt.Errorf("Trying to delete a collection that doesn't exist in memory (ID: %v)", id)
+	}
+
+	delete(s.collections, id)
+
+	return collection, nil
+}
+
+func (s *MemoryProboCollectorStore) createOrUpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) {
+	var collection *models.Collection
+
+	if r.Collection == nil {
+		return nil, false, errors.New("A request was made passing a nil collection object")
+	}
+
+	if id != "" { // we're updating a collection
+		collection = s.getCollectionFromID(id)
+		if collection == nil { // Quiz is not present in the store
+			return nil, false, fmt.Errorf("Collection ID %v doesn't exist in the store!", id)
+		}
+	} else {
+		id = uuid.New().String()
+		collection = new(models.Collection)
+	}
+
+	collection.Name = r.Collection.Name
+	collection.Query = r.Collection.Query
+
+	collection.Quizzes = s.query(collection.Query)
+
+	return s.createCollectionFromID(id, collection), true, nil
+}
+
+func (s *MemoryProboCollectorStore) query(query string) []*models.Quiz {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	result := make([]*models.Quiz, 0)
+
+	for _, quiz := range s.quizzes {
+		for _, tag := range quiz.Tags {
+			if query == tag.Name {
+				result = append(result, quiz)
+				break
+			}
+		}
+	}
+
+	return result
+}
+
+func (s *MemoryProboCollectorStore) createCollectionFromID(id string, collection *models.Collection) *models.Collection {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	collection.ID = id
+
+	s.collections[id] = collection
+
+	return collection
+}

+ 98 - 0
store/memory/collection_test.go

@@ -0,0 +1,98 @@
+package memory
+
+import (
+	"fmt"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/hasher/sha256"
+	"github.com/remogatto/prettytest"
+)
+
+type collectionTestSuite struct {
+	prettytest.Suite
+}
+
+func (t *collectionTestSuite) TestUpdateCollection() {
+	store := NewMemoryProboCollectorStore(
+		sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
+	)
+
+	quiz_1, _ := store.CreateQuiz(
+		&client.CreateUpdateQuizRequest{
+			Quiz: &client.Quiz{
+				Question: &client.Question{Text: "Question text with #tag1."},
+				Answers: []*client.Answer{
+					{Text: "Answer 1", Correct: true},
+					{Text: "Answer 2", Correct: false},
+					{Text: "Answer 3", Correct: false},
+					{Text: "Answer 4", Correct: false},
+				},
+			},
+		})
+
+	quiz_2, _ := store.CreateQuiz(
+		&client.CreateUpdateQuizRequest{
+			Quiz: &client.Quiz{
+				Question: &client.Question{Text: "Another question text with #tag1."},
+				Answers: []*client.Answer{
+					{Text: "Answer 1", Correct: true},
+					{Text: "Answer 2", Correct: false},
+					{Text: "Answer 3", Correct: false},
+					{Text: "Answer 4", Correct: false},
+				},
+			},
+		})
+
+	collection, _ := store.CreateCollection(
+		&client.CreateUpdateCollectionRequest{
+			Collection: &client.Collection{
+				Name: "MyCollection",
+			},
+		})
+
+	updatedCollection, updated, err := store.UpdateCollection(
+		&client.CreateUpdateCollectionRequest{
+			Collection: &client.Collection{
+				Name:  "MyUpdatedCollection",
+				Query: "#tag1",
+			},
+		}, collection.ID)
+
+	t.Nil(err, fmt.Sprintf("The update returned an error: %v", err))
+
+	if !t.Failed() {
+		t.True(updated)
+		t.Equal("MyUpdatedCollection", updatedCollection.Name)
+		t.True(len(updatedCollection.Quizzes) == 2)
+		if !t.Failed() {
+			count := 0
+			for _, q := range updatedCollection.Quizzes {
+				if quiz_1.ID == q.ID || quiz_2.ID == q.ID {
+					count++
+				}
+			}
+			t.Equal(2, count)
+		}
+	}
+}
+
+func (t *collectionTestSuite) TestDeleteCollection() {
+	store := NewMemoryProboCollectorStore(
+		sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
+	)
+	collection, _ := store.CreateCollection(
+		&client.CreateUpdateCollectionRequest{
+			Collection: &client.Collection{
+				Name:  "Collection to be deleted",
+				Query: "#tag1",
+			},
+		})
+
+	deletedCollection, err := store.DeleteCollection(&client.DeleteCollectionRequest{ID: collection.ID})
+
+	t.Equal(collection.ID, deletedCollection.ID, "Returned deleted collection ID should be equal to the request")
+	t.Nil(err, fmt.Sprintf("The update returned an error: %v", err))
+
+	_, err = store.ReadCollectionByID(deletedCollection.ID)
+	t.True(err != nil, "Reading a non existent quiz should return an error")
+}

+ 12 - 343
store/memory/memory.go

@@ -2,19 +2,20 @@ package memory
 
 import (
 	"errors"
-	"fmt"
-	"strings"
 	"sync"
 
-	"git.andreafazzi.eu/andrea/probo/client"
 	"git.andreafazzi.eu/andrea/probo/hasher"
 	"git.andreafazzi.eu/andrea/probo/models"
-	"github.com/google/uuid"
 )
 
 type MemoryProboCollectorStore struct {
-	quizzes         map[string]*models.Quiz
-	collections     map[string]*models.Collection
+
+	// IDs maps
+	quizzes      map[string]*models.Quiz
+	collections  map[string]*models.Collection
+	participants map[string]*models.Participant
+
+	// Hashes maps
 	questionsHashes map[string]*models.Question
 	answersHashes   map[string]*models.Answer
 	quizzesHashes   map[string]*models.Quiz
@@ -36,181 +37,17 @@ func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorSto
 
 	s.quizzes = make(map[string]*models.Quiz)
 	s.collections = make(map[string]*models.Collection)
+	s.participants = make(map[string]*models.Participant)
 
 	return s
 }
 
-func (s *MemoryProboCollectorStore) getQuizFromHash(hash string) *models.Quiz {
-	s.lock.RLock()
-	defer s.lock.RUnlock()
-
-	quiz, ok := s.quizzesHashes[hash]
-	if ok {
-		return quiz
-	}
-
-	return nil
-}
-
-func (s *MemoryProboCollectorStore) getQuizFromID(id string) *models.Quiz {
-	s.lock.RLock()
-	defer s.lock.RUnlock()
-
-	quiz, ok := s.quizzes[id]
-	if ok {
-		return quiz
-	}
-
-	return nil
-}
-
-func (s *MemoryProboCollectorStore) getCollectionFromID(id string) *models.Collection {
-	s.lock.RLock()
-	defer s.lock.RUnlock()
-
-	collection, ok := s.collections[id]
-	if ok {
-		return collection
-	}
-
-	return nil
-}
-
-func (s *MemoryProboCollectorStore) getQuestionFromHash(hash string) *models.Question {
-	s.lock.RLock()
-	defer s.lock.RUnlock()
-
-	question, ok := s.questionsHashes[hash]
-	if ok {
-		return question
-	}
-
-	return nil
-}
-
-func (s *MemoryProboCollectorStore) getAnswerFromHash(hash string) *models.Answer {
-	s.lock.RLock()
-	defer s.lock.RUnlock()
-
-	answer, ok := s.answersHashes[hash]
-	if ok {
-		return answer
-	}
-
-	return nil
-}
-
-func (s *MemoryProboCollectorStore) createQuizFromHash(id string, hash string, quiz *models.Quiz) *models.Quiz {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	quiz.ID = id
-	quiz.Hash = hash
-
-	s.quizzesHashes[hash] = quiz
-	s.quizzes[id] = quiz
-
-	return quiz
-}
-
-func (s *MemoryProboCollectorStore) createQuestionFromHash(hash string, question *models.Question) *models.Question {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	s.questionsHashes[hash] = question
-
-	return question
-}
-
-func (s *MemoryProboCollectorStore) createAnswerFromHash(hash string, answer *models.Answer) *models.Answer {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	s.answersHashes[hash] = answer
-
-	return answer
-}
-
-func (s *MemoryProboCollectorStore) deleteQuiz(id string) (*models.Quiz, error) {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	quiz := s.quizzes[id]
-	if quiz == nil {
-		return nil, fmt.Errorf("Trying to delete a quiz that doesn't exist in memory (ID: %v)", id)
-	}
-
-	delete(s.quizzes, id)
-	delete(s.quizzesHashes, quiz.Hash)
-
-	return quiz, nil
-}
-
-func (s *MemoryProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) {
-	result := make([]*models.Quiz, 0)
-	for id := range s.quizzes {
-		if quiz := s.getQuizFromID(id); quiz != nil {
-			result = append(result, quiz)
-		}
-	}
-	return result, nil
-}
-
-func (s *MemoryProboCollectorStore) ReadQuizByID(id string) (*models.Quiz, error) {
-	quiz := s.getQuizFromID(id)
-	if quiz == nil {
-		return nil, fmt.Errorf("Quiz with ID %s was not found in the store.", id)
+func Create[T any](s *MemoryProboCollectorStore, elem *T) (*T, error) {
+	if elem == nil {
+		return nil, errors.New("A creation request was made passing a nil element")
 	}
-	return quiz, nil
-}
-
-func (s *MemoryProboCollectorStore) ReadQuizByHash(hash string) (*models.Quiz, error) {
-	quiz := s.getQuizFromHash(hash)
-	if quiz == nil {
-		return nil, fmt.Errorf("Quiz with hash %s was not found in the store.", hash)
-	}
-	return quiz, nil
-}
 
-func (s *MemoryProboCollectorStore) CalculateQuizHash(quiz *client.Quiz) string {
-	hashes := s.hasher.QuizHashes(quiz)
-	return hashes[len(hashes)-1]
-}
-
-func (s *MemoryProboCollectorStore) parseTextForTags(text string, tags *[]*models.Tag) string {
-
-	// Trim the following chars
-	trimChars := "*:.,/\\@()[]{}<>"
-
-	// Split the text into words
-	words := strings.Fields(text)
-
-	for _, word := range words {
-		// If the word starts with '#', it is considered as a tag
-		if strings.HasPrefix(word, "#") {
-			// Check if the tag already exists in the tags slice
-			exists := false
-			for _, tag := range *tags {
-				if tag.Name == word {
-					exists = true
-					break
-				}
-			}
-
-			// If the tag does not exist in the tags slice, add it
-			if !exists {
-				*tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)})
-			}
-		}
-	}
-
-	return text
-}
-
-func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) {
-	if r.Quiz == nil {
-		return nil, false, errors.New("A request was made passing a nil quiz object")
-	}
+	// Check for duplicates
 	hashes := s.hasher.QuizHashes(r.Quiz)
 	quizHash := hashes[len(hashes)-1]
 
@@ -219,172 +56,4 @@ func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQui
 		return quiz, false, nil
 	}
 
-	if id != "" { // we're updating a quiz
-		quiz = s.getQuizFromID(id)
-		if quiz == nil { // Quiz is not present in the store
-			return nil, false, fmt.Errorf("Quiz ID %v doesn't exist in the store!", id)
-		}
-	} else {
-		if r.Meta != nil {
-			if r.Meta.ID != "" {
-				id = r.Meta.ID
-			} else {
-				id = uuid.New().String()
-			}
-		} else {
-			id = uuid.New().String()
-		}
-		quiz = new(models.Quiz)
-	}
-
-	if quiz.Tags == nil {
-		quiz.Tags = make([]*models.Tag, 0)
-	}
-
-	questionHash := hashes[0]
-	q := s.getQuestionFromHash(questionHash)
-	if q == nil { // if the question is not in the store then we should add it
-		q = s.createQuestionFromHash(questionHash, &models.Question{
-			Meta: models.Meta{ID: uuid.New().String()},
-			Text: s.parseTextForTags(r.Quiz.Question.Text, &quiz.Tags),
-		})
-	}
-
-	// Populate Question field
-	quiz.Question = q
-
-	// Reset answer slice
-	quiz.Answers = make([]*models.Answer, 0)
-
-	for i, answer := range r.Quiz.Answers {
-		answerHash := hashes[i+1]
-		a := s.getAnswerFromHash(answerHash)
-		if a == nil { // if the answer is not in the store add it
-			a = s.createAnswerFromHash(answerHash, &models.Answer{
-				ID:   uuid.New().String(),
-				Text: s.parseTextForTags(answer.Text, &quiz.Tags),
-			})
-		}
-		if answer.Correct {
-			quiz.Correct = a
-		}
-		quiz.Answers = append(quiz.Answers, a)
-	}
-
-	return s.createQuizFromHash(id, quizHash, quiz), true, nil
-}
-
-func (s *MemoryProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
-	q, _, err := s.createOrUpdateQuiz(r, "")
-	return q, err
-}
-
-func (s *MemoryProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) {
-	return s.createOrUpdateQuiz(r, id)
-}
-
-func (s *MemoryProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) {
-	return s.deleteQuiz(r.ID)
-}
-
-func (s *MemoryProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) {
-	result := make([]*models.Collection, 0)
-	for id := range s.collections {
-		if collection := s.getCollectionFromID(id); collection != nil {
-			result = append(result, collection)
-		}
-	}
-	return result, nil
-}
-
-func (s *MemoryProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
-	q, _, err := s.createOrUpdateCollection(r, "")
-	return q, err
-}
-
-func (s *MemoryProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) {
-	return s.createOrUpdateCollection(r, id)
-}
-
-func (s *MemoryProboCollectorStore) ReadCollectionByID(id string) (*models.Collection, error) {
-	if id == "" {
-		return nil, errors.New("ID should not be an empty string!")
-	}
-	collection := s.getCollectionFromID(id)
-	if collection == nil {
-		return nil, fmt.Errorf("Collection ID %v not found in the store", id)
-	}
-	return collection, nil
-}
-
-func (s *MemoryProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) {
-	return s.deleteCollection(r.ID)
-}
-
-func (s *MemoryProboCollectorStore) deleteCollection(id string) (*models.Collection, error) {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	collection := s.collections[id]
-	if collection == nil {
-		return nil, fmt.Errorf("Trying to delete a collection that doesn't exist in memory (ID: %v)", id)
-	}
-
-	delete(s.collections, id)
-
-	return collection, nil
-}
-
-func (s *MemoryProboCollectorStore) createOrUpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) {
-	var collection *models.Collection
-
-	if r.Collection == nil {
-		return nil, false, errors.New("A request was made passing a nil collection object")
-	}
-
-	if id != "" { // we're updating a collection
-		collection = s.getCollectionFromID(id)
-		if collection == nil { // Quiz is not present in the store
-			return nil, false, fmt.Errorf("Collection ID %v doesn't exist in the store!", id)
-		}
-	} else {
-		id = uuid.New().String()
-		collection = new(models.Collection)
-	}
-
-	collection.Name = r.Collection.Name
-	collection.Query = r.Collection.Query
-
-	collection.Quizzes = s.query(collection.Query)
-
-	return s.createCollectionFromID(id, collection), true, nil
-}
-
-func (s *MemoryProboCollectorStore) query(query string) []*models.Quiz {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	result := make([]*models.Quiz, 0)
-
-	for _, quiz := range s.quizzes {
-		for _, tag := range quiz.Tags {
-			if query == tag.Name {
-				result = append(result, quiz)
-				break
-			}
-		}
-	}
-
-	return result
-}
-
-func (s *MemoryProboCollectorStore) createCollectionFromID(id string, collection *models.Collection) *models.Collection {
-	s.lock.Lock()
-	defer s.lock.Unlock()
-
-	collection.ID = id
-
-	s.collections[id] = collection
-
-	return collection
 }

+ 1 - 0
store/memory/memory_test.go

@@ -19,6 +19,7 @@ func TestRunner(t *testing.T) {
 		t,
 		new(testSuite),
 		new(collectionTestSuite),
+		new(participantTestSuite),
 	)
 }
 

+ 108 - 0
store/memory/participant.go

@@ -0,0 +1,108 @@
+package memory
+
+import (
+	"errors"
+	"fmt"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/models"
+	"github.com/google/uuid"
+)
+
+func (s *MemoryProboCollectorStore) ReadAllParticipants() ([]*models.Participant, error) {
+	result := make([]*models.Participant, 0)
+	for id := range s.participants {
+		if participant := s.getParticipantFromID(id); participant != nil {
+			result = append(result, participant)
+		}
+	}
+	return result, nil
+}
+
+func (s *MemoryProboCollectorStore) getParticipantFromID(id string) *models.Participant {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	participant, ok := s.participants[id]
+	if ok {
+		return participant
+	}
+
+	return nil
+}
+
+func (s *MemoryProboCollectorStore) CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error) {
+	q, _, err := s.createOrUpdateParticipant(r, "")
+	return q, err
+}
+
+func (s *MemoryProboCollectorStore) UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) {
+	return s.createOrUpdateParticipant(r, id)
+}
+
+func (s *MemoryProboCollectorStore) ReadParticipantByID(id string) (*models.Participant, error) {
+	if id == "" {
+		return nil, errors.New("ID should not be an empty string!")
+	}
+	participant := s.getParticipantFromID(id)
+	if participant == nil {
+		return nil, fmt.Errorf("Participant ID %v not found in the store", id)
+	}
+	return participant, nil
+}
+
+func (s *MemoryProboCollectorStore) DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error) {
+	return s.deleteParticipant(r.ID)
+}
+
+func (s *MemoryProboCollectorStore) deleteParticipant(id string) (*models.Participant, error) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	participant := s.participants[id]
+	if participant == nil {
+		return nil, fmt.Errorf("Trying to delete a participant that doesn't exist in memory (ID: %v)", id)
+	}
+
+	delete(s.participants, id)
+
+	return participant, nil
+}
+
+func (s *MemoryProboCollectorStore) createOrUpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) {
+	var participant *models.Participant
+
+	if r.Participant == nil {
+		return nil, false, errors.New("A request was made passing a nil participant object")
+	}
+
+	if id != "" { // we're updating a participant
+		participant = s.getParticipantFromID(id)
+		if participant == nil { // Participant is not present in the store
+			return nil, false, fmt.Errorf("Participant ID %v doesn't exist in the store!", id)
+		}
+	} else {
+		id = uuid.New().String()
+		participant = new(models.Participant)
+	}
+
+	participant.Attributes = make(map[string]string)
+
+	participant.Firstname = r.Participant.Firstname
+	participant.Lastname = r.Participant.Lastname
+	participant.Token = r.Participant.Token
+	participant.Attributes = r.Participant.Attributes
+
+	return s.createParticipantFromID(id, participant), true, nil
+}
+
+func (s *MemoryProboCollectorStore) createParticipantFromID(id string, participant *models.Participant) *models.Participant {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	participant.ID = id
+
+	s.participants[id] = participant
+
+	return participant
+}

+ 62 - 0
store/memory/participant_test.go

@@ -0,0 +1,62 @@
+package memory
+
+import (
+	"fmt"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/hasher/sha256"
+	"github.com/remogatto/prettytest"
+)
+
+type participantTestSuite struct {
+	prettytest.Suite
+}
+
+func (t *participantTestSuite) TestUpdateParticipant() {
+	store := NewMemoryProboCollectorStore(
+		sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
+	)
+
+	participant, _ := store.CreateParticipant(&client.CreateUpdateParticipantRequest{
+		Participant: &client.Participant{
+			Firstname: "John",
+			Lastname:  "Doe",
+			Token:     1234,
+		},
+	})
+
+	updatedParticipant, updated, err := store.UpdateParticipant(&client.CreateUpdateParticipantRequest{
+		Participant: &client.Participant{
+			Firstname: "Jack",
+			Lastname:  "Smith",
+		},
+	}, participant.ID)
+
+	t.Nil(err, fmt.Sprintf("The update returned an error: %v", err))
+
+	if !t.Failed() {
+		t.True(updated)
+		t.Equal("Jack", updatedParticipant.Firstname)
+	}
+}
+
+func (t *participantTestSuite) TestDeleteParticipant() {
+	store := NewMemoryProboCollectorStore(
+		sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
+	)
+	participant, _ := store.CreateParticipant(
+		&client.CreateUpdateParticipantRequest{
+			Participant: &client.Participant{
+				Firstname: "Jack",
+				Lastname:  "Smith",
+			},
+		})
+
+	deletedParticipant, err := store.DeleteParticipant(&client.DeleteParticipantRequest{ID: participant.ID})
+
+	t.Equal(participant.ID, deletedParticipant.ID, "Returned deleted participant ID should be equal to the request")
+	t.Nil(err, fmt.Sprintf("The update returned an error: %v", err))
+
+	_, err = store.ReadParticipantByID(deletedParticipant.ID)
+	t.True(err != nil, "Reading a non existent participant should return an error")
+}

+ 246 - 0
store/memory/quiz.go

@@ -0,0 +1,246 @@
+package memory
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"git.andreafazzi.eu/andrea/probo/client"
+	"git.andreafazzi.eu/andrea/probo/models"
+	"github.com/google/uuid"
+)
+
+func (s *MemoryProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) {
+	result := make([]*models.Quiz, 0)
+	for id := range s.quizzes {
+		if quiz := s.getQuizFromID(id); quiz != nil {
+			result = append(result, quiz)
+		}
+	}
+	return result, nil
+}
+
+func (s *MemoryProboCollectorStore) ReadQuizByID(id string) (*models.Quiz, error) {
+	quiz := s.getQuizFromID(id)
+	if quiz == nil {
+		return nil, fmt.Errorf("Quiz with ID %s was not found in the store.", id)
+	}
+	return quiz, nil
+}
+
+func (s *MemoryProboCollectorStore) ReadQuizByHash(hash string) (*models.Quiz, error) {
+	quiz := s.getQuizFromHash(hash)
+	if quiz == nil {
+		return nil, fmt.Errorf("Quiz with hash %s was not found in the store.", hash)
+	}
+	return quiz, nil
+}
+
+func (s *MemoryProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
+	q, _, err := s.createOrUpdateQuiz(r, "")
+	return q, err
+}
+
+func (s *MemoryProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) {
+	return s.createOrUpdateQuiz(r, id)
+}
+
+func (s *MemoryProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) {
+	return s.deleteQuiz(r.ID)
+}
+
+func (s *MemoryProboCollectorStore) CalculateQuizHash(quiz *client.Quiz) string {
+	hashes := s.hasher.QuizHashes(quiz)
+	return hashes[len(hashes)-1]
+}
+
+func (s *MemoryProboCollectorStore) parseTextForTags(text string, tags *[]*models.Tag) string {
+
+	// Trim the following chars
+	trimChars := "*:.,/\\@()[]{}<>"
+
+	// Split the text into words
+	words := strings.Fields(text)
+
+	for _, word := range words {
+		// If the word starts with '#', it is considered as a tag
+		if strings.HasPrefix(word, "#") {
+			// Check if the tag already exists in the tags slice
+			exists := false
+			for _, tag := range *tags {
+				if tag.Name == word {
+					exists = true
+					break
+				}
+			}
+
+			// If the tag does not exist in the tags slice, add it
+			if !exists {
+				*tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)})
+			}
+		}
+	}
+
+	return text
+}
+
+func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) {
+	if r.Quiz == nil {
+		return nil, false, errors.New("A request was made passing a nil quiz object")
+	}
+	hashes := s.hasher.QuizHashes(r.Quiz)
+	quizHash := hashes[len(hashes)-1]
+
+	quiz := s.getQuizFromHash(quizHash)
+	if quiz != nil { // Quiz is already present in the store
+		return quiz, false, nil
+	}
+
+	if id != "" { // we're updating a quiz
+		quiz = s.getQuizFromID(id)
+		if quiz == nil { // Quiz is not present in the store
+			return nil, false, fmt.Errorf("Quiz ID %v doesn't exist in the store!", id)
+		}
+	} else {
+		if r.Meta != nil {
+			if r.Meta.ID != "" {
+				id = r.Meta.ID
+			} else {
+				id = uuid.New().String()
+			}
+		} else {
+			id = uuid.New().String()
+		}
+		quiz = new(models.Quiz)
+	}
+
+	if quiz.Tags == nil {
+		quiz.Tags = make([]*models.Tag, 0)
+	}
+
+	questionHash := hashes[0]
+	q := s.getQuestionFromHash(questionHash)
+	if q == nil { // if the question is not in the store then we should add it
+		q = s.createQuestionFromHash(questionHash, &models.Question{
+			Meta: models.Meta{ID: uuid.New().String()},
+			Text: s.parseTextForTags(r.Quiz.Question.Text, &quiz.Tags),
+		})
+	}
+
+	// Populate Question field
+	quiz.Question = q
+
+	// Reset answer slice
+	quiz.Answers = make([]*models.Answer, 0)
+
+	for i, answer := range r.Quiz.Answers {
+		answerHash := hashes[i+1]
+		a := s.getAnswerFromHash(answerHash)
+		if a == nil { // if the answer is not in the store add it
+			a = s.createAnswerFromHash(answerHash, &models.Answer{
+				ID:   uuid.New().String(),
+				Text: s.parseTextForTags(answer.Text, &quiz.Tags),
+			})
+		}
+		if answer.Correct {
+			quiz.Correct = a
+		}
+		quiz.Answers = append(quiz.Answers, a)
+	}
+
+	return s.createQuizFromHash(id, quizHash, quiz), true, nil
+}
+
+func (s *MemoryProboCollectorStore) getQuizFromHash(hash string) *models.Quiz {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	quiz, ok := s.quizzesHashes[hash]
+	if ok {
+		return quiz
+	}
+
+	return nil
+}
+
+func (s *MemoryProboCollectorStore) getQuizFromID(id string) *models.Quiz {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	quiz, ok := s.quizzes[id]
+	if ok {
+		return quiz
+	}
+
+	return nil
+}
+
+func (s *MemoryProboCollectorStore) getQuestionFromHash(hash string) *models.Question {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	question, ok := s.questionsHashes[hash]
+	if ok {
+		return question
+	}
+
+	return nil
+}
+
+func (s *MemoryProboCollectorStore) getAnswerFromHash(hash string) *models.Answer {
+	s.lock.RLock()
+	defer s.lock.RUnlock()
+
+	answer, ok := s.answersHashes[hash]
+	if ok {
+		return answer
+	}
+
+	return nil
+}
+
+func (s *MemoryProboCollectorStore) createQuizFromHash(id string, hash string, quiz *models.Quiz) *models.Quiz {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	quiz.ID = id
+	quiz.Hash = hash
+
+	s.quizzesHashes[hash] = quiz
+	s.quizzes[id] = quiz
+
+	return quiz
+}
+
+func (s *MemoryProboCollectorStore) createQuestionFromHash(hash string, question *models.Question) *models.Question {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.questionsHashes[hash] = question
+
+	return question
+}
+
+func (s *MemoryProboCollectorStore) createAnswerFromHash(hash string, answer *models.Answer) *models.Answer {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.answersHashes[hash] = answer
+
+	return answer
+}
+
+func (s *MemoryProboCollectorStore) deleteQuiz(id string) (*models.Quiz, error) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	quiz := s.quizzes[id]
+	if quiz == nil {
+		return nil, fmt.Errorf("Trying to delete a quiz that doesn't exist in memory (ID: %v)", id)
+	}
+
+	delete(s.quizzes, id)
+	delete(s.quizzesHashes, quiz.Hash)
+
+	return quiz, nil
+}