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/google/uuid v1.3.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/sirupsen/logrus v1.8.1
|
github.com/sirupsen/logrus v1.8.1
|
||||||
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
gorm.io/gorm v1.25.5
|
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=
|
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
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, ""))
|
return h.hashFn(strings.Join(orderedHashes, ""))
|
||||||
}
|
}
|
||||||
-
|
|
||||||
|
|
|
@ -1,6 +1,27 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
type Answer struct {
|
type Answer struct {
|
||||||
ID string `json:"id" gorm:"primaryKey"`
|
ID string `json:"id" gorm:"primaryKey"`
|
||||||
Text string `json:"text"`
|
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
|
package models
|
||||||
|
|
||||||
|
type Filter struct {
|
||||||
|
Tags []*Tag
|
||||||
|
}
|
||||||
|
|
||||||
type Collection struct {
|
type Collection struct {
|
||||||
Meta
|
Meta
|
||||||
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Query string `json:"query"`
|
Filter *Filter `json:"filter"`
|
||||||
|
|
||||||
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
|
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"`
|
ID string `json:"id" yaml:"id" gorm:"primaryKey"`
|
||||||
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
|
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
|
||||||
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/remogatto/prettytest"
|
"github.com/remogatto/prettytest"
|
||||||
|
@ -16,3 +18,59 @@ func TestRunner(t *testing.T) {
|
||||||
new(testSuite),
|
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
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
type Question struct {
|
type Question struct {
|
||||||
Meta
|
Meta
|
||||||
Text string `json:"text"`
|
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)))
|
||||||
|
}
|
||||||
|
|
195
models/quiz.go
195
models/quiz.go
|
@ -1,11 +1,196 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
type Quiz struct {
|
type Quiz struct {
|
||||||
Meta
|
Meta
|
||||||
|
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Question *Question `json:"question" gorm:"foreignKey:ID"`
|
Question *Question `json:"question" gorm:"foreignKey:ID"`
|
||||||
Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"`
|
Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"`
|
||||||
Correct *Answer `json:"correct" gorm:"foreignKey:ID"`
|
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
|
||||||
Type int `json:"type"`
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/client"
|
|
||||||
"git.andreafazzi.eu/andrea/probo/models"
|
"git.andreafazzi.eu/andrea/probo/models"
|
||||||
|
"git.andreafazzi.eu/andrea/probo/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *FileProboCollectorStore) GetCollectionsDir() string {
|
func NewCollectionFileStore() (*FileStore[*models.Collection, *store.Store[*models.Collection]], error) {
|
||||||
return s.collectionsDir
|
return NewFileStore[*models.Collection](
|
||||||
}
|
store.NewStore[*models.Collection](),
|
||||||
|
filepath.Join(BaseDir, CollectionsDir),
|
||||||
func (s *FileProboCollectorStore) GetCollectionPath(collection *models.Collection) (string, error) {
|
"collection",
|
||||||
s.lock.RLock()
|
".json",
|
||||||
defer s.lock.RUnlock()
|
func(s *store.Store[*models.Collection], filepath string, content []byte) (*models.Collection, error) {
|
||||||
|
collection := new(models.Collection)
|
||||||
path, ok := s.collectionsPaths[collection.ID]
|
err := json.Unmarshal(content, &collection)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return "", errors.New(fmt.Sprintf("Path not found for collection ID %v", collection.ID))
|
return nil, err
|
||||||
}
|
}
|
||||||
return path, nil
|
|
||||||
}
|
c, err := s.Create(collection)
|
||||||
|
if err != nil {
|
||||||
func (s *FileProboCollectorStore) SetCollectionPath(collection *models.Collection, path string) string {
|
return nil, err
|
||||||
s.lock.Lock()
|
}
|
||||||
defer s.lock.Unlock()
|
|
||||||
|
return c, nil
|
||||||
s.collectionsPaths[collection.ID] = path
|
},
|
||||||
|
func(s *store.Store[*models.Collection], filePath string, collection *models.Collection) error {
|
||||||
return path
|
jsonData, err := json.Marshal(collection)
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
func (s *FileProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) {
|
}
|
||||||
return s.memoryStore.ReadAllCollections()
|
|
||||||
}
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
func (s *FileProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
|
return err
|
||||||
collection, err := s.memoryStore.CreateCollection(r)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
defer file.Close()
|
||||||
}
|
|
||||||
|
_, err = file.Write(jsonData)
|
||||||
err = s.createOrUpdateCollectionFile(collection)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
|
return nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
package file
|
package file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/client"
|
|
||||||
"git.andreafazzi.eu/andrea/probo/models"
|
"git.andreafazzi.eu/andrea/probo/models"
|
||||||
|
"git.andreafazzi.eu/andrea/probo/store"
|
||||||
"github.com/remogatto/prettytest"
|
"github.com/remogatto/prettytest"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -16,158 +13,60 @@ type collectionTestSuite struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *collectionTestSuite) TestCreateCollection() {
|
func (t *collectionTestSuite) TestCreateCollection() {
|
||||||
store, err := NewFileProboCollectorStore(testdataDir)
|
quizStore := store.NewQuizStore()
|
||||||
t.Nil(err, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
|
|
||||||
|
|
||||||
if !t.Failed() {
|
quizStore.Create(
|
||||||
quiz_1, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
|
&models.Quiz{
|
||||||
Quiz: &client.Quiz{
|
Question: &models.Question{Text: "Question text #tag1 #tag3."},
|
||||||
Question: &client.Question{Text: "Question text with #tag1."},
|
Answers: []*models.Answer{
|
||||||
Answers: []*client.Answer{
|
{Text: "Answer 1"},
|
||||||
{Text: "Answer 1", Correct: true},
|
{Text: "Answer 2"},
|
||||||
{Text: "Answer 2", Correct: false},
|
{Text: "Answer 3"},
|
||||||
{Text: "Answer 3", Correct: false},
|
{Text: "Answer 4"},
|
||||||
{Text: "Answer 4", Correct: false},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Nil(err, "The quiz to be updated should be created without issue")
|
quizStore.Create(
|
||||||
|
&models.Quiz{
|
||||||
path_1, _ := store.GetQuizPath(quiz_1)
|
Question: &models.Question{Text: "Question text #tag2."},
|
||||||
|
Answers: []*models.Answer{
|
||||||
if !t.Failed() {
|
{Text: "Answer 1"},
|
||||||
quiz_2, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
|
{Text: "Answer 2"},
|
||||||
Quiz: &client.Quiz{
|
{Text: "Answer 3"},
|
||||||
Question: &client.Question{Text: "Another question text with #tag1."},
|
{Text: "Answer 4"},
|
||||||
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")
|
|
||||||
|
|
||||||
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},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Nil(err, "The quiz to be updated should be created without issue")
|
|
||||||
|
|
||||||
path_3, _ := store.GetQuizPath(quiz_3)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
|
|
||||||
collection, err := store.CreateCollection(
|
|
||||||
&client.CreateUpdateCollectionRequest{
|
|
||||||
Collection: &client.Collection{
|
|
||||||
Name: "MyCollection",
|
|
||||||
Query: "#tag1",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Nil(err, "Creating a collection should not return an error")
|
|
||||||
|
|
||||||
collectionPath, _ := store.GetCollectionPath(collection)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
t.Equal(2, len(collection.Quizzes))
|
|
||||||
|
|
||||||
os.Remove(path_1)
|
|
||||||
os.Remove(path_2)
|
|
||||||
os.Remove(path_3)
|
|
||||||
os.Remove(collectionPath)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
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"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if !t.Failed() {
|
store, err := NewCollectionFileStore()
|
||||||
clientCollection := &client.Collection{
|
t.Nil(err)
|
||||||
Name: "Updated collection name",
|
|
||||||
Query: "#tag2",
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedCollection, err := store.UpdateCollection(
|
c := new(models.Collection)
|
||||||
&client.CreateUpdateCollectionRequest{
|
c.Name = "MyCollection"
|
||||||
Collection: clientCollection,
|
|
||||||
}, collection.ID)
|
|
||||||
|
|
||||||
t.Nil(err, fmt.Sprintf("Collection should be updated without errors: %v", err))
|
quizStore.FilterInCollection(c, &models.Filter{
|
||||||
|
Tags: []*models.Tag{
|
||||||
|
{Name: "#tag3"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
t.Equal("#tag2", updatedCollection.Query)
|
_, err = store.Create(c)
|
||||||
|
|
||||||
if !t.Failed() {
|
exists, err := os.Stat(store.GetPath(c))
|
||||||
path, err := store.GetCollectionPath(updatedCollection)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
t.Nil(err)
|
||||||
t.Nil(err, "GetPath should not raise an error.")
|
t.Not(t.Nil(exists))
|
||||||
|
t.Equal(2, len(c.Quizzes))
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
defer os.Remove(store.GetPath(c))
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,68 +2,176 @@ package file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
|
"git.andreafazzi.eu/andrea/probo/store"
|
||||||
"git.andreafazzi.eu/andrea/probo/store/memory"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
|
ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
|
||||||
|
|
||||||
DefaultQuizzesDir = "quizzes"
|
BaseDir = "data"
|
||||||
DefaultCollectionsDir = "collections"
|
QuizzesDir = "quizzes"
|
||||||
|
CollectionsDir = "collections"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileProboCollectorStore struct {
|
type Storer[T store.Storable] interface {
|
||||||
Dir string
|
store.Storer[T]
|
||||||
|
// store.FilterStorer[T]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) {
|
type FileStore[T store.Storable, K Storer[T]] struct {
|
||||||
s := new(FileProboCollectorStore)
|
Storer K
|
||||||
|
|
||||||
s.Dir = dirname
|
Dir string
|
||||||
|
FilePrefix string
|
||||||
|
FileSuffix string
|
||||||
|
|
||||||
s.quizzesDir = filepath.Join(s.Dir, DefaultQuizzesDir)
|
MarshalFunc func(K, string, []byte) (T, error)
|
||||||
s.collectionsDir = filepath.Join(s.Dir, DefaultCollectionsDir)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return s, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileProboCollectorStore) Reindex() error {
|
func (s *FileStore[T, K]) Create(entity T) (T, error) {
|
||||||
s.memoryStore = memory.NewMemoryProboCollectorStore(
|
e, err := s.Storer.Create(entity)
|
||||||
sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
|
if err != nil {
|
||||||
)
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
s.quizzesPaths = make(map[string]string)
|
filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix))
|
||||||
s.collectionsPaths = make(map[string]string)
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.reindexCollections()
|
entityFiles := make([]fs.DirEntry, 0)
|
||||||
if err != nil {
|
|
||||||
return err
|
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
|
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
|
package file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
"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"
|
"github.com/remogatto/prettytest"
|
||||||
)
|
)
|
||||||
|
|
||||||
var testdataDir = "./testdata"
|
var testdataDir = "./testdata"
|
||||||
|
|
||||||
type quizTestSuite struct {
|
|
||||||
prettytest.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner(t *testing.T) {
|
func TestRunner(t *testing.T) {
|
||||||
prettytest.Run(
|
prettytest.Run(
|
||||||
t,
|
t,
|
||||||
|
@ -26,273 +15,3 @@ func TestRunner(t *testing.T) {
|
||||||
new(collectionTestSuite),
|
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,202 +4,109 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/client"
|
|
||||||
"git.andreafazzi.eu/andrea/probo/models"
|
"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 {
|
func NewQuizFileStore() (*FileStore[*models.Quiz, *store.QuizStore], error) {
|
||||||
return s.quizzesDir
|
return NewFileStore[*models.Quiz, *store.QuizStore](
|
||||||
}
|
store.NewQuizStore(),
|
||||||
|
filepath.Join(BaseDir, QuizzesDir),
|
||||||
func (s *FileProboCollectorStore) SetQuizPath(quiz *models.Quiz, path string) string {
|
"quiz",
|
||||||
s.lock.Lock()
|
".md",
|
||||||
defer s.lock.Unlock()
|
func(s *store.QuizStore, filepath string, content []byte) (*models.Quiz, error) {
|
||||||
|
quiz, meta, err := models.MarkdownToQuiz(string(content))
|
||||||
s.quizzesPaths[quiz.ID] = path
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
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")
|
var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent
|
||||||
|
|
||||||
if questionText == "" {
|
q, err := s.Create(quiz)
|
||||||
return nil, nil, fmt.Errorf("Question text should not be empty.")
|
if err != nil && !errors.As(err, &errQuizAlreadyPresent) {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if len(answers) < 2 {
|
if meta == nil {
|
||||||
return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
|
writeQuizHeader(filepath, &models.Meta{
|
||||||
}
|
ID: q.ID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
question := &client.Question{Text: questionText}
|
return q, nil
|
||||||
quiz := &client.Quiz{Question: question, Answers: answers}
|
},
|
||||||
|
func(s *store.QuizStore, filePath string, quiz *models.Quiz) error {
|
||||||
|
markdown, err := models.QuizToMarkdown(quiz)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return quiz, meta, nil
|
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) ReadMetaHeaderFromFile(filename string) (*models.Meta, error) {
|
func writeQuizHeader(path string, meta *models.Meta) (*models.Meta, error) {
|
||||||
data, err := os.ReadFile(path.Join(s.quizzesDir, filename))
|
readMeta, err := readQuizHeader(path)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if readMeta == nil {
|
if readMeta == nil {
|
||||||
_, err := writeMetaHeader(path.Join(s.quizzesDir, filename), meta)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -208,59 +115,40 @@ func (s *FileProboCollectorStore) WriteMetaHeaderToFile(filename string, meta *m
|
||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FileProboCollectorStore) reindexQuizzes() error {
|
func readQuizHeader(path string) (*models.Meta, error) {
|
||||||
files, err := os.ReadDir(s.quizzesDir)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
meta, _, err := models.ParseMetaHeaderFromMarkdown(string(data))
|
||||||
markdownFiles := make([]fs.DirEntry, 0)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
for _, file := range files {
|
|
||||||
filename := file.Name()
|
|
||||||
if !file.IsDir() && strings.HasSuffix(filename, ".md") {
|
|
||||||
markdownFiles = append(markdownFiles, file)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return meta, nil
|
||||||
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) {
|
func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) {
|
||||||
file, err := os.Open(path.Join(s.quizzesDir, filename))
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -313,7 +201,7 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err = os.Create(path.Join(s.quizzesDir, filename))
|
file, err = os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -326,169 +214,3 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M
|
||||||
|
|
||||||
return &meta, nil
|
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
|
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.
|
Question text 2.
|
||||||
|
|
||||||
|
@ -8,4 +9,3 @@ Question text 2.
|
||||||
* Answer 2
|
* Answer 2
|
||||||
* Answer 3
|
* Answer 3
|
||||||
* Answer 4
|
* 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
|
id: 5ebab010-8e96-41c9-905d-a092da037194
|
||||||
created_at: !!timestamp 2023-10-31T10:02:02.869395215+01:00
|
created_at: 2023-11-13T20:58:30.651064703+01:00
|
||||||
updated_at: !!timestamp 0001-01-01T00:00:00Z
|
updated_at: 0001-01-01T00:00:00Z
|
||||||
---
|
---
|
||||||
This quiz is initially without metadata.
|
This quiz is initially without metadata.
|
||||||
|
|
||||||
|
|
|
@ -1,31 +1,36 @@
|
||||||
package memory
|
package memory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/hasher"
|
"git.andreafazzi.eu/andrea/probo/hasher"
|
||||||
"git.andreafazzi.eu/andrea/probo/models"
|
"git.andreafazzi.eu/andrea/probo/models"
|
||||||
|
"git.andreafazzi.eu/andrea/probo/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MemoryProboCollectorStore struct {
|
type Store[T Storable] struct {
|
||||||
|
ids map[string]T
|
||||||
// IDs maps
|
hashes map[string]T
|
||||||
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
|
|
||||||
|
|
||||||
// A mutex is used to synchronize read/write access to the map
|
// A mutex is used to synchronize read/write access to the map
|
||||||
lock sync.RWMutex
|
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 {
|
func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorStore {
|
||||||
s := new(MemoryProboCollectorStore)
|
s := new(MemoryProboCollectorStore)
|
||||||
|
|
||||||
|
@ -41,19 +46,3 @@ func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorSto
|
||||||
|
|
||||||
return s
|
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
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.andreafazzi.eu/andrea/probo/client"
|
"fmt"
|
||||||
"git.andreafazzi.eu/andrea/probo/models"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuizReader interface {
|
type IDer interface {
|
||||||
ReadAllQuizzes() ([]*models.Quiz, error)
|
GetID() string
|
||||||
ReadQuizByID(id string) (*models.Quiz, error)
|
SetID(string)
|
||||||
ReadQuizByHash(hash string) (*models.Quiz, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuizWriter interface {
|
type Hasher interface {
|
||||||
CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error)
|
GetHash() string
|
||||||
UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error)
|
|
||||||
DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectionReader interface {
|
type Storable interface {
|
||||||
ReadAllCollections() ([]*models.Collection, error)
|
IDer
|
||||||
ReadCollectionByID(id string) (*models.Collection, error)
|
Hasher
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectionWriter interface {
|
type Storer[T Storable] interface {
|
||||||
CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error)
|
Create(T) (T, error)
|
||||||
UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error)
|
ReadAll() []T
|
||||||
DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error)
|
Read(string) (T, error)
|
||||||
|
Update(T, string) (T, error)
|
||||||
|
Delete(string) (T, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParticipantReader interface {
|
type FilterStorer[T Storable] interface {
|
||||||
ReadAllParticipants() ([]*models.Participant, error)
|
Storer[T]
|
||||||
ReadParticipantByID(id string) (*models.Participant, error)
|
|
||||||
|
Filter([]T, func(T) bool) []T
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParticipantWriter interface {
|
type Store[T Storable] struct {
|
||||||
CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error)
|
ids map[string]T
|
||||||
UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error)
|
hashes map[string]T
|
||||||
DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error)
|
|
||||||
|
lock sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExamReader interface {
|
type FilterStore[T Storable] struct {
|
||||||
ReadAllExams() ([]*models.Exam, error)
|
*Store[T]
|
||||||
ReadExamByID(id string) (*models.Exam, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExamWriter interface {
|
func NewFilterStore[T Storable]() *FilterStore[T] {
|
||||||
CreateExam(r *client.CreateUpdateExamRequest) (*models.Exam, error)
|
return &FilterStore[T]{NewStore[T]()}
|
||||||
UpdateExam(r *client.CreateUpdateExamRequest, id string) (*models.Exam, bool, error)
|
|
||||||
DeleteExam(r *client.DeleteExamRequest) (*models.Exam, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProboCollectorStore interface {
|
func (fs *FilterStore[T]) Filter(slice []T, f func(T) bool) []T {
|
||||||
QuizReader
|
result := make([]T, 0)
|
||||||
QuizWriter
|
|
||||||
CollectionReader
|
for _, item := range slice {
|
||||||
CollectionWriter
|
if f(item) {
|
||||||
ParticipantReader
|
result = append(result, item)
|
||||||
ParticipantWriter
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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