adding radiation odp search

This commit is contained in:
areeqakbr 2026-04-11 14:25:12 +07:00
parent 3d5ce0dc72
commit 1dbd96eebd
11 changed files with 981 additions and 385 deletions

66
AGENTS.md Normal file
View File

@ -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

View File

@ -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

View File

@ -29,13 +29,15 @@ func NewNearestDeviceController(nearestDeviceUC usecase.NearestDeviceUseCase, rg
func (c *NearestDeviceController) Route() { func (c *NearestDeviceController) Route() {
nearestDevices := c.rg.Group("/nearest-devices") nearestDevices := c.rg.Group("/nearest-devices")
nearestDevices.Use(middleware.ConditionalRequireAnyRole(c.cfg,"Teknisi", "Admin", "Super Admin")) nearestDevices.Use(middleware.ConditionalRequireAnyRole(c.cfg, "Teknisi", "Admin", "Super Admin"))
{ {
nearestDevices.POST("/search", c.getNearestDevices) nearestDevices.POST("/search", c.getNearestDevices)
nearestDevices.GET("/:id", c.getNearestDeviceByID) nearestDevices.GET("/:id", c.getNearestDeviceByID)
nearestDevices.POST("/towers/search", c.getNearestTowers) nearestDevices.POST("/towers/search", c.getNearestTowers)
nearestDevices.GET("/towers/:id", c.getNearestTowerByID) nearestDevices.GET("/towers/:id", c.getNearestTowerByID)
nearestDevices.POST("/odp/search", c.getNearestODPDevices)
} }
} }
@ -172,3 +174,36 @@ func (c *NearestDeviceController) getNearestDeviceByID(ctx *gin.Context) {
common.SingleResponses(ctx, "Device details retrieved successfully", device) 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)
}

14
model/AGENTS.md Normal file
View File

@ -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.

View File

@ -8,4 +8,5 @@ type NearestDeviceDTO struct {
Province *string `json:"province,omitempty"` Province *string `json:"province,omitempty"`
City *string `json:"city,omitempty"` City *string `json:"city,omitempty"`
District *string `json:"district,omitempty"` District *string `json:"district,omitempty"`
DeviceType *string `json:"device_type,omitempty"`
} }

20
repository/AGENTS.md Normal file
View File

@ -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

View File

@ -1,13 +1,13 @@
package repository package repository
import ( import (
"users_management/m/model/entity"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"users_management/m/model/entity"
) )
type NearestDeviceRepo interface { type NearestDeviceRepo interface {
GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.DeviceWithDistance, error) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error)
GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error) GetDeviceByIDWithConnections(id uuid.UUID) (entity.Device, error)
GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error) GetBackbonesByDeviceID(deviceID uuid.UUID) ([]entity.Backbone, error)
GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error) GetFishbonesByDeviceID(deviceID uuid.UUID) ([]entity.Fishbone, error)
@ -15,7 +15,6 @@ type NearestDeviceRepo interface {
CountConnectionsByDeviceID(deviceID uuid.UUID) (backboneCount, fishboneCount, towerCount int, err 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) GetNearestTowers(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.TowerWithDistance, error)
GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error) GetTowerByIDWithConnections(id uuid.UUID) (entity.Tower, error)
} }
type nearestDeviceRepo struct { type nearestDeviceRepo struct {
@ -67,17 +66,15 @@ func (r *nearestDeviceRepo) GetTowerByIDWithConnections(id uuid.UUID) (entity.To
return tower, err return tower, err
} }
func (r *nearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district *string) ([]entity.DeviceWithDistance, error) { func (r *nearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float64, limit int, province, city, district, deviceType *string) ([]entity.DeviceWithDistance, error) {
var devices []entity.DeviceWithDistance var devices []entity.DeviceWithDistance
// Build the subquery first
subQuery := r.db.Table("devices"). subQuery := r.db.Table("devices").
Select(`id, device_code, device_type, longitude, latitude, port_amount, status, Select(`id, device_code, device_type, longitude, latitude, port_amount, status,
region, province, city, district, image_url, created_at, updated_at, 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`, (6371 * acos(cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude)))) AS distance`,
latitude, longitude, latitude) latitude, longitude, latitude)
// Apply location filters to subquery
if province != nil && *province != "" { if province != nil && *province != "" {
subQuery = subQuery.Where("province = ?", *province) subQuery = subQuery.Where("province = ?", *province)
} }
@ -87,8 +84,10 @@ func (r *nearestDeviceRepo) GetNearestDevices(longitude, latitude, radius float6
if district != nil && *district != "" { if district != nil && *district != "" {
subQuery = subQuery.Where("district = ?", *district) subQuery = subQuery.Where("district = ?", *district)
} }
if deviceType != nil && *deviceType != "" {
subQuery = subQuery.Where("device_type = ?", *deviceType)
}
// Use the subquery in the main query
err := r.db.Table("(?) as devices_with_distance", subQuery). err := r.db.Table("(?) as devices_with_distance", subQuery).
Where("distance <= ?", radius). Where("distance <= ?", radius).
Order("distance ASC"). Order("distance ASC").

24
usecase/AGENTS.md Normal file
View File

@ -0,0 +1,24 @@
OVERVIEW
Scaffold for 14 highcomplexity 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)

View File

@ -113,6 +113,7 @@ func (u *nearestDeviceUseCase) GetNearestDevices(request req.NearestDeviceDTO) (
request.Province, request.Province,
request.City, request.City,
request.District, request.District,
request.DeviceType,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

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

25
utils/helper/AGENTS.md Normal file
View File

@ -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.