diff --git a/cmd/notes/main.go b/cmd/notes/main.go index 5b33e33..34b58c2 100644 --- a/cmd/notes/main.go +++ b/cmd/notes/main.go @@ -12,6 +12,8 @@ import ( "github.com/rd2w/go-notes/internal/logger" "github.com/rd2w/go-notes/internal/model" "github.com/rd2w/go-notes/internal/repository" + "github.com/rd2w/go-notes/internal/repository/storage/fs" + "github.com/rd2w/go-notes/internal/repository/storage/ram" "github.com/rd2w/go-notes/internal/service" ) @@ -30,7 +32,7 @@ const ( AppShutdownMsg = "Приложение \"Заметки\" успешно завершило выполнение программы!" ShutdownStartMsg = "Получен сигнал завершения, инициируем graceful shutdown..." ResultsHeader = "\n=== РЕЗУЛЬТАТЫ ===\n" - NoteCountMsg = "Всего заметок создано: %d\n" + NoteCountMsg = "Всего заметок в хранилище данных: %d\n" NoteDoesNotExistMsg = "Ошибка: заметка не существует" NoteHeaderMsg = "Заметка %d:\n" NoteIDMsg = " ID: %s\n" @@ -50,8 +52,12 @@ func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + // Явно регистрируем реализации + repository.Register(repository.RAM, ram.NewRamRepository) + repository.Register(repository.JSON, fs.NewJSONRepository) + // Инициализируем компоненты - repo := repository.NewRepository() + repo := repository.NewRepositoryByType(repository.JSON) svc := service.NewService(repo, ctx, DataGenInterval) newLogger := logger.NewLogger(repo, ctx, LoggerInterval) diff --git a/go.mod b/go.mod index 718f9a7..3773309 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/rd2w/go-notes go 1.25.3 -require github.com/stretchr/testify v1.11.1 +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index aa493cc..42976e0 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,9 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 99a9a0c..607c0ef 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -10,13 +10,13 @@ import ( // Logger отвечает за логирование изменений в данных type Logger struct { - repo *repository.Repository + repo repository.Repository ctx context.Context interval time.Duration } // NewLogger создает новый экземпляр логгера -func NewLogger(repo *repository.Repository, ctx context.Context, interval time.Duration) *Logger { +func NewLogger(repo repository.Repository, ctx context.Context, interval time.Duration) *Logger { return &Logger{ repo: repo, ctx: ctx, diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 1d0fd25..557dd2a 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/rd2w/go-notes/internal/model" - "github.com/rd2w/go-notes/internal/repository" + "github.com/rd2w/go-notes/internal/repository/storage/ram" "github.com/rd2w/go-notes/internal/service" "github.com/stretchr/testify/assert" ) @@ -42,7 +42,7 @@ func TestLogger_IntegrationWithService(t *testing.T) { // Создаем компоненты как в main() ctx, cancel := context.WithCancel(context.Background()) - repo := repository.NewRepository() + repo := ram.NewRamRepository() svc := service.NewService(repo, ctx, 50*time.Millisecond) logger := NewLogger(repo, ctx, 30*time.Millisecond) @@ -81,7 +81,7 @@ func TestLogger_StopWithContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - repo := repository.NewRepository() + repo := ram.NewRamRepository() // Увеличиваем интервал логгера чтобы он реже проверял logger := NewLogger(repo, ctx, 100*time.Millisecond) @@ -128,7 +128,7 @@ func TestLogger_MultipleNoteGeneration(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() logger := NewLogger(repo, ctx, 40*time.Millisecond) // Запускаем компоненты @@ -189,7 +189,7 @@ func TestLogger_NoNotesScenario(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() logger := NewLogger(repo, ctx, 30*time.Millisecond) // Запускаем только логгер, но не отправляем заметки @@ -214,7 +214,7 @@ func TestLogger_ConcurrentAccess(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() // Увеличиваем интервал для стабильности logger := NewLogger(repo, ctx, 30*time.Millisecond) @@ -263,7 +263,7 @@ func TestLogger_TimeFormatConsistency(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() logger := NewLogger(repo, ctx, 50*time.Millisecond) go logger.Start() @@ -304,7 +304,7 @@ func TestLogger_SimpleCase(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() // Очень короткий интервал для быстрого обнаружения logger := NewLogger(repo, ctx, 10*time.Millisecond) @@ -341,7 +341,7 @@ func TestLogger_SeesNotesBeforeStop(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - repo := repository.NewRepository() + repo := ram.NewRamRepository() // Очень короткий интервал для быстрого обнаружения logger := NewLogger(repo, ctx, 10*time.Millisecond) @@ -387,7 +387,7 @@ func TestLogger_ImmediateStop(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - repo := repository.NewRepository() + repo := ram.NewRamRepository() logger := NewLogger(repo, ctx, 10*time.Millisecond) // Останавливаем СРАЗУ ЖЕ @@ -420,7 +420,7 @@ func TestLogger_GracefulStop(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // На этот раз используем defer - repo := repository.NewRepository() + repo := ram.NewRamRepository() // Нормальный интервал logger := NewLogger(repo, ctx, 50*time.Millisecond) diff --git a/internal/model/common.go b/internal/model/common.go index f0c4391..2299896 100644 --- a/internal/model/common.go +++ b/internal/model/common.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "encoding/json" + "time" +) // TimeFields содержит общие временные метки для сущностей type TimeFields struct { @@ -29,3 +32,30 @@ func (t *TimeFields) initializeTimestamps() { t.createdAt = now t.updatedAt = now } + +// JSONTimeFields вспомогательная структура для JSON сериализации +type JSONTimeFields struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MarshalJSON реализует интерфейс json.Marshaler +func (t *TimeFields) MarshalJSON() ([]byte, error) { + return json.Marshal(JSONTimeFields{ + CreatedAt: t.createdAt, + UpdatedAt: t.updatedAt, + }) +} + +// UnmarshalJSON реализует интерфейс json.Unmarshaler +func (t *TimeFields) UnmarshalJSON(data []byte) error { + var jsonTimeFields JSONTimeFields + if err := json.Unmarshal(data, &jsonTimeFields); err != nil { + return err + } + + t.createdAt = jsonTimeFields.CreatedAt + t.updatedAt = jsonTimeFields.UpdatedAt + + return nil +} diff --git a/internal/model/note.go b/internal/model/note.go index ce94574..591082b 100644 --- a/internal/model/note.go +++ b/internal/model/note.go @@ -1,8 +1,10 @@ package model import ( - "crypto/rand" - "encoding/base64" + "encoding/json" + "time" + + "github.com/google/uuid" ) // Note представляет сущность заметки @@ -56,10 +58,41 @@ func (n *Note) SetContent(newContent string) { // generateID генерирует уникальный идентификатор func generateID() string { - b := make([]byte, 8) - _, err := rand.Read(b) - if err != nil { - panic("не удалось сгенерировать ID") + return uuid.New().String() +} + +// JSONNote вспомогательная структура для JSON сериализации +type JSONNote struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MarshalJSON реализует интерфейс json.Marshaler +func (n *Note) MarshalJSON() ([]byte, error) { + return json.Marshal(JSONNote{ + ID: n.id, + Title: n.title, + Content: n.content, + CreatedAt: n.createdAt, + UpdatedAt: n.updatedAt, + }) +} + +// UnmarshalJSON реализует интерфейс json.Unmarshaler +func (n *Note) UnmarshalJSON(data []byte) error { + var jsonNote JSONNote + if err := json.Unmarshal(data, &jsonNote); err != nil { + return err } - return base64.URLEncoding.EncodeToString(b) + + n.id = jsonNote.ID + n.title = jsonNote.Title + n.content = jsonNote.Content + n.createdAt = jsonNote.CreatedAt + n.updatedAt = jsonNote.UpdatedAt + + return nil } diff --git a/internal/model/note_test.go b/internal/model/note_test.go index ee46487..9da8ac5 100644 --- a/internal/model/note_test.go +++ b/internal/model/note_test.go @@ -1,7 +1,6 @@ package model import ( - "encoding/base64" "fmt" "testing" "time" @@ -261,11 +260,31 @@ func TestNoteIDGeneration(t *testing.T) { t.Error("All note IDs should be unique") } - // Проверяем формат ID (base64 URL encoding) + // Проверяем формат UUID id := note1.GetID() - _, err := base64.URLEncoding.DecodeString(id) - if err != nil { - t.Errorf("Note ID should be valid base64 URL encoding: %v", err) + + // Проверяем длину UUID + if len(id) != 36 { + t.Errorf("Note ID should be 36 characters long, got %d", len(id)) + } + + // Проверяем, что UUID имеет 4 дефиса на правильных позициях + dashes := []int{} + for i, char := range id { + if char == '-' { + dashes = append(dashes, i) + } + } + + expectedDashes := []int{8, 13, 18, 23} + if len(dashes) != 4 { + t.Errorf("Expected 4 dashes in UUID, got %d", len(dashes)) + } else { + for i, expectedPos := range expectedDashes { + if dashes[i] != expectedPos { + t.Errorf("Expected dash at position %d, got at position %d", expectedPos, dashes[i]) + } + } } } @@ -318,22 +337,40 @@ func TestNoteIDLengthAndFormat(t *testing.T) { note := NewNote("Test", "Content") id := note.GetID() - // Проверяем длину ID (base64 от 8 байт = 11 символов без padding или 12 с padding) - if len(id) != 11 && len(id) != 12 { - t.Errorf("Expected ID length 11 or 12, got %d for ID %q", len(id), id) + // Проверяем длину ID (UUID в формате string имеет длину 36 символов) + if len(id) != 36 { + t.Errorf("Expected ID length 36, got %d for ID %q", len(id), id) } - // Проверяем что ID состоит из валидных base64 URL символов - isValidBase64URLChar := func(char rune) bool { - return (char >= 'A' && char <= 'Z') || - (char >= 'a' && char <= 'z') || + // Проверяем формат UUID + isValidUUIDChar := func(char rune) bool { + return (char >= 'a' && char <= 'f') || (char >= '0' && char <= '9') || - char == '-' || char == '_' || char == '=' + char == '-' } for _, char := range id { - if !isValidBase64URLChar(char) { - t.Errorf("ID contains invalid base64 URL character: %c", char) + if !isValidUUIDChar(char) { + t.Errorf("ID contains invalid UUID character: %c", char) + } + } + + // Проверяем, что UUID имеет 4 дефиса на правильных позициях + dashes := []int{} + for i, char := range id { + if char == '-' { + dashes = append(dashes, i) + } + } + + expectedDashes := []int{8, 13, 18, 23} + if len(dashes) != 4 { + t.Errorf("Expected 4 dashes in UUID, got %d", len(dashes)) + } else { + for i, expectedPos := range expectedDashes { + if dashes[i] != expectedPos { + t.Errorf("Expected dash at position %d, got at position %d", expectedPos, dashes[i]) + } } } } diff --git a/internal/repository/factory.go b/internal/repository/factory.go new file mode 100644 index 0000000..599b836 --- /dev/null +++ b/internal/repository/factory.go @@ -0,0 +1,46 @@ +package repository + +import ( + "sync" +) + +// StorageType тип хранилища +type StorageType string + +const ( + RAM StorageType = "memory" + JSON StorageType = "json" +) + +// Creator функция для создания репозитория +type Creator func() Repository + +var ( + creators = make(map[StorageType]Creator) + creatorsLock sync.RWMutex + defaultType = JSON +) + +// Register регистрирует создателя репозитория для определенного типа +func Register(repoType StorageType, creator Creator) { + creatorsLock.Lock() + defer creatorsLock.Unlock() + creators[repoType] = creator +} + +// NewRepository создает новый экземпляр репозитория +func NewRepository() Repository { + return NewRepositoryByType(defaultType) +} + +// NewRepositoryByType создает репозиторий указанного типа +func NewRepositoryByType(repoType StorageType) Repository { + creatorsLock.RLock() + defer creatorsLock.RUnlock() + + creator, exists := creators[repoType] + if !exists { + creator = creators[RAM] + } + return creator() +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 0abc9e2..1eb758a 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -1,9 +1,6 @@ package repository import ( - "log" - "sync" - "github.com/rd2w/go-notes/internal/model" ) @@ -13,61 +10,10 @@ type Entity interface { GetType() string } -// Repository управляет хранением различных сущностей -type Repository struct { - notes []*model.Note - notesIndex map[string]*model.Note // для быстрого поиска по ID - mu sync.RWMutex -} - -// NewRepository создает новый экземпляр репозитория -func NewRepository() *Repository { - return &Repository{ - notes: make([]*model.Note, 0), - notesIndex: make(map[string]*model.Note), - } -} - -// Save сохраняет сущность в соответствующий слайс -func (r *Repository) Save(entity Entity) { - r.mu.Lock() - defer r.mu.Unlock() - - switch entity := entity.(type) { - case *model.Note: - r.notes = append(r.notes, entity) - r.notesIndex[entity.GetID()] = entity - log.Printf("Репозиторий: сохранена заметка ID=%s", entity.GetID()) - default: - log.Printf("Репозиторий: неподдерживаемый тип сущности: %T", entity) - } -} - -// GetAllNotes возвращает все сохраненные заметки -func (r *Repository) GetAllNotes() []*model.Note { - r.mu.RLock() - defer r.mu.RUnlock() - notes := make([]*model.Note, len(r.notes)) - copy(notes, r.notes) - return notes -} - -// GetNotesCount возвращает количество сохраненных заметок -func (r *Repository) GetNotesCount() int { - r.mu.RLock() - defer r.mu.RUnlock() - return len(r.notes) -} - -// GetNewNotes возвращает заметки, добавленные после указанного индекса -func (r *Repository) GetNewNotes(lastIndex int) []*model.Note { - r.mu.RLock() - defer r.mu.RUnlock() - if lastIndex >= len(r.notes) { - return []*model.Note{} - } - newNotes := r.notes[lastIndex:] - result := make([]*model.Note, len(newNotes)) - copy(result, newNotes) - return result +// Repository интерфейс для репозитория +type Repository interface { + Save(entity Entity) + GetAllNotes() []*model.Note + GetNotesCount() int + GetNewNotes(lastIndex int) []*model.Note } diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index ef64c7a..9d6d055 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -1,4 +1,4 @@ -package repository +package repository_test import ( "bytes" @@ -8,6 +8,8 @@ import ( "time" "github.com/rd2w/go-notes/internal/model" + "github.com/rd2w/go-notes/internal/repository" + "github.com/rd2w/go-notes/internal/repository/storage/ram" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -30,33 +32,62 @@ func (s *safeBuffer) String() string { return s.buf.String() } +// mockEntity реализует интерфейс Entity для тестирования неподдерживаемых типов +type mockEntity struct { + id string + entityType string +} + +func (m *mockEntity) GetID() string { + return m.id +} + +func (m *mockEntity) GetType() string { + return m.entityType +} + // TestRepository_Save тестирует метод Save с различными типами сущностей func TestRepository_Save(t *testing.T) { - // Перехватываем вывод лога для проверки - var buf safeBuffer - log.SetOutput(&buf) - defer log.SetOutput(log.Writer()) + tests := []struct { + name string + entity repository.Entity + expectedCount int + logContains string + }{ + { + name: "Save note", + entity: model.NewNote("Test Note", "Test Content"), + expectedCount: 1, + logContains: "Репозиторий: сохранена заметка ID=", + }, + { + name: "Save unsupported entity", + entity: &mockEntity{id: "test", entityType: "unsupported"}, + expectedCount: 0, + logContains: "Репозиторий: неподдерживаемый тип сущности", + }, + } - repo := NewRepository() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf safeBuffer + log.SetOutput(&buf) + defer log.SetOutput(log.Writer()) - // Тестируем сохранение заметки - note := model.NewNote("Test Note", "Test Content") - repo.Save(note) + repo := ram.NewRamRepository() + repo.Save(tt.entity) - // Проверяем, что заметка была сохранена - notes := repo.GetAllNotes() - require.Len(t, notes, 1, "Должна быть одна заметка") - assert.Equal(t, note.GetID(), notes[0].GetID()) - assert.Equal(t, note.GetTitle(), notes[0].GetTitle()) + assert.Equal(t, tt.expectedCount, repo.GetNotesCount()) - // Проверяем вывод в лог - logOutput := buf.String() - assert.Contains(t, logOutput, "Репозиторий: сохранена заметка ID="+note.GetID()) + logOutput := buf.String() + assert.Contains(t, logOutput, tt.logContains) + }) + } } // TestRepository_SaveMultipleNotes тестирует сохранение нескольких заметок func TestRepository_SaveMultipleNotes(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() // Сохраняем несколько заметок notes := []*model.Note{ @@ -80,29 +111,9 @@ func TestRepository_SaveMultipleNotes(t *testing.T) { } } -// TestRepository_SaveUnsupportedEntity тестирует обработку неподдерживаемых типов сущностей -func TestRepository_SaveUnsupportedEntity(t *testing.T) { - var buf safeBuffer - log.SetOutput(&buf) - defer log.SetOutput(log.Writer()) - - repo := NewRepository() - - // Создаем неподдерживаемую сущность - unsupportedEntity := &mockEntity{id: "test", entityType: "unsupported"} - repo.Save(unsupportedEntity) - - // Проверяем, что заметки не были сохранены для неподдерживаемых типов - assert.Equal(t, 0, repo.GetNotesCount(), "Не должно быть сохраненных заметок для неподдерживаемых сущностей") - - // Проверяем вывод в лог - logOutput := buf.String() - assert.Contains(t, logOutput, "Репозиторий: неподдерживаемый тип сущности") -} - // TestRepository_GetAllNotes тестирует метод GetAllNotes func TestRepository_GetAllNotes(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() // Добавляем заметки note1 := model.NewNote("Note 1", "Content 1") @@ -125,7 +136,7 @@ func TestRepository_GetAllNotes(t *testing.T) { // TestRepository_GetNotesCount тестирует метод GetNotesCount func TestRepository_GetNotesCount(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() // Начальное количество должно быть 0 assert.Equal(t, 0, repo.GetNotesCount()) @@ -140,9 +151,9 @@ func TestRepository_GetNotesCount(t *testing.T) { assert.Equal(t, 2, repo.GetNotesCount()) } -// TestRepository_GetNewNotes тестирует метод GetNewNotes +// TestRepository_GetNewNotes тестирует метод GetNewNotes с различными индексами func TestRepository_GetNewNotes(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() // Добавляем начальные заметки notes := []*model.Note{ @@ -166,6 +177,7 @@ func TestRepository_GetNewNotes(t *testing.T) { {"LastIndex 2", 2, 1}, {"LastIndex 3", 3, 0}, {"LastIndex 5", 5, 0}, + {"LastIndex negative", -1, 3}, // при отрицательном индексе должен возвращать все заметки } for _, tt := range tests { @@ -175,16 +187,49 @@ func TestRepository_GetNewNotes(t *testing.T) { // Проверяем, что возвращаются правильные заметки if tt.expected > 0 { - expectedNote := notes[tt.lastIndex] - assert.Equal(t, expectedNote.GetID(), newNotes[0].GetID()) + var expectedNoteIndex int + if tt.lastIndex < 0 { + expectedNoteIndex = 0 // для отрицательных индексов ожидаем первую заметку + } else { + expectedNoteIndex = tt.lastIndex + } + + if expectedNoteIndex < len(notes) { + expectedNote := notes[expectedNoteIndex] + assert.Equal(t, expectedNote.GetID(), newNotes[0].GetID()) + } } }) } } +// TestRepository_GetNewNotesOrder тестирует, что GetNewNotes возвращает заметки в правильном порядке +func TestRepository_GetNewNotesOrder(t *testing.T) { + repo := ram.NewRamRepository() + + // Добавляем заметки + notes := []*model.Note{ + model.NewNote("Note 1", "Content 1"), + model.NewNote("Note 2", "Content 2"), + model.NewNote("Note 3", "Content 3"), + } + + for _, note := range notes { + repo.Save(note) + } + + // Получаем новые заметки с индекса 1 + newNotes := repo.GetNewNotes(1) + + // Проверяем, что порядок правильный + assert.Len(t, newNotes, 2) + assert.Equal(t, notes[1].GetID(), newNotes[0].GetID()) + assert.Equal(t, notes[2].GetID(), newNotes[1].GetID()) +} + // TestRepository_ConcurrentAccess тестирует конкурентный доступ к репозиторию func TestRepository_ConcurrentAccess(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() var wg sync.WaitGroup // Конкурентные писатели @@ -216,31 +261,9 @@ func TestRepository_ConcurrentAccess(t *testing.T) { assert.Equal(t, 10, repo.GetNotesCount(), "Все конкурентные записи должны быть обработаны") } -// TestRepository_EmptyChannel тестирует поведение с пустым каналом -func TestRepository_EmptyChannel(t *testing.T) { - repo := NewRepository() - - // Должен продолжать работать без паники - assert.Equal(t, 0, repo.GetNotesCount()) -} - -// mockEntity реализует интерфейс Entity для тестирования неподдерживаемых типов -type mockEntity struct { - id string - entityType string -} - -func (m *mockEntity) GetID() string { - return m.id -} - -func (m *mockEntity) GetType() string { - return m.entityType -} - // TestRepository_DataIsolation тестирует, что внутренние данные не экспортируются func TestRepository_DataIsolation(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() // Добавляем заметку note := model.NewNote("Test Note", "Content") @@ -259,7 +282,7 @@ func TestRepository_DataIsolation(t *testing.T) { // TestRepository_NewNotesIsolation тестирует, что GetNewNotes возвращает копии func TestRepository_NewNotesIsolation(t *testing.T) { - repo := NewRepository() + repo := ram.NewRamRepository() // Добавляем заметки note1 := model.NewNote("Note 1", "Content 1") @@ -276,3 +299,36 @@ func TestRepository_NewNotesIsolation(t *testing.T) { assert.NotNil(t, allNotes[0]) assert.Equal(t, note1.GetID(), allNotes[0].GetID()) } + +// TestRepository_NegativeIndex тестирует поведение при отрицательных индексах +func TestRepository_NegativeIndex(t *testing.T) { + repo := ram.NewRamRepository() + + // Добавляем заметки + notes := []*model.Note{ + model.NewNote("Note 1", "Content 1"), + model.NewNote("Note 2", "Content 2"), + } + + for _, note := range notes { + repo.Save(note) + } + + // Проверяем, что отрицательный индекс ведет себя как 0 (возвращает все заметки) + allNotes := repo.GetAllNotes() + negativeIndexNotes := repo.GetNewNotes(-1) + + assert.Len(t, negativeIndexNotes, 2) + assert.Equal(t, allNotes[0].GetID(), negativeIndexNotes[0].GetID()) + assert.Equal(t, allNotes[1].GetID(), negativeIndexNotes[1].GetID()) +} + +// TestRepository_EmptyRepository тестирует поведение репозитория без заметок +func TestRepository_EmptyRepository(t *testing.T) { + repo := ram.NewRamRepository() + + assert.Equal(t, 0, repo.GetNotesCount()) + assert.Empty(t, repo.GetAllNotes()) + assert.Empty(t, repo.GetNewNotes(0)) + assert.Empty(t, repo.GetNewNotes(5)) +} diff --git a/internal/repository/storage/fs/json_repository.go b/internal/repository/storage/fs/json_repository.go new file mode 100644 index 0000000..84829d2 --- /dev/null +++ b/internal/repository/storage/fs/json_repository.go @@ -0,0 +1,290 @@ +package fs + +import ( + "encoding/json" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/rd2w/go-notes/internal/model" + "github.com/rd2w/go-notes/internal/repository" +) + +const ( + StorageDir = "data" + NoteFilePrefix = "notes" + TimeFormat = "2006-01-02_15-04-05" +) + +// Тестовые переменные, которые можно изменить в тестах +var ( + TestStorageDir = StorageDir + TestNoteFilePrefix = NoteFilePrefix + TestTimeFormat = TimeFormat +) + +// JSONRepository реализация репозитория с хранением данных в JSON файлах +type JSONRepository struct { + notes []*model.Note + notesIndex map[string]*model.Note + mu sync.RWMutex + startTime time.Time // Время запуска репозитория для формирования имени файла + initialNotesCount int // Количество заметок, загруженных из файла при инициализации +} + +// NewJSONRepository создает новый экземпляр JSON репозитория (возвращает интерфейс) +func NewJSONRepository() repository.Repository { + // Проверяем, есть ли уже существующие файлы с заметками + latestTime := findLatestNoteFileTime() + if latestTime.IsZero() { + // Если файлов нет, используем текущее время + latestTime = time.Now() + } + + repo := &JSONRepository{ + notes: make([]*model.Note, 0), + notesIndex: make(map[string]*model.Note), + startTime: latestTime, + initialNotesCount: 0, + } + + // Загружаем данные из хранилища при создании репозитория + repo.LoadFromStorage() + + return repo +} + +// findLatestNoteFileTime находит время самого последнего файла с заметками +func findLatestNoteFileTime() time.Time { + // Проверяем, существует ли директория + if _, err := os.Stat(TestStorageDir); os.IsNotExist(err) { + return time.Time{} + } + + // Читаем содержимое директории + files, err := os.ReadDir(TestStorageDir) + if err != nil { + log.Printf("Ошибка при чтении директории %s: %v", TestStorageDir, err) + return time.Time{} + } + + var latestTime time.Time + + for _, file := range files { + if file.IsDir() { + continue + } + + filename := file.Name() + if filepath.Ext(filename) == ".json" && len(filename) > len(TestNoteFilePrefix) && filename[:len(TestNoteFilePrefix)] == TestNoteFilePrefix { + // Извлекаем временную метку из имени файла + timeStr := filename[len(TestNoteFilePrefix)+1 : len(filename)-5] // убираем префикс_ и .json + if fileTime, err := time.Parse(TestTimeFormat, timeStr); err == nil { + if fileTime.After(latestTime) { + latestTime = fileTime + } + } + } + } + + return latestTime +} + +// Save сохраняет сущность в соответствующий слайс и в JSON файл +func (r *JSONRepository) Save(entity repository.Entity) { + r.mu.Lock() + defer r.mu.Unlock() + + switch entity := entity.(type) { + case *model.Note: + // Добавляем заметку в слайс и индекс + r.notes = append(r.notes, entity) + r.notesIndex[entity.GetID()] = entity + + log.Printf("Репозиторий: сохранена заметка ID=%s", entity.GetID()) + + // Сохраняем в JSON файл + if err := r.saveToJSON(); err != nil { + log.Printf("Ошибка при сохранении в JSON: %v", err) + } + default: + log.Printf("Репозиторий: неподдерживаемый тип сущности: %T", entity) + } +} + +// GetAllNotes возвращает все сохраненные заметки +func (r *JSONRepository) GetAllNotes() []*model.Note { + r.mu.RLock() + defer r.mu.RUnlock() + + notes := make([]*model.Note, len(r.notes)) + copy(notes, r.notes) + return notes +} + +// GetNotesCount возвращает количество сохраненных заметок +func (r *JSONRepository) GetNotesCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + + return len(r.notes) +} + +// GetNewNotes возвращает заметки, добавленные после указанного индекса +func (r *JSONRepository) GetNewNotes(lastIndex int) []*model.Note { + r.mu.RLock() + defer r.mu.RUnlock() + + // Обрабатываем отрицательные индексы как 0 + if lastIndex < 0 { + lastIndex = 0 + } + + // Если индекс меньше начального количества заметок (загруженных из файла), + // начинаем с начального количества, чтобы не возвращать загруженные заметки как "новые" + if lastIndex < r.initialNotesCount { + lastIndex = r.initialNotesCount + } + + if lastIndex >= len(r.notes) { + return []*model.Note{} + } + + newNotes := r.notes[lastIndex:] + result := make([]*model.Note, len(newNotes)) + copy(result, newNotes) + return result +} + +// LoadFromStorage загружает данные из JSON файла при старте приложения +func (r *JSONRepository) LoadFromStorage() { + r.mu.Lock() + defer r.mu.Unlock() + + // Находим файл с данными для заметок + noteFile, err := r.findLatestFile(NoteFilePrefix) + if err != nil { + // Если файл не найден, начинаем с пустого репозитория + log.Printf("Файл с данными не найден, начнем с пустого репозитория: %v", err) + return + } + + // Загружаем данные из файла + if err := r.loadFromJSONFile(noteFile); err != nil { + fmt.Printf("Ошибка при загрузке данных из файла %s: %v\n", noteFile, err) + return + } + + // Обновляем startTime на основе времени файла + filename := filepath.Base(noteFile) + timeStr := filename[len(NoteFilePrefix)+1 : len(filename)-5] // убираем префикс_ и .json + if fileTime, err := time.Parse(TimeFormat, timeStr); err == nil { + r.startTime = fileTime + } + + fmt.Printf("Загружено %d заметок из файла %s\n", len(r.notes), noteFile) + + // Сохраняем количество загруженных заметок как начальное + r.initialNotesCount = len(r.notes) +} + +// saveToJSON сохраняет данные в JSON файл с временной меткой запуска приложения +func (r *JSONRepository) saveToJSON() error { + // Создаем директорию, если она не существует + if err := os.MkdirAll(TestStorageDir, 0755); err != nil { + return fmt.Errorf("не удалось создать директорию %s: %w", TestStorageDir, err) + } + + // Формируем имя файла с временной меткой запуска приложения + timestamp := r.startTime.Format(TestTimeFormat) + filename := fmt.Sprintf("%s_%s.json", TestNoteFilePrefix, timestamp) + filePath := filepath.Join(TestStorageDir, filename) + + // Создаем/перезаписываем файл + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("не удалось создать файл %s: %w", filePath, err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + log.Printf("Ошибка при закрытии файла %s: %v", filePath, closeErr) + } + }() + + // Кодируем данные в JSON + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(r.notes); err != nil { + return fmt.Errorf("ошибка при кодировании в JSON: %w", err) + } + + return nil +} + +// loadFromJSONFile загружает данные из указанного JSON файла +func (r *JSONRepository) loadFromJSONFile(filepath string) error { + file, err := os.Open(filepath) + if err != nil { + return fmt.Errorf("не удалось открыть файл %s: %w", filepath, err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + log.Printf("Ошибка при закрытии файла %s: %v", filepath, closeErr) + } + }() + + // Читаем содержимое файла + byteValue, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("ошибка при чтении файла: %w", err) + } + + // Декодируем данные из JSON + var jsonNotes []json.RawMessage + if err := json.Unmarshal(byteValue, &jsonNotes); err != nil { + return fmt.Errorf("ошибка при декодировании JSON: %w", err) + } + + // Преобразуем каждый JSON объект в Note + notes := make([]*model.Note, len(jsonNotes)) + for i, rawNote := range jsonNotes { + note := &model.Note{} + if err := json.Unmarshal(rawNote, note); err != nil { + return fmt.Errorf("ошибка при десериализации заметки: %w", err) + } + notes[i] = note + } + + // Обновляем внутренние структуры + r.notes = notes + r.notesIndex = make(map[string]*model.Note) + for _, note := range notes { + r.notesIndex[note.GetID()] = note + } + + return nil +} + +// findLatestFile находит файл с указанным префиксом, используя время запуска приложения +func (r *JSONRepository) findLatestFile(prefix string) (string, error) { + // Проверяем, существует ли директория + if _, err := os.Stat(TestStorageDir); os.IsNotExist(err) { + return "", fmt.Errorf("директория %s не существует", TestStorageDir) + } + + // Формируем имя файла на основе времени запуска + timestamp := r.startTime.Format(TestTimeFormat) + filename := fmt.Sprintf("%s_%s.json", prefix, timestamp) + filePath := filepath.Join(TestStorageDir, filename) + + // Проверяем, существует ли файл + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return "", fmt.Errorf("файл %s не существует", filePath) + } + + return filePath, nil +} diff --git a/internal/repository/storage/fs/json_repository_test.go b/internal/repository/storage/fs/json_repository_test.go new file mode 100644 index 0000000..88200f3 --- /dev/null +++ b/internal/repository/storage/fs/json_repository_test.go @@ -0,0 +1,474 @@ +package fs_test + +import ( + "bytes" + "encoding/json" + "log" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/rd2w/go-notes/internal/model" + "github.com/rd2w/go-notes/internal/repository/storage/fs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// safeBuffer потокобезопасный буфер для логов +type safeBuffer struct { + buf bytes.Buffer + mu sync.RWMutex +} + +func (s *safeBuffer) Write(p []byte) (n int, err error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.buf.Write(p) +} + +func (s *safeBuffer) String() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.buf.String() +} + +// mockEntity реализует интерфейс Entity для тестирования неподдерживаемых типов +type mockEntity struct { + id string + entityType string +} + +func (m *mockEntity) GetID() string { + return m.id +} + +func (m *mockEntity) GetType() string { + return m.entityType +} + +// TestJSONRepository_Save тестирует метод Save с различными типами сущностей +func TestJSONRepository_Save(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Тестируем сохранение заметки + note := model.NewNote("Test Note", "Test Content") + repo.Save(note) + + // Проверяем, что заметка была сохранена + notes := repo.GetAllNotes() + require.Len(t, notes, 1, "Должна быть одна заметка") + assert.Equal(t, note.GetID(), notes[0].GetID()) + assert.Equal(t, note.GetTitle(), notes[0].GetTitle()) +} + +// TestJSONRepository_SaveUnsupportedEntity тестирует обработку неподдерживаемых типов сущностей +func TestJSONRepository_SaveUnsupportedEntity(t *testing.T) { + // Перехватываем вывод лога для проверки + var buf safeBuffer + log.SetOutput(&buf) + defer log.SetOutput(log.Writer()) + + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Создаем неподдерживаемую сущность + unsupportedEntity := &mockEntity{id: "test", entityType: "unsupported"} + repo.Save(unsupportedEntity) + + // Проверяем, что заметки не были сохранены для неподдерживаемых типов + assert.Equal(t, 0, repo.GetNotesCount(), "Не должно быть сохраненных заметок для неподдерживаемых сущностей") + + // Проверяем вывод в лог + logOutput := buf.String() + assert.Contains(t, logOutput, "Репозиторий: неподдерживаемый тип сущности") +} + +// TestJSONRepository_SaveMultipleNotes тестирует сохранение нескольких заметок +func TestJSONRepository_SaveMultipleNotes(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Сохраняем несколько заметок + notes := []*model.Note{ + model.NewNote("Note 1", "Content 1"), + model.NewNote("Note 2", "Content 2"), + model.NewNote("Note 3", "Content 3"), + } + + for _, note := range notes { + repo.Save(note) + } + + // Проверяем, что все заметки были сохранены + savedNotes := repo.GetAllNotes() + assert.Len(t, savedNotes, 3, "Должно быть 3 заметки") + + // Проверяем содержимое заметок + for i, note := range notes { + assert.Equal(t, note.GetID(), savedNotes[i].GetID()) + assert.Equal(t, note.GetTitle(), savedNotes[i].GetTitle()) + } +} + +// TestJSONRepository_GetAllNotes тестирует метод GetAllNotes +func TestJSONRepository_GetAllNotes(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Добавляем заметки + note1 := model.NewNote("Note 1", "Content 1") + note2 := model.NewNote("Note 2", "Content 2") + + repo.Save(note1) + repo.Save(note2) + + // Тестируем GetAllNotes + notes := repo.GetAllNotes() + require.Len(t, notes, 2) + + // Проверяем, что возвращаются копии, а не ссылки на внутренний слайс + notes[0] = nil // Это не должно повлиять на внутренний слайс репозитория + + internalNotes := repo.GetAllNotes() + assert.NotNil(t, internalNotes[0], "Изменение возвращенного слайса не должно влиять на репозиторий") + assert.Equal(t, note1.GetID(), internalNotes[0].GetID()) +} + +// TestJSONRepository_GetNotesCount тестирует метод GetNotesCount +func TestJSONRepository_GetNotesCount(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Начальное количество должно быть 0 + assert.Equal(t, 0, repo.GetNotesCount()) + + // Добавляем заметки и проверяем увеличение счетчика + note1 := model.NewNote("Note 1", "Content 1") + repo.Save(note1) + assert.Equal(t, 1, repo.GetNotesCount()) + + note2 := model.NewNote("Note 2", "Content 2") + repo.Save(note2) + assert.Equal(t, 2, repo.GetNotesCount()) +} + +// TestJSONRepository_GetNewNotes тестирует метод GetNewNotes с различными индексами +func TestJSONRepository_GetNewNotes(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Добавляем начальные заметки + notes := []*model.Note{ + model.NewNote("Note 1", "Content 1"), + model.NewNote("Note 2", "Content 2"), + model.NewNote("Note 3", "Content 3"), + } + + for _, note := range notes { + repo.Save(note) + } + + // Тестируем GetNewNotes с различными индексами + tests := []struct { + name string + lastIndex int + expected int + }{ + {"LastIndex 0", 0, 3}, + {"LastIndex 1", 1, 2}, + {"LastIndex 2", 2, 1}, + {"LastIndex 3", 3, 0}, + {"LastIndex 5", 5, 0}, + {"LastIndex negative", -1, 3}, // при отрицательном индексе должен возвращать все заметки + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newNotes := repo.GetNewNotes(tt.lastIndex) + assert.Len(t, newNotes, tt.expected) + + // Проверяем, что возвращаются правильные заметки + if tt.expected > 0 { + var expectedNoteIndex int + if tt.lastIndex < 0 { + expectedNoteIndex = 0 // для отрицательных индексов ожидаем первую заметку + } else { + expectedNoteIndex = tt.lastIndex + } + + if expectedNoteIndex < len(notes) { + expectedNote := notes[expectedNoteIndex] + assert.Equal(t, expectedNote.GetID(), newNotes[0].GetID()) + } + } + }) + } +} + +// TestJSONRepository_GetNewNotesOrder тестирует, что GetNewNotes возвращает заметки в правильном порядке +func TestJSONRepository_GetNewNotesOrder(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Добавляем заметки + notes := []*model.Note{ + model.NewNote("Note 1", "Content 1"), + model.NewNote("Note 2", "Content 2"), + model.NewNote("Note 3", "Content 3"), + } + + for _, note := range notes { + repo.Save(note) + } + + // Получаем новые заметки с индекса 1 + newNotes := repo.GetNewNotes(1) + + // Проверяем, что порядок правильный + assert.Len(t, newNotes, 2) + assert.Equal(t, notes[1].GetID(), newNotes[0].GetID()) + assert.Equal(t, notes[2].GetID(), newNotes[1].GetID()) +} + +// TestJSONRepository_DataIsolation тестирует, что внутренние данные не экспортируются +func TestJSONRepository_DataIsolation(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Добавляем заметку + note := model.NewNote("Test Note", "Content") + repo.Save(note) + + // Получаем заметки и изменяем возвращенный слайс + notes := repo.GetAllNotes() + originalID := notes[0].GetID() + notes[0] = nil // Это не должно повлиять на репозиторий + + // Получаем заметки снова - должны быть оригинальные данные + notesAgain := repo.GetAllNotes() + assert.NotNil(t, notesAgain[0]) + assert.Equal(t, originalID, notesAgain[0].GetID()) +} + +// TestJSONRepository_NewNotesIsolation тестирует, что GetNewNotes возвращает копии +func TestJSONRepository_NewNotesIsolation(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Добавляем заметки + note1 := model.NewNote("Note 1", "Content 1") + note2 := model.NewNote("Note 2", "Content 2") + repo.Save(note1) + repo.Save(note2) + + // Получаем новые заметки и изменяем их + newNotes := repo.GetNewNotes(0) + newNotes[0] = nil + + // Проверяем, что данные в репозитории не изменились + allNotes := repo.GetAllNotes() + assert.NotNil(t, allNotes[0]) + assert.Equal(t, note1.GetID(), allNotes[0].GetID()) +} + +// TestJSONRepository_EmptyRepository тестирует поведение репозитория без заметок +func TestJSONRepository_EmptyRepository(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + assert.Equal(t, 0, repo.GetNotesCount()) + assert.Empty(t, repo.GetAllNotes()) + assert.Empty(t, repo.GetNewNotes(0)) + assert.Empty(t, repo.GetNewNotes(5)) +} + +// TestJSONRepository_NegativeIndex тестирует поведение при отрицательных индексах +func TestJSONRepository_NegativeIndex(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Добавляем заметки + notes := []*model.Note{ + model.NewNote("Note 1", "Content 1"), + model.NewNote("Note 2", "Content 2"), + } + + for _, note := range notes { + repo.Save(note) + } + + // Проверяем, что отрицательный индекс ведет себя как 0 (возвращает все заметки) + allNotes := repo.GetAllNotes() + negativeIndexNotes := repo.GetNewNotes(-1) + + assert.Len(t, negativeIndexNotes, 2) + assert.Equal(t, allNotes[0].GetID(), negativeIndexNotes[0].GetID()) + assert.Equal(t, allNotes[1].GetID(), negativeIndexNotes[1].GetID()) +} + +// TestJSONRepository_SaveToFile тестирует сохранение в файл +func TestJSONRepository_SaveToFile(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + + // Сохраняем заметку + note := model.NewNote("Test Note", "Test Content") + repo.Save(note) + + // Проверяем, что файл был создан + files, err := os.ReadDir(tempDir) + require.NoError(t, err) + assert.NotEmpty(t, files) + + // Проверяем, что файл имеет правильное имя + var noteFileFound bool + for _, file := range files { + if filepath.Ext(file.Name()) == ".json" && len(file.Name()) > len(fs.TestNoteFilePrefix) && + file.Name()[:len(fs.TestNoteFilePrefix)] == fs.TestNoteFilePrefix { + noteFileFound = true + break + } + } + assert.True(t, noteFileFound, "Файл с заметками должен быть создан") +} + +// TestJSONRepository_LoadFromStorage тестирует загрузку данных из файла +func TestJSONRepository_LoadFromStorage(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + // Создаем JSON файл с заметками + note := model.NewNote("Loaded Note", "Loaded Content") + notes := []*model.Note{note} + + // Формируем имя файла с временной меткой + timestamp := time.Now().Format(fs.TestTimeFormat) + filename := filepath.Join(tempDir, fs.TestNoteFilePrefix+"_"+timestamp+".json") + + file, err := os.Create(filename) + require.NoError(t, err) + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + err = encoder.Encode(notes) + require.NoError(t, err) + + if closeErr := file.Close(); closeErr != nil { + log.Printf("Error closing file %s: %v", filename, closeErr) + } + + // Создаем новый репозиторий - он должен загрузить данные + repo := fs.NewJSONRepository() + + // Проверяем, что заметка была загружена + loadedNotes := repo.GetAllNotes() + assert.Len(t, loadedNotes, 1) + assert.Equal(t, note.GetID(), loadedNotes[0].GetID()) + assert.Equal(t, note.GetTitle(), loadedNotes[0].GetTitle()) +} + +// TestJSONRepository_ConcurrentAccess тестирует конкурентный доступ к репозиторию +func TestJSONRepository_ConcurrentAccess(t *testing.T) { + // Создаем временный каталог для тестирования + tempDir := t.TempDir() + originalStorageDir := fs.TestStorageDir + fs.TestStorageDir = tempDir + defer func() { fs.TestStorageDir = originalStorageDir }() + + repo := fs.NewJSONRepository() + var wg sync.WaitGroup + + // Конкурентные писатели + for i := 0; i < 10; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + note := model.NewNote("Concurrent Note", "Content") + repo.Save(note) + }(i) + } + + // Конкурентные читатели + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 3; j++ { + _ = repo.GetNotesCount() + _ = repo.GetAllNotes() + time.Sleep(1 * time.Millisecond) + } + }() + } + + wg.Wait() + + // Проверяем, что все заметки были сохранены + assert.Equal(t, 10, repo.GetNotesCount(), "Все конкурентные записи должны быть обработаны") +} diff --git a/internal/repository/storage/ram/ram_repository.go b/internal/repository/storage/ram/ram_repository.go new file mode 100644 index 0000000..2c7130d --- /dev/null +++ b/internal/repository/storage/ram/ram_repository.go @@ -0,0 +1,75 @@ +package ram + +import ( + "log" + "sync" + + "github.com/rd2w/go-notes/internal/model" + "github.com/rd2w/go-notes/internal/repository" +) + +// RamRepository управляет хранением различных сущностей в памяти +type RamRepository struct { + notes []*model.Note + notesIndex map[string]*model.Note // Для быстрого поиска по ID + mu sync.RWMutex +} + +// NewRamRepository создает новый экземпляр RAM репозитория (возвращает интерфейс) +func NewRamRepository() repository.Repository { + return &RamRepository{ + notes: make([]*model.Note, 0), + notesIndex: make(map[string]*model.Note), + } +} + +// Save сохраняет сущность в соответствующий слайс +func (r *RamRepository) Save(entity repository.Entity) { + r.mu.Lock() + defer r.mu.Unlock() + + switch entity := entity.(type) { + case *model.Note: + r.notes = append(r.notes, entity) + r.notesIndex[entity.GetID()] = entity + log.Printf("Репозиторий: сохранена заметка ID=%s", entity.GetID()) + default: + log.Printf("Репозиторий: неподдерживаемый тип сущности: %T", entity) + } +} + +// GetAllNotes возвращает все сохраненные заметки +func (r *RamRepository) GetAllNotes() []*model.Note { + r.mu.RLock() + defer r.mu.RUnlock() + notes := make([]*model.Note, len(r.notes)) + copy(notes, r.notes) + return notes +} + +// GetNotesCount возвращает количество сохраненных заметок +func (r *RamRepository) GetNotesCount() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.notes) +} + +// GetNewNotes возвращает заметки, добавленные после указанного индекса +func (r *RamRepository) GetNewNotes(lastIndex int) []*model.Note { + r.mu.RLock() + defer r.mu.RUnlock() + + // Обрабатываем отрицательные индексы как 0 + if lastIndex < 0 { + lastIndex = 0 + } + + if lastIndex >= len(r.notes) { + return []*model.Note{} + } + + newNotes := r.notes[lastIndex:] + result := make([]*model.Note, len(newNotes)) + copy(result, newNotes) + return result +} diff --git a/internal/service/service.go b/internal/service/service.go index 6dcd984..f6b75bb 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -17,13 +17,13 @@ const ( // Service содержит бизнес-логику приложения type Service struct { - repo *repository.Repository + repo repository.Repository ctx context.Context interval time.Duration } // NewService создает новый экземпляр сервиса -func NewService(repo *repository.Repository, ctx context.Context, interval time.Duration) *Service { +func NewService(repo repository.Repository, ctx context.Context, interval time.Duration) *Service { return &Service{ repo: repo, ctx: ctx, diff --git a/internal/service/service_test.go b/internal/service/service_test.go index b22a5d0..128c6eb 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -1,100 +1,61 @@ package service import ( - "bytes" "context" "fmt" - "log" + "os" + "path/filepath" "sync" "testing" "time" - "github.com/rd2w/go-notes/internal/repository" + "github.com/rd2w/go-notes/internal/repository/storage/fs" + "github.com/rd2w/go-notes/internal/repository/storage/ram" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// safeBuffer для service тестов тоже -type safeBuffer struct { - buf bytes.Buffer - mu sync.RWMutex -} - -func (s *safeBuffer) Write(p []byte) (n int, err error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.buf.Write(p) -} - -func (s *safeBuffer) String() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.buf.String() -} - // TestService_StopWithContext тестирует остановку генерации через контекст func TestService_StopWithContext(t *testing.T) { - var buf safeBuffer - log.SetOutput(&buf) - defer log.SetOutput(log.Writer()) - ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - repo := repository.NewRepository() - service := NewService(repo, ctx, 20*time.Millisecond) + repo := ram.NewRamRepository() + service := NewService(repo, ctx, 10*time.Millisecond) // Запускаем сервис service.Start() - // Даем время на отправку первой заметки - time.Sleep(25 * time.Millisecond) + // Ждем создание хотя бы одной заметки + time.Sleep(15 * time.Millisecond) // Останавливаем сервис cancel() - // Даем время на обработку завершения - time.Sleep(30 * time.Millisecond) - - logOutput := buf.String() - - // Должно быть сообщение о завершении - assert.Contains(t, logOutput, "Сервис: завершение работы генерации по сигналу", - "Должно быть сообщение о завершении по сигналу") + // Ждем завершения + time.Sleep(20 * time.Millisecond) - // Проверяем что была отправлена хотя бы одна заметка до остановки - assert.Contains(t, logOutput, "Сервис: создана заметка", - "Должна быть отправлена хотя бы одна заметка до остановки") + // Проверяем, что сервис остановился и создал заметки до остановки + notes := repo.GetAllNotes() + assert.Greater(t, len(notes), 0, "Должны быть созданы заметки до остановки") } // TestService_LimitTenNotes тестирует ограничение в 10 заметок func TestService_LimitTenNotes(t *testing.T) { - var buf safeBuffer - log.SetOutput(&buf) - defer log.SetOutput(log.Writer()) - ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() service := NewService(repo, ctx, 5*time.Millisecond) // Запускаем сервис service.Start() - // Ждем, пока будут созданы все заметки - time.Sleep(150 * time.Millisecond) + // Ждем, пока будут созданы все заметки (ограничение в 10) + time.Sleep(100 * time.Millisecond) // Проверяем что было создано ровно 10 заметок assert.Len(t, repo.GetAllNotes(), 10, "Должно быть создано ровно 10 заметок") - - // Проверяем логи - logOutput := buf.String() - assert.Contains(t, logOutput, "Сервис: создана заметка 10") - assert.Contains(t, logOutput, "Сервис: генерация тестовых данных завершена", - "Должно быть сообщение о завершении генерации") - - // Проверяем что нет сообщения о 11й заметке - assert.NotContains(t, logOutput, "Сервис: создана заметка 11") } // TestService_NoteTitles тестирует корректность заголовков заметок @@ -102,28 +63,23 @@ func TestService_NoteTitles(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() - service := NewService(repo, ctx, 10*time.Millisecond) + repo := ram.NewRamRepository() + service := NewService(repo, ctx, 5*time.Millisecond) // Запускаем сервис service.Start() // Ждем создание нескольких заметок - time.Sleep(50 * time.Millisecond) + time.Sleep(30 * time.Millisecond) // Проверяем заголовки notes := repo.GetAllNotes() require.GreaterOrEqual(t, len(notes), 3, "Должно быть создано как минимум 3 заметки") - expectedTitles := []string{ - "Тестовая заметка 1", - "Тестовая заметка 2", - "Тестовая заметка 3", - } - - for i := 0; i < len(expectedTitles) && i < len(notes); i++ { - assert.Equal(t, expectedTitles[i], notes[i].GetTitle(), - "Заголовок заметки %d должен быть '%s'", i+1, expectedTitles[i]) + for i := 0; i < len(notes) && i < 3; i++ { + expectedTitle := fmt.Sprintf("Тестовая заметка %d", i+1) + assert.Equal(t, expectedTitle, notes[i].GetTitle(), + "Заголовок заметки %d должен быть '%s'", i+1, expectedTitle) } } @@ -132,132 +88,370 @@ func TestService_ConcurrentSafety(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() // Создаем сервис - service := NewService(repo, ctx, 15*time.Millisecond) + service := NewService(repo, ctx, 10*time.Millisecond) // Запускаем сервис service.Start() // Ждем немного времени - time.Sleep(200 * time.Millisecond) + time.Sleep(100 * time.Millisecond) - // Проверяем, что сервисы работают без паники и создают заметки - assert.Greater(t, repo.GetNotesCount(), 0, "Должны быть созданы заметки") - t.Logf("Создано заметок: %d", repo.GetNotesCount()) + // Проверяем, что сервис работает без паники и создает заметки + notesCount := repo.GetNotesCount() + assert.Greater(t, notesCount, 0, "Должны быть созданы заметки") + t.Logf("Создано заметок: %d", notesCount) } -// TestService_ChannelBlocking тестирует поведение при блокировке канала -func TestService_ChannelBlocking(t *testing.T) { - var buf safeBuffer - log.SetOutput(&buf) - defer log.SetOutput(log.Writer()) +// TestService_ImmediateStop тестирует немедленную остановку сервиса +func TestService_ImmediateStop(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + // Останавливаем сервис сразу же + cancel() + + repo := ram.NewRamRepository() + service := NewService(repo, ctx, 10*time.Millisecond) + service.Start() + + // Даем время на обработку + time.Sleep(20 * time.Millisecond) + + // Проверяем, что не было создано заметок + notes := repo.GetAllNotes() + assert.Len(t, notes, 0, "Не должно быть созданных заметок при немедленной остановке") +} +// TestService_NoteContent тестирует содержимое заметок +func TestService_NoteContent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - repo := repository.NewRepository() + repo := ram.NewRamRepository() service := NewService(repo, ctx, 5*time.Millisecond) // Запускаем сервис service.Start() - // Ждем немного времени + // Ждем создание заметок + time.Sleep(40 * time.Millisecond) + + // Проверяем содержимое заметок + notes := repo.GetAllNotes() + require.GreaterOrEqual(t, len(notes), 3, "Должно быть создано как минимум 3 заметки") + + for i := 0; i < len(notes) && i < 3; i++ { + expectedContent := fmt.Sprintf("Это содержимое тестовой заметки номер %d", i+1) + assert.Equal(t, expectedContent, notes[i].GetContent(), + "Содержимое заметки %d должно быть '%s'", i+1, expectedContent) + } +} + +// TestService_SimpleCase тестирует простой сценарий работы сервиса +func TestService_SimpleCase(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repo := ram.NewRamRepository() + service := NewService(repo, ctx, 20*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Ждем создание хотя бы одной заметки time.Sleep(30 * time.Millisecond) + // Проверяем, что создана хотя бы одна заметка + assert.GreaterOrEqual(t, repo.GetNotesCount(), 1, "Должна быть создана хотя бы одна заметка") +} + +// TestService_MultipleInstances тестирует работу нескольких экземпляров сервиса +func TestService_MultipleInstances(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repo := ram.NewRamRepository() + + // Создаем два сервиса + service1 := NewService(repo, ctx, 15*time.Millisecond) + service2 := NewService(repo, ctx, 20*time.Millisecond) + + // Запускаем оба сервиса + service1.Start() + service2.Start() + + // Ждем некоторое время + time.Sleep(100 * time.Millisecond) + + // Проверяем, что оба сервиса создают заметки + notesCount := repo.GetNotesCount() + assert.Greater(t, notesCount, 0, "Должны быть созданы заметки от обоих сервисов") + t.Logf("Создано заметок от обоих сервисов: %d", notesCount) +} + +// TestService_GoroutineSafety тестирует безопасность при одновременном доступе к репозиторию +func TestService_GoroutineSafety(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repo := ram.NewRamRepository() + service := NewService(repo, ctx, 5*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Одновременно читаем заметки из репозитория в нескольких горутинах + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 10; j++ { + _ = repo.GetAllNotes() + time.Sleep(2 * time.Millisecond) + } + }() + } + + // Ждем выполнения чтения + time.Sleep(50 * time.Millisecond) + + // Отменяем контекст для остановки сервиса + cancel() + + // Даем время на завершение + time.Sleep(20 * time.Millisecond) + + // Завершаем горутины чтения + wg.Wait() + + // Проверяем, что заметки были созданы + notesCount := repo.GetNotesCount() + assert.Greater(t, notesCount, 0, "Должны быть созданы заметки") +} + +// TestService_DataGenerationAndSaving тестирует полный цикл генерации и сохранения данных +func TestService_DataGenerationAndSaving(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + repo := ram.NewRamRepository() + service := NewService(repo, ctx, 10*time.Millisecond) + + // Сохраняем начальное количество заметок + initialCount := repo.GetNotesCount() + + // Запускаем сервис + service.Start() + + // Ждем создание нескольких заметок + time.Sleep(60 * time.Millisecond) + + // Проверяем, что количество заметок увеличилось + finalCount := repo.GetNotesCount() + assert.Greater(t, finalCount, initialCount, "Количество заметок должно увеличиться после запуска сервиса") + + // Проверяем, что все заметки имеют корректные ID + notes := repo.GetAllNotes() + for _, note := range notes { + assert.NotEmpty(t, note.GetID(), "У заметки должен быть ID") + assert.Equal(t, "note", note.GetType(), "Тип заметки должен быть 'note'") + } +} + +// TestService_StopWithContext_WithJSON тестирует остановку генерации через контекст с JSON репозиторием +func TestService_StopWithContext_WithJSON(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 10*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Ждем создание хотя бы одной заметки + time.Sleep(15 * time.Millisecond) + // Останавливаем сервис cancel() + + // Ждем завершения time.Sleep(20 * time.Millisecond) - logOutput := buf.String() + // Проверяем, что сервис остановился и создал заметки до остановки + notes := repo.GetAllNotes() + assert.Greater(t, len(notes), 0, "Должны быть созданы заметки до остановки") - // Сервис должен корректно завершиться по сигналу контекста - assert.Contains(t, logOutput, "Сервис: завершение", - "Сервис должен корректно завершиться. Вывод: %s", logOutput) + // Очищаем файлы после теста + _ = cleanupJSONFiles() } -// TestService_ImmediateStop тестирует немедленную остановку сервиса -func TestService_ImmediateStop(t *testing.T) { - var buf safeBuffer - log.SetOutput(&buf) - defer log.SetOutput(log.Writer()) +// TestService_LimitTenNotes_WithJSON тестирует ограничение в 10 заметок с JSON репозиторием +func TestService_LimitTenNotes_WithJSON(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 5*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Ждем, пока будут созданы все заметки (ограничение в 10) + time.Sleep(60 * time.Millisecond) + + // Проверяем что было создано ровно 10 заметок + assert.Len(t, repo.GetAllNotes(), 10, "Должно быть создано ровно 10 заметок") + + // Очищаем файлы после теста + _ = cleanupJSONFiles() +} + +// TestService_NoteTitles_WithJSON тестирует корректность заголовков заметок с JSON репозиторием +func TestService_NoteTitles_WithJSON(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 5*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Ждем создание нескольких заметок + time.Sleep(30 * time.Millisecond) + + // Проверяем заголовки + notes := repo.GetAllNotes() + require.GreaterOrEqual(t, len(notes), 3, "Должно быть создано как минимум 3 заметки") + + for i := 0; i < len(notes) && i < 3; i++ { + expectedTitle := fmt.Sprintf("Тестовая заметка %d", i+1) + assert.Equal(t, expectedTitle, notes[i].GetTitle(), + "Заголовок заметки %d должен быть '%s'", i+1, expectedTitle) + } + + // Очищаем файлы после теста + _ = cleanupJSONFiles() +} + +// TestService_ConcurrentSafety_WithJSON тестирует безопасность конкурентного доступа с JSON репозиторием +func TestService_ConcurrentSafety_WithJSON(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + + // Создаем сервис + service := NewService(repo, ctx, 10*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Ждем немного времени + time.Sleep(100 * time.Millisecond) + + // Проверяем, что сервис работает без паники и создает заметки + notesCount := repo.GetNotesCount() + assert.Greater(t, notesCount, 0, "Должны быть созданы заметки") + t.Logf("Создано заметок: %d", notesCount) + + // Очищаем файлы после теста + _ = cleanupJSONFiles() +} +// TestService_ImmediateStop_WithJSON тестирует немедленную остановку сервиса с JSON репозиторием +func TestService_ImmediateStop_WithJSON(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) // Останавливаем сервис сразу же cancel() - repo := repository.NewRepository() + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() service := NewService(repo, ctx, 10*time.Millisecond) service.Start() // Даем время на обработку time.Sleep(20 * time.Millisecond) - logOutput := buf.String() - - // Должно быть сообщение о завершении - assert.Contains(t, logOutput, "Сервис: завершение работы генерации по сигналу", - "Должно быть сообщение о немедленном завершении") + // Проверяем, что не было создано заметок + notes := repo.GetAllNotes() + assert.Len(t, notes, 0, "Не должно быть созданных заметок при немедленной остановке") - // Не должно быть отправленных заметок - assert.NotContains(t, logOutput, "Сервис: создана заметка", - "Не должно быть созданных заметок при немедленной остановке") + // Очищаем файлы после теста + _ = cleanupJSONFiles() } -// TestService_NoteContent тестирует содержимое заметок -func TestService_NoteContent(t *testing.T) { +// TestService_NoteContent_WithJSON тестирует содержимое заметок с JSON репозиторием +func TestService_NoteContent_WithJSON(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() - service := NewService(repo, ctx, 10*time.Millisecond) + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 5*time.Millisecond) // Запускаем сервис service.Start() // Ждем создание заметок - time.Sleep(100 * time.Millisecond) + time.Sleep(40 * time.Millisecond) // Проверяем содержимое заметок notes := repo.GetAllNotes() require.GreaterOrEqual(t, len(notes), 3, "Должно быть создано как минимум 3 заметки") - for i := 1; i <= len(notes) && i <= 3; i++ { - expectedContent := fmt.Sprintf("Это содержимое тестовой заметки номер %d", i) - assert.Equal(t, expectedContent, notes[i-1].GetContent(), - "Содержимое заметки %d должно быть '%s'", i, expectedContent) + for i := 0; i < len(notes) && i < 3; i++ { + expectedContent := fmt.Sprintf("Это содержимое тестовой заметки номер %d", i+1) + assert.Equal(t, expectedContent, notes[i].GetContent(), + "Содержимое заметки %d должно быть '%s'", i+1, expectedContent) } + + // Очищаем файлы после теста + _ = cleanupJSONFiles() } -// TestService_SimpleCase тестирует простой сценарий работы сервиса -func TestService_SimpleCase(t *testing.T) { +// TestService_SimpleCase_WithJSON тестирует простой сценарий работы сервиса с JSON репозиторием +func TestService_SimpleCase_WithJSON(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() - service := NewService(repo, ctx, 30*time.Millisecond) + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 20*time.Millisecond) // Запускаем сервис service.Start() // Ждем создание хотя бы одной заметки - time.Sleep(40 * time.Millisecond) + time.Sleep(30 * time.Millisecond) // Проверяем, что создана хотя бы одна заметка assert.GreaterOrEqual(t, repo.GetNotesCount(), 1, "Должна быть создана хотя бы одна заметка") + + // Очищаем файлы после теста + _ = cleanupJSONFiles() } -// TestService_MultipleInstances тестирует работу нескольких экземпляров сервиса -func TestService_MultipleInstances(t *testing.T) { +// TestService_MultipleInstances_WithJSON тестирует работу нескольких экземпляров сервиса с JSON репозиторием +func TestService_MultipleInstances_WithJSON(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - repo := repository.NewRepository() + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() // Создаем два сервиса - service1 := NewService(repo, ctx, 20*time.Millisecond) - service2 := NewService(repo, ctx, 25*time.Millisecond) + service1 := NewService(repo, ctx, 15*time.Millisecond) + service2 := NewService(repo, ctx, 20*time.Millisecond) // Запускаем оба сервиса service1.Start() @@ -267,6 +461,109 @@ func TestService_MultipleInstances(t *testing.T) { time.Sleep(100 * time.Millisecond) // Проверяем, что оба сервиса создают заметки - assert.Greater(t, repo.GetNotesCount(), 0, "Должны быть созданы заметки от обоих сервисов") - t.Logf("Создано заметок от обоих сервисов: %d", repo.GetNotesCount()) + notesCount := repo.GetNotesCount() + assert.Greater(t, notesCount, 0, "Должны быть созданы заметки от обоих сервисов") + t.Logf("Создано заметок от обоих сервисов: %d", notesCount) + + // Очищаем файлы после теста + _ = cleanupJSONFiles() +} + +// TestService_GoroutineSafety_WithJSON тестирует безопасность при одновременном доступе к JSON репозиторию +func TestService_GoroutineSafety_WithJSON(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 5*time.Millisecond) + + // Запускаем сервис + service.Start() + + // Одновременно читаем заметки из репозитория в нескольких горутинах + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 10; j++ { + _ = repo.GetAllNotes() + time.Sleep(2 * time.Millisecond) + } + }() + } + + // Ждем выполнения чтения + time.Sleep(50 * time.Millisecond) + + // Отменяем контекст для остановки сервиса + cancel() + + // Даем время на завершение + time.Sleep(20 * time.Millisecond) + + // Завершаем горутины чтения + wg.Wait() + + // Проверяем, что заметки были созданы + notesCount := repo.GetNotesCount() + assert.Greater(t, notesCount, 0, "Должны быть созданы заметки") + + // Очищаем файлы после теста + _ = cleanupJSONFiles() +} + +// TestService_DataGenerationAndSaving_WithJSON тестирует полный цикл генерации и сохранения данных с JSON репозиторием +func TestService_DataGenerationAndSaving_WithJSON(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Создаем временный JSON репозиторий + repo := fs.NewJSONRepository() + service := NewService(repo, ctx, 10*time.Millisecond) + + // Сохраняем начальное количество заметок + initialCount := repo.GetNotesCount() + + // Запускаем сервис + service.Start() + + // Ждем создание нескольких заметок + time.Sleep(60 * time.Millisecond) + + // Проверяем, что количество заметок увеличилось + finalCount := repo.GetNotesCount() + assert.Greater(t, finalCount, initialCount, "Количество заметок должно увеличиться после запуска сервиса") + + // Проверяем, что все заметки имеют корректные ID + notes := repo.GetAllNotes() + for _, note := range notes { + assert.NotEmpty(t, note.GetID(), "У заметки должен быть ID") + assert.Equal(t, "note", note.GetType(), "Тип заметки должен быть 'note'") + } + + // Очищаем файлы после теста + _ = cleanupJSONFiles() +} + +// cleanupJSONFiles удаляет все JSON файлы из директории data +func cleanupJSONFiles() error { + files, err := os.ReadDir(fs.StorageDir) + if err != nil { + return err + } + + for _, file := range files { + if filepath.Ext(file.Name()) == ".json" && + len(file.Name()) > len(fs.NoteFilePrefix) && + file.Name()[:len(fs.NoteFilePrefix)] == fs.NoteFilePrefix { + filePath := filepath.Join(fs.StorageDir, file.Name()) + if err := os.Remove(filePath); err != nil { + return err + } + } + } + + return nil }