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:
2026-06-27 23:23:00 +00:00
parent 39c4fa5621
commit 84656c6c56
3 changed files with 411 additions and 0 deletions

View 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)
})
}

View 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")
}

View 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")
}