test: add unit tests for domain and business logic (#4)
Adds 14 unit tests across 3 files covering inMemoryMapper, create/list/get/stats handlers, and pagination logic.
This commit is contained in:
122
internal/kurious/adapters/memory_mapper_test.go
Normal file
122
internal/kurious/adapters/memory_mapper_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
106
internal/kurious/app/command/command_test.go
Normal file
106
internal/kurious/app/command/command_test.go
Normal file
@ -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")
|
||||
}
|
||||
183
internal/kurious/app/query/query_test.go
Normal file
183
internal/kurious/app/query/query_test.go
Normal file
@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user