diff --git a/models/models_test.go b/models/models_test.go new file mode 100644 index 0000000..ad52032 --- /dev/null +++ b/models/models_test.go @@ -0,0 +1,18 @@ +package models + +import ( + "testing" + + "github.com/remogatto/prettytest" +) + +type testSuite struct { + prettytest.Suite +} + +func TestRunner(t *testing.T) { + prettytest.Run( + t, + new(testSuite), + ) +} diff --git a/store/file/file.go b/store/file/file.go index 5ddb722..e1f1d1a 100644 --- a/store/file/file.go +++ b/store/file/file.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strings" + "sync" + "time" "git.andreafazzi.eu/andrea/probo/client" "git.andreafazzi.eu/andrea/probo/hasher/sha256" @@ -20,16 +22,30 @@ type FileProboCollectorStore struct { memoryStore *memory.MemoryProboCollectorStore paths map[string]string + + // A mutex is used to synchronize read/write access to the map + lock sync.RWMutex } func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) { s := new(FileProboCollectorStore) - files, err := ioutil.ReadDir(dirname) + s.Dir = dirname + + err := s.Reindex() if err != nil { return nil, err } + return s, nil +} + +func (s *FileProboCollectorStore) Reindex() error { + files, err := ioutil.ReadDir(s.Dir) + if err != nil { + return err + } + s.paths = make(map[string]string) markdownFiles := make([]fs.FileInfo, 0) @@ -42,7 +58,7 @@ func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error } if len(markdownFiles) == 0 { - return nil, fmt.Errorf("The directory is empty.") + return fmt.Errorf("The directory is empty.") } s.memoryStore = memory.NewMemoryProboCollectorStore( @@ -51,24 +67,27 @@ func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error for _, file := range markdownFiles { filename := file.Name() - fullPath := filepath.Join(dirname, filename) + fullPath := filepath.Join(s.Dir, filename) content, err := os.ReadFile(fullPath) if err != nil { - return nil, err + return err } quiz, err := QuizFromMarkdown(string(content)) if err != nil { - return nil, err + return err } - s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{ + q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{ Quiz: quiz, }) + if err != nil { + return err + } + + s.SetPath(q, fullPath) } - s.Dir = dirname - - return s, nil + return nil } func (s *FileProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { @@ -86,7 +105,12 @@ func (s *FileProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) return nil, err } - return quiz, nil + 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) { @@ -95,7 +119,35 @@ func (s *FileProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, return nil, err } - return quiz, nil + 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) GetPath(quiz *models.Quiz) (string, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + path, ok := s.paths[quiz.ID] + if !ok { + return "", errors.New(fmt.Sprintf("Path not found for quiz ID %v", quiz.ID)) + } + return path, nil +} + +func (s *FileProboCollectorStore) SetPath(quiz *models.Quiz, path string) string { + s.lock.Lock() + defer s.lock.Unlock() + s.paths[quiz.ID] = path + return path } func MarkdownFromQuiz(quiz *models.Quiz) (string, error) { @@ -103,19 +155,21 @@ func MarkdownFromQuiz(quiz *models.Quiz) (string, error) { return "", errors.New("Quiz should contain a question but it wasn't provided.") } - if len(quiz.Answers) == 0 { + 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 non was provided.") + 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 { - otherAnswers += "* " + answer.Text + "\n" + if quiz.Correct != answer { + otherAnswers += "* " + answer.Text + "\n" + } } markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers @@ -160,20 +214,21 @@ func QuizFromMarkdown(markdown string) (*client.Quiz, error) { } func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error { - var filename string - markdown, err := MarkdownFromQuiz(quiz) if err != nil { return err } - fn := s.paths[quiz.ID] + + fn, _ := s.GetPath(quiz) if fn == "" { - filename = filepath.Join(s.Dir, quiz.Hash+".md") + fn = filepath.Join(s.Dir, fmt.Sprintf("quiz_%v.%s", time.Now().Unix(), "md")) } - file, err := os.Create(filename) + + file, err := os.Create(fn) if err != nil { return err } + defer file.Close() _, err = file.Write([]byte(markdown)) @@ -181,20 +236,7 @@ func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) return err } + s.SetPath(quiz, fn) + return nil } - -// func (s *FileProboCollectorStore) writeMarkdownFile(quiz *models.Quiz) error { -// markdown, err := MarkdownFromQuiz(quiz) -// if err != nil { -// return err -// } - -// filename := filepath.Join(s.Dir, quiz.Hash+".md") -// err = ioutil.WriteFile(filename, []byte(markdown), 0644) -// if err != nil { -// return err -// } - -// return nil -// } diff --git a/store/file/file_test.go b/store/file/file_test.go index cda5992..2897b43 100644 --- a/store/file/file_test.go +++ b/store/file/file_test.go @@ -3,12 +3,13 @@ package file import ( "fmt" "os" - "path/filepath" "reflect" "testing" "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store/memory" "github.com/remogatto/prettytest" ) @@ -54,7 +55,7 @@ Question text (3). } func (t *testSuite) TestReadAllQuizzes() { - store, err := NewFileProboCollectorStore("./test/quizzes") + store, err := NewFileProboCollectorStore("./testdata/quizzes") t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) if !t.Failed() { @@ -68,19 +69,14 @@ func (t *testSuite) TestReadAllQuizzes() { len(result), fmt.Sprintf("The store contains 3 files but only 2 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v", len(result)), ) - t.Equal("Question text 1.", result[0].Question.Text) } } } -func (t *testSuite) TestCreateQuiz() { - dirname := "./test/quizzes" - store, err := NewFileProboCollectorStore(dirname) - - t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) - - _, err = store.CreateQuiz( +func (t *testSuite) 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."}, @@ -92,25 +88,70 @@ func (t *testSuite) TestCreateQuiz() { }, }, }) + md, err := MarkdownFromQuiz(quiz) + t.Nil(err, "Conversion to markdown should not raise an error") + if !t.Failed() { + t.Equal(`Newly created question text. - t.Nil(err, fmt.Sprintf("An error was raised when saving the quiz on disk: %v", err)) +* Answer 1 +* Answer 2 +* Answer 3 +* Answer 4 +`, md) + } +} - newFilename := filepath.Join( - dirname, - "94ed4e9cdf8e0a75a2c5ce925cb791ebc5977ce1801e12059f58ce4d66c0c7f6.md", - ) - exists, err := os.Stat(newFilename) - t.Nil(err, "Stat should not return an error") +func (t *testSuite) TestCreateQuiz() { + dirname := "./testdata/quizzes" + store, err := NewFileProboCollectorStore(dirname) + + t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) if !t.Failed() { - t.True(exists != nil, "The new quiz file was not created.") - err := os.Remove(newFilename) - t.Nil(err, "Stat should not return an error") + 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.GetPath(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 *testSuite) TestUpdateQuiz() { - dirname := "./test/quizzes" + dirname := "./testdata/quizzes" store, err := NewFileProboCollectorStore(dirname) t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err)) @@ -130,18 +171,40 @@ func (t *testSuite) TestUpdateQuiz() { t.Nil(err, "The quiz to be updated should be created without issue") if !t.Failed() { - _, err = store.UpdateQuiz( + 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}, + }, + } + + updatedQuiz, err := store.UpdateQuiz( &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Updated 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: clientQuiz, }, quiz.ID) + + t.Nil(err, fmt.Sprintf("Quiz should be updated without errors: %v", err)) + + if !t.Failed() { + path, err := store.GetPath(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") + } + } + } + } } } @@ -152,6 +215,14 @@ func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQu } +func readQuizFromDisk(path string) (*client.Quiz, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return QuizFromMarkdown(string(content)) +} + func testsAreEqual(got, want []*models.Quiz) bool { return reflect.DeepEqual(got, want) } diff --git a/store/file/test/quizzes/94ed4e9cdf8e0a75a2c5ce925cb791ebc5977ce1801e12059f58ce4d66c0c7f6.md b/store/file/test/quizzes/94ed4e9cdf8e0a75a2c5ce925cb791ebc5977ce1801e12059f58ce4d66c0c7f6.md deleted file mode 100644 index 356d066..0000000 --- a/store/file/test/quizzes/94ed4e9cdf8e0a75a2c5ce925cb791ebc5977ce1801e12059f58ce4d66c0c7f6.md +++ /dev/null @@ -1,7 +0,0 @@ -Newly created question text. - -* Answer 1 -* Answer 1 -* Answer 2 -* Answer 3 -* Answer 4 diff --git a/store/file/test/quizzes/quiz_1.md b/store/file/testdata/quizzes/quiz_1.md similarity index 100% rename from store/file/test/quizzes/quiz_1.md rename to store/file/testdata/quizzes/quiz_1.md diff --git a/store/file/test/quizzes/quiz_2.md b/store/file/testdata/quizzes/quiz_2.md similarity index 100% rename from store/file/test/quizzes/quiz_2.md rename to store/file/testdata/quizzes/quiz_2.md diff --git a/store/file/test/quizzes/quiz_3.md b/store/file/testdata/quizzes/quiz_3.md similarity index 100% rename from store/file/test/quizzes/quiz_3.md rename to store/file/testdata/quizzes/quiz_3.md diff --git a/store/memory/memory.go b/store/memory/memory.go index 9ae8fd5..f922d9b 100644 --- a/store/memory/memory.go +++ b/store/memory/memory.go @@ -124,7 +124,21 @@ func (s *MemoryProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { return result, nil } +func (s *MemoryProboCollectorStore) ReadQuizByHash(hash string) (*models.Quiz, error) { + quiz := s.getQuizFromHash(hash) + if quiz == nil { + return nil, fmt.Errorf("Quiz with hash %s was not found in the store.", hash) + } + return quiz, nil +} + +func (s *MemoryProboCollectorStore) CalculateQuizHash(quiz *client.Quiz) string { + hashes := s.hasher.QuizHashes(quiz) + return hashes[len(hashes)-1] +} + func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) { + hashes := s.hasher.QuizHashes(r.Quiz) quizHash := hashes[len(hashes)-1] diff --git a/store/memory/memory_test.go b/store/memory/memory_test.go new file mode 100644 index 0000000..cea9959 --- /dev/null +++ b/store/memory/memory_test.go @@ -0,0 +1,78 @@ +package memory + +import ( + "reflect" + "testing" + + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" + "github.com/remogatto/prettytest" +) + +type testSuite struct { + prettytest.Suite +} + +func TestRunner(t *testing.T) { + prettytest.Run( + t, + new(testSuite), + ) +} + +func (t *testSuite) TestReadQuizByHash() { + store := NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + quiz, _ := 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}, + }, + }, + }) + quizFromMemory, err := store.ReadQuizByHash(quiz.Hash) + t.Nil(err, "Quiz should be found in the store") + if !t.Failed() { + t.True(reflect.DeepEqual(quizFromMemory, quiz), "Quiz should be equal") + } + +} + +func (t *testSuite) TestUpdateQuiz() { + store := NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + quiz, _ := 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}, + }, + }, + }) + + updatedQuiz, _ := store.CreateQuiz( + &client.CreateUpdateQuizRequest{ + Quiz: &client.Quiz{ + Question: &client.Question{Text: "Updated 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.True(quiz.Hash != updatedQuiz.Hash, "The two hashes should not be equal.") +} diff --git a/store/store.go b/store/store.go index a76e9c7..38a31be 100644 --- a/store/store.go +++ b/store/store.go @@ -8,6 +8,8 @@ import ( type ProboCollectorStore interface { ReadAllQuizzes() ([]*models.Quiz, error) + ReadQuizByHash(hash string) (*models.Quiz, error) + CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) }