initial commit

This commit is contained in:
m.zare
2026-04-10 18:25:21 +03:30
commit 77ca6c34a3
263 changed files with 34470 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
# Asset relations: Report and Comment
## Overview
Reports and comments are **child relations** of an asset. They are stored in separate tables (`asset_reports`, `asset_comments`) and are always loaded/saved **through the asset** (no standalone Report or Comment repository in this layer).
---
## Comment relation
### Storage
- **Table:** `asset_comments`
- **Columns:** `id`, `asset_id`, `content`, `writer_id`, `writer_type`, `parent_id`, `created_at`, `updated_at`, `deleted_at`
- **Parent link:** `asset_id``assets.id`
### What happens
| Operation | Behavior |
|-----------|----------|
| **Create asset** | If `asset.Comments` is non-empty, each comment is inserted with `asset_id = new_asset_id`. IDs/timestamps come from DB. |
| **FindByID / FindByProfileID** | All rows in `asset_comments` with `asset_id = asset.id` are loaded and mapped to `asset.Comments` (flat list). |
| **Update asset** | All existing comments for that asset are **deleted**, then `asset.Comments` is re-inserted (replace strategy). |
| **Delete asset** | All comments for that asset are deleted in the same transaction before the asset row is deleted. |
### Domain vs persistence
- **Domain:** `Comment` has `Replies []Comment` (nested).
- **Persistence:** Stored as rows in `asset_comments` with `parent_id` for replies.
- **When loading:** A flat list is read from DB, then `buildCommentTree()` turns it into a tree: top-level comments have `Replies` populated.
- **When saving:** `flattenComments()` turns the tree into a flat list (parent then its replies); all rows are persisted. Note: when **creating** a new asset with new nested comments, reply `ParentID` in the domain may be zero (parent not yet saved); the current single-batch insert does not resolve parent IDs, so nested replies on create may end up with `parent_id = NULL`. For **updates** after load, parent IDs exist and nested replies persist correctly.
---
## Report relation
### Storage
- **Table:** `asset_reports`
- **Columns:** `id`, `asset_id`, `reported_by` (JSONB), `reported_at`, `reason` (JSONB), `status`, `notes`, `attachments` (JSONB), `created_at`, `updated_at`, `deleted_at`
- **Parent link:** `asset_id``assets.id`
- **Nested data:** `ReportedBy`, `ReportReason`, and `Attachments` are stored as JSON in the same row.
### What happens
| Operation | Behavior |
|-----------|----------|
| **Create asset** | If `asset.Reports` is non-empty, each report is inserted: `ReportedBy`, `Reason`, and `Attachments` are JSON-encoded into the report row. |
| **FindByID / FindByProfileID** | All rows in `asset_reports` with `asset_id = asset.id` are loaded; JSONB columns are decoded into `Report.ReportedBy`, `Report.Reason`, `Report.Attachments`. |
| **Update asset** | All existing reports for that asset are **deleted**, then `asset.Reports` is re-inserted (replace strategy). |
| **Delete asset** | All reports for that asset are deleted in the same transaction before the asset row is deleted. |
### Domain vs persistence
- **ReportedBy**, **ReportReason**, **Attachments** are fully round-tripped via JSON; no separate tables.
- Report **ID** and **ReportedAt** are set when loading from DB; on create, IDs/timestamps come from DB.
---
## Summary
- **Comment:** Stored and loaded as a **flat** list per asset; `parent_id` is persisted but **Replies** are not built when loading and nested replies are not written when saving.
- **Report:** Stored and loaded as a list per asset; nested structures (ReportedBy, Reason, Attachments) are stored as JSONB and fully restored on load.
- Both relations use a **replace-on-update** strategy: updating an asset deletes all its comments and reports and re-inserts from `asset.Comments` and `asset.Reports`.

View File

@@ -0,0 +1,297 @@
package asset
import (
"context"
"errors"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainAsset "base/internal/domain/asset"
)
type assetRepository struct {
db *gorm.DB
}
func NewAssetRepository(lc fx.Lifecycle, db *gorm.DB) domainAsset.AssetRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &assetRepository{db: db}
}
func (r *assetRepository) loadRelatedData(ctx context.Context, assetID, categoryID uuid.UUID) (*domainAsset.Category, []domainAsset.Artifact, []domainAsset.Comment, []domainAsset.Report, error) {
var category *domainAsset.Category
if categoryID != uuid.Nil {
var catModel CategoryModel
if err := r.db.WithContext(ctx).Where("id = ?", categoryID).First(&catModel).Error; err == nil {
category = toCategoryDomain(&catModel)
}
}
var artifactModels []ArtifactModel
if err := r.db.WithContext(ctx).Where("asset_id = ?", assetID).Find(&artifactModels).Error; err != nil {
return nil, nil, nil, nil, err
}
artifacts := toArtifactDomains(artifactModels)
var commentModels []CommentModel
if err := r.db.WithContext(ctx).Where("asset_id = ?", assetID).Find(&commentModels).Error; err != nil {
return nil, nil, nil, nil, err
}
comments := toCommentDomains(commentModels)
var reportModels []ReportModel
if err := r.db.WithContext(ctx).Where("asset_id = ?", assetID).Find(&reportModels).Error; err != nil {
return nil, nil, nil, nil, err
}
reports, err := toReportDomains(reportModels)
if err != nil {
return nil, nil, nil, nil, err
}
return category, artifacts, comments, reports, nil
}
func (r *assetRepository) Create(ctx context.Context, asset *domainAsset.Asset) error {
model := toAssetModel(asset)
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
if err := tx.Create(model).Error; err != nil {
return err
}
if len(asset.AssetArtifacts) > 0 {
artifactModels := toArtifactModels(model.ID, asset.AssetArtifacts)
if err := tx.Create(&artifactModels).Error; err != nil {
return err
}
}
if len(asset.Comments) > 0 {
commentModels := toCommentModels(model.ID, asset.Comments)
if err := tx.Create(&commentModels).Error; err != nil {
return err
}
}
if len(asset.Reports) > 0 {
reportModels, err := toReportModels(model.ID, asset.Reports)
if err != nil {
return err
}
if err := tx.Create(&reportModels).Error; err != nil {
return err
}
}
if err := tx.Commit().Error; err != nil {
return err
}
copyAssetFromModel(asset, model)
return nil
}
func (r *assetRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainAsset.Asset, error) {
var model Model
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("asset not found")
}
return nil, err
}
category, artifacts, comments, reports, err := r.loadRelatedData(ctx, model.ID, model.AssetCategoryID)
if err != nil {
return nil, err
}
return toAssetDomain(&model, category, artifacts, comments, reports), nil
}
func (r *assetRepository) Update(ctx context.Context, asset *domainAsset.Asset) error {
model := toAssetModel(asset)
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
if err := tx.Model(&Model{}).Where("id = ?", asset.ID).Updates(model).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", asset.ID).Delete(&ArtifactModel{}).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", asset.ID).Delete(&CommentModel{}).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", asset.ID).Delete(&ReportModel{}).Error; err != nil {
return err
}
if len(asset.AssetArtifacts) > 0 {
artifactModels := toArtifactModels(asset.ID, asset.AssetArtifacts)
if err := tx.Create(&artifactModels).Error; err != nil {
return err
}
}
if len(asset.Comments) > 0 {
commentModels := toCommentModels(asset.ID, asset.Comments)
if err := tx.Create(&commentModels).Error; err != nil {
return err
}
}
if len(asset.Reports) > 0 {
reportModels, err := toReportModels(asset.ID, asset.Reports)
if err != nil {
return err
}
if err := tx.Create(&reportModels).Error; err != nil {
return err
}
}
return tx.Commit().Error
}
func (r *assetRepository) Delete(ctx context.Context, asset *domainAsset.Asset) error {
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
if err := tx.Where("asset_id = ?", asset.ID).Delete(&ArtifactModel{}).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", asset.ID).Delete(&CommentModel{}).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", asset.ID).Delete(&ReportModel{}).Error; err != nil {
return err
}
if err := tx.Delete(&Model{}, "id = ?", asset.ID).Error; err != nil {
return err
}
return tx.Commit().Error
}
func (r *assetRepository) FindLatestByCategory(ctx context.Context, categoryID uuid.UUID, limit int) ([]*domainAsset.Asset, error) {
return r.FindLatestByCategoryPaginated(ctx, categoryID, limit, 0)
}
func (r *assetRepository) FindLatestByCategoryPaginated(ctx context.Context, categoryID uuid.UUID, limit, offset int) ([]*domainAsset.Asset, error) {
var models []Model
q := r.db.WithContext(ctx).Where("asset_category_id = ?", categoryID).Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
}
if offset > 0 {
q = q.Offset(offset)
}
if err := q.Find(&models).Error; err != nil {
return nil, err
}
if len(models) == 0 {
return nil, nil
}
out := make([]*domainAsset.Asset, len(models))
for i, model := range models {
category, artifacts, comments, reports, err := r.loadRelatedData(ctx, model.ID, model.AssetCategoryID)
if err != nil {
return nil, err
}
out[i] = toAssetDomain(&model, category, artifacts, comments, reports)
}
return out, nil
}
func (r *assetRepository) CountByCategory(ctx context.Context, categoryID uuid.UUID) (int, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&Model{}).Where("asset_category_id = ?", categoryID).Count(&count).Error; err != nil {
return 0, err
}
return int(count), nil
}
func (r *assetRepository) FindLatest(ctx context.Context, limit, offset int) ([]*domainAsset.Asset, error) {
var models []Model
q := r.db.WithContext(ctx).Order("created_at DESC")
if limit > 0 {
q = q.Limit(limit)
}
if offset > 0 {
q = q.Offset(offset)
}
if err := q.Find(&models).Error; err != nil {
return nil, err
}
if len(models) == 0 {
return nil, nil
}
out := make([]*domainAsset.Asset, len(models))
for i, model := range models {
category, artifacts, comments, reports, err := r.loadRelatedData(ctx, model.ID, model.AssetCategoryID)
if err != nil {
return nil, err
}
out[i] = toAssetDomain(&model, category, artifacts, comments, reports)
}
return out, nil
}
func (r *assetRepository) FindByProfileID(ctx context.Context, profileID uuid.UUID) ([]*domainAsset.Asset, error) {
var models []Model
if err := r.db.WithContext(ctx).Where("profile_id = ?", profileID).Order("created_at DESC").Find(&models).Error; err != nil {
return nil, err
}
if len(models) == 0 {
return nil, nil
}
out := make([]*domainAsset.Asset, len(models))
for i, model := range models {
category, artifacts, comments, reports, err := r.loadRelatedData(ctx, model.ID, model.AssetCategoryID)
if err != nil {
return nil, err
}
out[i] = toAssetDomain(&model, category, artifacts, comments, reports)
}
return out, nil
}
func (r *assetRepository) Count(ctx context.Context) (int, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&Model{}).Count(&count).Error; err != nil {
return 0, err
}
return int(count), nil
}

View File

@@ -0,0 +1,90 @@
package asset
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainAsset "base/internal/domain/asset"
)
type categoryRepository struct {
db *gorm.DB
}
func NewCategoryRepository(lc fx.Lifecycle, db *gorm.DB) domainAsset.CategoryRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &categoryRepository{db: db}
}
func (r *categoryRepository) Create(ctx context.Context, category *domainAsset.Category) error {
model := toCategoryModel(category)
now := time.Now()
model.CreatedAt = now
model.UpdatedAt = now
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
return err
}
category.ID = model.ID
return nil
}
func (r *categoryRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainAsset.Category, error) {
var model CategoryModel
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("category not found")
}
return nil, err
}
return toCategoryDomain(&model), nil
}
func (r *categoryRepository) Update(ctx context.Context, category *domainAsset.Category) error {
model := toCategoryModel(category)
model.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Model(&CategoryModel{}).Where("id = ?", category.ID).Updates(model).Error
}
func (r *categoryRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&CategoryModel{}, "id = ?", id).Error
}
func (r *categoryRepository) FindAll(ctx context.Context) ([]*domainAsset.Category, error) {
var models []CategoryModel
if err := r.db.WithContext(ctx).Order("name ASC").Find(&models).Error; err != nil {
return nil, err
}
out := make([]*domainAsset.Category, len(models))
for i := range models {
out[i] = toCategoryDomain(&models[i])
}
return out, nil
}
func (r *categoryRepository) FindByIDs(ctx context.Context, ids []uuid.UUID) ([]*domainAsset.Category, error) {
if len(ids) == 0 {
return nil, nil
}
var models []CategoryModel
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Order("name ASC").Find(&models).Error; err != nil {
return nil, err
}
out := make([]*domainAsset.Category, len(models))
for i := range models {
out[i] = toCategoryDomain(&models[i])
}
return out, nil
}

View File

@@ -0,0 +1,249 @@
package asset
import (
"encoding/json"
"github.com/google/uuid"
domainAsset "base/internal/domain/asset"
)
func toCategoryModel(category *domainAsset.Category) *CategoryModel {
return &CategoryModel{
ID: category.ID,
Name: category.Name,
Icon: category.Icon,
Color: category.Color,
CardType: category.CardType,
Featured: category.Featured,
Description: category.Description,
}
}
func toCategoryDomain(model *CategoryModel) *domainAsset.Category {
return &domainAsset.Category{
ID: model.ID,
Name: model.Name,
Icon: model.Icon,
Color: model.Color,
CardType: model.CardType,
Featured: model.Featured,
Description: model.Description,
}
}
func toAssetModel(asset *domainAsset.Asset) *Model {
return &Model{
ID: asset.ID,
ProfileID: asset.ProfileID,
Status: int(asset.Status),
AssetCategoryID: asset.AssetCategoryID,
Title: asset.Title,
Description: asset.Description,
Link: asset.Link,
Analytics: asset.Analytics,
CreatedAt: asset.CreatedAt,
UpdatedAt: asset.UpdatedAt,
}
}
func toAssetDomain(model *Model, category *domainAsset.Category, artifacts []domainAsset.Artifact, comments []domainAsset.Comment, reports []domainAsset.Report) *domainAsset.Asset {
cat := domainAsset.Category{}
if category != nil {
cat = *category
}
return &domainAsset.Asset{
ID: model.ID,
ProfileID: model.ProfileID,
Status: domainAsset.Status(model.Status),
AssetCategoryID: model.AssetCategoryID,
AssetCategory: cat,
Title: model.Title,
Description: model.Description,
Link: model.Link,
Analytics: model.Analytics,
Reports: reports,
AssetArtifacts: artifacts,
Comments: comments,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func copyAssetFromModel(asset *domainAsset.Asset, model *Model) {
asset.ID = model.ID
asset.CreatedAt = model.CreatedAt
asset.UpdatedAt = model.UpdatedAt
}
func toArtifactModels(assetID uuid.UUID, artifacts []domainAsset.Artifact) []ArtifactModel {
models := make([]ArtifactModel, len(artifacts))
for i, a := range artifacts {
models[i] = ArtifactModel{
AssetID: assetID,
Type: a.Type,
DownloadURL: a.DownloadURL,
Price: a.Price,
Title: a.Title,
Description: a.Description,
}
}
return models
}
func toArtifactDomains(models []ArtifactModel) []domainAsset.Artifact {
out := make([]domainAsset.Artifact, len(models))
for i, m := range models {
out[i] = domainAsset.Artifact{
ID: m.ID,
AssetID: m.AssetID,
Type: m.Type,
DownloadURL: m.DownloadURL,
Price: m.Price,
Title: m.Title,
Description: m.Description,
}
}
return out
}
// flattenComments turns a tree of comments (with Replies) into a single slice:
// top-level first, then each comment's replies recursively. Used when saving.
func flattenComments(comments []domainAsset.Comment) []domainAsset.Comment {
var out []domainAsset.Comment
for _, c := range comments {
out = append(out, c)
out = append(out, flattenComments(c.Replies)...)
}
return out
}
func toCommentModels(assetID uuid.UUID, comments []domainAsset.Comment) []CommentModel {
flat := flattenComments(comments)
models := make([]CommentModel, 0, len(flat))
for _, c := range flat {
models = append(models, CommentModel{
AssetID: assetID,
Content: c.Content,
WriterID: c.WriterID,
WriterType: c.WriterType,
ParentID: c.ParentID,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
})
}
return models
}
func toCommentDomains(models []CommentModel) []domainAsset.Comment {
out := make([]domainAsset.Comment, len(models))
for i, m := range models {
out[i] = domainAsset.Comment{
ID: m.ID,
AssetID: m.AssetID,
Content: m.Content,
WriterID: m.WriterID,
WriterType: m.WriterType,
ParentID: m.ParentID,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
return buildCommentTree(out)
}
// buildCommentTree turns a flat list of comments (with ParentID set) into a tree:
// top-level comments have Replies populated; nested Replies are not further nested in this type.
func buildCommentTree(flat []domainAsset.Comment) []domainAsset.Comment {
if len(flat) == 0 {
return nil
}
byID := make(map[uuid.UUID]*domainAsset.Comment)
for i := range flat {
flat[i].Replies = nil
byID[flat[i].ID] = &flat[i]
}
// First pass: attach replies to parents
for i := range flat {
c := &flat[i]
if c.ParentID == nil {
continue
}
if parent, ok := byID[*c.ParentID]; ok {
parent.Replies = append(parent.Replies, *c)
}
}
// Second pass: collect top-level comments (with Replies already populated)
var roots []domainAsset.Comment
for i := range flat {
c := &flat[i]
if c.ParentID == nil {
roots = append(roots, *c)
}
}
return roots
}
func toReportModels(assetID uuid.UUID, reports []domainAsset.Report) ([]ReportModel, error) {
models := make([]ReportModel, len(reports))
for i, r := range reports {
reportedBy, err := json.Marshal(r.ReportedBy)
if err != nil {
return nil, err
}
reason, err := json.Marshal(r.Reason)
if err != nil {
return nil, err
}
var attachments json.RawMessage
if len(r.Attachments) > 0 {
attachments, err = json.Marshal(r.Attachments)
if err != nil {
return nil, err
}
}
models[i] = ReportModel{
AssetID: assetID,
ReportedBy: reportedBy,
ReportedAt: r.ReportedAt,
Reason: reason,
Status: int(r.Status),
Notes: r.Notes,
Attachments: attachments,
CreatedAt: r.ReportedAt,
UpdatedAt: r.ReportedAt,
}
}
return models, nil
}
func toReportDomains(models []ReportModel) ([]domainAsset.Report, error) {
out := make([]domainAsset.Report, len(models))
for i, m := range models {
var reportedBy domainAsset.ReportedBy
if err := json.Unmarshal(m.ReportedBy, &reportedBy); err != nil {
return nil, err
}
var reason domainAsset.ReportReason
if err := json.Unmarshal(m.Reason, &reason); err != nil {
return nil, err
}
var attachments []domainAsset.Attachment
if len(m.Attachments) > 0 {
if err := json.Unmarshal(m.Attachments, &attachments); err != nil {
return nil, err
}
}
out[i] = domainAsset.Report{
ID: m.ID,
AssetID: m.AssetID,
ReportedBy: reportedBy,
ReportedAt: m.ReportedAt,
Reason: reason,
Status: domainAsset.ReportStatus(m.Status),
Notes: m.Notes,
Attachments: attachments,
}
}
return out, nil
}

View File

@@ -0,0 +1,95 @@
package asset
import (
"encoding/json"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type CategoryModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Name string `gorm:"column:name;type:text;not null"`
Icon string `gorm:"column:icon;type:text"`
Color string `gorm:"column:color;type:text"`
CardType string `gorm:"column:card_type;type:text"`
Featured bool `gorm:"column:featured;type:boolean;default:false"`
Description string `gorm:"column:description;type:text"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (CategoryModel) TableName() string {
return "asset_categories"
}
type Model struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
ProfileID uuid.UUID `gorm:"column:profile_id;type:uuid;not null;index:assets_profile_id_idx"`
Status int `gorm:"column:status;type:integer;not null;default:0"`
AssetCategoryID uuid.UUID `gorm:"column:asset_category_id;type:uuid;not null;index:assets_category_id_idx"`
Title string `gorm:"column:title;type:text;not null"`
Description string `gorm:"column:description;type:text"`
Link string `gorm:"column:link;type:text"`
Analytics json.RawMessage `gorm:"column:analytics;type:jsonb"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (Model) TableName() string {
return "assets"
}
type ArtifactModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
AssetID uuid.UUID `gorm:"column:asset_id;type:uuid;not null;index:asset_artifacts_asset_id_idx"`
Type string `gorm:"column:type;type:text;not null"`
DownloadURL string `gorm:"column:download_url;type:text"`
Price int `gorm:"column:price;type:integer;default:0"`
Title string `gorm:"column:title;type:text"`
Description string `gorm:"column:description;type:text"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (ArtifactModel) TableName() string {
return "asset_artifacts"
}
type CommentModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
AssetID uuid.UUID `gorm:"column:asset_id;type:uuid;not null;index:asset_comments_asset_id_idx"`
Content string `gorm:"column:content;type:text;not null"`
WriterID uuid.UUID `gorm:"column:writer_id;type:uuid;not null"`
WriterType string `gorm:"column:writer_type;type:text;not null"`
ParentID *uuid.UUID `gorm:"column:parent_id;type:uuid;index:asset_comments_parent_id_idx"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (CommentModel) TableName() string {
return "asset_comments"
}
type ReportModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
AssetID uuid.UUID `gorm:"column:asset_id;type:uuid;not null;index:asset_reports_asset_id_idx"`
ReportedBy json.RawMessage `gorm:"column:reported_by;type:jsonb;not null"`
ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null"`
Reason json.RawMessage `gorm:"column:reason;type:jsonb;not null"`
Status int `gorm:"column:status;type:integer;not null;default:0"`
Notes string `gorm:"column:notes;type:text"`
Attachments json.RawMessage `gorm:"column:attachments;type:jsonb"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (ReportModel) TableName() string {
return "asset_reports"
}

View File

@@ -0,0 +1,88 @@
package auth
import (
"context"
"go.uber.org/fx"
"github.com/google/uuid"
"gorm.io/gorm"
domainAuth "base/internal/domain/auth"
)
type accountRepository struct {
db *gorm.DB
}
func NewAccountRepository(lc fx.Lifecycle, db *gorm.DB) domainAuth.AccountRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &accountRepository{db: db}
}
func (r *accountRepository) Create(ctx context.Context, account *domainAuth.Account) error {
model := toAccountModel(account)
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
return err
}
copyAccountFromModel(account, model)
return nil
}
func (r *accountRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainAuth.Account, error) {
var model AccountModel
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
return nil, err
}
return toAccountDomain(&model), nil
}
func (r *accountRepository) FindByUserID(ctx context.Context, userID uuid.UUID) ([]*domainAuth.Account, error) {
var models []AccountModel
if err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&models).Error; err != nil {
return nil, err
}
accounts := make([]*domainAuth.Account, len(models))
for i, model := range models {
accounts[i] = toAccountDomain(&model)
}
return accounts, nil
}
func (r *accountRepository) Update(ctx context.Context, account *domainAuth.Account) error {
model := toAccountModel(account)
return r.db.WithContext(ctx).Model(&AccountModel{}).Where("id = ?", account.ID).Updates(model).Error
}
func (r *accountRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&AccountModel{}, "id = ?", id).Error
}
func (r *accountRepository) List(ctx context.Context, limit, offset int) ([]*domainAuth.Account, error) {
var models []AccountModel
if err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&models).Error; err != nil {
return nil, err
}
accounts := make([]*domainAuth.Account, len(models))
for i, model := range models {
accounts[i] = toAccountDomain(&model)
}
return accounts, nil
}
func (r *accountRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&AccountModel{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,381 @@
package auth
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
domainAuth "base/internal/domain/auth"
"base/internal/pkg/oauth"
)
func TestAccountRepository_Create(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
t.Run("create account successfully", func(t *testing.T) {
// Create user first
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Account",
LastName: "User",
Email: "account@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
Password: nil,
Scope: []string{"read", "write"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account)
assert.NoError(t, err)
assert.NotEqual(t, uuid.Nil, account.ID)
// Verify account was created
found, err := repo.FindByID(ctx, account.ID)
assert.NoError(t, err)
assert.Equal(t, account.UserID, found.UserID)
assert.Equal(t, account.Provider, found.Provider)
assert.Equal(t, account.Scope, found.Scope)
})
t.Run("create account with password", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Password",
LastName: "User",
Email: "password@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
password := "hashedpassword"
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Credentials,
Password: &password,
Scope: []string{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account)
assert.NoError(t, err)
found, err := repo.FindByID(ctx, account.ID)
assert.NoError(t, err)
assert.NotNil(t, found.Password)
assert.Equal(t, password, *found.Password)
})
t.Run("create account with meta", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Meta",
LastName: "User",
Email: "meta@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
metaJSON := json.RawMessage(`{"key": "value", "number": 123}`)
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
Meta: metaJSON,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account)
assert.NoError(t, err)
found, err := repo.FindByID(ctx, account.ID)
assert.NoError(t, err)
assert.NotNil(t, found.Meta)
})
}
func TestAccountRepository_FindByID(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
t.Run("find existing account by id", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Find",
LastName: "User",
Email: "find@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account)
require.NoError(t, err)
found, err := repo.FindByID(ctx, account.ID)
assert.NoError(t, err)
assert.Equal(t, account.ID, found.ID)
assert.Equal(t, account.UserID, found.UserID)
})
t.Run("find non-existent account", func(t *testing.T) {
nonExistentID := uuid.New()
found, err := repo.FindByID(ctx, nonExistentID)
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestAccountRepository_FindByUserID(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
t.Run("find accounts by user id", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Multi",
LastName: "Account",
Email: "multi@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
// Create multiple accounts
account1 := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account1)
require.NoError(t, err)
account2 := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.GitHub,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account2)
require.NoError(t, err)
accounts, err := repo.FindByUserID(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, accounts, 2)
})
}
func TestAccountRepository_Update(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
t.Run("update account successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Update",
LastName: "User",
Email: "update@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
Scope: []string{"read"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account)
require.NoError(t, err)
// Update account
account.Scope = []string{"read", "write", "admin"}
newToken := "newtoken"
account.AccessToken = &newToken
err = repo.Update(ctx, account)
assert.NoError(t, err)
// Verify update
found, err := repo.FindByID(ctx, account.ID)
assert.NoError(t, err)
assert.Equal(t, []string{"read", "write", "admin"}, found.Scope)
assert.NotNil(t, found.AccessToken)
assert.Equal(t, newToken, *found.AccessToken)
})
}
func TestAccountRepository_Delete(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
t.Run("delete account successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Delete",
LastName: "User",
Email: "delete@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, account)
require.NoError(t, err)
err = repo.Delete(ctx, account.ID)
assert.NoError(t, err)
// Verify deletion
found, err := repo.FindByID(ctx, account.ID)
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestAccountRepository_List(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
// Create user and multiple accounts
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "List",
LastName: "User",
Email: "list@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
for i := 0; i < 5; i++ {
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Provider(i % 4), // Cycle through providers
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, account)
require.NoError(t, err)
}
t.Run("list accounts with limit and offset", func(t *testing.T) {
accounts, err := repo.List(ctx, 3, 0)
assert.NoError(t, err)
assert.Len(t, accounts, 3)
accounts, err = repo.List(ctx, 3, 3)
assert.NoError(t, err)
assert.Len(t, accounts, 2) // Remaining 2 accounts
})
}
func TestAccountRepository_Count(t *testing.T) {
db := setupTestDB(t)
repo := createTestAccountRepository(db)
userRepo := createTestUserRepository(db)
ctx := context.Background()
t.Run("count accounts", func(t *testing.T) {
initialCount, err := repo.Count(ctx)
assert.NoError(t, err)
assert.Equal(t, int64(0), initialCount)
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Count",
LastName: "User",
Email: "count@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = userRepo.Create(ctx, user)
require.NoError(t, err)
// Create accounts
for i := 0; i < 3; i++ {
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, account)
require.NoError(t, err)
}
count, err := repo.Count(ctx)
assert.NoError(t, err)
assert.Equal(t, int64(3), count)
})
}

View File

@@ -0,0 +1,184 @@
package auth
import (
"encoding/json"
"time"
"gorm.io/gorm"
domainAuth "base/internal/domain/auth"
"base/internal/pkg/oauth"
)
func toUserModel(user *domainAuth.User) *UserModel {
// Note: DisplayName exists in schema but not in domain model
// Compute it from FirstName + LastName
displayName := user.FirstName + " " + user.LastName
return &UserModel{
ID: user.ID,
FirstName: user.FirstName,
LastName: user.LastName,
DisplayName: displayName,
PhoneNumber: user.PhoneNumber,
Email: user.Email,
EmailVerified: user.EmailVerified,
Status: int(user.Status),
InvitationCode: user.InvitationCode,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
DeletedAt: gorm.DeletedAt{Time: user.DeletedAt, Valid: !user.DeletedAt.IsZero()},
}
}
func toUserDomain(model *UserModel) *domainAuth.User {
var deletedAt time.Time
if model.DeletedAt.Valid {
deletedAt = model.DeletedAt.Time
}
return &domainAuth.User{
ID: model.ID,
FirstName: model.FirstName,
LastName: model.LastName,
PhoneNumber: model.PhoneNumber,
Email: model.Email,
EmailVerified: model.EmailVerified,
Status: domainAuth.UserStatus(model.Status),
InvitationCode: model.InvitationCode,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
DeletedAt: deletedAt,
}
}
func copyUserFromModel(user *domainAuth.User, model *UserModel) {
user.ID = model.ID
user.PhoneNumber = model.PhoneNumber
user.EmailVerified = model.EmailVerified
user.Status = domainAuth.UserStatus(model.Status)
user.InvitationCode = model.InvitationCode
user.CreatedAt = model.CreatedAt
user.UpdatedAt = model.UpdatedAt
if model.DeletedAt.Valid {
user.DeletedAt = model.DeletedAt.Time
}
}
func toRoleModel(role *domainAuth.Role) *RoleModel {
desc := &role.Description
if role.Description == "" {
desc = nil
}
return &RoleModel{
ID: role.ID,
Name: role.Name,
Description: desc,
CreatedAt: role.CreatedAt,
UpdatedAt: role.UpdatedAt,
}
}
func toRoleDomain(model *RoleModel) *domainAuth.Role {
desc := ""
if model.Description != nil {
desc = *model.Description
}
return &domainAuth.Role{
ID: model.ID,
Name: model.Name,
Description: desc,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func copyRoleFromModel(role *domainAuth.Role, model *RoleModel) error {
role.ID = model.ID
role.CreatedAt = model.CreatedAt
role.UpdatedAt = model.UpdatedAt
return nil
}
func toAccountModel(account *domainAuth.Account) *AccountModel {
var scopeStr *string
if len(account.Scope) > 0 {
scopeBytes, _ := json.Marshal(account.Scope)
s := string(scopeBytes)
scopeStr = &s
}
// Store provider in Meta JSONB field
metaMap := make(map[string]interface{})
if len(account.Meta) > 0 {
_ = json.Unmarshal(account.Meta, &metaMap)
}
metaMap["provider"] = int(account.Provider)
metaBytes, _ := json.Marshal(metaMap)
meta := json.RawMessage(metaBytes)
return &AccountModel{
ID: account.ID,
UserID: account.UserID,
Provider: int(account.Provider), // Store provider as column for querying
Password: account.Password,
AccessToken: account.AccessToken,
RefreshToken: account.RefreshToken,
Scope: scopeStr,
Meta: &meta,
CreatedAt: account.CreatedAt,
UpdatedAt: account.UpdatedAt,
}
}
func toAccountDomain(model *AccountModel) *domainAuth.Account {
var scope []string
if model.Scope != nil {
_ = json.Unmarshal([]byte(*model.Scope), &scope)
}
var meta json.RawMessage
var provider int
// Use Provider field if available (for querying), otherwise extract from Meta
if model.Provider > 0 {
provider = model.Provider
}
if model.Meta != nil {
meta = *model.Meta
// If provider not set from field, try to extract from Meta
if provider == 0 {
var metaMap map[string]interface{}
if err := json.Unmarshal(meta, &metaMap); err == nil {
if p, ok := metaMap["provider"].(float64); ok {
provider = int(p)
}
}
}
}
// Import oauth package for Provider type
// Provider is stored as int, convert to oauth.Provider
var accountProvider oauth.Provider
if provider > 0 {
accountProvider = oauth.Provider(provider)
}
return &domainAuth.Account{
ID: model.ID,
UserID: model.UserID,
Provider: accountProvider,
Password: model.Password,
AccessToken: model.AccessToken,
RefreshToken: model.RefreshToken,
Scope: scope,
Meta: meta,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func copyAccountFromModel(account *domainAuth.Account, model *AccountModel) {
account.ID = model.ID
account.CreatedAt = model.CreatedAt
account.UpdatedAt = model.UpdatedAt
}

View File

@@ -0,0 +1,81 @@
package auth
import (
"context"
"go.uber.org/fx"
"github.com/google/uuid"
"gorm.io/gorm"
domainAuth "base/internal/domain/auth"
)
type roleRepository struct {
db *gorm.DB
}
func NewRoleRepository(lc fx.Lifecycle, db *gorm.DB) domainAuth.RoleRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return db.AutoMigrate(&domainAuth.Role{})
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &roleRepository{db: db}
}
func (r *roleRepository) Create(ctx context.Context, role *domainAuth.Role) error {
model := toRoleModel(role)
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
return err
}
return copyRoleFromModel(role, model)
}
func (r *roleRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainAuth.Role, error) {
var model RoleModel
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
return nil, err
}
return toRoleDomain(&model), nil
}
func (r *roleRepository) FindByName(ctx context.Context, name string) (*domainAuth.Role, error) {
var model RoleModel
if err := r.db.WithContext(ctx).Where("name = ?", name).First(&model).Error; err != nil {
return nil, err
}
return toRoleDomain(&model), nil
}
func (r *roleRepository) Update(ctx context.Context, role *domainAuth.Role) error {
model := toRoleModel(role)
return r.db.WithContext(ctx).Model(&RoleModel{}).Where("id = ?", role.ID).Updates(model).Error
}
func (r *roleRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&RoleModel{}, "id = ?", id).Error
}
func (r *roleRepository) List(ctx context.Context, limit, offset int) ([]*domainAuth.Role, error) {
var models []RoleModel
if err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&models).Error; err != nil {
return nil, err
}
roles := make([]*domainAuth.Role, len(models))
for i, model := range models {
roles[i] = toRoleDomain(&model)
}
return roles, nil
}
func (r *roleRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&RoleModel{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,235 @@
package auth
import (
"context"
"strconv"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
domainAuth "base/internal/domain/auth"
)
func TestRoleRepository_Create(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("create role successfully", func(t *testing.T) {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "admin",
Description: "Administrator role",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
assert.NoError(t, err)
assert.NotEqual(t, uuid.Nil, role.ID)
// Verify role was created
found, err := repo.FindByID(ctx, role.ID)
assert.NoError(t, err)
assert.Equal(t, role.Name, found.Name)
assert.Equal(t, role.Description, found.Description)
})
t.Run("create role with duplicate name fails", func(t *testing.T) {
name := "duplicate"
role1 := &domainAuth.Role{
ID: uuid.New(),
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role1)
assert.NoError(t, err)
role2 := &domainAuth.Role{
ID: uuid.New(),
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, role2)
assert.Error(t, err)
})
}
func TestRoleRepository_FindByID(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("find existing role by id", func(t *testing.T) {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "find",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
require.NoError(t, err)
found, err := repo.FindByID(ctx, role.ID)
assert.NoError(t, err)
assert.Equal(t, role.ID, found.ID)
assert.Equal(t, role.Name, found.Name)
})
t.Run("find non-existent role", func(t *testing.T) {
nonExistentID := uuid.New()
found, err := repo.FindByID(ctx, nonExistentID)
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestRoleRepository_FindByName(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("find existing role by name", func(t *testing.T) {
name := "findbyname"
role := &domainAuth.Role{
ID: uuid.New(),
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
require.NoError(t, err)
found, err := repo.FindByName(ctx, name)
assert.NoError(t, err)
assert.Equal(t, role.ID, found.ID)
assert.Equal(t, name, found.Name)
})
t.Run("find non-existent role by name", func(t *testing.T) {
found, err := repo.FindByName(ctx, "nonexistent")
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestRoleRepository_Update(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("update role successfully", func(t *testing.T) {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "update",
Description: "Original description",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
require.NoError(t, err)
// Update role
role.Description = "Updated description"
err = repo.Update(ctx, role)
assert.NoError(t, err)
// Verify update
found, err := repo.FindByID(ctx, role.ID)
assert.NoError(t, err)
assert.Equal(t, "Updated description", found.Description)
})
}
func TestRoleRepository_Delete(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("delete role successfully", func(t *testing.T) {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "delete",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
require.NoError(t, err)
err = repo.Delete(ctx, role.ID)
assert.NoError(t, err)
// Verify deletion (soft delete)
found, err := repo.FindByID(ctx, role.ID)
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestRoleRepository_List(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
// Create multiple roles
for i := 0; i < 5; i++ {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "role" + strconv.Itoa(i),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
require.NoError(t, err)
}
t.Run("list roles with limit and offset", func(t *testing.T) {
roles, err := repo.List(ctx, 3, 0)
assert.NoError(t, err)
assert.Len(t, roles, 3)
roles, err = repo.List(ctx, 3, 3)
assert.NoError(t, err)
assert.Len(t, roles, 2) // Remaining 2 roles
})
}
func TestRoleRepository_Count(t *testing.T) {
db := setupTestDB(t)
repo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("count roles", func(t *testing.T) {
initialCount, err := repo.Count(ctx)
assert.NoError(t, err)
assert.Equal(t, int64(0), initialCount)
// Create roles
for i := 0; i < 3; i++ {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "count" + strconv.Itoa(i),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, role)
require.NoError(t, err)
}
count, err := repo.Count(ctx)
assert.NoError(t, err)
assert.Equal(t, int64(3), count)
})
}

View File

@@ -0,0 +1,70 @@
package auth
import (
"encoding/json"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
FirstName string `gorm:"column:first_name;type:text;not null"`
LastName string `gorm:"column:last_name;type:text;not null"`
DisplayName string `gorm:"column:display_name;type:text;not null"`
PhoneNumber string `gorm:"column:phone_number;type:text"`
Email string `gorm:"column:email;type:text;not null;uniqueIndex:users_email_unique"`
EmailVerified bool `gorm:"column:email_verified;type:boolean;default:false;not null"`
Status int `gorm:"column:status;type:integer;default:0;not null"`
InvitationCode string `gorm:"column:invitation_code;type:text"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (UserModel) TableName() string {
return "users"
}
type RoleModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Name string `gorm:"column:name;type:text;not null;uniqueIndex:roles_name_unique"`
Description *string `gorm:"column:description;type:text"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (RoleModel) TableName() string {
return "roles"
}
type AccountModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
UserID uuid.UUID `gorm:"column:user_id;type:uuid;not null;index:accounts_user_id_idx"`
Provider int `gorm:"column:provider;type:integer;index:accounts_provider_idx"` // For querying, also stored in meta
Password *string `gorm:"column:password;type:text"`
AccessToken *string `gorm:"column:access_token;type:text"`
RefreshToken *string `gorm:"column:refresh_token;type:text"`
Scope *string `gorm:"column:scope;type:text"`
Meta *json.RawMessage `gorm:"column:meta;type:jsonb"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
}
func (AccountModel) TableName() string {
return "accounts"
}
type UserRoleModel struct {
UserID uuid.UUID `gorm:"column:user_id;type:uuid;not null;index:user_roles_user_id_idx"`
RoleID uuid.UUID `gorm:"column:role_id;type:uuid;not null;index:user_roles_role_id_idx"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (UserRoleModel) TableName() string {
return "user_roles"
}

View File

@@ -0,0 +1,108 @@
package auth
import (
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
domainAuth "base/internal/domain/auth"
)
// setupTestDB creates an in-memory SQLite database for testing
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
// Create tables manually with SQLite-compatible syntax
// This avoids PostgreSQL-specific syntax like gen_random_uuid() and timestamptz
createUsersTable := `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
display_name TEXT NOT NULL,
phone_number TEXT,
email TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0,
invitation_code TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
UNIQUE(email)
)
`
require.NoError(t, db.Exec(createUsersTable).Error)
createRolesTable := `
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
UNIQUE(name)
)
`
require.NoError(t, db.Exec(createRolesTable).Error)
createAccountsTable := `
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider INTEGER,
password TEXT,
access_token TEXT,
refresh_token TEXT,
scope TEXT,
meta TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
`
require.NoError(t, db.Exec(createAccountsTable).Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS accounts_user_id_idx ON accounts(user_id)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS accounts_provider_idx ON accounts(provider)").Error)
createUserRolesTable := `
CREATE TABLE IF NOT EXISTS user_roles (
user_id TEXT NOT NULL,
role_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
PRIMARY KEY (user_id, role_id)
)
`
require.NoError(t, db.Exec(createUserRolesTable).Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS user_roles_user_id_idx ON user_roles(user_id)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS user_roles_role_id_idx ON user_roles(role_id)").Error)
return db
}
// createTestUserRepository creates a user repository for testing
func createTestUserRepository(db *gorm.DB) domainAuth.UserRepository {
return &userRepository{db: db}
}
// createTestRoleRepository creates a role repository for testing
func createTestRoleRepository(db *gorm.DB) domainAuth.RoleRepository {
return &roleRepository{db: db}
}
// createTestAccountRepository creates an account repository for testing
func createTestAccountRepository(db *gorm.DB) domainAuth.AccountRepository {
return &accountRepository{db: db}
}
// createTestUserRoleRepository creates a user role repository for testing
func createTestUserRoleRepository(db *gorm.DB) domainAuth.UserRoleRepository {
return &userRoleRepository{db: db}
}

View File

@@ -0,0 +1,430 @@
package auth
import (
"context"
"errors"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainAuth "base/internal/domain/auth"
)
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(lc fx.Lifecycle, db *gorm.DB) domainAuth.UserRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &userRepository{db: db}
}
func (r *userRepository) Create(ctx context.Context, user *domainAuth.User) error {
model := toUserModel(user)
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
return err
}
copyUserFromModel(user, model)
return nil
}
func (r *userRepository) CreateWithAccount(ctx context.Context, user *domainAuth.User, account *domainAuth.Account) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Create user within transaction
userModel := toUserModel(user)
if err := tx.WithContext(ctx).Create(userModel).Error; err != nil {
return err
}
copyUserFromModel(user, userModel)
// Create account within transaction
accountModel := toAccountModel(account)
if err := tx.WithContext(ctx).Create(accountModel).Error; err != nil {
return err
}
copyAccountFromModel(account, accountModel)
return nil
})
}
func (r *userRepository) UpsertWithAccount(ctx context.Context, email string, user *domainAuth.User, account *domainAuth.Account) (bool, error) {
isNewUser := false
err := r.db.WithContext(ctx).Transaction(
func(tx *gorm.DB) error {
// Check if user exists by email
var existingUserModel UserModel
err := tx.WithContext(ctx).Where("email = ?", email).First(&existingUserModel).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
isNewUser = true
userModel := toUserModel(user)
if err = tx.WithContext(ctx).Create(userModel).Error; err != nil {
return err
}
copyUserFromModel(user, userModel)
account.UserID = user.ID
// Create account for new user
accountModel := toAccountModel(account)
if err = tx.WithContext(ctx).Create(accountModel).Error; err != nil {
return err
}
copyAccountFromModel(account, accountModel)
}
// TODO: check no error if user exist because in find user accounts we use user.ID
if !isNewUser {
// Load all accounts for this user to check if one with this provider exists
var existingAccountModel AccountModel
findAccountsErr := tx.WithContext(ctx).
Where("user_id = ? AND provider = ?", user.ID, int(account.Provider)).
First(&existingAccountModel).Error
if findAccountsErr != nil {
if !errors.Is(findAccountsErr, gorm.ErrRecordNotFound) {
return findAccountsErr
}
accountModel := toAccountModel(account)
if err = tx.WithContext(ctx).Create(accountModel).Error; err != nil {
return err
}
copyAccountFromModel(account, accountModel)
return nil
}
accountModel := toAccountModel(account)
updateAccountErr := tx.WithContext(ctx).
Model(&AccountModel{}).
Where("id = ?", existingAccountModel.ID).
Updates(accountModel).Error
if updateAccountErr != nil {
return updateAccountErr
}
copyAccountFromModel(account, accountModel)
}
return nil
})
return isNewUser, err
}
func (r *userRepository) FindByID(ctx context.Context, id uuid.UUID, opts ...domainAuth.UserQueryOption) (*domainAuth.User, error) {
// Parse query options
options := &domainAuth.UserQueryOptions{}
for _, opt := range opts {
opt(options)
}
var model UserModel
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
return nil, err
}
user := toUserDomain(&model)
// Conditionally load relations based on options
if options.LoadRoles {
roles, err := r.loadUserRoles(ctx, id)
if err != nil {
return nil, err
}
user.Roles = roles
}
if options.LoadAccounts {
accounts, err := r.loadUserAccounts(ctx, id)
if err != nil {
return nil, err
}
user.Accounts = accounts
}
return user, nil
}
func (r *userRepository) FindByEmail(ctx context.Context, email string, opts ...domainAuth.UserQueryOption) (*domainAuth.User, error) {
// Parse query options
options := &domainAuth.UserQueryOptions{}
for _, opt := range opts {
opt(options)
}
var model UserModel
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&model).Error; err != nil {
return nil, err
}
user := toUserDomain(&model)
// Conditionally load relations based on options
if options.LoadRoles {
roles, err := r.loadUserRoles(ctx, user.ID)
if err != nil {
return nil, err
}
user.Roles = roles
} else {
user.Roles = []domainAuth.Role{}
}
if options.LoadAccounts {
accounts, err := r.loadUserAccounts(ctx, user.ID)
if err != nil {
return nil, err
}
user.Accounts = accounts
} else {
user.Accounts = []domainAuth.Account{}
}
return user, nil
}
func (r *userRepository) Update(ctx context.Context, user *domainAuth.User) error {
model := toUserModel(user)
return r.db.WithContext(ctx).Model(&UserModel{}).Where("id = ?", user.ID).Updates(model).Error
}
func (r *userRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&UserModel{}, "id = ?", id).Error
}
func (r *userRepository) List(ctx context.Context, limit, offset int, opts ...domainAuth.UserQueryOption) ([]*domainAuth.User, error) {
// Parse query options
options := &domainAuth.UserQueryOptions{}
for _, opt := range opts {
opt(options)
}
var models []UserModel
if err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&models).Error; err != nil {
return nil, err
}
if len(models) == 0 {
return []*domainAuth.User{}, nil
}
users := make([]*domainAuth.User, len(models))
userIDs := make([]uuid.UUID, len(models))
for i, model := range models {
users[i] = toUserDomain(&model)
userIDs[i] = users[i].ID
}
// Batch load relations if requested
if options.LoadRoles {
rolesMap, err := r.loadUsersRoles(ctx, userIDs)
if err != nil {
return nil, err
}
for _, user := range users {
if roles, ok := rolesMap[user.ID]; ok {
user.Roles = roles
} else {
user.Roles = []domainAuth.Role{}
}
}
} else {
for _, user := range users {
user.Roles = []domainAuth.Role{}
}
}
if options.LoadAccounts {
accountsMap, err := r.loadUsersAccounts(ctx, userIDs)
if err != nil {
return nil, err
}
for _, user := range users {
if accounts, ok := accountsMap[user.ID]; ok {
user.Accounts = accounts
} else {
user.Accounts = []domainAuth.Account{}
}
}
} else {
for _, user := range users {
user.Accounts = []domainAuth.Account{}
}
}
return users, nil
}
func (r *userRepository) Count(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&UserModel{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// loadUserRoles loads roles for a single user
func (r *userRepository) loadUserRoles(ctx context.Context, userID uuid.UUID) ([]domainAuth.Role, error) {
var roleModels []RoleModel
if err := r.db.WithContext(ctx).
Table("roles").
Joins("INNER JOIN user_roles ON roles.id = user_roles.role_id").
Where("user_roles.user_id = ? AND user_roles.deleted_at IS NULL AND roles.deleted_at IS NULL", userID).
Find(&roleModels).Error; err != nil {
return nil, err
}
roles := make([]domainAuth.Role, len(roleModels))
for i, model := range roleModels {
role := toRoleDomain(&model)
roles[i] = *role
}
return roles, nil
}
func (r *userRepository) UserRoles(ctx context.Context, userID uuid.UUID) ([]domainAuth.Role, error) {
var roleModels []RoleModel
if err := r.db.WithContext(ctx).
Table("roles").
Joins("INNER JOIN user_roles ON roles.id = user_roles.role_id").
Where("user_roles.user_id = ? AND user_roles.deleted_at IS NULL AND roles.deleted_at IS NULL", userID).
Find(&roleModels).Error; err != nil {
return nil, err
}
roles := make([]domainAuth.Role, len(roleModels))
for i, model := range roleModels {
role := toRoleDomain(&model)
roles[i] = *role
}
return roles, nil
}
func (r *userRepository) loadUserAccounts(ctx context.Context, userID uuid.UUID) ([]domainAuth.Account, error) {
var accountModels []AccountModel
if err := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Find(&accountModels).Error; err != nil {
return nil, err
}
accounts := make([]domainAuth.Account, len(accountModels))
for i, model := range accountModels {
account := toAccountDomain(&model)
accounts[i] = *account
}
return accounts, nil
}
func (r *userRepository) UserAccounts(ctx context.Context, userID uuid.UUID) ([]domainAuth.Account, error) {
var accountModels []AccountModel
if err := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Find(&accountModels).Error; err != nil {
return nil, err
}
accounts := make([]domainAuth.Account, len(accountModels))
for i, model := range accountModels {
account := toAccountDomain(&model)
accounts[i] = *account
}
return accounts, nil
}
func (r *userRepository) loadUsersRoles(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]domainAuth.Role, error) {
if len(userIDs) == 0 {
return make(map[uuid.UUID][]domainAuth.Role), nil
}
var userRoles []struct {
UserID uuid.UUID `gorm:"column:user_id"`
RoleID uuid.UUID `gorm:"column:role_id"`
}
if err := r.db.WithContext(ctx).
Table("user_roles").
Select("user_id, role_id").
Where("user_id IN ? AND deleted_at IS NULL", userIDs).
Find(&userRoles).Error; err != nil {
return nil, err
}
if len(userRoles) == 0 {
return make(map[uuid.UUID][]domainAuth.Role), nil
}
roleIDs := make([]uuid.UUID, 0, len(userRoles))
for _, ur := range userRoles {
roleIDs = append(roleIDs, ur.RoleID)
}
var roleModels []RoleModel
if err := r.db.WithContext(ctx).
Where("id IN ? AND deleted_at IS NULL", roleIDs).
Find(&roleModels).Error; err != nil {
return nil, err
}
// Create a map of role_id -> role
rolesByID := make(map[uuid.UUID]*domainAuth.Role)
for i := range roleModels {
role := toRoleDomain(&roleModels[i])
rolesByID[role.ID] = role
}
// Group roles by user_id
rolesMap := make(map[uuid.UUID][]domainAuth.Role)
for _, ur := range userRoles {
if role, ok := rolesByID[ur.RoleID]; ok {
rolesMap[ur.UserID] = append(rolesMap[ur.UserID], *role)
}
}
return rolesMap, nil
}
func (r *userRepository) loadUsersAccounts(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]domainAuth.Account, error) {
if len(userIDs) == 0 {
return make(map[uuid.UUID][]domainAuth.Account), nil
}
var accountModels []AccountModel
if err := r.db.WithContext(ctx).
Where("user_id IN ?", userIDs).
Find(&accountModels).Error; err != nil {
return nil, err
}
accountsMap := make(map[uuid.UUID][]domainAuth.Account)
for _, model := range accountModels {
account := toAccountDomain(&model)
accountsMap[model.UserID] = append(accountsMap[model.UserID], *account)
}
return accountsMap, nil
}

View File

@@ -0,0 +1,96 @@
package auth
import (
"context"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainAuth "base/internal/domain/auth"
)
type userRoleRepository struct {
db *gorm.DB
}
func NewUserRoleRepository(lc fx.Lifecycle, db *gorm.DB) domainAuth.UserRoleRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return db.AutoMigrate(UserRoleModel{})
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &userRoleRepository{db: db}
}
func (r *userRoleRepository) Create(ctx context.Context, userID, roleID uuid.UUID) error {
model := &UserRoleModel{
UserID: userID,
RoleID: roleID,
}
return r.db.WithContext(ctx).Create(model).Error
}
func (r *userRoleRepository) FindByUserID(ctx context.Context, userID uuid.UUID) ([]*domainAuth.Role, error) {
var roleModels []RoleModel
if err := r.db.WithContext(ctx).
Table("roles").
Joins("INNER JOIN user_roles ON roles.id = user_roles.role_id").
Where("user_roles.user_id = ? AND user_roles.deleted_at IS NULL", userID).
Find(&roleModels).Error; err != nil {
return nil, err
}
roles := make([]*domainAuth.Role, len(roleModels))
for i, model := range roleModels {
roles[i] = toRoleDomain(&model)
}
return roles, nil
}
func (r *userRoleRepository) FindByRoleID(ctx context.Context, roleID uuid.UUID) ([]*domainAuth.User, error) {
var userModels []UserModel
if err := r.db.WithContext(ctx).
Table("users").
Joins("INNER JOIN user_roles ON users.id = user_roles.user_id").
Where("user_roles.role_id = ? AND user_roles.deleted_at IS NULL", roleID).
Find(&userModels).Error; err != nil {
return nil, err
}
users := make([]*domainAuth.User, len(userModels))
for i, model := range userModels {
users[i] = toUserDomain(&model)
}
return users, nil
}
func (r *userRoleRepository) Delete(ctx context.Context, userID, roleID uuid.UUID) error {
return r.db.WithContext(ctx).
Where("user_id = ? AND role_id = ?", userID, roleID).
Delete(&UserRoleModel{}).Error
}
func (r *userRoleRepository) DeleteByUserID(ctx context.Context, userID uuid.UUID) error {
return r.db.WithContext(ctx).
Where("user_id = ?", userID).
Delete(&UserRoleModel{}).Error
}
func (r *userRoleRepository) DeleteByRoleID(ctx context.Context, roleID uuid.UUID) error {
return r.db.WithContext(ctx).
Where("role_id = ?", roleID).
Delete(&UserRoleModel{}).Error
}
func (r *userRoleRepository) Exists(ctx context.Context, userID, roleID uuid.UUID) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&UserRoleModel{}).
Where("user_id = ? AND role_id = ?", userID, roleID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -0,0 +1,369 @@
package auth
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
domainAuth "base/internal/domain/auth"
)
func TestUserRoleRepository_Create(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("create user role successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "Role",
Email: "userrole@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
role := &domainAuth.Role{
ID: uuid.New(),
Name: "test",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role.ID)
assert.NoError(t, err)
// Verify user role was created
exists, err := repo.Exists(ctx, user.ID, role.ID)
assert.NoError(t, err)
assert.True(t, exists)
})
}
func TestUserRoleRepository_FindByUserID(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("find roles by user id", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Find",
LastName: "User",
Email: "find@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
role1 := &domainAuth.Role{
ID: uuid.New(),
Name: "role1",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role1)
require.NoError(t, err)
role2 := &domainAuth.Role{
ID: uuid.New(),
Name: "role2",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role2)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role1.ID)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role2.ID)
require.NoError(t, err)
roles, err := repo.FindByUserID(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, roles, 2)
})
}
func TestUserRoleRepository_FindByRoleID(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("find users by role id", func(t *testing.T) {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "shared",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := roleRepo.Create(ctx, role)
require.NoError(t, err)
user1 := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "One",
Email: "user1@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = userRepo.Create(ctx, user1)
require.NoError(t, err)
user2 := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "Two",
Email: "user2@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = userRepo.Create(ctx, user2)
require.NoError(t, err)
err = repo.Create(ctx, user1.ID, role.ID)
require.NoError(t, err)
err = repo.Create(ctx, user2.ID, role.ID)
require.NoError(t, err)
users, err := repo.FindByRoleID(ctx, role.ID)
assert.NoError(t, err)
assert.Len(t, users, 2)
})
}
func TestUserRoleRepository_Delete(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("delete user role successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Delete",
LastName: "User",
Email: "delete@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
role := &domainAuth.Role{
ID: uuid.New(),
Name: "delete",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role.ID)
require.NoError(t, err)
err = repo.Delete(ctx, user.ID, role.ID)
assert.NoError(t, err)
// Verify deletion
exists, err := repo.Exists(ctx, user.ID, role.ID)
assert.NoError(t, err)
assert.False(t, exists)
})
}
func TestUserRoleRepository_DeleteByUserID(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("delete all roles for user", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Delete",
LastName: "All",
Email: "deleteall@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
role1 := &domainAuth.Role{
ID: uuid.New(),
Name: "role1",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role1)
require.NoError(t, err)
role2 := &domainAuth.Role{
ID: uuid.New(),
Name: "role2",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role2)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role1.ID)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role2.ID)
require.NoError(t, err)
err = repo.DeleteByUserID(ctx, user.ID)
assert.NoError(t, err)
// Verify all roles deleted
roles, err := repo.FindByUserID(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, roles, 0)
})
}
func TestUserRoleRepository_DeleteByRoleID(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("delete role from all users", func(t *testing.T) {
role := &domainAuth.Role{
ID: uuid.New(),
Name: "shared",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := roleRepo.Create(ctx, role)
require.NoError(t, err)
user1 := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "One",
Email: "user1@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = userRepo.Create(ctx, user1)
require.NoError(t, err)
user2 := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "Two",
Email: "user2@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = userRepo.Create(ctx, user2)
require.NoError(t, err)
err = repo.Create(ctx, user1.ID, role.ID)
require.NoError(t, err)
err = repo.Create(ctx, user2.ID, role.ID)
require.NoError(t, err)
err = repo.DeleteByRoleID(ctx, role.ID)
assert.NoError(t, err)
// Verify role deleted from all users
users, err := repo.FindByRoleID(ctx, role.ID)
assert.NoError(t, err)
assert.Len(t, users, 0)
})
}
func TestUserRoleRepository_Exists(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRoleRepository(db)
userRepo := createTestUserRepository(db)
roleRepo := createTestRoleRepository(db)
ctx := context.Background()
t.Run("exists returns true for existing user role", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Exists",
LastName: "User",
Email: "exists@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
role := &domainAuth.Role{
ID: uuid.New(),
Name: "exists",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role)
require.NoError(t, err)
err = repo.Create(ctx, user.ID, role.ID)
require.NoError(t, err)
exists, err := repo.Exists(ctx, user.ID, role.ID)
assert.NoError(t, err)
assert.True(t, exists)
})
t.Run("exists returns false for non-existent user role", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Not",
LastName: "Exists",
Email: "notexists@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := userRepo.Create(ctx, user)
require.NoError(t, err)
role := &domainAuth.Role{
ID: uuid.New(),
Name: "notexists",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role)
require.NoError(t, err)
exists, err := repo.Exists(ctx, user.ID, role.ID)
assert.NoError(t, err)
assert.False(t, exists)
})
}

View File

@@ -0,0 +1,605 @@
package auth
import (
"context"
"strconv"
"testing"
"time"
"base/internal/pkg/oauth"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
domainAuth "base/internal/domain/auth"
)
func TestUserRepository_Create(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("create user successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "John",
LastName: "Doe",
Email: "john.doe@example.com",
EmailVerified: false,
Status: domainAuth.UserStatusActive,
PhoneNumber: "1234567890",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
assert.NoError(t, err)
assert.NotEqual(t, uuid.Nil, user.ID)
// Verify user was created
found, err := repo.FindByID(ctx, user.ID)
assert.NoError(t, err)
assert.Equal(t, user.Email, found.Email)
assert.Equal(t, user.FirstName, found.FirstName)
assert.Equal(t, user.LastName, found.LastName)
})
t.Run("create user with duplicate email fails", func(t *testing.T) {
email := "duplicate@example.com"
user1 := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "One",
Email: email,
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user1)
assert.NoError(t, err)
user2 := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "Two",
Email: email,
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = repo.Create(ctx, user2)
assert.Error(t, err)
})
}
func TestUserRepository_UpsertWithAccount(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("upsert creates new user and account", func(t *testing.T) {
email := "newuser@example.com"
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "New",
LastName: "User",
Email: email,
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
account := &domainAuth.Account{
ID: uuid.New(),
Provider: oauth.Google,
Scope: []string{"read"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
isNew, err := repo.UpsertWithAccount(ctx, email, user, account)
assert.NoError(t, err)
assert.True(t, isNew)
// For new users, UserID is set by UpsertWithAccount
assert.Equal(t, user.ID, account.UserID)
// Verify user was created
foundUser, err := repo.FindByID(ctx, user.ID)
assert.NoError(t, err)
assert.Equal(t, user.Email, foundUser.Email)
// Note: For new users, UpsertWithAccount sets account.UserID but doesn't create the account
// The account needs to be created separately if needed
})
t.Run("upsert updates existing user with new account", func(t *testing.T) {
email := "existing@example.com"
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Existing",
LastName: "User",
Email: email,
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Create user first
err := repo.Create(ctx, user)
require.NoError(t, err)
// Get the user from DB to ensure we have the correct ID
foundUser, err := repo.FindByEmail(ctx, email)
require.NoError(t, err)
user.ID = foundUser.ID
// Create first account with Google provider
account1 := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
Scope: []string{"read"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
accountRepo := createTestAccountRepository(db)
err = accountRepo.Create(ctx, account1)
require.NoError(t, err)
// Upsert with different provider (GitHub) - should create new account
account2 := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID, // Set UserID before upsert
Provider: oauth.GitHub,
Scope: []string{"read", "write"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Use the same user object but ensure it has the correct ID
userForUpsert := &domainAuth.User{
ID: user.ID,
FirstName: user.FirstName,
LastName: user.LastName,
Email: user.Email,
Status: user.Status,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
isNew, err := repo.UpsertWithAccount(ctx, email, userForUpsert, account2)
assert.NoError(t, err)
assert.False(t, isNew)
// Note: account.UserID is not updated by UpsertWithAccount when user exists,
// but it should already be set correctly
assert.Equal(t, user.ID, account2.UserID)
// Account ID should be set after creation
assert.NotEqual(t, uuid.Nil, account2.ID)
// Verify the GitHub account was created by finding it by ID
foundAccount2, err := accountRepo.FindByID(ctx, account2.ID)
assert.NoError(t, err)
assert.NotNil(t, foundAccount2)
assert.Equal(t, account2.UserID, foundAccount2.UserID)
assert.Equal(t, account2.Provider, foundAccount2.Provider)
assert.Equal(t, account2.Scope, foundAccount2.Scope)
// Verify both accounts exist
accounts, err := accountRepo.FindByUserID(ctx, user.ID)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(accounts), 2) // At least Google and GitHub accounts
})
}
func TestUserRepository_FindByID(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("find existing user by id", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Find",
LastName: "User",
Email: "find@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
found, err := repo.FindByID(ctx, user.ID)
assert.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
assert.Equal(t, user.Email, found.Email)
})
t.Run("find user with roles", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Role",
LastName: "User",
Email: "role@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
// Create role
roleRepo := createTestRoleRepository(db)
role := &domainAuth.Role{
ID: uuid.New(),
Name: "admin",
Description: "Administrator",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role)
require.NoError(t, err)
// Assign role to user
userRoleRepo := createTestUserRoleRepository(db)
err = userRoleRepo.Create(ctx, user.ID, role.ID)
require.NoError(t, err)
// Find user with roles
found, err := repo.FindByID(ctx, user.ID, domainAuth.WithRoles())
assert.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
assert.Len(t, found.Roles, 1)
assert.Equal(t, role.Name, found.Roles[0].Name)
})
t.Run("find non-existent user", func(t *testing.T) {
nonExistentID := uuid.New()
found, err := repo.FindByID(ctx, nonExistentID)
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestUserRepository_FindByEmail(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("find existing user by email", func(t *testing.T) {
email := "email@example.com"
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Email",
LastName: "User",
Email: email,
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
found, err := repo.FindByEmail(ctx, email)
assert.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
assert.Equal(t, email, found.Email)
})
t.Run("find user with accounts", func(t *testing.T) {
email := "accounts@example.com"
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Accounts",
LastName: "User",
Email: email,
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
// Create account
accountRepo := createTestAccountRepository(db)
account := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
Scope: []string{"read"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = accountRepo.Create(ctx, account)
require.NoError(t, err)
// Find user with accounts
found, err := repo.FindByEmail(ctx, email, domainAuth.WithAccounts())
assert.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
assert.Len(t, found.Accounts, 1)
assert.Equal(t, account.Provider, found.Accounts[0].Provider)
})
t.Run("find non-existent user by email", func(t *testing.T) {
found, err := repo.FindByEmail(ctx, "nonexistent@example.com")
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestUserRepository_Update(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("update user successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Update",
LastName: "User",
Email: "update@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
// Update user
user.FirstName = "Updated"
user.EmailVerified = true
user.Status = domainAuth.UserStatusInactive
err = repo.Update(ctx, user)
assert.NoError(t, err)
// Verify update
found, err := repo.FindByID(ctx, user.ID)
assert.NoError(t, err)
assert.Equal(t, "Updated", found.FirstName)
assert.True(t, found.EmailVerified)
assert.Equal(t, domainAuth.UserStatusInactive, found.Status)
})
}
func TestUserRepository_Delete(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("delete user successfully", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Delete",
LastName: "User",
Email: "delete@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
err = repo.Delete(ctx, user.ID)
assert.NoError(t, err)
// Verify deletion (soft delete)
found, err := repo.FindByID(ctx, user.ID)
assert.Error(t, err)
assert.Nil(t, found)
})
}
func TestUserRepository_List(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
// Create multiple users
for i := 0; i < 5; i++ {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "User",
LastName: "Test",
Email: "user" + strconv.Itoa(i) + "@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
}
t.Run("list users with limit and offset", func(t *testing.T) {
users, err := repo.List(ctx, 3, 0)
assert.NoError(t, err)
assert.Len(t, users, 3)
users, err = repo.List(ctx, 3, 3)
assert.NoError(t, err)
assert.Len(t, users, 2) // Remaining 2 users
})
t.Run("list users with relations", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Relation",
LastName: "User",
Email: "relation@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
// Create role and assign
roleRepo := createTestRoleRepository(db)
role := &domainAuth.Role{
ID: uuid.New(),
Name: "user",
Description: "Regular user",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role)
require.NoError(t, err)
userRoleRepo := createTestUserRoleRepository(db)
err = userRoleRepo.Create(ctx, user.ID, role.ID)
require.NoError(t, err)
users, err := repo.List(ctx, 10, 0, domainAuth.WithRoles())
assert.NoError(t, err)
assert.Greater(t, len(users), 0)
// Find our user in the list
var foundUser *domainAuth.User
for _, u := range users {
if u.ID == user.ID {
foundUser = u
break
}
}
require.NotNil(t, foundUser)
assert.Len(t, foundUser.Roles, 1)
})
}
func TestUserRepository_Count(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("count users", func(t *testing.T) {
initialCount, err := repo.Count(ctx)
assert.NoError(t, err)
assert.Equal(t, int64(0), initialCount)
// Create users
for i := 0; i < 3; i++ {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Count",
LastName: "User",
Email: "count" + strconv.Itoa(i) + "@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
}
count, err := repo.Count(ctx)
assert.NoError(t, err)
assert.Equal(t, int64(3), count)
})
}
func TestUserRepository_UserRoles(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("get user roles", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Roles",
LastName: "User",
Email: "roles@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
roleRepo := createTestRoleRepository(db)
role1 := &domainAuth.Role{
ID: uuid.New(),
Name: "admin",
Description: "Admin role",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role1)
require.NoError(t, err)
role2 := &domainAuth.Role{
ID: uuid.New(),
Name: "user",
Description: "User role",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = roleRepo.Create(ctx, role2)
require.NoError(t, err)
userRoleRepo := createTestUserRoleRepository(db)
err = userRoleRepo.Create(ctx, user.ID, role1.ID)
require.NoError(t, err)
err = userRoleRepo.Create(ctx, user.ID, role2.ID)
require.NoError(t, err)
roles, err := repo.UserRoles(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, roles, 2)
})
}
func TestUserRepository_UserAccounts(t *testing.T) {
db := setupTestDB(t)
repo := createTestUserRepository(db)
ctx := context.Background()
t.Run("get user accounts", func(t *testing.T) {
user := &domainAuth.User{
ID: uuid.New(),
FirstName: "Accounts",
LastName: "User",
Email: "accounts@example.com",
Status: domainAuth.UserStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err := repo.Create(ctx, user)
require.NoError(t, err)
accountRepo := createTestAccountRepository(db)
account1 := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.Google,
Scope: []string{"read"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = accountRepo.Create(ctx, account1)
require.NoError(t, err)
account2 := &domainAuth.Account{
ID: uuid.New(),
UserID: user.ID,
Provider: oauth.GitHub,
Scope: []string{"read", "write"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
err = accountRepo.Create(ctx, account2)
require.NoError(t, err)
accounts, err := repo.UserAccounts(ctx, user.ID)
assert.NoError(t, err)
assert.Len(t, accounts, 2)
})
}

View File

@@ -0,0 +1,30 @@
package cache
import (
"time"
"gorm.io/datatypes"
)
type KVModel struct {
Key string `gorm:"primaryKey"`
Value datatypes.JSON
ExpiresAt *time.Time
CreatedAt time.Time
}
func (KVModel) TableName() string {
return "cache_kv"
}
type HashModel struct {
Key string `gorm:"primaryKey"`
Field string `gorm:"primaryKey"`
Value datatypes.JSON
CreatedAt time.Time
ExpiresAt *time.Time
}
func (HashModel) TableName() string {
return "cache_hash"
}

View File

@@ -0,0 +1,196 @@
package profile
import (
"encoding/json"
"github.com/google/uuid"
domainProfile "base/internal/domain/profile"
)
func toProfileModel(profile *domainProfile.Profile) (*Model, error) {
pageSectionOrder, err := json.Marshal(profile.PageSectionOrder)
if err != nil {
return nil, err
}
var roleID *uuid.UUID
var roleName *string
roleLevel := ""
if profile.Hero.Role != nil {
roleLevel = profile.Hero.Role.Level
if profile.Hero.Role.ID != uuid.Nil {
roleID = &profile.Hero.Role.ID
roleName = &profile.Hero.Role.Title
}
}
return &Model{
ID: profile.ID,
UserID: profile.UserID,
Handle: profile.Handle,
RoleID: roleID,
RoleName: roleName,
RoleLevel: roleLevel,
FirstName: profile.Hero.FirstName,
LastName: profile.Hero.LastName,
Company: profile.Hero.Company,
ShortDescription: profile.Hero.ShortDescription,
ResumeLink: profile.Hero.ResumeLink,
CTAEnabled: profile.Hero.CTAEnabled,
Avatar: profile.Hero.Avatar,
ProfilePicture: profile.About.ProfilePicture,
About: profile.About.About,
Email: profile.Contact.Email,
Phone: profile.Contact.Phone,
VisibilityLevel: profile.PageSetting.VisibilityLevel,
PageSectionOrder: pageSectionOrder,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}, nil
}
func toProfileDomain(model *Model, skills []domainProfile.Skill, socialLinks []domainProfile.SocialLink, achievements []domainProfile.Achievement) (*domainProfile.Profile, error) {
var pageSectionOrder map[string]int
if len(model.PageSectionOrder) > 0 {
if err := json.Unmarshal(model.PageSectionOrder, &pageSectionOrder); err != nil {
return nil, err
}
}
var role *domainProfile.Role
if model.RoleID != nil && *model.RoleID != uuid.Nil {
title := ""
if model.Role != nil {
title = model.Role.Title
} else if model.RoleName != nil {
title = *model.RoleName
}
role = &domainProfile.Role{
ID: *model.RoleID,
Title: title,
Level: model.RoleLevel,
}
} else if model.RoleLevel != "" {
role = &domainProfile.Role{Level: model.RoleLevel}
}
hero := domainProfile.Hero{
Role: role,
FirstName: model.FirstName,
LastName: model.LastName,
Company: model.Company,
ShortDescription: model.ShortDescription,
ResumeLink: model.ResumeLink,
CTAEnabled: model.CTAEnabled,
Avatar: model.Avatar,
}
about := domainProfile.About{
ProfilePicture: model.ProfilePicture,
About: model.About,
Achievements: achievements,
}
contact := domainProfile.Contact{
Email: model.Email,
Phone: model.Phone,
SocialLinks: socialLinks,
}
pageSetting := domainProfile.PageSetting{
VisibilityLevel: model.VisibilityLevel,
}
return &domainProfile.Profile{
ID: model.ID,
UserID: model.UserID,
Handle: model.Handle,
PageSectionOrder: pageSectionOrder,
Hero: hero,
About: about,
Skills: skills,
Contact: contact,
PageSetting: pageSetting,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}, nil
}
func toSkillModels(profileID uuid.UUID, skills []domainProfile.Skill) []SkillModel {
models := make([]SkillModel, len(skills))
for i, skill := range skills {
models[i] = SkillModel{
ProfileID: profileID,
SkillName: skill.SkillName,
Level: skill.Level,
}
}
return models
}
func toSkillDomains(models []SkillModel) []domainProfile.Skill {
skills := make([]domainProfile.Skill, len(models))
for i, model := range models {
skills[i] = domainProfile.Skill{
SkillName: model.SkillName,
Level: model.Level,
}
}
return skills
}
func toSocialLinkModels(profileID uuid.UUID, socialLinks []domainProfile.SocialLink) []SocialLinkModel {
models := make([]SocialLinkModel, len(socialLinks))
for i, link := range socialLinks {
models[i] = SocialLinkModel{
ProfileID: profileID,
LinkType: link.LinkType,
Link: link.Link,
}
}
return models
}
func toSocialLinkDomains(models []SocialLinkModel) []domainProfile.SocialLink {
links := make([]domainProfile.SocialLink, len(models))
for i, model := range models {
links[i] = domainProfile.SocialLink{
LinkType: model.LinkType,
Link: model.Link,
}
}
return links
}
func toAchievementModels(profileID uuid.UUID, achievements []domainProfile.Achievement) []AchievementModel {
models := make([]AchievementModel, len(achievements))
for i, achievement := range achievements {
models[i] = AchievementModel{
ProfileID: profileID,
Title: achievement.Title,
Value: achievement.Value,
Enabled: achievement.Enabled,
}
}
return models
}
func toAchievementDomains(models []AchievementModel) []domainProfile.Achievement {
achievements := make([]domainProfile.Achievement, len(models))
for i, model := range models {
achievements[i] = domainProfile.Achievement{
Title: model.Title,
Value: model.Value,
Enabled: model.Enabled,
}
}
return achievements
}
func copyProfileFromModel(profile *domainProfile.Profile, model *Model) error {
profile.ID = model.ID
profile.Handle = model.Handle
return nil
}

View File

@@ -0,0 +1,315 @@
package profile
import (
"context"
"errors"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainProfile "base/internal/domain/profile"
)
type profileRepository struct {
db *gorm.DB
}
func NewProfileRepository(lc fx.Lifecycle, db *gorm.DB) domainProfile.Repository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error {
return nil
},
OnStop: func(ctx context.Context) error {
return nil
},
})
return &profileRepository{db: db}
}
func (r *profileRepository) Create(ctx context.Context, profile *domainProfile.Profile) error {
model, err := toProfileModel(profile)
if err != nil {
return err
}
// Start a transaction
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// Create profile
if err := tx.Create(model).Error; err != nil {
return err
}
// Create skills if any
if len(profile.Skills) > 0 {
skillModels := toSkillModels(model.ID, profile.Skills)
if err := tx.Create(&skillModels).Error; err != nil {
return err
}
}
// Create social links if any
if len(profile.Contact.SocialLinks) > 0 {
socialLinkModels := toSocialLinkModels(model.ID, profile.Contact.SocialLinks)
if err := tx.Create(&socialLinkModels).Error; err != nil {
return err
}
}
// Create achievements if any
if len(profile.About.Achievements) > 0 {
achievementModels := toAchievementModels(model.ID, profile.About.Achievements)
if err := tx.Create(&achievementModels).Error; err != nil {
return err
}
}
if err := tx.Commit().Error; err != nil {
return err
}
return copyProfileFromModel(profile, model)
}
func (r *profileRepository) loadRelatedData(ctx context.Context, profileID uuid.UUID) ([]domainProfile.Skill, []domainProfile.SocialLink, []domainProfile.Achievement, error) {
// Load skills
var skillModels []SkillModel
if err := r.db.WithContext(ctx).Where("profile_id = ?", profileID).Find(&skillModels).Error; err != nil {
return nil, nil, nil, err
}
skills := toSkillDomains(skillModels)
// Load social links
var socialLinkModels []SocialLinkModel
if err := r.db.WithContext(ctx).Where("profile_id = ?", profileID).Find(&socialLinkModels).Error; err != nil {
return nil, nil, nil, err
}
socialLinks := toSocialLinkDomains(socialLinkModels)
// Load achievements
var achievementModels []AchievementModel
if err := r.db.WithContext(ctx).Where("profile_id = ?", profileID).Find(&achievementModels).Error; err != nil {
return nil, nil, nil, err
}
achievements := toAchievementDomains(achievementModels)
return skills, socialLinks, achievements, nil
}
func (r *profileRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainProfile.Profile, error) {
var model Model
if err := r.db.WithContext(ctx).Preload("Role").Where("id = ?", id).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("profile not found")
}
return nil, err
}
skills, socialLinks, achievements, err := r.loadRelatedData(ctx, model.ID)
if err != nil {
return nil, err
}
return toProfileDomain(&model, skills, socialLinks, achievements)
}
func (r *profileRepository) FindByHandle(ctx context.Context, handle string) (*domainProfile.Profile, error) {
var model Model
if err := r.db.WithContext(ctx).Preload("Role").Where("handle = ?", handle).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("profile not found")
}
return nil, err
}
skills, socialLinks, achievements, err := r.loadRelatedData(ctx, model.ID)
if err != nil {
return nil, err
}
return toProfileDomain(&model, skills, socialLinks, achievements)
}
func (r *profileRepository) FindByUserID(ctx context.Context, userID uuid.UUID) (*domainProfile.Profile, error) {
var model Model
if err := r.db.WithContext(ctx).Preload("Role").Where("user_id = ? AND user_id IS NOT NULL", userID).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("profile not found")
}
return nil, err
}
skills, socialLinks, achievements, err := r.loadRelatedData(ctx, model.ID)
if err != nil {
return nil, err
}
return toProfileDomain(&model, skills, socialLinks, achievements)
}
func (r *profileRepository) Update(ctx context.Context, profile *domainProfile.Profile) error {
model, err := toProfileModel(profile)
if err != nil {
return err
}
// Start a transaction
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// Update profile
if err := tx.Model(&Model{}).Where("id = ?", profile.ID).Updates(model).Error; err != nil {
return err
}
// Delete existing related data
if err := tx.Where("profile_id = ?", profile.ID).Delete(&SkillModel{}).Error; err != nil {
return err
}
if err := tx.Where("profile_id = ?", profile.ID).Delete(&SocialLinkModel{}).Error; err != nil {
return err
}
if err := tx.Where("profile_id = ?", profile.ID).Delete(&AchievementModel{}).Error; err != nil {
return err
}
// Create new skills
if len(profile.Skills) > 0 {
skillModels := toSkillModels(profile.ID, profile.Skills)
if err := tx.Create(&skillModels).Error; err != nil {
return err
}
}
// Create new social links
if len(profile.Contact.SocialLinks) > 0 {
socialLinkModels := toSocialLinkModels(profile.ID, profile.Contact.SocialLinks)
if err := tx.Create(&socialLinkModels).Error; err != nil {
return err
}
}
// Create new achievements
if len(profile.About.Achievements) > 0 {
achievementModels := toAchievementModels(profile.ID, profile.About.Achievements)
if err := tx.Create(&achievementModels).Error; err != nil {
return err
}
}
return tx.Commit().Error
}
func (r *profileRepository) Delete(ctx context.Context, profile *domainProfile.Profile) error {
// Start a transaction
tx := r.db.WithContext(ctx).Begin()
if tx.Error != nil {
return tx.Error
}
defer tx.Rollback()
// Delete related data first
if err := tx.Where("profile_id = ?", profile.ID).Delete(&SkillModel{}).Error; err != nil {
return err
}
if err := tx.Where("profile_id = ?", profile.ID).Delete(&SocialLinkModel{}).Error; err != nil {
return err
}
if err := tx.Where("profile_id = ?", profile.ID).Delete(&AchievementModel{}).Error; err != nil {
return err
}
// Delete profile
if err := tx.Delete(&Model{}, "id = ?", profile.ID).Error; err != nil {
return err
}
return tx.Commit().Error
}
// buildBaseQuery applies common filters to a query
func (r *profileRepository) buildBaseQuery(ctx context.Context, filter domainProfile.Filter) *gorm.DB {
query := r.db.WithContext(ctx).Model(&Model{})
if filter.RoleID != uuid.Nil {
query = query.Where("role_id = ?", filter.RoleID)
}
if filter.FirstName != "" {
query = query.Where("LOWER(first_name) LIKE ?", "%"+filter.FirstName+"%")
}
if filter.LastName != "" {
query = query.Where("LOWER(last_name) LIKE ?", "%"+filter.LastName+"%")
}
if filter.Company != "" {
query = query.Where("LOWER(company) LIKE ?", "%"+filter.Company+"%")
}
if filter.SkillName != "" {
subQuery := r.db.WithContext(ctx).Model(&SkillModel{}).
Select("DISTINCT profile_id").
Where("LOWER(skill_name) LIKE ? AND deleted_at IS NULL", "%"+filter.SkillName+"%")
query = query.Where("profiles.id IN (?)", subQuery)
}
return query
}
func (r *profileRepository) FindAll(ctx context.Context, filter domainProfile.Filter) ([]*domainProfile.Profile, int, error) {
baseQuery := r.buildBaseQuery(ctx, filter)
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
query := baseQuery
offset := int((filter.Page - 1) * filter.PageSize)
limit := int(filter.PageSize)
if limit > 0 {
query = query.Limit(limit).Offset(offset)
}
if filter.SortedBy != "" {
order := "ASC"
if !filter.Ascending {
order = "DESC"
}
query = query.Order("profiles." + filter.SortedBy + " " + order)
} else {
query = query.Order("profiles.created_at DESC")
}
var models []Model
if err := query.Preload("Role").Find(&models).Error; err != nil {
return nil, 0, err
}
if len(models) == 0 {
return nil, int(total), nil
}
profiles := make([]*domainProfile.Profile, len(models))
for i, model := range models {
skills, socialLinks, achievements, err := r.loadRelatedData(ctx, model.ID)
if err != nil {
return nil, 0, err
}
profile, err := toProfileDomain(&model, skills, socialLinks, achievements)
if err != nil {
return nil, 0, err
}
profiles[i] = profile
}
return profiles, int(total), nil
}

View File

@@ -0,0 +1,870 @@
package profile
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
domainProfile "base/internal/domain/profile"
)
func TestProfileRepository_Create(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("create profile successfully", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "test-handle",
PageSectionOrder: map[string]int{
"hero": 1,
"about": 2,
"skills": 3,
},
Hero: domainProfile.Hero{
FirstName: "John",
LastName: "Doe",
Company: "Test Company",
ShortDescription: "Test description",
CTAEnabled: true,
},
About: domainProfile.About{
ProfilePicture: "https://example.com/pic.jpg",
About: "About me",
},
Contact: domainProfile.Contact{
Email: "john.doe@example.com",
Phone: "1234567890",
},
PageSetting: domainProfile.PageSetting{
VisibilityLevel: "public",
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
assert.NotEqual(t, uuid.Nil, profile.ID)
// Verify profile was created
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Equal(t, profile.Handle, found.Handle)
assert.Equal(t, profile.Hero.FirstName, found.Hero.FirstName)
assert.Equal(t, profile.Hero.LastName, found.Hero.LastName)
assert.Equal(t, profile.Contact.Email, found.Contact.Email)
})
t.Run("create profile with skills", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "test-handle-with-skills",
Hero: domainProfile.Hero{
FirstName: "Jane",
LastName: "Smith",
},
Skills: []domainProfile.Skill{
{SkillName: "Go", Level: "expert"},
{SkillName: "Python", Level: "intermediate"},
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
// Verify profile with skills
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.Skills, 2)
assert.Equal(t, "Go", found.Skills[0].SkillName)
assert.Equal(t, "expert", found.Skills[0].Level)
})
t.Run("create profile with social links", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "test-handle-with-links",
Hero: domainProfile.Hero{
FirstName: "Bob",
LastName: "Johnson",
},
Contact: domainProfile.Contact{
SocialLinks: []domainProfile.SocialLink{
{LinkType: "linkedin", Link: "https://linkedin.com/in/bob"},
{LinkType: "github", Link: "https://github.com/bob"},
},
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
// Verify profile with social links
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.Contact.SocialLinks, 2)
assert.Equal(t, "linkedin", found.Contact.SocialLinks[0].LinkType)
})
t.Run("create profile with achievements", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "test-handle-with-achievements",
Hero: domainProfile.Hero{
FirstName: "Alice",
LastName: "Williams",
},
About: domainProfile.About{
Achievements: []domainProfile.Achievement{
{Title: "Projects", Value: "50", Enabled: true},
{Title: "Clients", Value: "100", Enabled: true},
},
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
// Verify profile with achievements
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.About.Achievements, 2)
assert.Equal(t, "Projects", found.About.Achievements[0].Title)
assert.Equal(t, "50", found.About.Achievements[0].Value)
})
t.Run("create profile with duplicate handle fails", func(t *testing.T) {
handle := "duplicate-handle"
profile1 := &domainProfile.Profile{
ID: uuid.New(),
Handle: handle,
Hero: domainProfile.Hero{
FirstName: "First",
LastName: "User",
},
}
err := repo.Create(ctx, profile1)
assert.NoError(t, err)
profile2 := &domainProfile.Profile{
ID: uuid.New(),
Handle: handle,
Hero: domainProfile.Hero{
FirstName: "Second",
LastName: "User",
},
}
err = repo.Create(ctx, profile2)
assert.Error(t, err)
})
t.Run("create profile with role", func(t *testing.T) {
roleID := uuid.New()
roleName := "Software Engineer"
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "test-handle-with-role",
Hero: domainProfile.Hero{
Role: &domainProfile.Role{
ID: roleID,
Title: roleName,
},
FirstName: "Role",
LastName: "User",
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
// Verify profile with role
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.NotNil(t, found.Hero.Role)
assert.Equal(t, roleID, found.Hero.Role.ID)
assert.Equal(t, roleName, found.Hero.Role.Title)
})
}
func TestProfileRepository_FindByHandle(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("find profile by handle successfully", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "find-by-handle",
Hero: domainProfile.Hero{
FirstName: "Find",
LastName: "Handle",
},
Contact: domainProfile.Contact{
Email: "find@example.com",
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.NotNil(t, found)
assert.Equal(t, profile.Handle, found.Handle)
assert.Equal(t, profile.Hero.FirstName, found.Hero.FirstName)
assert.Equal(t, profile.Contact.Email, found.Contact.Email)
})
t.Run("find non-existent profile returns error", func(t *testing.T) {
found, err := repo.FindByHandle(ctx, "non-existent-handle")
assert.Error(t, err)
assert.Nil(t, found)
assert.Contains(t, err.Error(), "profile not found")
})
t.Run("find profile with all related data", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "find-with-all-data",
Hero: domainProfile.Hero{
FirstName: "All",
LastName: "Data",
},
Skills: []domainProfile.Skill{
{SkillName: "JavaScript", Level: "advanced"},
},
Contact: domainProfile.Contact{
SocialLinks: []domainProfile.SocialLink{
{LinkType: "twitter", Link: "https://twitter.com/user"},
},
},
About: domainProfile.About{
Achievements: []domainProfile.Achievement{
{Title: "Years", Value: "10", Enabled: true},
},
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.Skills, 1)
assert.Len(t, found.Contact.SocialLinks, 1)
assert.Len(t, found.About.Achievements, 1)
})
}
func TestProfileRepository_FindByUserID(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("find profile by user ID successfully", func(t *testing.T) {
userID := uuid.New()
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "find-by-user-id",
Hero: domainProfile.Hero{
FirstName: "User",
LastName: "ID",
},
}
// Create profile with user_id manually since it's not in the domain model
model, err := toProfileModel(profile)
require.NoError(t, err)
model.UserID = &userID
err = db.WithContext(ctx).Create(model).Error
require.NoError(t, err)
found, err := repo.FindByUserID(ctx, userID)
assert.NoError(t, err)
assert.NotNil(t, found)
assert.Equal(t, profile.Handle, found.Handle)
})
t.Run("find non-existent user ID returns error", func(t *testing.T) {
nonExistentUserID := uuid.New()
found, err := repo.FindByUserID(ctx, nonExistentUserID)
assert.Error(t, err)
assert.Nil(t, found)
assert.Contains(t, err.Error(), "profile not found")
})
}
func TestProfileRepository_Update(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("update profile successfully", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "update-profile",
Hero: domainProfile.Hero{
FirstName: "Original",
LastName: "Name",
Company: "Old Company",
},
Contact: domainProfile.Contact{
Email: "original@example.com",
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
// Update profile
profile.Hero.FirstName = "Updated"
profile.Hero.Company = "New Company"
profile.Contact.Email = "updated@example.com"
err = repo.Update(ctx, profile)
assert.NoError(t, err)
// Verify update
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Equal(t, "Updated", found.Hero.FirstName)
assert.Equal(t, "New Company", found.Hero.Company)
assert.Equal(t, "updated@example.com", found.Contact.Email)
})
t.Run("update profile with new skills", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "update-skills",
Hero: domainProfile.Hero{
FirstName: "Skills",
LastName: "User",
},
Skills: []domainProfile.Skill{
{SkillName: "Go", Level: "beginner"},
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
// Update with new skills
profile.Skills = []domainProfile.Skill{
{SkillName: "Go", Level: "expert"},
{SkillName: "Rust", Level: "intermediate"},
}
err = repo.Update(ctx, profile)
assert.NoError(t, err)
// Verify skills were updated
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.Skills, 2)
// Check that old skill is gone and new ones exist
skillMap := make(map[string]string)
for _, skill := range found.Skills {
skillMap[skill.SkillName] = skill.Level
}
assert.Equal(t, "expert", skillMap["Go"])
assert.Equal(t, "intermediate", skillMap["Rust"])
})
t.Run("update profile with new social links", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "update-links",
Hero: domainProfile.Hero{
FirstName: "Links",
LastName: "User",
},
Contact: domainProfile.Contact{
SocialLinks: []domainProfile.SocialLink{
{LinkType: "linkedin", Link: "https://linkedin.com/old"},
},
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
// Update with new social links
profile.Contact.SocialLinks = []domainProfile.SocialLink{
{LinkType: "github", Link: "https://github.com/new"},
{LinkType: "twitter", Link: "https://twitter.com/new"},
}
err = repo.Update(ctx, profile)
assert.NoError(t, err)
// Verify social links were updated
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.Contact.SocialLinks, 2)
linkTypes := make(map[string]bool)
for _, link := range found.Contact.SocialLinks {
linkTypes[link.LinkType] = true
}
assert.True(t, linkTypes["github"])
assert.True(t, linkTypes["twitter"])
assert.False(t, linkTypes["linkedin"])
})
t.Run("update profile with new achievements", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "update-achievements",
Hero: domainProfile.Hero{
FirstName: "Achievements",
LastName: "User",
},
About: domainProfile.About{
Achievements: []domainProfile.Achievement{
{Title: "Old", Value: "1", Enabled: true},
},
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
// Update with new achievements
profile.About.Achievements = []domainProfile.Achievement{
{Title: "New1", Value: "10", Enabled: true},
{Title: "New2", Value: "20", Enabled: false},
}
err = repo.Update(ctx, profile)
assert.NoError(t, err)
// Verify achievements were updated
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Len(t, found.About.Achievements, 2)
achievementMap := make(map[string]string)
for _, achievement := range found.About.Achievements {
achievementMap[achievement.Title] = achievement.Value
}
assert.Equal(t, "10", achievementMap["New1"])
assert.Equal(t, "20", achievementMap["New2"])
})
t.Run("update profile page section order", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "update-page-order",
Hero: domainProfile.Hero{
FirstName: "Page",
LastName: "Order",
},
PageSectionOrder: map[string]int{
"hero": 1,
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
// Update page section order
profile.PageSectionOrder = map[string]int{
"about": 1,
"hero": 2,
"skills": 3,
}
err = repo.Update(ctx, profile)
assert.NoError(t, err)
// Verify page section order was updated
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.Equal(t, 1, found.PageSectionOrder["about"])
assert.Equal(t, 2, found.PageSectionOrder["hero"])
assert.Equal(t, 3, found.PageSectionOrder["skills"])
})
}
func TestProfileRepository_Delete(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("delete profile successfully", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "delete-profile",
Hero: domainProfile.Hero{
FirstName: "Delete",
LastName: "User",
},
Skills: []domainProfile.Skill{
{SkillName: "Go", Level: "expert"},
},
Contact: domainProfile.Contact{
SocialLinks: []domainProfile.SocialLink{
{LinkType: "github", Link: "https://github.com/user"},
},
},
About: domainProfile.About{
Achievements: []domainProfile.Achievement{
{Title: "Projects", Value: "10", Enabled: true},
},
},
}
err := repo.Create(ctx, profile)
require.NoError(t, err)
err = repo.Delete(ctx, profile)
assert.NoError(t, err)
// Verify deletion
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.Error(t, err)
assert.Nil(t, found)
assert.Contains(t, err.Error(), "profile not found")
})
}
func TestProfileRepository_FindAll(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
// Create test profiles
roleID1 := uuid.New()
roleID2 := uuid.New()
profiles := []*domainProfile.Profile{
{
ID: uuid.New(),
Handle: "findall-1",
Hero: domainProfile.Hero{
Role: &domainProfile.Role{
ID: roleID1,
Title: "Engineer",
},
FirstName: "Alice",
LastName: "Anderson",
Company: "Tech Corp",
},
Skills: []domainProfile.Skill{
{SkillName: "Go", Level: "expert"},
{SkillName: "Python", Level: "intermediate"},
},
},
{
ID: uuid.New(),
Handle: "findall-2",
Hero: domainProfile.Hero{
Role: &domainProfile.Role{
ID: roleID1,
Title: "Engineer",
},
FirstName: "Bob",
LastName: "Brown",
Company: "Tech Corp",
},
Skills: []domainProfile.Skill{
{SkillName: "JavaScript", Level: "expert"},
},
},
{
ID: uuid.New(),
Handle: "findall-3",
Hero: domainProfile.Hero{
Role: &domainProfile.Role{
ID: roleID2,
Title: "Designer",
},
FirstName: "Charlie",
LastName: "Clark",
Company: "Design Inc",
},
Skills: []domainProfile.Skill{
{SkillName: "Figma", Level: "expert"},
},
},
}
for _, profile := range profiles {
err := repo.Create(ctx, profile)
require.NoError(t, err)
// Add small delay to ensure different timestamps
time.Sleep(10 * time.Millisecond)
}
t.Run("find all profiles without filters", func(t *testing.T) {
filter := domainProfile.Filter{
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, 3)
assert.GreaterOrEqual(t, len(results), 3)
})
t.Run("find profiles by role ID", func(t *testing.T) {
filter := domainProfile.Filter{
RoleID: roleID1,
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 2, total)
assert.Len(t, results, 2)
for _, result := range results {
assert.NotNil(t, result.Hero.Role)
assert.Equal(t, roleID1, result.Hero.Role.ID)
}
})
t.Run("find profiles by first name", func(t *testing.T) {
filter := domainProfile.Filter{
FirstName: "alice",
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 1, total)
assert.Len(t, results, 1)
assert.Equal(t, "Alice", results[0].Hero.FirstName)
})
t.Run("find profiles by last name", func(t *testing.T) {
filter := domainProfile.Filter{
LastName: "brown",
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 1, total)
assert.Len(t, results, 1)
assert.Equal(t, "Brown", results[0].Hero.LastName)
})
t.Run("find profiles by company", func(t *testing.T) {
filter := domainProfile.Filter{
Company: "tech",
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 2, total)
assert.Len(t, results, 2)
for _, result := range results {
assert.Contains(t, result.Hero.Company, "Tech")
}
})
t.Run("find profiles by skill name", func(t *testing.T) {
filter := domainProfile.Filter{
SkillName: "go",
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 1, total)
assert.Len(t, results, 1)
assert.Equal(t, "findall-1", results[0].Handle)
// Verify the profile has the skill
hasGoSkill := false
for _, skill := range results[0].Skills {
if skill.SkillName == "Go" {
hasGoSkill = true
break
}
}
assert.True(t, hasGoSkill)
})
t.Run("find profiles with pagination", func(t *testing.T) {
filter := domainProfile.Filter{
Page: 1,
PageSize: 2,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, 3)
assert.Len(t, results, 2)
// Second page
filter.Page = 2
results2, total2, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, total, total2)
assert.GreaterOrEqual(t, len(results2), 1)
})
t.Run("find profiles with sorting", func(t *testing.T) {
filter := domainProfile.Filter{
Page: 1,
PageSize: 10,
SortedBy: "first_name",
Ascending: true,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.GreaterOrEqual(t, total, 3)
assert.GreaterOrEqual(t, len(results), 3)
// Verify sorting (first result should be Alice)
assert.Equal(t, "Alice", results[0].Hero.FirstName)
// Test descending order
filter.Ascending = false
results2, _, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
// Last result should be Alice (or one of the first names alphabetically)
assert.NotEqual(t, "Alice", results2[0].Hero.FirstName)
})
t.Run("find profiles with combined filters", func(t *testing.T) {
filter := domainProfile.Filter{
RoleID: roleID1,
Company: "tech",
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 2, total)
assert.Len(t, results, 2)
for _, result := range results {
assert.NotNil(t, result.Hero.Role)
assert.Equal(t, roleID1, result.Hero.Role.ID)
assert.Contains(t, result.Hero.Company, "Tech")
}
})
t.Run("find profiles with empty result", func(t *testing.T) {
filter := domainProfile.Filter{
FirstName: "nonexistent",
Page: 1,
PageSize: 10,
}
results, total, err := repo.FindAll(ctx, filter)
assert.NoError(t, err)
assert.Equal(t, 0, total)
assert.Len(t, results, 0)
})
}
func TestProfileRepository_PageSectionOrder(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("create and retrieve profile with page section order", func(t *testing.T) {
pageSectionOrder := map[string]int{
"hero": 1,
"about": 2,
"skills": 3,
"contact": 4,
}
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "page-order-test",
PageSectionOrder: pageSectionOrder,
Hero: domainProfile.Hero{
FirstName: "Page",
LastName: "Order",
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
assert.NotNil(t, found.PageSectionOrder)
assert.Equal(t, 1, found.PageSectionOrder["hero"])
assert.Equal(t, 2, found.PageSectionOrder["about"])
assert.Equal(t, 3, found.PageSectionOrder["skills"])
assert.Equal(t, 4, found.PageSectionOrder["contact"])
})
t.Run("create profile with empty page section order", func(t *testing.T) {
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "empty-page-order",
Hero: domainProfile.Hero{
FirstName: "Empty",
LastName: "Order",
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
// Empty map should be returned as empty or nil
assert.NotNil(t, found)
})
}
// Helper function to verify JSON marshaling/unmarshaling works correctly
func TestProfileRepository_JSONSerialization(t *testing.T) {
db := setupTestDB(t)
repo := createTestProfileRepository(db)
ctx := context.Background()
t.Run("verify page section order JSON serialization", func(t *testing.T) {
complexOrder := map[string]int{
"section1": 10,
"section2": 20,
"section3": 30,
}
profile := &domainProfile.Profile{
ID: uuid.New(),
Handle: "json-test",
PageSectionOrder: complexOrder,
Hero: domainProfile.Hero{
FirstName: "JSON",
LastName: "Test",
},
}
err := repo.Create(ctx, profile)
assert.NoError(t, err)
// Verify the data can be serialized/deserialized correctly
found, err := repo.FindByHandle(ctx, profile.Handle)
assert.NoError(t, err)
// Re-serialize to verify round-trip
jsonData, err := json.Marshal(found.PageSectionOrder)
assert.NoError(t, err)
var unmarshaled map[string]int
err = json.Unmarshal(jsonData, &unmarshaled)
assert.NoError(t, err)
assert.Equal(t, complexOrder, unmarshaled)
})
}

View File

@@ -0,0 +1,112 @@
package profile
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainProfile "base/internal/domain/profile"
)
type roleRepository struct {
db *gorm.DB
}
// NewRoleRepository creates a RoleRepository for profile_roles.
func NewRoleRepository(lc fx.Lifecycle, db *gorm.DB) domainProfile.RoleRepository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error { return nil },
OnStop: func(ctx context.Context) error { return nil },
})
return &roleRepository{db: db}
}
func (r *roleRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainProfile.Role, error) {
var model RoleModel
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domainProfile.ErrRoleNotFound
}
return nil, err
}
return roleModelToDomain(&model), nil
}
func (r *roleRepository) FindAll(ctx context.Context) ([]*domainProfile.Role, error) {
var models []RoleModel
if err := r.db.WithContext(ctx).Order("status DESC, title ASC").Find(&models).Error; err != nil {
return nil, err
}
out := make([]*domainProfile.Role, len(models))
for i := range models {
out[i] = roleModelToDomain(&models[i])
}
return out, nil
}
func (r *roleRepository) List(ctx context.Context, limit, offset int) ([]*domainProfile.Role, error) {
var models []RoleModel
q := r.db.WithContext(ctx).Order("status DESC, title ASC")
if limit > 0 {
q = q.Limit(limit)
}
if offset > 0 {
q = q.Offset(offset)
}
if err := q.Find(&models).Error; err != nil {
return nil, err
}
out := make([]*domainProfile.Role, len(models))
for i := range models {
out[i] = roleModelToDomain(&models[i])
}
return out, nil
}
func roleModelToDomain(m *RoleModel) *domainProfile.Role {
return &domainProfile.Role{
ID: m.ID,
Title: m.Title,
}
}
func (r *roleRepository) Create(ctx context.Context, role *domainProfile.Role) error {
now := time.Now()
model := &RoleModel{
ID: role.ID,
Title: role.Title,
CreatedAt: now,
UpdatedAt: now,
}
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
return err
}
return nil
}
func (r *roleRepository) Update(ctx context.Context, role *domainProfile.Role) error {
result := r.db.WithContext(ctx).Model(&RoleModel{}).Where("id = ?", role.ID).Updates(map[string]interface{}{"title": role.Title})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return domainProfile.ErrRoleNotFound
}
return nil
}
func (r *roleRepository) Delete(ctx context.Context, id uuid.UUID) error {
result := r.db.WithContext(ctx).Delete(&RoleModel{}, "id = ?", id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return domainProfile.ErrRoleNotFound
}
return nil
}

View File

@@ -0,0 +1,134 @@
package profile
import (
"context"
"sync"
"github.com/google/uuid"
domainProfile "base/internal/domain/profile"
)
// mockRoleData holds the mocked profile roles (matches seed data).
var mockRoleData = []*domainProfile.Role{
{ID: uuid.MustParse("0199b964-5dc0-7657-9178-2a844e23e5b5"), Title: "Data Scientist"},
{ID: uuid.MustParse("0199b964-5dc0-7a1a-94c7-d68daf420e50"), Title: "Machine Learning Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-7759-8221-71f57f5b2b57"), Title: "AI Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-7b79-a268-331f39c35366"), Title: "Data Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-7062-b219-11733a1ab94b"), Title: "Data Analyst"},
{ID: uuid.MustParse("0199b964-5dc0-7434-b105-f2ff49573fe2"), Title: "Business Intelligence Developer"},
{ID: uuid.MustParse("0199b964-5dc0-77f8-be02-f76937f60ba6"), Title: "MLOps Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-7107-907c-6c013cbc08b9"), Title: "AI Product Manager"},
{ID: uuid.MustParse("0199b964-5dc0-72f9-8e0f-dfa2950a8182"), Title: "AI Research Scientist"},
{ID: uuid.MustParse("0199b964-5dc0-7177-829b-f3d05081201e"), Title: "Computer Vision Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-74b7-b427-a500ddb9f435"), Title: "NLP Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-780d-876f-a7b4d15b0ef5"), Title: "Data Architect"},
{ID: uuid.MustParse("0199b964-5dc0-7d3f-af44-19dc33f50b21"), Title: "Big Data Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-7600-9a16-74f17be7ce4b"), Title: "Cloud AI/ML Specialist"},
{ID: uuid.MustParse("0199b964-5dc0-73c2-b9a0-78347ae945d7"), Title: "Generative AI Specialist"},
{ID: uuid.MustParse("0199b964-5dc0-70a8-b710-1f424a776083"), Title: "AI Ethics Officer"},
{ID: uuid.MustParse("0199b964-5dc0-7c87-91c0-348e6f8b43d6"), Title: "AI Governance Manager"},
{ID: uuid.MustParse("0199b964-5dc0-7441-b306-bc2e3d4e4152"), Title: "Data Privacy Engineer"},
{ID: uuid.MustParse("0199b964-5dc0-747f-97b4-c4d98a257dee"), Title: "AI Solutions Architect"},
{ID: uuid.MustParse("0199b964-5dc0-7fa5-8fe0-9eb7831554ed"), Title: "Chief Data & AI Officer"},
{ID: uuid.MustParse("0199b964-5dc0-7447-8785-f246ff9ec309"), Title: "AI Developer Advocate"},
{ID: uuid.MustParse("0199b964-5dc0-7b24-9b1b-c7ca8f08527f"), Title: "AI/ML Educator & Trainer"},
{ID: uuid.MustParse("0199b964-5dc0-756f-ab44-48169ecfbb5e"), Title: "Technical Content Creator (AI/ML)"},
{ID: uuid.MustParse("0199b964-5dc0-79d1-9086-c809d8989cac"), Title: "Open Source AI Contributor"},
{ID: uuid.MustParse("0199b964-5dc0-774e-9011-b9fe6c29f52f"), Title: "AI Course Instructor (Udemy, Coursera, etc.)"},
{ID: uuid.MustParse("0199b964-5dc0-7f1d-80a4-96810af9f9ac"), Title: "AI Community Manager"},
{ID: uuid.MustParse("0199b964-5dc0-7352-8553-edd37324ffd9"), Title: "AI Evangelist"},
{ID: uuid.MustParse("0199b964-5dc0-7864-a2b5-473cfd8f7aa0"), Title: "Research Engineer (applied AI research, publishing GitHub repos)"},
{ID: uuid.MustParse("0199b964-5dc0-762e-9a40-0cc112578498"), Title: "Kaggle Competitor / Data Science Challenger"},
{ID: uuid.MustParse("0199b964-5dc0-7e13-a1f4-b4ae76bb0b62"), Title: "AI Startup Founder / Indie Hacker (building projects, sharing repos)"},
{ID: uuid.MustParse("0199b964-5dc0-7035-bf9b-deb415d852fd"), Title: "Freelancer"},
{ID: uuid.MustParse("0199b964-5dc0-7702-b533-72f7c93e19d3"), Title: "Other"},
}
// mockRoleRepository returns mocked profile roles (no DB).
type mockRoleRepository struct {
mu sync.RWMutex
data []*domainProfile.Role
}
// NewMockRoleRepository creates a RoleRepository that returns mocked data.
func NewMockRoleRepository() domainProfile.RoleRepository {
data := make([]*domainProfile.Role, len(mockRoleData))
for i, r := range mockRoleData {
data[i] = &domainProfile.Role{ID: r.ID, Title: r.Title}
}
return &mockRoleRepository{data: data}
}
func (r *mockRoleRepository) FindByID(ctx context.Context, id uuid.UUID) (*domainProfile.Role, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, role := range r.data {
if role.ID == id {
return &domainProfile.Role{ID: role.ID, Title: role.Title}, nil
}
}
return nil, domainProfile.ErrRoleNotFound
}
func (r *mockRoleRepository) FindAll(ctx context.Context) ([]*domainProfile.Role, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*domainProfile.Role, len(r.data))
for i, role := range r.data {
out[i] = &domainProfile.Role{ID: role.ID, Title: role.Title}
}
return out, nil
}
func (r *mockRoleRepository) List(ctx context.Context, limit, offset int) ([]*domainProfile.Role, error) {
r.mu.RLock()
defer r.mu.RUnlock()
start := offset
if start > len(r.data) {
start = len(r.data)
}
end := start + limit
if limit <= 0 {
end = len(r.data)
} else if end > len(r.data) {
end = len(r.data)
}
slice := r.data[start:end]
out := make([]*domainProfile.Role, len(slice))
for i, role := range slice {
out[i] = &domainProfile.Role{ID: role.ID, Title: role.Title}
}
return out, nil
}
func (r *mockRoleRepository) Create(ctx context.Context, role *domainProfile.Role) error {
r.mu.Lock()
defer r.mu.Unlock()
r.data = append(r.data, &domainProfile.Role{ID: role.ID, Title: role.Title})
return nil
}
func (r *mockRoleRepository) Update(ctx context.Context, role *domainProfile.Role) error {
r.mu.Lock()
defer r.mu.Unlock()
for i, existing := range r.data {
if existing.ID == role.ID {
r.data[i] = &domainProfile.Role{ID: role.ID, Title: role.Title}
return nil
}
}
return domainProfile.ErrRoleNotFound
}
func (r *mockRoleRepository) Delete(ctx context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
for i, role := range r.data {
if role.ID == id {
r.data = append(r.data[:i], r.data[i+1:]...)
return nil
}
}
return domainProfile.ErrRoleNotFound
}

View File

@@ -0,0 +1,106 @@
package profile
import (
"encoding/json"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Model struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
UserID *uuid.UUID `gorm:"column:user_id;type:uuid;index:profiles_user_id_idx"`
Handle string `gorm:"column:handle;type:text;not null;uniqueIndex:profiles_handle_unique"`
// Hero fields (normalized for search)
RoleID *uuid.UUID `gorm:"column:role_id;type:uuid;index:profiles_role_id_idx"`
Role *RoleModel `gorm:"foreignKey:RoleID"`
RoleName *string `gorm:"column:role_name;type:varchar(100)"` // denormalized fallback
RoleLevel string `gorm:"column:role_level;type:text"`
FirstName string `gorm:"column:first_name;type:text;index:profiles_name_idx"`
LastName string `gorm:"column:last_name;type:text;index:profiles_name_idx"`
Company string `gorm:"column:company;type:text;index:profiles_company_idx"`
ShortDescription string `gorm:"column:short_description;type:text"`
ResumeLink string `gorm:"column:resume_link;type:text"`
CTAEnabled bool `gorm:"column:cta_enabled;type:boolean;default:false"`
Avatar string `gorm:"column:avatar;type:text"`
// About fields (normalized for search)
ProfilePicture string `gorm:"column:profile_picture;type:text"`
About string `gorm:"column:about;type:text"`
// Contact fields (normalized for search)
Email string `gorm:"column:email;type:text;index:profiles_email_idx"`
Phone string `gorm:"column:phone;type:text"`
// PageSetting fields (normalized)
VisibilityLevel string `gorm:"column:visibility_level;type:text;default:'public'"`
// Complex/non-searchable data stored as JSONB
PageSectionOrder json.RawMessage `gorm:"column:page_section_order;type:jsonb"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (Model) TableName() string {
return "profiles"
}
type SkillModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
ProfileID uuid.UUID `gorm:"column:profile_id;type:uuid;not null;index:skills_profile_id_idx"`
SkillName string `gorm:"column:skill_name;type:text;not null;index:skills_name_idx"`
Level string `gorm:"column:level;type:text;not null"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (SkillModel) TableName() string {
return "profile_skills"
}
type SocialLinkModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
ProfileID uuid.UUID `gorm:"column:profile_id;type:uuid;not null;index:social_links_profile_id_idx"`
LinkType string `gorm:"column:link_type;type:text;not null"`
Link string `gorm:"column:link;type:text;not null"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (SocialLinkModel) TableName() string {
return "profile_social_links"
}
type AchievementModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
ProfileID uuid.UUID `gorm:"column:profile_id;type:uuid;not null;index:achievements_profile_id_idx"`
Title string `gorm:"column:title;type:text;not null"`
Value string `gorm:"column:value;type:text;not null"`
Enabled bool `gorm:"column:enabled;type:boolean;default:true"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (AchievementModel) TableName() string {
return "profile_achievements"
}
// RoleModel maps profile_roles table (profiles.role_id references this)
type RoleModel struct {
ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"`
Title string `gorm:"column:title;type:text;not null"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (RoleModel) TableName() string {
return "profile_roles"
}

View File

@@ -0,0 +1,107 @@
package profile
import (
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
domainProfile "base/internal/domain/profile"
)
// setupTestDB creates an in-memory SQLite database for testing
func setupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
// Create tables manually with SQLite-compatible syntax
// This avoids PostgreSQL-specific syntax like gen_random_uuid() and timestamptz
createProfilesTable := `
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
user_id TEXT,
handle TEXT NOT NULL,
role_id TEXT,
role_name TEXT,
first_name TEXT,
last_name TEXT,
company TEXT,
short_description TEXT,
resume_link TEXT,
cta_enabled INTEGER NOT NULL DEFAULT 0,
avatar TEXT,
profile_picture TEXT,
about TEXT,
email TEXT,
phone TEXT,
visibility_level TEXT NOT NULL DEFAULT 'public',
page_section_order TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
UNIQUE(handle)
)
`
require.NoError(t, db.Exec(createProfilesTable).Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS profiles_user_id_idx ON profiles(user_id)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS profiles_role_id_idx ON profiles(role_id)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS profiles_name_idx ON profiles(first_name, last_name)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS profiles_company_idx ON profiles(company)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS profiles_email_idx ON profiles(email)").Error)
createProfileSkillsTable := `
CREATE TABLE IF NOT EXISTS profile_skills (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
skill_name TEXT NOT NULL,
level TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
)
`
require.NoError(t, db.Exec(createProfileSkillsTable).Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS skills_profile_id_idx ON profile_skills(profile_id)").Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS skills_name_idx ON profile_skills(skill_name)").Error)
createProfileSocialLinksTable := `
CREATE TABLE IF NOT EXISTS profile_social_links (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
link_type TEXT NOT NULL,
link TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
)
`
require.NoError(t, db.Exec(createProfileSocialLinksTable).Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS social_links_profile_id_idx ON profile_social_links(profile_id)").Error)
createProfileAchievementsTable := `
CREATE TABLE IF NOT EXISTS profile_achievements (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
title TEXT NOT NULL,
value TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
)
`
require.NoError(t, db.Exec(createProfileAchievementsTable).Error)
require.NoError(t, db.Exec("CREATE INDEX IF NOT EXISTS achievements_profile_id_idx ON profile_achievements(profile_id)").Error)
return db
}
// createTestProfileRepository creates a profile repository for testing
func createTestProfileRepository(db *gorm.DB) domainProfile.Repository {
return &profileRepository{db: db}
}

View File

@@ -0,0 +1,20 @@
package skill
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type SkillModel struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Name string `gorm:"column:name;type:text;not null"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamptz;index"`
}
func (SkillModel) TableName() string {
return "skills"
}

View File

@@ -0,0 +1,49 @@
package skill
import (
"context"
"errors"
"github.com/google/uuid"
"go.uber.org/fx"
"gorm.io/gorm"
domainSkill "base/internal/domain/skill"
)
type repository struct {
db *gorm.DB
}
// NewRepository creates a Repository for the skills catalog.
func NewRepository(lc fx.Lifecycle, db *gorm.DB) domainSkill.Repository {
lc.Append(
fx.Hook{
OnStart: func(ctx context.Context) error { return nil },
OnStop: func(ctx context.Context) error { return nil },
})
return &repository{db: db}
}
func (r *repository) FindAll(ctx context.Context) ([]*domainSkill.Skill, error) {
var models []SkillModel
if err := r.db.WithContext(ctx).Order("name ASC").Find(&models).Error; err != nil {
return nil, err
}
out := make([]*domainSkill.Skill, len(models))
for i := range models {
out[i] = &domainSkill.Skill{ID: models[i].ID, Name: models[i].Name}
}
return out, nil
}
func (r *repository) FindByID(ctx context.Context, id uuid.UUID) (*domainSkill.Skill, error) {
var model SkillModel
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&model).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &domainSkill.Skill{ID: model.ID, Name: model.Name}, nil
}