diff --git a/internal/kurious/adapters/memory_mapper_test.go b/internal/kurious/adapters/memory_mapper_test.go new file mode 100644 index 0000000..c405c49 --- /dev/null +++ b/internal/kurious/adapters/memory_mapper_test.go @@ -0,0 +1,122 @@ +package adapters + +import ( + "context" + "testing" + + "git.loyso.art/frx/kurious/internal/kurious/domain" + mockrepo "git.loyso.art/frx/kurious/internal/kurious/domain/mocks" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// fullRepo embeds the generated CourseRepository mock and adds the missing +// ListStatistics method so the type fully satisfies domain.CourseRepository. +type fullRepo struct { + *mockrepo.CourseRepository +} + +func (m *fullRepo) ListStatistics(ctx context.Context, p domain.ListStatisticsParams) (domain.ListStatisticsResult, error) { + args := m.Called(ctx, p) + if rf, ok := args.Get(0).(func(context.Context, domain.ListStatisticsParams) (domain.ListStatisticsResult, error)); ok { + return rf(ctx, p) + } + var r0 domain.ListStatisticsResult + if v, ok := args.Get(0).(domain.ListStatisticsResult); ok { + r0 = v + } + return r0, args.Error(1) +} + +func TestInMemoryMapper_CollectCounts(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + + courses := []domain.Course{ + {LearningTypeID: "lt1", ThematicID: "th1"}, + {LearningTypeID: "lt1", ThematicID: "th1"}, + {LearningTypeID: "lt1", ThematicID: "th2"}, + {LearningTypeID: "lt2", ThematicID: "th3"}, + } + repo.EXPECT().List(mock.Anything, domain.ListCoursesParams{ + LearningType: "", + CourseThematic: "", + Limit: 1001, + Offset: 0, + }).Return(domain.ListCoursesResult{Courses: courses, Count: len(courses)}, nil).Once() + + m := NewMemoryMapper(nil, nil) + assert.NoError(t, m.CollectCounts(context.Background(), repo)) + + stats := m.GetStats(false) + assert.Equal(t, domain.LearningTypeStat{Count: 3, CourseThematic: map[string]int{"th1": 2, "th2": 1}}, stats["lt1"]) + assert.Equal(t, domain.LearningTypeStat{Count: 1, CourseThematic: map[string]int{"th3": 1}}, stats["lt2"]) + assert.Equal(t, 4, m.GetCounts("", "")) +} + +func TestInMemoryMapper_GetCounts(t *testing.T) { + m := NewMemoryMapper(nil, nil) + m.stats = map[string]domain.LearningTypeStat{ + "lt1": {Count: 5, CourseThematic: map[string]int{"th1": 3, "th2": 2}}, + "lt2": {Count: 2, CourseThematic: map[string]int{"th3": 2}}, + } + m.courseThematicByLearningType = map[string]string{"th1": "lt1", "th2": "lt1", "th3": "lt2"} + m.totalCount = 7 + + tests := []struct { + name string + byCourseThematic string + byLearningType string + want int + }{ + {"both empty returns total count", "", "", 7}, + {"byCourseThematic only resolves learningType via lookup", "th1", "", 3}, + {"byLearningType only returns stat.Count", "", "lt1", 5}, + {"both set returns nested thematic count", "th1", "lt1", 3}, + {"unknown thematic resolves to empty learningType -> total", "missing", "", 7}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, m.GetCounts(tc.byCourseThematic, tc.byLearningType)) + }) + } +} + +func TestInMemoryMapper_GetStats(t *testing.T) { + newMapper := func() *inMemoryMapper { + m := NewMemoryMapper(nil, nil) + m.stats = map[string]domain.LearningTypeStat{ + "lt1": {Count: 5, CourseThematic: map[string]int{"th1": 3}}, + } + return m + } + + t.Run("copyMap false returns internal map", func(t *testing.T) { + m := newMapper() + got := m.GetStats(false) + + got["lt2"] = domain.LearningTypeStat{Count: 1} + s := got["lt1"] + s.Count = 99 + got["lt1"] = s + + assert.Contains(t, m.stats, "lt2", "returned map should be the same reference as internal") + assert.Equal(t, 99, m.stats["lt1"].Count) + }) + + t.Run("copyMap true returns deep copy", func(t *testing.T) { + m := newMapper() + got := m.GetStats(true) + + got["lt2"] = domain.LearningTypeStat{Count: 1} + s := got["lt1"] + s.Count = 99 + s.CourseThematic["th1"] = 99 + s.CourseThematic["new"] = 99 + got["lt1"] = s + + assert.NotContains(t, m.stats, "lt2") + assert.Equal(t, 5, m.stats["lt1"].Count) + assert.Equal(t, map[string]int{"th1": 3}, m.stats["lt1"].CourseThematic) + }) +} diff --git a/internal/kurious/app/command/command_test.go b/internal/kurious/app/command/command_test.go new file mode 100644 index 0000000..4fbe012 --- /dev/null +++ b/internal/kurious/app/command/command_test.go @@ -0,0 +1,106 @@ +package command + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "git.loyso.art/frx/kurious/internal/common/nullable" + "git.loyso.art/frx/kurious/internal/kurious/domain" + mockrepo "git.loyso.art/frx/kurious/internal/kurious/domain/mocks" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// fullRepo embeds the generated CourseRepository mock and adds the missing +// ListStatistics method so the type fully satisfies domain.CourseRepository. +type fullRepo struct { + *mockrepo.CourseRepository +} + +func (m *fullRepo) ListStatistics(ctx context.Context, p domain.ListStatisticsParams) (domain.ListStatisticsResult, error) { + args := m.Called(ctx, p) + if rf, ok := args.Get(0).(func(context.Context, domain.ListStatisticsParams) (domain.ListStatisticsResult, error)); ok { + return rf(ctx, p) + } + var r0 domain.ListStatisticsResult + if v, ok := args.Get(0).(domain.ListStatisticsResult); ok { + r0 = v + } + return r0, args.Error(1) +} + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestCreateCourseHandler(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + h := NewCreateCourseHandler(repo, quietLogger()) + + cmd := CreateCourse{ + ID: "c1", + ExternalID: nullable.NewValue("ext1"), + Name: "name", + SourceType: domain.SourceTypeParsed, + CourseThematic: "ct", + LearningType: "lt", + OrganizationID: "org1", + } + repo.EXPECT().Create(mock.Anything, domain.CreateCourseParams(cmd)). + Return(domain.Course{ID: "c1"}, nil).Once() + + err := h.Handle(context.Background(), cmd) + require.NoError(t, err) +} + +func TestCreateCourseHandler_Error(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + h := NewCreateCourseHandler(repo, quietLogger()) + + sentinel := errors.New("db down") + cmd := CreateCourse{ID: "c1"} + repo.EXPECT().Create(mock.Anything, domain.CreateCourseParams(cmd)). + Return(domain.Course{}, sentinel).Once() + + err := h.Handle(context.Background(), cmd) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Contains(t, err.Error(), "creating course") +} + +func TestCreateCoursesHandler(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + h := NewCreateCoursesHandler(repo, quietLogger()) + + cmd := CreateCourses{Courses: []CreateCourse{ + {ID: "c1", CourseThematic: "ct1", LearningType: "lt1"}, + {ID: "c2", CourseThematic: "ct2", LearningType: "lt2"}, + }} + repo.EXPECT().CreateBatch(mock.Anything, + domain.CreateCourseParams(cmd.Courses[0]), + domain.CreateCourseParams(cmd.Courses[1]), + ).Return(nil).Once() + + err := h.Handle(context.Background(), cmd) + require.NoError(t, err) +} + +func TestCreateCoursesHandler_Error(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + h := NewCreateCoursesHandler(repo, quietLogger()) + + sentinel := errors.New("batch failed") + cmd := CreateCourses{Courses: []CreateCourse{{ID: "x"}}} + repo.EXPECT().CreateBatch(mock.Anything, domain.CreateCourseParams(cmd.Courses[0])). + Return(sentinel).Once() + + err := h.Handle(context.Background(), cmd) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Contains(t, err.Error(), "creating course") +} diff --git a/internal/kurious/app/query/query_test.go b/internal/kurious/app/query/query_test.go new file mode 100644 index 0000000..ecb89dd --- /dev/null +++ b/internal/kurious/app/query/query_test.go @@ -0,0 +1,183 @@ +package query + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "git.loyso.art/frx/kurious/internal/kurious/domain" + mockrepo "git.loyso.art/frx/kurious/internal/kurious/domain/mocks" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// fullRepo embeds the generated CourseRepository mock and adds the missing +// ListStatistics method so the type fully satisfies domain.CourseRepository. +type fullRepo struct { + *mockrepo.CourseRepository +} + +func (m *fullRepo) ListStatistics(ctx context.Context, p domain.ListStatisticsParams) (domain.ListStatisticsResult, error) { + args := m.Called(ctx, p) + if rf, ok := args.Get(0).(func(context.Context, domain.ListStatisticsParams) (domain.ListStatisticsResult, error)); ok { + return rf(ctx, p) + } + var r0 domain.ListStatisticsResult + if v, ok := args.Get(0).(domain.ListStatisticsResult); ok { + r0 = v + } + return r0, args.Error(1) +} + +// stubMapper is a controllable domain.CourseMapper used in handler tests. +type stubMapper struct { + thematicNames map[string]string + learningTypeNames map[string]string + stats map[string]domain.LearningTypeStat +} + +func (s stubMapper) CourseThematicNameByID(id string) string { return s.thematicNames[id] } +func (s stubMapper) LearningTypeNameByID(id string) string { return s.learningTypeNames[id] } +func (s stubMapper) CollectCounts(context.Context, domain.CourseRepository) error { + return nil +} +func (s stubMapper) GetCounts(string, string) int { return 0 } +func (s stubMapper) GetStats(bool) map[string]domain.LearningTypeStat { + return s.stats +} + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func makeCourses(n int) []domain.Course { + out := make([]domain.Course, n) + for i := range out { + out[i] = domain.Course{LearningTypeID: "lid1", ThematicID: "tid1"} + } + return out +} + +func TestListCourseHandler_DrainFull(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + mapper := stubMapper{ + thematicNames: map[string]string{"tid1": "ThematicName"}, + learningTypeNames: map[string]string{"lid1": "LearningName"}, + } + h := NewListCourseHandler(repo, mapper, quietLogger()) + + page1 := makeCourses(1000) + page2 := makeCourses(1000) + page3 := makeCourses(400) + + repo.EXPECT().List(mock.Anything, domain.ListCoursesParams{Limit: 1000, Offset: 0}). + Return(domain.ListCoursesResult{Courses: page1, Count: 2400}, nil).Once() + repo.EXPECT().List(mock.Anything, domain.ListCoursesParams{Limit: 1000, Offset: 1000}). + Return(domain.ListCoursesResult{Courses: page2, Count: 2400}, nil).Once() + repo.EXPECT().List(mock.Anything, domain.ListCoursesParams{Limit: 1000, Offset: 2000}). + Return(domain.ListCoursesResult{Courses: page3, Count: 2400}, nil).Once() + + out, err := h.Handle(context.Background(), ListCourse{Limit: 0}) + require.NoError(t, err) + require.Len(t, out.Courses, 2400) + assert.Equal(t, 2400, out.Count) + + assert.Equal(t, "LearningName", out.Courses[0].LearningType) + assert.Equal(t, "ThematicName", out.Courses[0].Thematic) + last := out.Courses[len(out.Courses)-1] + assert.Equal(t, "LearningName", last.LearningType) + assert.Equal(t, "ThematicName", last.Thematic) +} + +func TestListCourseHandler_Limited(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + mapper := stubMapper{ + thematicNames: map[string]string{"tid1": "ThematicName"}, + learningTypeNames: map[string]string{"lid1": "LearningName"}, + } + h := NewListCourseHandler(repo, mapper, quietLogger()) + + repo.EXPECT().List(mock.Anything, domain.ListCoursesParams{Limit: 5, Offset: 0}). + Return(domain.ListCoursesResult{Courses: makeCourses(5), Count: 5}, nil).Once() + + out, err := h.Handle(context.Background(), ListCourse{Limit: 5}) + require.NoError(t, err) + require.Len(t, out.Courses, 5) + assert.Equal(t, 5, out.Count) + assert.Equal(t, "LearningName", out.Courses[0].LearningType) + assert.Equal(t, "ThematicName", out.Courses[0].Thematic) +} + +func TestListCoursesStatsHandler_ByOrganization(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + h := NewListCoursesStatsHandler(stubMapper{}, repo, quietLogger()) + + units := []domain.StatisticUnit{ + {LearningTypeID: "lt1", CourseThematicID: "th1"}, + {LearningTypeID: "lt1", CourseThematicID: "th1"}, + {LearningTypeID: "lt1", CourseThematicID: "th2"}, + {LearningTypeID: "lt2", CourseThematicID: "th3"}, + } + repo.On("ListStatistics", mock.Anything, domain.ListStatisticsParams{ + LearningTypeID: "lt1", + CourseThematicID: "thX", + OrganizaitonID: "org1", + }).Return(domain.ListStatisticsResult{LearningTypeStatistics: units}, nil).Once() + + out, err := h.Handle(context.Background(), ListCoursesStats{ + LearningTypeID: "lt1", + CourseThematicsID: "thX", + OrganizationID: "org1", + }) + require.NoError(t, err) + assert.Equal(t, domain.LearningTypeStat{Count: 3, CourseThematic: map[string]int{"th1": 2, "th2": 1}}, out.StatsByLearningType["lt1"]) + assert.Equal(t, domain.LearningTypeStat{Count: 1, CourseThematic: map[string]int{"th3": 1}}, out.StatsByLearningType["lt2"]) +} + +func TestListCoursesStatsHandler_FromMapper(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + stats := map[string]domain.LearningTypeStat{ + "lt1": {Count: 9, CourseThematic: map[string]int{"th1": 9}}, + } + h := NewListCoursesStatsHandler(stubMapper{stats: stats}, repo, quietLogger()) + + out, err := h.Handle(context.Background(), ListCoursesStats{}) + require.NoError(t, err) + assert.Equal(t, stats, out.StatsByLearningType) +} + +func TestGetCourseHandler(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + mapper := stubMapper{ + thematicNames: map[string]string{"tid1": "ThematicName"}, + learningTypeNames: map[string]string{"lid1": "LearningName"}, + } + h := NewGetCourseHandler(repo, mapper, quietLogger()) + + repo.EXPECT().Get(mock.Anything, "c1").Return(domain.Course{ + ID: "c1", LearningTypeID: "lid1", ThematicID: "tid1", + }, nil).Once() + + got, err := h.Handle(context.Background(), GetCourse{ID: "c1"}) + require.NoError(t, err) + assert.Equal(t, "c1", got.ID) + assert.Equal(t, "LearningName", got.LearningType) + assert.Equal(t, "ThematicName", got.Thematic) +} + +func TestGetCourseHandler_Error(t *testing.T) { + repo := &fullRepo{mockrepo.NewCourseRepository(t)} + h := NewGetCourseHandler(repo, stubMapper{}, quietLogger()) + + sentinel := errors.New("boom") + repo.EXPECT().Get(mock.Anything, "c1").Return(domain.Course{}, sentinel).Once() + + _, err := h.Handle(context.Background(), GetCourse{ID: "c1"}) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel) + assert.Contains(t, err.Error(), "getting course") +}