Generic refactoring almost completed
This commit is contained in:
parent
ce9dd7fd63
commit
45bcf24ecf
26 changed files with 1562 additions and 1089 deletions
1
go.mod
1
go.mod
|
@ -6,6 +6,7 @@ require (
|
|||
github.com/google/uuid v1.3.1
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
|
|
2
go.sum
2
go.sum
|
@ -39,6 +39,8 @@ golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
|||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||
|
|
|
@ -52,4 +52,3 @@ func (h *Default256Hasher) Calculate(hashes []string) string {
|
|||
|
||||
return h.hashFn(strings.Join(orderedHashes, ""))
|
||||
}
|
||||
-
|
||||
|
|
|
@ -1,6 +1,27 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Answer struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (a *Answer) String() string {
|
||||
return a.Text
|
||||
}
|
||||
|
||||
func (a *Answer) GetID() string {
|
||||
return a.ID
|
||||
}
|
||||
|
||||
func (a *Answer) SetID(id string) {
|
||||
a.ID = id
|
||||
}
|
||||
|
||||
func (a *Answer) GetHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(a.Text)))
|
||||
}
|
||||
|
|
|
@ -1,10 +1,30 @@
|
|||
package models
|
||||
|
||||
type Filter struct {
|
||||
Tags []*Tag
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
Meta
|
||||
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Filter *Filter `json:"filter"`
|
||||
|
||||
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
|
||||
}
|
||||
|
||||
func (c *Collection) String() string {
|
||||
return c.Name
|
||||
}
|
||||
|
||||
func (c *Collection) GetID() string {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (c *Collection) SetID(id string) {
|
||||
c.ID = id
|
||||
}
|
||||
|
||||
func (c *Collection) GetHash() string {
|
||||
return ""
|
||||
}
|
||||
|
|
|
@ -6,5 +6,4 @@ type Meta struct {
|
|||
ID string `json:"id" yaml:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
|
||||
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/remogatto/prettytest"
|
||||
|
@ -16,3 +18,59 @@ func TestRunner(t *testing.T) {
|
|||
new(testSuite),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *testSuite) TestQuizFromMarkdown() {
|
||||
markdown := `Question text (1).
|
||||
|
||||
Question text (2).
|
||||
|
||||
Question text with #tag1 #tag2 (3).
|
||||
|
||||
* Answer 1
|
||||
* Answer 2
|
||||
* Answer 3
|
||||
* Answer 4`
|
||||
|
||||
expectedQuiz := &Quiz{
|
||||
Question: &Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."},
|
||||
Answers: []*Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
}
|
||||
|
||||
quiz, _, err := MarkdownToQuiz(markdown)
|
||||
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testSuite) TestMarkdownFromQuiz() {
|
||||
quiz := &Quiz{
|
||||
Question: &Question{Text: "Newly created question text."},
|
||||
Answers: []*Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
}
|
||||
md, err := QuizToMarkdown(quiz)
|
||||
t.Nil(err, fmt.Sprintf("Conversion to markdown should not raise an error: %v", err))
|
||||
if !t.Failed() {
|
||||
t.Equal(`Newly created question text.
|
||||
|
||||
* Answer 1
|
||||
* Answer 2
|
||||
* Answer 3
|
||||
* Answer 4
|
||||
`, md)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,27 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Question struct {
|
||||
Meta
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (q *Question) String() string {
|
||||
return q.Text
|
||||
}
|
||||
|
||||
func (q *Question) GetID() string {
|
||||
return q.ID
|
||||
}
|
||||
|
||||
func (q *Question) SetID(id string) {
|
||||
q.ID = id
|
||||
}
|
||||
|
||||
func (q *Question) GetHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(q.Text)))
|
||||
}
|
||||
|
|
185
models/quiz.go
185
models/quiz.go
|
@ -1,11 +1,196 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type Quiz struct {
|
||||
Meta
|
||||
|
||||
Hash string `json:"hash"`
|
||||
Question *Question `json:"question" gorm:"foreignKey:ID"`
|
||||
Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"`
|
||||
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
|
||||
Correct *Answer `json:"correct" gorm:"foreignKey:ID"`
|
||||
CorrectPos uint `gorm:"-"` // Position of the correct answer during quiz creation
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) {
|
||||
meta, remainingMarkdown, err := ParseMetaHeaderFromMarkdown(markdown)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(remainingMarkdown, "\n")
|
||||
|
||||
questionText := ""
|
||||
answers := []*Answer{}
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "*") {
|
||||
answerText := strings.TrimPrefix(line, "* ")
|
||||
answer := &Answer{Text: answerText}
|
||||
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 := &Question{Text: questionText}
|
||||
quiz := &Quiz{Question: question, Answers: answers, CorrectPos: 0}
|
||||
|
||||
if meta != nil {
|
||||
quiz.Meta = *meta
|
||||
}
|
||||
|
||||
return quiz, meta, nil
|
||||
}
|
||||
|
||||
func QuizToMarkdown(quiz *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.")
|
||||
}
|
||||
|
||||
quiz.Correct = quiz.Answers[quiz.CorrectPos]
|
||||
|
||||
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 pos, answer := range quiz.Answers {
|
||||
if quiz.CorrectPos != uint(pos) {
|
||||
otherAnswers += "* " + answer.Text + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers
|
||||
|
||||
return markdown, nil
|
||||
}
|
||||
|
||||
func (q *Quiz) GetID() string {
|
||||
return q.ID
|
||||
}
|
||||
|
||||
func (q *Quiz) SetID(id string) {
|
||||
q.ID = id
|
||||
}
|
||||
|
||||
func (q *Quiz) GetHash() string {
|
||||
return q.calculateHash()
|
||||
}
|
||||
|
||||
func (q *Quiz) calculateHash() string {
|
||||
result := make([]string, 0)
|
||||
|
||||
result = append(result, q.Question.GetHash())
|
||||
|
||||
for _, a := range q.Answers {
|
||||
result = append(result, a.GetHash())
|
||||
}
|
||||
|
||||
orderedHashes := make([]string, len(result))
|
||||
|
||||
copy(orderedHashes, result)
|
||||
sort.Strings(orderedHashes)
|
||||
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(orderedHashes, ""))))
|
||||
}
|
||||
|
||||
func ParseMetaHeaderFromMarkdown(markdown string) (*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 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
|
||||
}
|
||||
|
|
75
store/collection_test.go
Normal file
75
store/collection_test.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
type collectionTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func (t *collectionTestSuite) TestCreateCollection() {
|
||||
quizStore := NewQuizStore()
|
||||
quiz_1, _ := quizStore.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text #tag1 #tag3."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
})
|
||||
|
||||
quizStore.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text #tag2."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
})
|
||||
|
||||
quiz_2, _ := quizStore.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text #tag3."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
})
|
||||
|
||||
collectionStore := NewStore[*models.Collection]()
|
||||
collection, err := collectionStore.Create(
|
||||
&models.Collection{
|
||||
Name: "My Collection",
|
||||
})
|
||||
t.Nil(err, "Collection should be created without error")
|
||||
|
||||
if !t.Failed() {
|
||||
quizzes := quizStore.FilterInCollection(collection, &models.Filter{
|
||||
Tags: []*models.Tag{
|
||||
{Name: "#tag1"},
|
||||
{Name: "#tag3"},
|
||||
},
|
||||
})
|
||||
|
||||
t.Equal(1, len(quizzes))
|
||||
|
||||
count := 0
|
||||
for _, q := range collection.Quizzes {
|
||||
if quiz_1.ID == q.ID || quiz_2.ID == q.ID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
t.Equal(1, count)
|
||||
t.Equal(1, len(collection.Quizzes))
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
Newly created question text.
|
||||
|
||||
* Answer 1
|
||||
* Answer 1
|
||||
* Answer 2
|
||||
* Answer 3
|
||||
* Answer 4
|
|
@ -2,159 +2,52 @@ 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"
|
||||
"git.andreafazzi.eu/andrea/probo/store"
|
||||
)
|
||||
|
||||
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)
|
||||
func NewCollectionFileStore() (*FileStore[*models.Collection, *store.Store[*models.Collection]], error) {
|
||||
return NewFileStore[*models.Collection](
|
||||
store.NewStore[*models.Collection](),
|
||||
filepath.Join(BaseDir, CollectionsDir),
|
||||
"collection",
|
||||
".json",
|
||||
func(s *store.Store[*models.Collection], filepath string, content []byte) (*models.Collection, error) {
|
||||
collection := new(models.Collection)
|
||||
err := json.Unmarshal(content, &collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.createOrUpdateCollectionFile(collection)
|
||||
c, err := s.Create(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)
|
||||
return c, nil
|
||||
},
|
||||
func(s *store.Store[*models.Collection], filePath string, collection *models.Collection) error {
|
||||
jsonData, err := json.Marshal(collection)
|
||||
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)
|
||||
file, err := os.Create(filePath)
|
||||
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))
|
||||
_, err = file.Write(jsonData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.SetCollectionPath(collection, fn)
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/client"
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"git.andreafazzi.eu/andrea/probo/store"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
|
@ -16,158 +13,60 @@ type collectionTestSuite struct {
|
|||
}
|
||||
|
||||
func (t *collectionTestSuite) TestCreateCollection() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
t.Nil(err, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
quizStore := store.NewQuizStore()
|
||||
|
||||
if !t.Failed() {
|
||||
quiz_1, err := createQuizOnDisk(store, &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},
|
||||
},
|
||||
quizStore.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text #tag1 #tag3."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "The quiz to be updated should be created without issue")
|
||||
|
||||
path_1, _ := store.GetQuizPath(quiz_1)
|
||||
|
||||
if !t.Failed() {
|
||||
quiz_2, err := createQuizOnDisk(store, &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},
|
||||
},
|
||||
quizStore.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text #tag2."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "The quiz to be updated should be created without issue")
|
||||
|
||||
path_2, _ := store.GetQuizPath(quiz_2)
|
||||
|
||||
if !t.Failed() {
|
||||
|
||||
quiz_3, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
|
||||
Quiz: &client.Quiz{
|
||||
Question: &client.Question{Text: "Question text without tags."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
quizStore.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text #tag3."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "The quiz to be updated should be created without issue")
|
||||
store, err := NewCollectionFileStore()
|
||||
t.Nil(err)
|
||||
|
||||
path_3, _ := store.GetQuizPath(quiz_3)
|
||||
c := new(models.Collection)
|
||||
c.Name = "MyCollection"
|
||||
|
||||
if !t.Failed() {
|
||||
|
||||
collection, err := store.CreateCollection(
|
||||
&client.CreateUpdateCollectionRequest{
|
||||
Collection: &client.Collection{
|
||||
Name: "MyCollection",
|
||||
Query: "#tag1",
|
||||
quizStore.FilterInCollection(c, &models.Filter{
|
||||
Tags: []*models.Tag{
|
||||
{Name: "#tag3"},
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "Creating a collection should not return an error")
|
||||
_, err = store.Create(c)
|
||||
|
||||
collectionPath, _ := store.GetCollectionPath(collection)
|
||||
exists, err := os.Stat(store.GetPath(c))
|
||||
|
||||
if !t.Failed() {
|
||||
t.Equal(2, len(collection.Quizzes))
|
||||
|
||||
os.Remove(path_1)
|
||||
os.Remove(path_2)
|
||||
os.Remove(path_3)
|
||||
os.Remove(collectionPath)
|
||||
t.Nil(err)
|
||||
t.Not(t.Nil(exists))
|
||||
t.Equal(2, len(c.Quizzes))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *collectionTestSuite) TestUpdateCollection() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
collection, err := createCollectionOnDisk(store, &client.CreateUpdateCollectionRequest{
|
||||
Collection: &client.Collection{
|
||||
Name: "Collection name",
|
||||
Query: "#tag1",
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "The collection to be updated should be created without issue")
|
||||
|
||||
if !t.Failed() {
|
||||
clientCollection := &client.Collection{
|
||||
Name: "Updated collection name",
|
||||
Query: "#tag2",
|
||||
}
|
||||
|
||||
updatedCollection, err := store.UpdateCollection(
|
||||
&client.CreateUpdateCollectionRequest{
|
||||
Collection: clientCollection,
|
||||
}, collection.ID)
|
||||
|
||||
t.Nil(err, fmt.Sprintf("Collection should be updated without errors: %v", err))
|
||||
|
||||
t.Equal("#tag2", updatedCollection.Query)
|
||||
|
||||
if !t.Failed() {
|
||||
path, err := store.GetCollectionPath(updatedCollection)
|
||||
|
||||
if !t.Failed() {
|
||||
t.Nil(err, "GetPath should not raise an error.")
|
||||
|
||||
if !t.Failed() {
|
||||
|
||||
collectionFromDisk, err := readCollectionFromDisk(path)
|
||||
t.Nil(err, fmt.Sprintf("Collection should be read from disk without errors but an issue was reported: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(clientCollection, collectionFromDisk), "Collection should be updated.")
|
||||
err := os.Remove(path)
|
||||
t.Nil(err, "Stat should not return an error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createCollectionOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
|
||||
return store.CreateCollection(req)
|
||||
|
||||
}
|
||||
|
||||
func readCollectionFromDisk(path string) (*client.Collection, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
collection := new(client.Collection)
|
||||
err = json.Unmarshal(content, &collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return collection, nil
|
||||
defer os.Remove(store.GetPath(c))
|
||||
}
|
||||
|
|
|
@ -2,68 +2,176 @@ package file
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
|
||||
"git.andreafazzi.eu/andrea/probo/store/memory"
|
||||
"git.andreafazzi.eu/andrea/probo/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
|
||||
|
||||
DefaultQuizzesDir = "quizzes"
|
||||
DefaultCollectionsDir = "collections"
|
||||
BaseDir = "data"
|
||||
QuizzesDir = "quizzes"
|
||||
CollectionsDir = "collections"
|
||||
)
|
||||
|
||||
type FileProboCollectorStore struct {
|
||||
Dir string
|
||||
|
||||
memoryStore *memory.MemoryProboCollectorStore
|
||||
|
||||
quizzesPaths map[string]string
|
||||
collectionsPaths map[string]string
|
||||
|
||||
quizzesDir string
|
||||
collectionsDir string
|
||||
|
||||
// A mutex is used to synchronize read/write access to the map
|
||||
lock sync.RWMutex
|
||||
type Storer[T store.Storable] interface {
|
||||
store.Storer[T]
|
||||
// store.FilterStorer[T]
|
||||
}
|
||||
|
||||
func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) {
|
||||
s := new(FileProboCollectorStore)
|
||||
type FileStore[T store.Storable, K Storer[T]] struct {
|
||||
Storer K
|
||||
|
||||
s.Dir = dirname
|
||||
Dir string
|
||||
FilePrefix string
|
||||
FileSuffix string
|
||||
|
||||
s.quizzesDir = filepath.Join(s.Dir, DefaultQuizzesDir)
|
||||
s.collectionsDir = filepath.Join(s.Dir, DefaultCollectionsDir)
|
||||
MarshalFunc func(K, string, []byte) (T, error)
|
||||
UnmarshalFunc func(K, string, T) error
|
||||
|
||||
err := s.Reindex()
|
||||
lock sync.RWMutex
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
func NewFileStore[T store.Storable, K Storer[T]](
|
||||
storer K,
|
||||
dir string,
|
||||
prefix string,
|
||||
suffix string,
|
||||
marshalFunc func(K, string, []byte) (T, error),
|
||||
unmarshalFunc func(K, string, T) error,
|
||||
) (*FileStore[T, K], error) {
|
||||
store := &FileStore[T, K]{
|
||||
Storer: storer,
|
||||
Dir: dir,
|
||||
FilePrefix: prefix,
|
||||
FileSuffix: suffix,
|
||||
MarshalFunc: marshalFunc,
|
||||
UnmarshalFunc: unmarshalFunc,
|
||||
paths: make(map[string]string, 0),
|
||||
}
|
||||
|
||||
err := store.IndexDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
}
|
||||
|
||||
return s, nil
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *FileProboCollectorStore) Reindex() error {
|
||||
s.memoryStore = memory.NewMemoryProboCollectorStore(
|
||||
sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
|
||||
)
|
||||
func (s *FileStore[T, K]) Create(entity T) (T, error) {
|
||||
e, err := s.Storer.Create(entity)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
s.quizzesPaths = make(map[string]string)
|
||||
s.collectionsPaths = make(map[string]string)
|
||||
filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix))
|
||||
|
||||
err := s.reindexQuizzes()
|
||||
err = s.UnmarshalFunc(s.Storer, filePath, e)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
s.SetPath(e, filePath)
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Update(entity T, id string) (T, error) {
|
||||
e, err := s.Storer.Update(entity, id)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix))
|
||||
|
||||
err = s.UnmarshalFunc(s.Storer, filePath, e)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
s.SetPath(e, filePath)
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Read(id string) (T, error) {
|
||||
return s.Storer.Read(id)
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) ReadAll() []T {
|
||||
return s.Storer.ReadAll()
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Delete(id string) (T, error) {
|
||||
e, err := s.Storer.Delete(id)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
err = os.Remove(s.GetPath(e))
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) IndexDir() error {
|
||||
files, err := os.ReadDir(s.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.reindexCollections()
|
||||
entityFiles := make([]fs.DirEntry, 0)
|
||||
|
||||
for _, file := range files {
|
||||
filename := file.Name()
|
||||
if !file.IsDir() && strings.HasSuffix(filename, s.FileSuffix) {
|
||||
entityFiles = append(entityFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range entityFiles {
|
||||
filename := file.Name()
|
||||
fullPath := filepath.Join(s.Dir, filename)
|
||||
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entity, err := s.MarshalFunc(s.Storer, fullPath, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.SetPath(entity, fullPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) GetPath(entity T) string {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
return s.paths[entity.GetID()]
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) SetPath(entity T, path string) string {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.paths[entity.GetID()] = path
|
||||
|
||||
return path
|
||||
}
|
||||
|
|
|
@ -1,24 +1,13 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/client"
|
||||
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"git.andreafazzi.eu/andrea/probo/store/memory"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
var testdataDir = "./testdata"
|
||||
|
||||
type quizTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func TestRunner(t *testing.T) {
|
||||
prettytest.Run(
|
||||
t,
|
||||
|
@ -26,273 +15,3 @@ func TestRunner(t *testing.T) {
|
|||
new(collectionTestSuite),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestQuizFromMarkdown() {
|
||||
markdown := `Question text (1).
|
||||
|
||||
Question text (2).
|
||||
|
||||
Question text with #tag1 #tag2 (3).
|
||||
|
||||
* Answer 1
|
||||
* Answer 2
|
||||
* Answer 3
|
||||
* Answer 4`
|
||||
|
||||
expectedQuiz := &client.Quiz{
|
||||
Question: &client.Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
}
|
||||
|
||||
quiz, _, err := QuizFromMarkdown(markdown)
|
||||
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestReadAllQuizzes() {
|
||||
store, err := NewFileProboCollectorStore("./testdata/")
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
result, err := store.ReadAllQuizzes()
|
||||
|
||||
t.True(err == nil, fmt.Sprintf("Quizzes should be returned without errors: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
t.Equal(
|
||||
4,
|
||||
len(result),
|
||||
fmt.Sprintf("The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v", len(result)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestMarkdownFromQuiz() {
|
||||
store := memory.NewMemoryProboCollectorStore(sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn))
|
||||
quiz, err := store.CreateQuiz(
|
||||
&client.CreateUpdateQuizRequest{
|
||||
Quiz: &client.Quiz{
|
||||
Question: &client.Question{Text: "Newly created question text."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
},
|
||||
})
|
||||
md, err := MarkdownFromQuiz(quiz)
|
||||
t.Nil(err, "Conversion to markdown should not raise an error")
|
||||
if !t.Failed() {
|
||||
t.Equal(`Newly created question text.
|
||||
|
||||
* Answer 1
|
||||
* Answer 2
|
||||
* Answer 3
|
||||
* Answer 4
|
||||
`, md)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestCreateQuiz() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
clientQuiz := &client.Quiz{
|
||||
Question: &client.Question{Text: "Newly created question text."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
}
|
||||
quiz, err := store.CreateQuiz(
|
||||
&client.CreateUpdateQuizRequest{
|
||||
Quiz: clientQuiz,
|
||||
},
|
||||
)
|
||||
|
||||
t.Nil(err, fmt.Sprintf("An error was raised when saving the quiz on disk: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
path, err := store.GetQuizPath(quiz)
|
||||
t.Nil(err, "GetPath should not raise an error.")
|
||||
|
||||
if !t.Failed() {
|
||||
exists, err := os.Stat(path)
|
||||
t.Nil(err, "Stat should not return an error")
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(exists != nil, "The new quiz file was not created.")
|
||||
if !t.Failed() {
|
||||
quizFromDisk, _, err := readQuizFromDisk(path)
|
||||
t.Nil(err, "Quiz should be read from disk without errors.")
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(quizFromDisk, clientQuiz), "Quiz read from disk and stored in memory should be equal.")
|
||||
err := os.Remove(path)
|
||||
t.Nil(err, "Test file should be removed without errors.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestDeleteQuiz() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
quiz, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
|
||||
Quiz: &client.Quiz{
|
||||
Question: &client.Question{Text: "This quiz should be deleted."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "The quiz to be deleted should be created without issue")
|
||||
|
||||
path, err := store.GetQuizPath(quiz)
|
||||
t.True(path != "", "Quiz path should be obtained without errors")
|
||||
|
||||
if !t.Failed() {
|
||||
deletedQuiz, err := store.DeleteQuiz(&client.DeleteQuizRequest{ID: quiz.ID})
|
||||
|
||||
t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err))
|
||||
t.True(reflect.DeepEqual(quiz, deletedQuiz), "Quiz should be updateEd.")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestUpdateQuiz() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
quiz, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
|
||||
Quiz: &client.Quiz{
|
||||
Question: &client.Question{Text: "Newly created question text."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
t.Nil(err, "The quiz to be updated should be created without issue")
|
||||
|
||||
if !t.Failed() {
|
||||
clientQuiz := &client.Quiz{
|
||||
Question: &client.Question{Text: "Updated question text with #tag."},
|
||||
Answers: []*client.Answer{
|
||||
{Text: "Answer 1", Correct: true},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 4", Correct: false},
|
||||
},
|
||||
}
|
||||
|
||||
updatedQuiz, err := store.UpdateQuiz(
|
||||
&client.CreateUpdateQuizRequest{
|
||||
Quiz: clientQuiz,
|
||||
}, quiz.ID)
|
||||
|
||||
t.Nil(err, fmt.Sprintf("Quiz should be updated without errors: %v", err))
|
||||
t.Equal(updatedQuiz.ID, quiz.ID, fmt.Sprintf("IDs should remain the same after an update: updated ID %v original ID %v", updatedQuiz.ID, quiz.ID))
|
||||
t.True(len(updatedQuiz.Tags) == 1, "Length of tags array should be 1")
|
||||
|
||||
if !t.Failed() {
|
||||
path, err := store.GetQuizPath(updatedQuiz)
|
||||
|
||||
if !t.Failed() {
|
||||
t.Nil(err, "GetPath should not raise an error.")
|
||||
|
||||
if !t.Failed() {
|
||||
quizFromDisk, _, err := readQuizFromDisk(path)
|
||||
t.Nil(err, "Quiz should be read from disk without errors.")
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(clientQuiz, quizFromDisk), "Quiz should be updated.")
|
||||
err := os.Remove(path)
|
||||
t.Nil(err, "Stat should not return an error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestReadMetaHeaderFromFile() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
meta, err := store.ReadMetaHeaderFromFile("quiz_4.md")
|
||||
t.True(err == nil, fmt.Sprintf("An error occurred: %v", err))
|
||||
if !t.Failed() {
|
||||
t.True(meta.ID != "")
|
||||
t.True(meta.CreatedAt.String() != "")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestWriteMetaHeaderToFile() {
|
||||
store, err := NewFileProboCollectorStore(testdataDir)
|
||||
|
||||
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
meta, err := store.ReadMetaHeaderFromFile("quiz_5.md")
|
||||
t.True(err == nil, fmt.Sprintf("Reading the header returns the following error: %v", err))
|
||||
if !t.Failed() {
|
||||
t.True(meta != nil, "Meta header should not be nil")
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(meta.ID != "", "ID should not be empty")
|
||||
|
||||
if !t.Failed() {
|
||||
_, err = store.removeMetaFromFile("quiz_5.md")
|
||||
t.True(err == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
|
||||
return store.CreateQuiz(req)
|
||||
|
||||
}
|
||||
|
||||
func readQuizFromDisk(path string) (*client.Quiz, *models.Meta, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return QuizFromMarkdown(string(content))
|
||||
}
|
||||
|
||||
func testsAreEqual(got, want []*models.Quiz) bool {
|
||||
return reflect.DeepEqual(got, want)
|
||||
}
|
||||
|
|
|
@ -4,263 +4,151 @@ 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"
|
||||
"git.andreafazzi.eu/andrea/probo/store"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
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)
|
||||
func NewQuizFileStore() (*FileStore[*models.Quiz, *store.QuizStore], error) {
|
||||
return NewFileStore[*models.Quiz, *store.QuizStore](
|
||||
store.NewQuizStore(),
|
||||
filepath.Join(BaseDir, QuizzesDir),
|
||||
"quiz",
|
||||
".md",
|
||||
func(s *store.QuizStore, filepath string, content []byte) (*models.Quiz, error) {
|
||||
quiz, meta, err := models.MarkdownToQuiz(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.createOrUpdateMarkdownFile(quiz)
|
||||
if err != nil {
|
||||
var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent
|
||||
|
||||
q, err := s.Create(quiz)
|
||||
if err != nil && !errors.As(err, &errQuizAlreadyPresent) {
|
||||
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{
|
||||
writeQuizHeader(filepath, &models.Meta{
|
||||
ID: q.ID,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
s.SetQuizPath(q, fullPath)
|
||||
|
||||
return q, nil
|
||||
},
|
||||
func(s *store.QuizStore, filePath string, quiz *models.Quiz) error {
|
||||
markdown, err := models.QuizToMarkdown(quiz)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
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
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) {
|
||||
file, err := os.Open(path.Join(s.quizzesDir, filename))
|
||||
func writeQuizHeader(path string, meta *models.Meta) (*models.Meta, error) {
|
||||
readMeta, err := readQuizHeader(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if readMeta == nil {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var buffer bytes.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
|
||||
}
|
||||
|
||||
_, err = io.Copy(&buffer, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err = os.Create(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, &buffer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func readQuizHeader(path string) (*models.Meta, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _, err := models.ParseMetaHeaderFromMarkdown(string(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return meta, 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 removeQuizHeader(path string) (*models.Meta, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -313,7 +201,7 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M
|
|||
return nil, err
|
||||
}
|
||||
|
||||
file, err = os.Create(path.Join(s.quizzesDir, filename))
|
||||
file, err = os.Create(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -326,169 +214,3 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M
|
|||
|
||||
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
|
||||
}
|
||||
|
|
232
store/file/quiz_test.go
Normal file
232
store/file/quiz_test.go
Normal file
|
@ -0,0 +1,232 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
type quizTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) BeforeAll() {
|
||||
BaseDir = "testdata"
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestReadAllQuizzes() {
|
||||
store, err := NewQuizFileStore()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
result := store.ReadAll()
|
||||
|
||||
t.Equal(
|
||||
4,
|
||||
len(result),
|
||||
fmt.Sprintf(
|
||||
"The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v",
|
||||
len(result),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestCreateQuiz() {
|
||||
store, err := NewQuizFileStore()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err)
|
||||
t.Equal(2, len(quiz.Tags))
|
||||
|
||||
if !t.Failed() {
|
||||
path := store.GetPath(quiz)
|
||||
t.True(path != "", "Path should not be empty.")
|
||||
|
||||
exists, err := os.Stat(path)
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(exists != nil, "The new quiz file was not created.")
|
||||
|
||||
if !t.Failed() {
|
||||
quizFromDisk, _, err := readQuizFromDisk(path)
|
||||
defer os.Remove(path)
|
||||
|
||||
quizFromDisk.Correct = quiz.Answers[0]
|
||||
quizFromDisk.Tags = quiz.Tags
|
||||
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
t.Equal(quizFromDisk.Question.Text, quiz.Question.Text)
|
||||
for i, a := range quizFromDisk.Answers {
|
||||
t.Equal(a.Text, quiz.Answers[i].Text)
|
||||
}
|
||||
for i, tag := range quizFromDisk.Tags {
|
||||
t.Equal(tag.Name, quiz.Tags[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestDeleteQuiz() {
|
||||
store, err := NewQuizFileStore()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "This quiz should be deleted."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err)
|
||||
if !t.Failed() {
|
||||
path := store.GetPath(quiz)
|
||||
_, err := store.Delete(quiz.ID)
|
||||
t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err))
|
||||
if !t.Failed() {
|
||||
_, err := os.Stat(path)
|
||||
t.Not(t.Nil(err))
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestUpdateQuiz() {
|
||||
store, err := NewQuizFileStore()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err)
|
||||
|
||||
_, err = store.Update(&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2 #tag3."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 1,
|
||||
}, quiz.ID)
|
||||
|
||||
t.Nil(err)
|
||||
|
||||
updatedQuizFromMemory, err := store.Read(quiz.ID)
|
||||
t.Equal(len(updatedQuizFromMemory.Tags), 3)
|
||||
t.Equal("Answer 2", updatedQuizFromMemory.Correct.Text)
|
||||
|
||||
defer os.Remove(store.GetPath(quiz))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestAutowriteHeader() {
|
||||
store, err := NewQuizFileStore()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
|
||||
meta, err := readQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
t.Not(t.Nil(meta))
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(meta.ID != "", "ID should not be empty")
|
||||
|
||||
if !t.Failed() {
|
||||
_, err = removeQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
|
||||
t.True(err == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func (t *quizTestSuite) TestReadMetaHeaderFromFile() {
|
||||
// store, err := NewFileProboCollectorStore(testdataDir)
|
||||
// t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
// meta, err := store.ReadMetaHeaderFromFile("quiz_4.md")
|
||||
// t.True(err == nil, fmt.Sprintf("An error occurred: %v", err))
|
||||
// if !t.Failed() {
|
||||
// t.True(meta.ID != "")
|
||||
// t.True(meta.CreatedAt.String() != "")
|
||||
// }
|
||||
// }
|
||||
|
||||
// func (t *quizTestSuite) TestWriteMetaHeaderToFile() {
|
||||
// store, err := NewFileProboCollectorStore(testdataDir)
|
||||
|
||||
// t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
||||
|
||||
// if !t.Failed() {
|
||||
// meta, err := store.ReadMetaHeaderFromFile("quiz_5.md")
|
||||
// t.True(err == nil, fmt.Sprintf("Reading the header returns the following error: %v", err))
|
||||
// if !t.Failed() {
|
||||
// t.True(meta != nil, "Meta header should not be nil")
|
||||
|
||||
// if !t.Failed() {
|
||||
// t.True(meta.ID != "", "ID should not be empty")
|
||||
|
||||
// if !t.Failed() {
|
||||
// _, err = store.removeMetaFromFile("quiz_5.md")
|
||||
// t.True(err == nil)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
|
||||
// return store.CreateQuiz(req)
|
||||
|
||||
// }
|
||||
|
||||
func readQuizFromDisk(path string) (*models.Quiz, *models.Meta, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return models.MarkdownToQuiz(string(content))
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
{"id":"0386ac85-0701-48e7-a5a7-bf9713f63dac","name":"Updated collection name","query":"#tag2","ids":[]}
|
|
@ -1 +0,0 @@
|
|||
{"id":"b30c4392-52f3-46c5-8865-319ae7d1fbe0","name":"Updated collection name","query":"#tag2","ids":[]}
|
4
store/file/testdata/quizzes/quiz_2.md
vendored
4
store/file/testdata/quizzes/quiz_2.md
vendored
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
id: a09045c3-af87-4a83-a2bb-7283a2ac67d6
|
||||
created_at: !!timestamp 2023-09-22T09:08:50.366639817+02:00
|
||||
created_at: 2023-09-22T09:08:50.366639817+02:00
|
||||
updated_at: 0001-01-01T00:00:00Z
|
||||
---
|
||||
Question text 2.
|
||||
|
||||
|
@ -8,4 +9,3 @@ Question text 2.
|
|||
* Answer 2
|
||||
* Answer 3
|
||||
* Answer 4
|
||||
|
||||
|
|
6
store/file/testdata/quizzes/quiz_5.md
vendored
6
store/file/testdata/quizzes/quiz_5.md
vendored
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
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
|
||||
id: 5ebab010-8e96-41c9-905d-a092da037194
|
||||
created_at: 2023-11-13T20:58:30.651064703+01:00
|
||||
updated_at: 0001-01-01T00:00:00Z
|
||||
---
|
||||
This quiz is initially without metadata.
|
||||
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/hasher"
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"git.andreafazzi.eu/andrea/probo/store"
|
||||
)
|
||||
|
||||
type MemoryProboCollectorStore struct {
|
||||
|
||||
// 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
|
||||
|
||||
hasher hasher.Hasher
|
||||
type Store[T Storable] struct {
|
||||
ids map[string]T
|
||||
hashes map[string]T
|
||||
|
||||
// A mutex is used to synchronize read/write access to the map
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func NewStore[T store.Storable]() *Store[T] {
|
||||
store := new(Store[T])
|
||||
|
||||
store.ids = make(map[string]T)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
type QuizStore struct {
|
||||
*Store[*Quiz]
|
||||
|
||||
questions *Store[*Question]
|
||||
answers *Store[*Answer]
|
||||
}
|
||||
|
||||
func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorStore {
|
||||
s := new(MemoryProboCollectorStore)
|
||||
|
||||
|
@ -41,19 +46,3 @@ func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorSto
|
|||
|
||||
return s
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
173
store/quiz.go
Normal file
173
store/quiz.go
Normal file
|
@ -0,0 +1,173 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
)
|
||||
|
||||
type ErrQuizAlreadyPresent struct {
|
||||
hash string
|
||||
}
|
||||
|
||||
func (e *ErrQuizAlreadyPresent) Error() string {
|
||||
return fmt.Sprintf("Quiz with hash %v is already present in the store.", e.hash)
|
||||
}
|
||||
|
||||
type QuizStore struct {
|
||||
// Memory store for quizzes. It satisfies FilterStorer
|
||||
// interface.
|
||||
*FilterStore[*models.Quiz]
|
||||
|
||||
questions *Store[*models.Question]
|
||||
answers *Store[*models.Answer]
|
||||
}
|
||||
|
||||
func NewQuizStore() *QuizStore {
|
||||
store := new(QuizStore)
|
||||
|
||||
store.questions = NewStore[*models.Question]()
|
||||
store.answers = NewStore[*models.Answer]()
|
||||
store.FilterStore = NewFilterStore[*models.Quiz]()
|
||||
|
||||
return store
|
||||
|
||||
}
|
||||
|
||||
func (s *QuizStore) Create(quiz *models.Quiz) (*models.Quiz, error) {
|
||||
if hash := quiz.GetHash(); hash != "" {
|
||||
q, ok := s.hashes[hash]
|
||||
if ok {
|
||||
return q, &ErrQuizAlreadyPresent{hash}
|
||||
}
|
||||
}
|
||||
|
||||
question, err := s.questions.Create(quiz.Question)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
answers := make([]*models.Answer, 0)
|
||||
|
||||
for _, a := range quiz.Answers {
|
||||
storedAnswer, err := s.answers.Create(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, storedAnswer)
|
||||
}
|
||||
|
||||
tags := make([]*models.Tag, 0)
|
||||
|
||||
q, err := s.Store.Create(&models.Quiz{
|
||||
Meta: quiz.Meta,
|
||||
Question: parseTags[*models.Question](&tags, question)[0],
|
||||
Answers: parseTags[*models.Answer](&tags, answers...),
|
||||
Correct: answers[quiz.CorrectPos],
|
||||
CorrectPos: quiz.CorrectPos,
|
||||
Tags: tags,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (s *QuizStore) Update(quiz *models.Quiz, id string) (*models.Quiz, error) {
|
||||
_, err := s.Read(id)
|
||||
if err != nil {
|
||||
return quiz, err
|
||||
}
|
||||
|
||||
question, err := s.questions.Create(quiz.Question)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
answers := make([]*models.Answer, 0)
|
||||
|
||||
for _, a := range quiz.Answers {
|
||||
storedAnswer, err := s.answers.Create(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answers = append(answers, storedAnswer)
|
||||
}
|
||||
|
||||
tags := make([]*models.Tag, 0)
|
||||
|
||||
q, err := s.Store.Update(&models.Quiz{
|
||||
Question: parseTags[*models.Question](&tags, question)[0],
|
||||
Answers: parseTags[*models.Answer](&tags, answers...),
|
||||
Correct: answers[quiz.CorrectPos],
|
||||
CorrectPos: quiz.CorrectPos,
|
||||
Tags: tags,
|
||||
}, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (s *QuizStore) FilterInCollection(collection *models.Collection, filter *models.Filter) []*models.Quiz {
|
||||
quizzes := s.ReadAll()
|
||||
filteredQuizzes := s.Filter(quizzes, func(q *models.Quiz) bool {
|
||||
count := 0
|
||||
for _, qTag := range q.Tags {
|
||||
if s.isTagInFilter(qTag, filter) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == len(filter.Tags) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
collection.Quizzes = filteredQuizzes
|
||||
|
||||
return collection.Quizzes
|
||||
}
|
||||
|
||||
func (s *QuizStore) isTagInFilter(tag *models.Tag, filter *models.Filter) bool {
|
||||
for _, fTag := range filter.Tags {
|
||||
if tag.Name == fTag.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseTags[T fmt.Stringer](tags *[]*models.Tag, entities ...T) []T {
|
||||
for _, entity := range entities {
|
||||
// Trim the following chars
|
||||
trimChars := "*:.,/\\@()[]{}<>"
|
||||
|
||||
// Split the text into words
|
||||
words := strings.Fields(entity.String())
|
||||
|
||||
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 entities
|
||||
}
|
252
store/quiz_test.go
Normal file
252
store/quiz_test.go
Normal file
|
@ -0,0 +1,252 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
type quizTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestCreateQuiz() {
|
||||
store := NewQuizStore()
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text with #tag."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err, "Quiz should be created without error")
|
||||
if !t.Failed() {
|
||||
quizFromMemory, err := store.Read(quiz.GetID())
|
||||
t.Nil(err, "Quiz should be found in the store")
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(quizFromMemory.GetID() != "")
|
||||
t.Equal(len(quizFromMemory.Tags), 1)
|
||||
t.Equal(len(store.questions.ids), 1)
|
||||
t.Equal(len(store.answers.ids), 4)
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(quizFromMemory, quiz), "Quiz should be equal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestDuplicateQuiz() {
|
||||
store := NewQuizStore()
|
||||
_, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
|
||||
t.Nil(err, "Quiz 1 should be created without error")
|
||||
|
||||
_, err = store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 1,
|
||||
})
|
||||
|
||||
t.Not(t.Nil(err), "Quiz 2 should not be created")
|
||||
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestReadQuiz() {
|
||||
store := NewQuizStore()
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
|
||||
t.Nil(err, "Quiz should be created without error")
|
||||
|
||||
if !t.Failed() {
|
||||
quizzes := store.ReadAll()
|
||||
|
||||
t.Equal(1, len(quizzes))
|
||||
|
||||
storedQuiz, err := store.Read(quiz.GetID())
|
||||
|
||||
t.Nil(err, "Quiz should be read without error")
|
||||
if !t.Failed() {
|
||||
t.Equal(quiz.ID, storedQuiz.ID)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestUpdateQuiz() {
|
||||
store := NewQuizStore()
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err, "Quiz should be created without error")
|
||||
if !t.Failed() {
|
||||
updatedQuiz, err := store.Update(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Updated question text with #tag."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
}, quiz.GetID())
|
||||
|
||||
updatedQuizFromMemory, err := store.Read(quiz.GetID())
|
||||
t.Nil(err, "Quiz should be found in the store")
|
||||
|
||||
if !t.Failed() {
|
||||
t.Equal(updatedQuizFromMemory.GetID(), updatedQuiz.GetID())
|
||||
t.Equal(updatedQuizFromMemory.Question.Text, updatedQuiz.Question.Text)
|
||||
t.Equal(len(updatedQuizFromMemory.Tags), 1)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestDeleteQuiz() {
|
||||
store := NewQuizStore()
|
||||
_, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err, "Quiz should be created without error")
|
||||
if !t.Failed() {
|
||||
quiz_2, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Newly created question text 2."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err, "Quiz should be created without error")
|
||||
|
||||
if !t.Failed() {
|
||||
_, err := store.Delete(quiz_2.GetID())
|
||||
t.Nil(err, "Quiz should be deleted without error")
|
||||
t.Equal(1, len(store.ids))
|
||||
t.Equal(1, len(store.hashes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestFilter() {
|
||||
store := NewQuizStore()
|
||||
|
||||
quiz_1, _ := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text with #tag1."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2 with #tag2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
|
||||
quiz_2, _ := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text with #tag3."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2 with #tag4"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
|
||||
quizzes := store.Filter([]*models.Quiz{quiz_1, quiz_2}, func(q *models.Quiz) bool {
|
||||
for _, t := range q.Tags {
|
||||
if t.Name == "#tag1" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
t.Equal(1, len(quizzes))
|
||||
}
|
||||
|
||||
func (t *quizTestSuite) TestParseTextForTags() {
|
||||
store := NewQuizStore()
|
||||
|
||||
quiz, err := store.Create(
|
||||
&models.Quiz{
|
||||
Question: &models.Question{Text: "Question text with #tag1."},
|
||||
Answers: []*models.Answer{
|
||||
{Text: "Answer 1"},
|
||||
{Text: "Answer 2 with #tag2"},
|
||||
{Text: "Answer 3"},
|
||||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
})
|
||||
t.Nil(err, "Quiz should be created without errors.")
|
||||
|
||||
if !t.Failed() {
|
||||
storedQuiz, err := store.Read(quiz.GetID())
|
||||
|
||||
t.Nil(err, "Quiz should be found in the store.")
|
||||
t.Equal(2, len(storedQuiz.Tags))
|
||||
if !t.Failed() {
|
||||
t.Equal("#tag1", storedQuiz.Tags[0].Name)
|
||||
t.Equal("#tag2", storedQuiz.Tags[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
175
store/store.go
175
store/store.go
|
@ -1,60 +1,159 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"git.andreafazzi.eu/andrea/probo/client"
|
||||
"git.andreafazzi.eu/andrea/probo/models"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type QuizReader interface {
|
||||
ReadAllQuizzes() ([]*models.Quiz, error)
|
||||
ReadQuizByID(id string) (*models.Quiz, error)
|
||||
ReadQuizByHash(hash string) (*models.Quiz, error)
|
||||
type IDer interface {
|
||||
GetID() string
|
||||
SetID(string)
|
||||
}
|
||||
|
||||
type QuizWriter interface {
|
||||
CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error)
|
||||
UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error)
|
||||
DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error)
|
||||
type Hasher interface {
|
||||
GetHash() string
|
||||
}
|
||||
|
||||
type CollectionReader interface {
|
||||
ReadAllCollections() ([]*models.Collection, error)
|
||||
ReadCollectionByID(id string) (*models.Collection, error)
|
||||
type Storable interface {
|
||||
IDer
|
||||
Hasher
|
||||
}
|
||||
|
||||
type CollectionWriter interface {
|
||||
CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error)
|
||||
UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error)
|
||||
DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error)
|
||||
type Storer[T Storable] interface {
|
||||
Create(T) (T, error)
|
||||
ReadAll() []T
|
||||
Read(string) (T, error)
|
||||
Update(T, string) (T, error)
|
||||
Delete(string) (T, error)
|
||||
}
|
||||
|
||||
type ParticipantReader interface {
|
||||
ReadAllParticipants() ([]*models.Participant, error)
|
||||
ReadParticipantByID(id string) (*models.Participant, error)
|
||||
type FilterStorer[T Storable] interface {
|
||||
Storer[T]
|
||||
|
||||
Filter([]T, func(T) bool) []T
|
||||
}
|
||||
|
||||
type ParticipantWriter interface {
|
||||
CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error)
|
||||
UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error)
|
||||
DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error)
|
||||
type Store[T Storable] struct {
|
||||
ids map[string]T
|
||||
hashes map[string]T
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
type ExamReader interface {
|
||||
ReadAllExams() ([]*models.Exam, error)
|
||||
ReadExamByID(id string) (*models.Exam, error)
|
||||
type FilterStore[T Storable] struct {
|
||||
*Store[T]
|
||||
}
|
||||
|
||||
type ExamWriter interface {
|
||||
CreateExam(r *client.CreateUpdateExamRequest) (*models.Exam, error)
|
||||
UpdateExam(r *client.CreateUpdateExamRequest, id string) (*models.Exam, bool, error)
|
||||
DeleteExam(r *client.DeleteExamRequest) (*models.Exam, error)
|
||||
func NewFilterStore[T Storable]() *FilterStore[T] {
|
||||
return &FilterStore[T]{NewStore[T]()}
|
||||
}
|
||||
|
||||
type ProboCollectorStore interface {
|
||||
QuizReader
|
||||
QuizWriter
|
||||
CollectionReader
|
||||
CollectionWriter
|
||||
ParticipantReader
|
||||
ParticipantWriter
|
||||
func (fs *FilterStore[T]) Filter(slice []T, f func(T) bool) []T {
|
||||
result := make([]T, 0)
|
||||
|
||||
for _, item := range slice {
|
||||
if f(item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func NewStore[T Storable]() *Store[T] {
|
||||
store := new(Store[T])
|
||||
|
||||
store.ids = make(map[string]T)
|
||||
store.hashes = make(map[string]T)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *Store[T]) Create(entity T) (T, error) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
if hash := entity.GetHash(); hash != "" {
|
||||
storedEntity, ok := s.hashes[hash]
|
||||
if ok {
|
||||
return storedEntity, nil
|
||||
}
|
||||
s.hashes[hash] = entity
|
||||
}
|
||||
|
||||
id := entity.GetID()
|
||||
|
||||
if id == "" {
|
||||
id = uuid.New().String()
|
||||
}
|
||||
|
||||
entity.SetID(id)
|
||||
s.ids[id] = entity
|
||||
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (s *Store[T]) ReadAll() []T {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
result := make([]T, 0)
|
||||
|
||||
for _, v := range s.ids {
|
||||
result = append(result, v)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store[T]) Read(id string) (T, error) {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
entity, ok := s.ids[id]
|
||||
if !ok {
|
||||
return entity, fmt.Errorf("Entity with ID %s was not found in the store.", id)
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
|
||||
}
|
||||
|
||||
func (s *Store[T]) Update(entity T, id string) (T, error) {
|
||||
sEntity, err := s.Read(id)
|
||||
if err != nil {
|
||||
return sEntity, err
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
entity.SetID(id)
|
||||
s.ids[id] = entity
|
||||
|
||||
if hash := entity.GetHash(); hash != "" {
|
||||
s.hashes[hash] = entity
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (s *Store[T]) Delete(id string) (T, error) {
|
||||
sEntity, err := s.Read(id)
|
||||
if err != nil {
|
||||
return sEntity, err
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
delete(s.ids, id)
|
||||
|
||||
if hash := sEntity.GetHash(); hash != "" {
|
||||
delete(s.hashes, hash)
|
||||
}
|
||||
|
||||
return sEntity, nil
|
||||
}
|
||||
|
|
15
store/store_test.go
Normal file
15
store/store_test.go
Normal file
|
@ -0,0 +1,15 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
func TestRunner(t *testing.T) {
|
||||
prettytest.Run(
|
||||
t,
|
||||
new(quizTestSuite),
|
||||
new(collectionTestSuite),
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue