From 1dbd96eebd9a3a30d494c8d19cba1ae4011d8065 Mon Sep 17 00:00:00 2001 From: areeqakbr Date: Sat, 11 Apr 2026 14:25:12 +0700 Subject: [PATCH] adding radiation odp search --- AGENTS.md | 66 +++ delivery/controller/AGENTS.md | 30 ++ .../controller/nearest_device_controller.go | 281 +++++++------ model/AGENTS.md | 14 + model/dto/req/nearest_devices.go | 17 +- repository/AGENTS.md | 20 + repository/nearest_device_repo.go | 237 ++++++----- usecase/AGENTS.md | 24 ++ usecase/nearest_device.go | 271 ++++++------- usecase/nearest_device_test.go | 381 ++++++++++++++++++ utils/helper/AGENTS.md | 25 ++ 11 files changed, 981 insertions(+), 385 deletions(-) create mode 100644 AGENTS.md create mode 100644 delivery/controller/AGENTS.md create mode 100644 model/AGENTS.md create mode 100644 repository/AGENTS.md create mode 100644 usecase/AGENTS.md create mode 100644 usecase/nearest_device_test.go create mode 100644 utils/helper/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4d7d2e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,66 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-04-11 +**Commit:** 3d5ce0d +**Branch:** dev + +## OVERVIEW +Go backend API for telecom network asset management (NAM). Manages ODP/OTB/OLT/Tower infrastructure with Gin + GORM. + +## STRUCTURE +``` +backend_nam/ +├── config/ # Environment config (DB, JWT, API) +├── delivery/ # HTTP layer (server.go, controllers/) +├── manager/ # Dependency injection wiring +├── middleware/ # Auth, CORS, RBAC, rate limiting, logging +├── model/ # DTOs (req/res) + Entities +├── repository/ # Database operations +├── usecase/ # Business logic +├── utils/ # Helpers, services, migrations, common +├── main.go # Entry point +└── go.mod +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Add API endpoint | delivery/controller/ | New {name}_controller.go | +| Business logic | usecase/ | New {name}_usecase.go | +| DB operations | repository/ | New {name}_repo.go | +| Data models | model/entity/ | GORM structs | +| Config changes | config/config.go | env vars | + +## CONVENTIONS +- **Package path**: `users_management/m/...` +- **File naming**: snake_case (`device_usecase.go`) +- **Interface naming**: `{Entity}Repo`, `{Entity}UseCase` +- **Constructor**: `New{Entity}{Layer}(deps) *{entity}{Layer}` +- **GORM tags**: `json:"field" gorm:"type:uuid;primaryKey"` +- **Response format**: `common.SingleResponses(c, "message", data)` +- **Error format**: `common.ErrorResponses(c, http.StatusBadRequest, "message")` + +## ANTI-PATTERNS (THIS PROJECT) +- **DO NOT** use `as any` - strict typing required +- **DO NOT** skip validation in usecases - use `validator.New()` +- **DO NOT** commit `.env` files +- **DO NOT** hardcode file paths - use constants in helper/ + +## UNIQUE STYLES +- **Auth middleware**: Conditional based on `USER_AUTH_ENABLED` env var +- **Image handling**: Local filesystem at `./uploads/` + `/uploads/` static route +- **Bulk operations**: Return `{Successful, Failed, TotalRequested, Errors[], Results[]}` +- **DevicePort auto-create**: Created automatically when Device is created + +## COMMANDS +```bash +go run main.go # Local dev +go build -o main . # Build binary +docker build -t nam-backend . # Docker build +``` + +## NOTES +- Port: `:5678` (Dockerfile EXPOSE) +- UUID primary keys everywhere +- Geocoding service with caching layer +- Activity logging middleware captures all mutations diff --git a/delivery/controller/AGENTS.md b/delivery/controller/AGENTS.md new file mode 100644 index 0000000..5a5187b --- /dev/null +++ b/delivery/controller/AGENTS.md @@ -0,0 +1,30 @@ +## OVERVIEW +High-complexity controller layer: 17 controllers wired to usecases, router groups, and config. + +## WHERE TO LOOK +The 17 controller files: +- activity_logs_controller.go +- auth_controller.go +- backbone_controller.go +- cable_connections_controller.go +- count_assets_controller.go +- creatin_admin.go +- device_details.go +- device_inspections_controller.go +- devicePort_controller.go +- devices_controller.go +- fishbone_controller.go +- nearest_device_controller.go +- olt_controller.go +- tower_controller.go +- user_management_controller.go +- user_registration.go +- users_controller.go + +Pattern: struct {Entity}Controller, New{Entity}Controller(usecase, routerGroup, config), Route() method. + +## ANTI-PATTERNS +- Put business logic in controllers; delegate to usecases +- Access DB directly in controllers +- Skip input validation; use validator +- Duplicate route wiring diff --git a/delivery/controller/nearest_device_controller.go b/delivery/controller/nearest_device_controller.go index 08986c0..405fa66 100644 --- a/delivery/controller/nearest_device_controller.go +++ b/delivery/controller/nearest_device_controller.go @@ -14,161 +14,196 @@ import ( ) type NearestDeviceController struct { - nearestDeviceUC usecase.NearestDeviceUseCase - rg *gin.RouterGroup - cfg *config.Config + nearestDeviceUC usecase.NearestDeviceUseCase + rg *gin.RouterGroup + cfg *config.Config } func NewNearestDeviceController(nearestDeviceUC usecase.NearestDeviceUseCase, rg *gin.RouterGroup, cfg *config.Config) *NearestDeviceController { - return &NearestDeviceController{ - nearestDeviceUC: nearestDeviceUC, - rg: rg, - cfg: cfg, - } + return &NearestDeviceController{ + nearestDeviceUC: nearestDeviceUC, + rg: rg, + cfg: cfg, + } } func (c *NearestDeviceController) Route() { - nearestDevices := c.rg.Group("/nearest-devices") - nearestDevices.Use(middleware.ConditionalRequireAnyRole(c.cfg,"Teknisi", "Admin", "Super Admin")) - { - nearestDevices.POST("/search", c.getNearestDevices) - nearestDevices.GET("/:id", c.getNearestDeviceByID) + nearestDevices := c.rg.Group("/nearest-devices") + nearestDevices.Use(middleware.ConditionalRequireAnyRole(c.cfg, "Teknisi", "Admin", "Super Admin")) + { + nearestDevices.POST("/search", c.getNearestDevices) + nearestDevices.GET("/:id", c.getNearestDeviceByID) - nearestDevices.POST("/towers/search", c.getNearestTowers) - nearestDevices.GET("/towers/:id", c.getNearestTowerByID) - } + nearestDevices.POST("/towers/search", c.getNearestTowers) + nearestDevices.GET("/towers/:id", c.getNearestTowerByID) + + nearestDevices.POST("/odp/search", c.getNearestODPDevices) + } } func (c *NearestDeviceController) getNearestTowers(ctx *gin.Context) { - var request req.NearestTowerDTO - if err := ctx.ShouldBindJSON(&request); err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) - return - } + var request req.NearestTowerDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } - towers, err := c.nearestDeviceUC.GetNearestTowers(request) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) - return - } + towers, err := c.nearestDeviceUC.GetNearestTowers(request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } - response := gin.H{ - "towers": towers, - "total": len(towers), - "search_params": gin.H{ - "latitude": request.Latitude, - "longitude": request.Longitude, - "radius": request.Radius, - "province": request.Province, - "city": request.City, - "district": request.District, - }, - } + response := gin.H{ + "towers": towers, + "total": len(towers), + "search_params": gin.H{ + "latitude": request.Latitude, + "longitude": request.Longitude, + "radius": request.Radius, + "province": request.Province, + "city": request.City, + "district": request.District, + }, + } - common.SingleResponses(ctx, "Nearest towers retrieved successfully", response) + common.SingleResponses(ctx, "Nearest towers retrieved successfully", response) } func (c *NearestDeviceController) getNearestTowerByID(ctx *gin.Context) { - id := ctx.Param("id") - towerID, err := uuid.Parse(id) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid tower ID") - return - } + id := ctx.Param("id") + towerID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid tower ID") + return + } - // Get user coordinates from query params - latStr := ctx.Query("lat") - lngStr := ctx.Query("lng") - - if latStr == "" || lngStr == "" { - common.ErrorResponses(ctx, http.StatusBadRequest, "User coordinates (lat, lng) are required") - return - } + // Get user coordinates from query params + latStr := ctx.Query("lat") + lngStr := ctx.Query("lng") - userLat, err := strconv.ParseFloat(latStr, 64) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude") - return - } + if latStr == "" || lngStr == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "User coordinates (lat, lng) are required") + return + } - userLng, err := strconv.ParseFloat(lngStr, 64) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude") - return - } + userLat, err := strconv.ParseFloat(latStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude") + return + } - tower, err := c.nearestDeviceUC.GetNearestTowerByID(towerID, userLat, userLng) - if err != nil { - common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) - return - } + userLng, err := strconv.ParseFloat(lngStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude") + return + } - common.SingleResponses(ctx, "Tower details retrieved successfully", tower) + tower, err := c.nearestDeviceUC.GetNearestTowerByID(towerID, userLat, userLng) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + + common.SingleResponses(ctx, "Tower details retrieved successfully", tower) } func (c *NearestDeviceController) getNearestDevices(ctx *gin.Context) { - var request req.NearestDeviceDTO - if err := ctx.ShouldBindJSON(&request); err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) - return - } + var request req.NearestDeviceDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } - devices, err := c.nearestDeviceUC.GetNearestDevices(request) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) - return - } + devices, err := c.nearestDeviceUC.GetNearestDevices(request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } - response := gin.H{ - "devices": devices, - "total": len(devices), - "search_params": gin.H{ - "latitude": request.Latitude, - "longitude": request.Longitude, - "radius": request.Radius, - "province": request.Province, - "city": request.City, - "district": request.District, - }, - } + response := gin.H{ + "devices": devices, + "total": len(devices), + "search_params": gin.H{ + "latitude": request.Latitude, + "longitude": request.Longitude, + "radius": request.Radius, + "province": request.Province, + "city": request.City, + "district": request.District, + }, + } - common.SingleResponses(ctx, "Nearest devices retrieved successfully", response) + common.SingleResponses(ctx, "Nearest devices retrieved successfully", response) } func (c *NearestDeviceController) getNearestDeviceByID(ctx *gin.Context) { - id := ctx.Param("id") - deviceID, err := uuid.Parse(id) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") - return - } + id := ctx.Param("id") + deviceID, err := uuid.Parse(id) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid device ID") + return + } - // Get user coordinates from query params - latStr := ctx.Query("lat") - lngStr := ctx.Query("lng") - - if latStr == "" || lngStr == "" { - common.ErrorResponses(ctx, http.StatusBadRequest, "User coordinates (lat, lng) are required") - return - } + // Get user coordinates from query params + latStr := ctx.Query("lat") + lngStr := ctx.Query("lng") - userLat, err := strconv.ParseFloat(latStr, 64) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude") - return - } + if latStr == "" || lngStr == "" { + common.ErrorResponses(ctx, http.StatusBadRequest, "User coordinates (lat, lng) are required") + return + } - userLng, err := strconv.ParseFloat(lngStr, 64) - if err != nil { - common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude") - return - } + userLat, err := strconv.ParseFloat(latStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid latitude") + return + } - device, err := c.nearestDeviceUC.GetNearestDeviceByID(deviceID, userLat, userLng) - if err != nil { - common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) - return - } + userLng, err := strconv.ParseFloat(lngStr, 64) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, "Invalid longitude") + return + } - common.SingleResponses(ctx, "Device details retrieved successfully", device) -} \ No newline at end of file + device, err := c.nearestDeviceUC.GetNearestDeviceByID(deviceID, userLat, userLng) + if err != nil { + common.ErrorResponses(ctx, http.StatusNotFound, err.Error()) + return + } + + common.SingleResponses(ctx, "Device details retrieved successfully", device) +} + +func (c *NearestDeviceController) getNearestODPDevices(ctx *gin.Context) { + var request req.NearestDeviceDTO + if err := ctx.ShouldBindJSON(&request); err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + odpType := "ODP" + request.DeviceType = &odpType + + devices, err := c.nearestDeviceUC.GetNearestDevices(request) + if err != nil { + common.ErrorResponses(ctx, http.StatusBadRequest, err.Error()) + return + } + + response := gin.H{ + "devices": devices, + "total": len(devices), + "search_params": gin.H{ + "latitude": request.Latitude, + "longitude": request.Longitude, + "radius": request.Radius, + "device_type": "ODP", + "province": request.Province, + "city": request.City, + "district": request.District, + }, + } + + common.SingleResponses(ctx, "Nearest ODP devices retrieved successfully", response) +} diff --git a/model/AGENTS.md b/model/AGENTS.md new file mode 100644 index 0000000..60e1f83 --- /dev/null +++ b/model/AGENTS.md @@ -0,0 +1,14 @@ +# OVERVIEW +Provides domain entities, DTOs, and ORM mappings for NAM's data model. + +# STRUCTURE +- dto/req: Request DTOs with validation tags for incoming data. +- dto/res: Response DTOs for outgoing data. +- entity: GORM-based domain entities located in model/entity/. + +# CONVENTIONS +- UUID primary keys are defined with gorm:"type:uuid;primaryKey" on ID fields. +- DTOs in model/dto/req/ use validation tags (e.g., validate:"required", binding:"required") and JSON tags. +- Response DTOs in model/dto/res/ carry serialized data with appropriate JSON tags. +- Tables names are controlled by a TableName() string method on each entity. +- Entities are in model/entity/ with GORM tags; DTOs reflect input/output shapes for API boundaries. diff --git a/model/dto/req/nearest_devices.go b/model/dto/req/nearest_devices.go index 1313e82..bb1390a 100644 --- a/model/dto/req/nearest_devices.go +++ b/model/dto/req/nearest_devices.go @@ -1,11 +1,12 @@ package req type NearestDeviceDTO struct { - Longitude float64 `json:"longitude" validate:"required"` - Latitude float64 `json:"latitude" validate:"required"` - Radius float64 `json:"radius" validate:"omitempty,min=0.1,max=50"` // Default 5km, max 50km - Limit int `json:"limit" validate:"omitempty,min=1,max=100"` // Default 10, max 100 - Province *string `json:"province,omitempty"` - City *string `json:"city,omitempty"` - District *string `json:"district,omitempty"` -} \ No newline at end of file + Longitude float64 `json:"longitude" validate:"required"` + Latitude float64 `json:"latitude" validate:"required"` + Radius float64 `json:"radius" validate:"omitempty,min=0.1,max=50"` // Default 5km, max 50km + Limit int `json:"limit" validate:"omitempty,min=1,max=100"` // Default 10, max 100 + Province *string `json:"province,omitempty"` + City *string `json:"city,omitempty"` + District *string `json:"district,omitempty"` + DeviceType *string `json:"device_type,omitempty"` +} diff --git a/repository/AGENTS.md b/repository/AGENTS.md new file mode 100644 index 0000000..afd3ffb --- /dev/null +++ b/repository/AGENTS.md @@ -0,0 +1,20 @@ +## OVERVIEW +Repository layer for NAM with 13 repos using interface-driven design and GORM transactions. + +## WHERE TO LOOK +- repository/ for all implementations +- Interface: {Entity}Repo, Constructor: New{Entity}Repo(db *gorm.DB) +- Transaction pattern: db.Transaction(func(tx *gorm.DB) error {...}) +- Files: devices_repo.go, users_repo.go, tower_repo.go, olt_repo.go, etc. + +## CONVENTIONS +- Interface naming: {Entity}Repo +- Constructor: New{Entity}Repo(db *gorm.DB) {Entity}Repo +- Return (data, error) pairs +- Use wrapped errors for context + +## ANTI-PATTERNS +- Skip transactions for mutating operations +- Return nil error on failures +- Embed business logic in repos +- Instantiate DB connections inside repos diff --git a/repository/nearest_device_repo.go b/repository/nearest_device_repo.go index 75865f5..f7ecc37 100644 --- a/repository/nearest_device_repo.go +++ b/repository/nearest_device_repo.go @@ -1,159 +1,158 @@ package repository import ( - "users_management/m/model/entity" - "github.com/google/uuid" - "gorm.io/gorm" + "github.com/google/uuid" + "gorm.io/gorm" + "users_management/m/model/entity" ) type NearestDeviceRepo interface { - GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.DeviceWithDistance, error) - GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) - GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) - GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) - GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) - CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err error) - GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error) - GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) - + GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) + GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) + GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) + GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) + GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) + CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err error) + GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error) + GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) } type nearestDeviceRepo struct { - db *gorm.DB + db *gorm.DB } func NewNearestDeviceRepo(db *gorm.DB) NearestDeviceRepo { - return &nearestDeviceRepo{ - db: db, - } + return &nearestDeviceRepo{ + db: db, + } } func (r *nearestDeviceRepo) GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error) { - var towers []entity.TowerWithDistance - - // Build the subquery first - join with devices to get location filters - subQuery := r.db.Table("towers"). - Select(`towers.id, towers.tower_code, towers.longitude, towers.latitude, + var towers []entity.TowerWithDistance + + // Build the subquery first - join with devices to get location filters + subQuery := r.db.Table("towers"). + Select(`towers.id, towers.tower_code, towers.longitude, towers.latitude, towers.image_url, towers.external_tower, towers.dev_id, towers.created_at, towers.updated_at, devices.device_code, devices.province, devices.city, devices.district, (6371 * acos(cos(radians(?)) * cos(radians(towers.latitude)) * cos(radians(towers.longitude) - radians(?)) + sin(radians(?)) * sin(radians(towers.latitude)))) AS distance`, - latitude, longitude, latitude). - Joins("LEFT JOIN devices ON towers.dev_id = devices.id") - - // Apply location filters to subquery - if province != nil && *province != "" { - subQuery = subQuery.Where("devices.province = ?", *province) - } - if city != nil && *city != "" { - subQuery = subQuery.Where("devices.city = ?", *city) - } - if district != nil && *district != "" { - subQuery = subQuery.Where("devices.district = ?", *district) - } - - // Use the subquery in the main query - err := r.db.Table("(?) as towers_with_distance", subQuery). - Where("distance <= ?", radius). - Order("distance ASC"). - Limit(limit). - Scan(&towers).Error - - return towers, err + latitude, longitude, latitude). + Joins("LEFT JOIN devices ON towers.dev_id = devices.id") + + // Apply location filters to subquery + if province != nil && *province != "" { + subQuery = subQuery.Where("devices.province = ?", *province) + } + if city != nil && *city != "" { + subQuery = subQuery.Where("devices.city = ?", *city) + } + if district != nil && *district != "" { + subQuery = subQuery.Where("devices.district = ?", *district) + } + + // Use the subquery in the main query + err := r.db.Table("(?) as towers_with_distance", subQuery). + Where("distance <= ?", radius). + Order("distance ASC"). + Limit(limit). + Scan(&towers).Error + + return towers, err } func (r *nearestDeviceRepo) GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) { - var tower entity.Tower - err := r.db.Preload("Device").Where("id = ?", id).First(&tower).Error - return tower, err + var tower entity.Tower + err := r.db.Preload("Device").Where("id = ?", id).First(&tower).Error + return tower, err } -func (r *nearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.DeviceWithDistance, error) { - var devices []entity.DeviceWithDistance - - // Build the subquery first - subQuery := r.db.Table("devices"). - Select(`id, device_code, device_type, longitude, latitude, port_amount, status, +func (r *nearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + var devices []entity.DeviceWithDistance + + subQuery := r.db.Table("devices"). + Select(`id, device_code, device_type, longitude, latitude, port_amount, status, region, province, city, district, image_url, created_at, updated_at, (6371 * acos(cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude)))) AS distance`, - latitude, longitude, latitude) - - // Apply location filters to subquery - if province != nil && *province != "" { - subQuery = subQuery.Where("province = ?", *province) - } - if city != nil && *city != "" { - subQuery = subQuery.Where("city = ?", *city) - } - if district != nil && *district != "" { - subQuery = subQuery.Where("district = ?", *district) - } - - // Use the subquery in the main query - err := r.db.Table("(?) as devices_with_distance", subQuery). - Where("distance <= ?", radius). - Order("distance ASC"). - Limit(limit). - Scan(&devices).Error - - return devices, err + latitude, longitude, latitude) + + if province != nil && *province != "" { + subQuery = subQuery.Where("province = ?", *province) + } + if city != nil && *city != "" { + subQuery = subQuery.Where("city = ?", *city) + } + if district != nil && *district != "" { + subQuery = subQuery.Where("district = ?", *district) + } + if deviceType != nil && *deviceType != "" { + subQuery = subQuery.Where("device_type = ?", *deviceType) + } + + err := r.db.Table("(?) as devices_with_distance", subQuery). + Where("distance <= ?", radius). + Order("distance ASC"). + Limit(limit). + Scan(&devices).Error + + return devices, err } func (r *nearestDeviceRepo) GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) { - var device entity.Device - err := r.db.Where("id = ?", id).First(&device).Error - return device, err + var device entity.Device + err := r.db.Where("id = ?", id).First(&device).Error + return device, err } func (r *nearestDeviceRepo) GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) { - var backbones []entity.Backbone - err := r.db.Preload("DeviceStart").Preload("DeviceEnd"). - Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). - Find(&backbones).Error - return backbones, err + var backbones []entity.Backbone + err := r.db.Preload("DeviceStart").Preload("DeviceEnd"). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Find(&backbones).Error + return backbones, err } func (r *nearestDeviceRepo) GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) { - var fishbones []entity.Fishbone - err := r.db.Preload("Backbone").Preload("DeviceStart").Preload("DeviceEnd"). - Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). - Find(&fishbones).Error - return fishbones, err + var fishbones []entity.Fishbone + err := r.db.Preload("Backbone").Preload("DeviceStart").Preload("DeviceEnd"). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Find(&fishbones).Error + return fishbones, err } func (r *nearestDeviceRepo) GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) { - var towers []entity.Tower - err := r.db.Preload("Device"). - Where("dev_id = ?", deviceID). - Find(&towers).Error - return towers, err + var towers []entity.Tower + err := r.db.Preload("Device"). + Where("dev_id = ?", deviceID). + Find(&towers).Error + return towers, err } func (r *nearestDeviceRepo) CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err error) { - var backboneCountInt64, fishboneCountInt64, towerCountInt64 int64 - - // Count backbones - err = r.db.Model(&entity.Backbone{}). - Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). - Count(&backboneCountInt64).Error - if err != nil { - return 0, 0, 0, err - } - - // Count fishbones - err = r.db.Model(&entity.Fishbone{}). - Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). - Count(&fishboneCountInt64).Error - if err != nil { - return 0, 0, 0, err - } - - // Count towers - err = r.db.Model(&entity.Tower{}). - Where("dev_id = ?", deviceID). - Count(&towerCountInt64).Error - if err != nil { - return 0, 0, 0, err - } - - return int(backboneCountInt64), int(fishboneCountInt64), int(towerCountInt64), nil -} \ No newline at end of file + var backboneCountInt64, fishboneCountInt64, towerCountInt64 int64 + + // Count backbones + err = r.db.Model(&entity.Backbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Count(&backboneCountInt64).Error + if err != nil { + return 0, 0, 0, err + } + + // Count fishbones + err = r.db.Model(&entity.Fishbone{}). + Where("dev_start_id = ? OR dev_end_id = ?", deviceID, deviceID). + Count(&fishboneCountInt64).Error + if err != nil { + return 0, 0, 0, err + } + + // Count towers + err = r.db.Model(&entity.Tower{}). + Where("dev_id = ?", deviceID). + Count(&towerCountInt64).Error + if err != nil { + return 0, 0, 0, err + } + + return int(backboneCountInt64), int(fishboneCountInt64), int(towerCountInt64), nil +} diff --git a/usecase/AGENTS.md b/usecase/AGENTS.md new file mode 100644 index 0000000..77fe329 --- /dev/null +++ b/usecase/AGENTS.md @@ -0,0 +1,24 @@ +OVERVIEW +Scaffold for 14 high‑complexity use cases with strict interfaces and DI. + +WHERE TO LOOK +- usecase/ directory for interfaces and New{Entity}UseCase constructors +- repository/ for repository interfaces consumed by use cases +- model/ for DTOs and domain entities used by use cases +- manager/ for DI wiring examples (injecting GeocodingService) +- config/ or utils/ for validator setup references invoked by use cases + +CONVENTIONS +- Use case interfaces define all methods for an entity +- Constructor: New{Entity}UseCase(repo, geocoder) returns {Entity}UseCase interface +- Validation uses validator.New() inside use cases +- GeocodingService is injected via dependency injection (not instantiated in use cases) +- File naming uses snake_case; interfaces named {Entity}UseCase / {Entity}Repo +- Tests follow existing project patterns and naming + +ANTI-PATTERNS +- Do not skip validation in use cases; always validate inputs via validator +- Do not couple to concrete repositories; depend on interfaces only +- Do not instantiate GeocodingService inside use cases; rely on DI +- Do not mix business logic into delivery layer +- Do not bypass existing patterns for separation of concerns (use cases separate from controllers) diff --git a/usecase/nearest_device.go b/usecase/nearest_device.go index c5dd4fd..97a2cf2 100644 --- a/usecase/nearest_device.go +++ b/usecase/nearest_device.go @@ -1,163 +1,164 @@ package usecase import ( - "math" - "users_management/m/model/dto/req" - "users_management/m/model/dto/res" - "users_management/m/repository" - "users_management/m/utils/helper" - "users_management/m/utils/service" - - "github.com/go-playground/validator/v10" - "github.com/google/uuid" + "math" + "users_management/m/model/dto/req" + "users_management/m/model/dto/res" + "users_management/m/repository" + "users_management/m/utils/helper" + "users_management/m/utils/service" + + "github.com/go-playground/validator/v10" + "github.com/google/uuid" ) type NearestDeviceUseCase interface { - GetNearestDevices(request req.NearestDeviceDTO) ([]res.NearestDeviceResponse, error) - GetNearestDeviceByID(id uuid.UUID, userLat, userLng float64) (res.NearestDeviceDetailResponse, error) - GetNearestTowers(request req.NearestTowerDTO) ([]res.NearestTowerResponse, error) - GetNearestTowerByID(id uuid.UUID, userLat, userLng float64) (res.NearestTowerDetailResponse, error) + GetNearestDevices(request req.NearestDeviceDTO) ([]res.NearestDeviceResponse, error) + GetNearestDeviceByID(id uuid.UUID, userLat, userLng float64) (res.NearestDeviceDetailResponse, error) + GetNearestTowers(request req.NearestTowerDTO) ([]res.NearestTowerResponse, error) + GetNearestTowerByID(id uuid.UUID, userLat, userLng float64) (res.NearestTowerDetailResponse, error) } type nearestDeviceUseCase struct { - nearestDeviceRepo repository.NearestDeviceRepo - geocoder service.GeocodingService - validate *validator.Validate + nearestDeviceRepo repository.NearestDeviceRepo + geocoder service.GeocodingService + validate *validator.Validate } func NewNearestDeviceUseCase(nearestDeviceRepo repository.NearestDeviceRepo, geocoder service.GeocodingService) NearestDeviceUseCase { - return &nearestDeviceUseCase{ - nearestDeviceRepo: nearestDeviceRepo, - geocoder: geocoder, - validate: validator.New(), - } + return &nearestDeviceUseCase{ + nearestDeviceRepo: nearestDeviceRepo, + geocoder: geocoder, + validate: validator.New(), + } } func (u *nearestDeviceUseCase) GetNearestTowers(request req.NearestTowerDTO) ([]res.NearestTowerResponse, error) { - err := u.validate.Struct(request) - if err != nil { - return nil, err - } - - // Set defaults - radius := request.Radius - if radius == 0 { - radius = 5.0 // Default 5km - } - - limit := request.Limit - if limit == 0 { - limit = 10 // Default 10 towers - } - - towers, err := u.nearestDeviceRepo.GetNearestTowers( - request.Longitude, - request.Latitude, - radius, - limit, - request.Province, - request.City, - request.District, - ) - if err != nil { - return nil, err - } - - responses, err := helper.ConvertToNearestTowerResponses(towers, u.geocoder) - if err != nil { - return nil, err - } - - return responses, nil + err := u.validate.Struct(request) + if err != nil { + return nil, err + } + + // Set defaults + radius := request.Radius + if radius == 0 { + radius = 5.0 // Default 5km + } + + limit := request.Limit + if limit == 0 { + limit = 10 // Default 10 towers + } + + towers, err := u.nearestDeviceRepo.GetNearestTowers( + request.Longitude, + request.Latitude, + radius, + limit, + request.Province, + request.City, + request.District, + ) + if err != nil { + return nil, err + } + + responses, err := helper.ConvertToNearestTowerResponses(towers, u.geocoder) + if err != nil { + return nil, err + } + + return responses, nil } func (u *nearestDeviceUseCase) GetNearestTowerByID(id uuid.UUID, userLat, userLng float64) (res.NearestTowerDetailResponse, error) { - tower, err := u.nearestDeviceRepo.GetTowerByIDWithConnections(id) - if err != nil { - return res.NearestTowerDetailResponse{}, err - } - - // Calculate distance - distance := calculateDistance(userLat, userLng, tower.Latitude, tower.Longitude) - - response, err := helper.ConvertToNearestTowerDetailResponse(tower, distance, u.nearestDeviceRepo, u.geocoder) - if err != nil { - return res.NearestTowerDetailResponse{}, err - } - - return response, nil + tower, err := u.nearestDeviceRepo.GetTowerByIDWithConnections(id) + if err != nil { + return res.NearestTowerDetailResponse{}, err + } + + // Calculate distance + distance := calculateDistance(userLat, userLng, tower.Latitude, tower.Longitude) + + response, err := helper.ConvertToNearestTowerDetailResponse(tower, distance, u.nearestDeviceRepo, u.geocoder) + if err != nil { + return res.NearestTowerDetailResponse{}, err + } + + return response, nil } func (u *nearestDeviceUseCase) GetNearestDevices(request req.NearestDeviceDTO) ([]res.NearestDeviceResponse, error) { - err := u.validate.Struct(request) - if err != nil { - return nil, err - } - - // Set defaults - radius := request.Radius - if radius == 0 { - radius = 5.0 // Default 5km - } - - limit := request.Limit - if limit == 0 { - limit = 10 // Default 10 devices - } - - devices, err := u.nearestDeviceRepo.GetNearestDevices( - request.Longitude, - request.Latitude, - radius, - limit, - request.Province, - request.City, - request.District, - ) - if err != nil { - return nil, err - } - - // Updated function call - removed userLat, userLng parameters since distance is already calculated - responses, err := helper.ConvertToNearestDeviceResponses(devices, u.nearestDeviceRepo, u.geocoder) - if err != nil { - return nil, err - } - - return responses, nil + err := u.validate.Struct(request) + if err != nil { + return nil, err + } + + // Set defaults + radius := request.Radius + if radius == 0 { + radius = 5.0 // Default 5km + } + + limit := request.Limit + if limit == 0 { + limit = 10 // Default 10 devices + } + + devices, err := u.nearestDeviceRepo.GetNearestDevices( + request.Longitude, + request.Latitude, + radius, + limit, + request.Province, + request.City, + request.District, + request.DeviceType, + ) + if err != nil { + return nil, err + } + + // Updated function call - removed userLat, userLng parameters since distance is already calculated + responses, err := helper.ConvertToNearestDeviceResponses(devices, u.nearestDeviceRepo, u.geocoder) + if err != nil { + return nil, err + } + + return responses, nil } func (u *nearestDeviceUseCase) GetNearestDeviceByID(id uuid.UUID, userLat, userLng float64) (res.NearestDeviceDetailResponse, error) { - device, err := u.nearestDeviceRepo.GetDeviceByIDWithConnections(id) - if err != nil { - return res.NearestDeviceDetailResponse{}, err - } - - // Calculate distance - distance := calculateDistance(userLat, userLng, device.Latitude, device.Longitude) - - response, err := helper.ConvertToNearestDeviceDetailResponse(device, distance, u.nearestDeviceRepo, u.geocoder) - if err != nil { - return res.NearestDeviceDetailResponse{}, err - } - - return response, nil + device, err := u.nearestDeviceRepo.GetDeviceByIDWithConnections(id) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + // Calculate distance + distance := calculateDistance(userLat, userLng, device.Latitude, device.Longitude) + + response, err := helper.ConvertToNearestDeviceDetailResponse(device, distance, u.nearestDeviceRepo, u.geocoder) + if err != nil { + return res.NearestDeviceDetailResponse{}, err + } + + return response, nil } // calculateDistance calculates the distance between two coordinates using Haversine formula func calculateDistance(lat1, lng1, lat2, lng2 float64) float64 { - const earthRadius = 6371 // Earth's radius in kilometers - - lat1Rad := lat1 * math.Pi / 180 - lng1Rad := lng1 * math.Pi / 180 - lat2Rad := lat2 * math.Pi / 180 - lng2Rad := lng2 * math.Pi / 180 - - dlat := lat2Rad - lat1Rad - dlng := lng2Rad - lng1Rad - - a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dlng/2)*math.Sin(dlng/2) - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) - - return earthRadius * c -} \ No newline at end of file + const earthRadius = 6371 // Earth's radius in kilometers + + lat1Rad := lat1 * math.Pi / 180 + lng1Rad := lng1 * math.Pi / 180 + lat2Rad := lat2 * math.Pi / 180 + lng2Rad := lng2 * math.Pi / 180 + + dlat := lat2Rad - lat1Rad + dlng := lng2Rad - lng1Rad + + a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dlng/2)*math.Sin(dlng/2) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return earthRadius * c +} diff --git a/usecase/nearest_device_test.go b/usecase/nearest_device_test.go new file mode 100644 index 0000000..fc5b0b4 --- /dev/null +++ b/usecase/nearest_device_test.go @@ -0,0 +1,381 @@ +package usecase + +import ( + "errors" + "testing" + "time" + "users_management/m/model/dto/req" + "users_management/m/model/entity" + + "github.com/google/uuid" +) + +type mockNearestDeviceRepo struct { + GetNearestDevicesFunc func(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) + GetDeviceByIDWithConnectionsFunc func(id uuid.UUID) (entity.Device, error) + CountConnectionsByDeviceIDFunc func(deviceID uuid.UUID) (int, int, int, error) +} + +func (m *mockNearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + if m.GetNearestDevicesFunc != nil { + return m.GetNearestDevicesFunc(longitude, latitude, radius, limit, province, city, district, deviceType) + } + return nil, nil +} + +func (m *mockNearestDeviceRepo) GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) { + if m.GetDeviceByIDWithConnectionsFunc != nil { + return m.GetDeviceByIDWithConnectionsFunc(id) + } + return entity.Device{}, nil +} + +func (m *mockNearestDeviceRepo) GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) { + return nil, nil +} + +func (m *mockNearestDeviceRepo) GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) { + return nil, nil +} + +func (m *mockNearestDeviceRepo) GetTowersByDeviceID(deviceID uuid.UUID) ([]entity.Tower, error) { + return nil, nil +} + +func (m *mockNearestDeviceRepo) CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err error) { + if m.CountConnectionsByDeviceIDFunc != nil { + return m.CountConnectionsByDeviceIDFunc(deviceID) + } + return 0, 0, 0, nil +} + +func (m *mockNearestDeviceRepo) GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error) { + return nil, nil +} + +func (m *mockNearestDeviceRepo) GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) { + return entity.Tower{}, nil +} + +type mockGeocoder struct{} + +func (m *mockGeocoder) GetAddressFromCoordinates(lat, lng float64) (string, error) { + return "Mock Address, City", nil +} + +func TestGetNearestDevices_Success(t *testing.T) { + deviceID := uuid.New() + provinceStr := "Jakarta" + cityStr := "Jakarta Selatan" + + mockRepo := &mockNearestDeviceRepo{ + GetNearestDevicesFunc: func(longitude, latitude, radius float64, limit int, prov, cty, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + if longitude != 106.8 { + t.Errorf("expected longitude 106.8, got %f", longitude) + } + if latitude != -6.2 { + t.Errorf("expected latitude -6.2, got %f", latitude) + } + if radius != 5.0 { + t.Errorf("expected radius 5.0, got %f", radius) + } + if limit != 10 { + t.Errorf("expected limit 10, got %d", limit) + } + _ = prov + _ = cty + return []entity.DeviceWithDistance{ + { + ID: deviceID, + DeviceCode: "ODP-001", + DeviceType: entity.ODP, + Longitude: 106.81, + Latitude: -6.21, + PortAmount: 8, + Status: entity.ActiveDev, + Province: &provinceStr, + City: &cityStr, + Distance: 1.5, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }, nil + }, + CountConnectionsByDeviceIDFunc: func(deviceID uuid.UUID) (int, int, int, error) { + return 2, 3, 1, nil + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + request := req.NearestDeviceDTO{ + Longitude: 106.8, + Latitude: -6.2, + Radius: 0, + Limit: 0, + } + + devices, err := uc.GetNearestDevices(request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(devices) != 1 { + t.Fatalf("expected 1 device, got %d", len(devices)) + } + + if devices[0].DeviceCode != "ODP-001" { + t.Errorf("expected device code ODP-001, got %s", devices[0].DeviceCode) + } +} + +func TestGetNearestDevices_WithDeviceTypeFilter(t *testing.T) { + deviceType := "ODP" + + mockRepo := &mockNearestDeviceRepo{ + GetNearestDevicesFunc: func(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + if deviceType == nil || *deviceType != "ODP" { + t.Errorf("expected deviceType filter to be ODP") + } + return []entity.DeviceWithDistance{ + { + ID: uuid.New(), + DeviceCode: "ODP-002", + DeviceType: entity.ODP, + Longitude: 106.82, + Latitude: -6.22, + PortAmount: 16, + Status: entity.ActiveDev, + Distance: 2.5, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }, nil + }, + CountConnectionsByDeviceIDFunc: func(deviceID uuid.UUID) (int, int, int, error) { + return 1, 2, 0, nil + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + request := req.NearestDeviceDTO{ + Longitude: 106.8, + Latitude: -6.2, + Radius: 5.0, + Limit: 10, + DeviceType: &deviceType, + } + + devices, err := uc.GetNearestDevices(request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(devices) != 1 { + t.Fatalf("expected 1 device, got %d", len(devices)) + } + + if devices[0].DeviceType != "ODP" { + t.Errorf("expected device type ODP, got %s", devices[0].DeviceType) + } +} + +func TestGetNearestDevices_RepositoryError(t *testing.T) { + mockRepo := &mockNearestDeviceRepo{ + GetNearestDevicesFunc: func(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + return nil, errors.New("database connection failed") + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + request := req.NearestDeviceDTO{ + Longitude: 106.8, + Latitude: -6.2, + } + + _, err := uc.GetNearestDevices(request) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestGetNearestDevices_EmptyResult(t *testing.T) { + mockRepo := &mockNearestDeviceRepo{ + GetNearestDevicesFunc: func(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + return []entity.DeviceWithDistance{}, nil + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + request := req.NearestDeviceDTO{ + Longitude: 106.8, + Latitude: -6.2, + } + + devices, err := uc.GetNearestDevices(request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(devices) != 0 { + t.Errorf("expected 0 devices, got %d", len(devices)) + } +} + +func TestGetNearestDevices_ValidationError(t *testing.T) { + mockRepo := &mockNearestDeviceRepo{} + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + request := req.NearestDeviceDTO{ + Longitude: 0, + Latitude: 0, + } + + _, err := uc.GetNearestDevices(request) + if err == nil { + t.Fatal("expected validation error for missing required fields") + } +} + +func TestGetNearestDevices_CustomRadiusAndLimit(t *testing.T) { + customRadius := 0.3 + customLimit := 5 + + mockRepo := &mockNearestDeviceRepo{ + GetNearestDevicesFunc: func(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) { + if radius != customRadius { + t.Errorf("expected radius %f, got %f", customRadius, radius) + } + if limit != customLimit { + t.Errorf("expected limit %d, got %d", customLimit, limit) + } + return []entity.DeviceWithDistance{}, nil + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + request := req.NearestDeviceDTO{ + Longitude: 106.8, + Latitude: -6.2, + Radius: customRadius, + Limit: customLimit, + } + + _, err := uc.GetNearestDevices(request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestCalculateDistance(t *testing.T) { + tests := []struct { + name string + lat1 float64 + lng1 float64 + lat2 float64 + lng2 float64 + expected float64 + delta float64 + }{ + { + name: "Same point", + lat1: -6.2, + lng1: 106.8, + lat2: -6.2, + lng2: 106.8, + expected: 0, + delta: 0.001, + }, + { + name: "Jakarta to Bandung", + lat1: -6.2088, + lng1: 106.8456, + lat2: -6.9175, + lng2: 107.6191, + expected: 115.0, + delta: 5.0, + }, + { + name: "Short distance ~350m", + lat1: -6.2, + lng1: 106.8, + lat2: -6.20225, + lng2: 106.80225, + expected: 0.35, + delta: 0.05, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := calculateDistance(tt.lat1, tt.lng1, tt.lat2, tt.lng2) + if result < tt.expected-tt.delta || result > tt.expected+tt.delta { + t.Errorf("calculateDistance(%f, %f, %f, %f) = %f, expected %f ± %f", + tt.lat1, tt.lng1, tt.lat2, tt.lng2, result, tt.expected, tt.delta) + } + }) + } +} + +func TestGetNearestDeviceByID_Success(t *testing.T) { + deviceID := uuid.New() + province := "Jakarta" + city := "Jakarta" + + mockRepo := &mockNearestDeviceRepo{ + GetDeviceByIDWithConnectionsFunc: func(id uuid.UUID) (entity.Device, error) { + if id != deviceID { + t.Errorf("expected device ID %s, got %s", deviceID, id) + } + return entity.Device{ + ID: deviceID, + DeviceCode: "ODP-003", + DeviceType: entity.ODP, + Longitude: 106.81, + Latitude: -6.21, + PortAmount: 8, + Status: entity.ActiveDev, + Province: &province, + City: &city, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + device, err := uc.GetNearestDeviceByID(deviceID, -6.2, 106.8) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if device.DeviceCode != "ODP-003" { + t.Errorf("expected device code ODP-003, got %s", device.DeviceCode) + } + + if device.Distance <= 0 { + t.Errorf("expected positive distance, got %f", device.Distance) + } +} + +func TestGetNearestDeviceByID_NotFound(t *testing.T) { + deviceID := uuid.New() + + mockRepo := &mockNearestDeviceRepo{ + GetDeviceByIDWithConnectionsFunc: func(id uuid.UUID) (entity.Device, error) { + return entity.Device{}, errors.New("record not found") + }, + } + + uc := NewNearestDeviceUseCase(mockRepo, &mockGeocoder{}) + + _, err := uc.GetNearestDeviceByID(deviceID, -6.2, 106.8) + if err == nil { + t.Fatal("expected error for not found device") + } +} diff --git a/utils/helper/AGENTS.md b/utils/helper/AGENTS.md new file mode 100644 index 0000000..47dafba --- /dev/null +++ b/utils/helper/AGENTS.md @@ -0,0 +1,25 @@ +OVERVIEW: Utility helpers for image upload, geo conversions, and response construction used across NAM backend. + +WHERE TO LOOK +- utils/helper/image_uploader.go + - Image uploaders: SaveDeviceImagesBulk, SaveTowerImagesBulk + - Image upload constants: MaxFileSize (5MB), TowerUploadDir, DeviceUploadDir +- utils/helper/geo_helpers.go + - Geo helper functions: ConvertToDeviceResponse, ConvertToOLTResponse, etc. +- utils/helper/response_builder.go + - Centralized response builders used by delivery layer +- Additional helper files under utils/helper/ as needed for conversions and validators + +CONVENTIONS +- File naming follows snake_case; exported functions use PascalCase. +- Constants use CamelCase with explicit units (MaxFileSize int64 = 5*1024*1024). +- Upload directories are defined as constants (TowerUploadDir, DeviceUploadDir) and used across helpers. +- Errors propagate with context; use standard errors or fmt.Errorf wrappers. +- Tests should cover public helpers; avoid testing private internals directly. + +ANTI-PATTERNS +- Do not hardcode absolute system paths; rely on defined constants for directories. +- Do not bloat helper files with business logic; keep them pure and focused. +- Do not create global mutable state in helpers. +- Do not introduce circular imports between helper and delivery layers. +- Do not bypass validation for uploads; enforce MaxFileSize and allowed MIME types.