diff --git a/src/backend/api/internal/handlers/tenant_handler.go b/src/backend/api/internal/handlers/tenant_handler.go new file mode 100644 index 0000000..f2f2821 --- /dev/null +++ b/src/backend/api/internal/handlers/tenant_handler.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/ysnarafat/tenantly/internal/interfaces" + "github.com/ysnarafat/tenantly/internal/models" +) + +// TenantHandler handles HTTP requests for tenant operations +type TenantHandler struct { + tenantService interfaces.TenantServiceInterface +} + +// NewTenantHandler creates a new TenantHandler +func NewTenantHandler(tenantService interfaces.TenantServiceInterface) *TenantHandler { + return &TenantHandler{tenantService: tenantService} +} + +// CreateTenant handles tenant creation +func (h *TenantHandler) CreateTenant(c *gin.Context) { + var req models.CreateTenantRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID := c.GetInt("userID") + tenant, err := h.tenantService.CreateTenant(&req, userID) + if err != nil { + // Check for uniqueness errors + if err.Error() == "email already exists" || err.Error() == "NID number already exists" { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, tenant) +} diff --git a/src/backend/api/internal/interfaces/interfaces.go b/src/backend/api/internal/interfaces/interfaces.go index 16ea1f8..1ca06fb 100644 --- a/src/backend/api/internal/interfaces/interfaces.go +++ b/src/backend/api/internal/interfaces/interfaces.go @@ -254,10 +254,18 @@ type NotificationServiceInterface interface { // TenantRepositoryInterface defines the interface for tenant repository operations type TenantRepositoryInterface interface { + Create(req *models.CreateTenantRequest) (*models.Tenant, error) + CheckEmailExists(email string, excludeID int) (bool, error) + CheckNIDExists(nid string, excludeID int) (bool, error) GetByID(id int) (*models.Tenant, error) GetByUnitID(unitID int) (*models.Tenant, error) } +// TenantServiceInterface defines the interface for tenant service operations +type TenantServiceInterface interface { + CreateTenant(req *models.CreateTenantRequest, userID int) (*models.TenantResponse, error) +} + // PropertyRepositoryInterface defines the interface for property repository operations type PropertyRepositoryInterface interface { GetByID(id int) (*models.Property, error) diff --git a/src/backend/api/internal/interfaces/mocks/interfaces_mock.go b/src/backend/api/internal/interfaces/mocks/interfaces_mock.go new file mode 100644 index 0000000..9e4b90e --- /dev/null +++ b/src/backend/api/internal/interfaces/mocks/interfaces_mock.go @@ -0,0 +1,60 @@ +package mocks + +import ( + "github.com/stretchr/testify/mock" + "github.com/ysnarafat/tenantly/internal/models" +) + +// TenantRepositoryInterface is a mock of TenantRepositoryInterface +type TenantRepositoryInterface struct { + mock.Mock +} + +func (m *TenantRepositoryInterface) Create(req *models.CreateTenantRequest) (*models.Tenant, error) { + args := m.Called(req) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Tenant), args.Error(1) +} + +func (m *TenantRepositoryInterface) CheckEmailExists(email string, excludeID int) (bool, error) { + args := m.Called(email, excludeID) + return args.Bool(0), args.Error(1) +} + +func (m *TenantRepositoryInterface) CheckNIDExists(nid string, excludeID int) (bool, error) { + args := m.Called(nid, excludeID) + return args.Bool(0), args.Error(1) +} + +func (m *TenantRepositoryInterface) GetByID(id int) (*models.Tenant, error) { + args := m.Called(id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Tenant), args.Error(1) +} + +func (m *TenantRepositoryInterface) GetByUnitID(unitID int) (*models.Tenant, error) { + args := m.Called(unitID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Tenant), args.Error(1) +} + +// AuditServiceInterface is a mock of AuditServiceInterface +type AuditServiceInterface struct { + mock.Mock +} + +func (m *AuditServiceInterface) LogSystemAction(action, entity string, entityID *int, oldData, newData interface{}) error { + args := m.Called(action, entity, entityID, oldData, newData) + return args.Error(0) +} + +func (m *AuditServiceInterface) LogUserAction(userID int, action, entity string, entityID *int, oldData, newData interface{}) error { + args := m.Called(userID, action, entity, entityID, oldData, newData) + return args.Error(0) +} diff --git a/src/backend/api/internal/models/columns/tenant_columns.go b/src/backend/api/internal/models/columns/tenant_columns.go new file mode 100644 index 0000000..90c6e76 --- /dev/null +++ b/src/backend/api/internal/models/columns/tenant_columns.go @@ -0,0 +1,23 @@ +package columns + +// Tenant table and column constants +const ( + TenantTable = "tenants" + + // Tenant columns + TenantID = "id" + TenantName = "name" + TenantType = "tenant_type" + TenantPhoneNumber = "phone_number" + TenantEmail = "email" + TenantNIDNumber = "nid_number" + TenantAddress = "address" + TenantActive = "active" + TenantCreatedAt = "created_at" + TenantUpdatedAt = "updated_at" +) + +// TenantAllColumns returns all tenant columns for SELECT queries +func TenantAllColumns() string { + return "id, name, tenant_type, phone_number, email, nid_number, address, active, created_at, updated_at" +} diff --git a/src/backend/api/internal/models/tenant.go b/src/backend/api/internal/models/tenant.go index bf9b29c..d334e61 100644 --- a/src/backend/api/internal/models/tenant.go +++ b/src/backend/api/internal/models/tenant.go @@ -23,12 +23,42 @@ type Tenant struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } +// TenantResponse represents the API response for a tenant +type TenantResponse struct { + ID int `json:"id"` + Name string `json:"name"` + TenantType TenantType `json:"tenant_type"` + PhoneNumber string `json:"phone_number"` + Email string `json:"email"` + NIDNumber string `json:"nid_number"` + Address string `json:"address"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ToResponse converts a Tenant model to a TenantResponse DTO +func (t *Tenant) ToResponse() *TenantResponse { + return &TenantResponse{ + ID: t.ID, + Name: t.Name, + TenantType: t.TenantType, + PhoneNumber: t.PhoneNumber, + Email: t.Email, + NIDNumber: t.NIDNumber, + Address: t.Address, + Active: t.Active, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } +} + type CreateTenantRequest struct { Name string `json:"name" binding:"required,max=100"` TenantType TenantType `json:"tenant_type" binding:"required,oneof=Individual Business"` - PhoneNumber string `json:"phone_number" binding:"omitempty,max=20"` + PhoneNumber string `json:"phone_number" binding:"required,max=20"` Email string `json:"email" binding:"omitempty,email"` - NIDNumber string `json:"nid_number" binding:"omitempty,max=20"` + NIDNumber string `json:"nid_number" binding:"required,max=20"` Address string `json:"address" binding:"omitempty"` } diff --git a/src/backend/api/internal/repositories/tenant_repository.go b/src/backend/api/internal/repositories/tenant_repository.go new file mode 100644 index 0000000..314a6ba --- /dev/null +++ b/src/backend/api/internal/repositories/tenant_repository.go @@ -0,0 +1,143 @@ +package repositories + +import ( + "database/sql" + "fmt" + + "github.com/ysnarafat/tenantly/internal/models" + "github.com/ysnarafat/tenantly/internal/models/columns" +) + +// TenantRepository implements the TenantRepositoryInterface +type TenantRepository struct { + db *sql.DB +} + +// NewTenantRepository creates a new TenantRepository +func NewTenantRepository(db *sql.DB) *TenantRepository { + return &TenantRepository{db: db} +} + +// Create creates a new tenant +func (r *TenantRepository) Create(req *models.CreateTenantRequest) (*models.Tenant, error) { + query := fmt.Sprintf(` + INSERT INTO %s ( + %s, %s, %s, %s, %s, %s, %s + ) VALUES ($1, $2, $3, $4, $5, $6, true) + RETURNING %s, %s, %s`, + columns.TenantTable, + columns.TenantName, columns.TenantType, columns.TenantPhoneNumber, + columns.TenantEmail, columns.TenantNIDNumber, columns.TenantAddress, columns.TenantActive, + columns.TenantID, columns.TenantCreatedAt, columns.TenantUpdatedAt) + + tenant := &models.Tenant{ + Name: req.Name, + TenantType: req.TenantType, + PhoneNumber: req.PhoneNumber, + Email: req.Email, + NIDNumber: req.NIDNumber, + Address: req.Address, + Active: true, + } + + err := r.db.QueryRow( + query, + tenant.Name, + tenant.TenantType, + tenant.PhoneNumber, + tenant.Email, + tenant.NIDNumber, + tenant.Address, + ).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt) + + if err != nil { + return nil, fmt.Errorf("failed to create tenant: %w", err) + } + + return tenant, nil +} + +// CheckEmailExists checks if an email already exists for another tenant +func (r *TenantRepository) CheckEmailExists(email string, excludeID int) (bool, error) { + if email == "" { + return false, nil + } + + query := fmt.Sprintf(` + SELECT EXISTS( + SELECT 1 FROM %s + WHERE %s = $1 AND %s != $2 AND %s = true + )`, + columns.TenantTable, + columns.TenantEmail, columns.TenantID, columns.TenantActive) + + var exists bool + err := r.db.QueryRow(query, email, excludeID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("failed to check email existence: %w", err) + } + + return exists, nil +} + +// CheckNIDExists checks if a NID number already exists for another tenant +func (r *TenantRepository) CheckNIDExists(nid string, excludeID int) (bool, error) { + if nid == "" { + return false, nil + } + + query := fmt.Sprintf(` + SELECT EXISTS( + SELECT 1 FROM %s + WHERE %s = $1 AND %s != $2 AND %s = true + )`, + columns.TenantTable, + columns.TenantNIDNumber, columns.TenantID, columns.TenantActive) + + var exists bool + err := r.db.QueryRow(query, nid, excludeID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("failed to check NID existence: %w", err) + } + + return exists, nil +} + +// GetByID retrieves a tenant by ID +func (r *TenantRepository) GetByID(id int) (*models.Tenant, error) { + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = $1 AND %s = true`, + columns.TenantAllColumns(), + columns.TenantTable, + columns.TenantID, columns.TenantActive) + + tenant := &models.Tenant{} + err := r.db.QueryRow(query, id).Scan( + &tenant.ID, + &tenant.Name, + &tenant.TenantType, + &tenant.PhoneNumber, + &tenant.Email, + &tenant.NIDNumber, + &tenant.Address, + &tenant.Active, + &tenant.CreatedAt, + &tenant.UpdatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("tenant not found") + } + return nil, fmt.Errorf("failed to get tenant: %w", err) + } + + return tenant, nil +} + +// GetByUnitID retrieves a tenant by unit ID (will be implemented with lease functionality) +func (r *TenantRepository) GetByUnitID(unitID int) (*models.Tenant, error) { + return nil, fmt.Errorf("not implemented yet") +} diff --git a/src/backend/api/internal/repositories/tenant_repository_test.go b/src/backend/api/internal/repositories/tenant_repository_test.go new file mode 100644 index 0000000..cd30e6c --- /dev/null +++ b/src/backend/api/internal/repositories/tenant_repository_test.go @@ -0,0 +1,118 @@ +package repositories + +import ( + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/ysnarafat/tenantly/internal/models" +) + +func TestTenantRepository_Create(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + repo := NewTenantRepository(db) + + t.Run("success", func(t *testing.T) { + req := &models.CreateTenantRequest{ + Name: "John Doe", + TenantType: models.TenantTypeIndividual, + PhoneNumber: "1234567890", + Email: "john@example.com", + NIDNumber: "NID123", + Address: "123 Main St", + } + + mock.ExpectQuery(regexp.QuoteMeta(` + INSERT INTO tenants ( + name, tenant_type, phone_number, email, nid_number, address, active + ) VALUES ($1, $2, $3, $4, $5, $6, true) + RETURNING id, created_at, updated_at`)). + WithArgs( + req.Name, req.TenantType, req.PhoneNumber, req.Email, req.NIDNumber, req.Address, + ). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). + AddRow(1, time.Now(), time.Now())) + + tenant, err := repo.Create(req) + assert.NoError(t, err) + assert.NotNil(t, tenant) + assert.Equal(t, 1, tenant.ID) + assert.Equal(t, req.Name, tenant.Name) + }) + + t.Run("database error", func(t *testing.T) { + req := &models.CreateTenantRequest{Name: "John Doe"} + + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO tenants")). + WillReturnError(assert.AnError) + + tenant, err := repo.Create(req) + assert.Error(t, err) + assert.Nil(t, tenant) + }) +} + +func TestTenantRepository_CheckEmailExists(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + repo := NewTenantRepository(db) + + t.Run("exists", func(t *testing.T) { + email := "john@example.com" + mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS")). + WithArgs(email, 0). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + exists, err := repo.CheckEmailExists(email, 0) + assert.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("does not exist", func(t *testing.T) { + email := "new@example.com" + mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS")). + WithArgs(email, 0). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + exists, err := repo.CheckEmailExists(email, 0) + assert.NoError(t, err) + assert.False(t, exists) + }) +} + +func TestTenantRepository_CheckNIDExists(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + repo := NewTenantRepository(db) + + t.Run("exists", func(t *testing.T) { + nid := "NID123" + mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS")). + WithArgs(nid, 0). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + exists, err := repo.CheckNIDExists(nid, 0) + assert.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("does not exist", func(t *testing.T) { + nid := "NID999" + mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS")). + WithArgs(nid, 0). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + exists, err := repo.CheckNIDExists(nid, 0) + assert.NoError(t, err) + assert.False(t, exists) + }) +} diff --git a/src/backend/api/internal/server/server.go b/src/backend/api/internal/server/server.go index e4d08c2..3667bdb 100644 --- a/src/backend/api/internal/server/server.go +++ b/src/backend/api/internal/server/server.go @@ -64,6 +64,7 @@ func (s *Server) setupRoutes() { propertyRepo := repositories.NewPropertyRepository(s.db) buildingRepo := repositories.NewBuildingRepository(s.db) unitRepo := repositories.NewUnitRepository(s.db) + tenantRepo := repositories.NewTenantRepository(s.db) // Initialize metadata validator metadataValidator := services.NewBuildingMetadataValidator() @@ -73,12 +74,14 @@ func (s *Server) setupRoutes() { propertyService := services.NewPropertyService(propertyRepo, auditService) buildingService := services.NewBuildingService(buildingRepo, propertyRepo, auditService, metadataValidator) unitService := services.NewUnitService(unitRepo, buildingRepo, propertyRepo, auditService) + tenantService := services.NewTenantService(tenantRepo, auditService) // Initialize handlers userHandler := handlers.NewUserHandler(userService) propertyHandler := handlers.NewPropertyHandler(propertyService) buildingHandler := handlers.NewBuildingHandler(buildingService) unitHandler := handlers.NewUnitHandler(unitService) + tenantHandler := handlers.NewTenantHandler(tenantService) // Health check endpoint s.router.GET("/health", func(c *gin.Context) { @@ -137,7 +140,7 @@ func (s *Server) setupRoutes() { // Property-building relationship endpoints with property validation middleware properties.GET("/:id/buildings", middleware.RequireAnyRole(), middleware.PropertyValidationMiddleware(propertyRepo), buildingHandler.GetPropertyBuildings) properties.POST("/:id/buildings/bulk", middleware.RequireAdminOrPropertyManager(), middleware.PropertyValidationMiddleware(propertyRepo), buildingHandler.BulkCreateBuildings) - + // Property-unit relationship endpoints properties.GET("/:id/units", middleware.RequireAnyRole(), middleware.PropertyValidationMiddleware(propertyRepo), unitHandler.GetUnitsByProperty) } @@ -156,7 +159,7 @@ func (s *Server) setupRoutes() { buildings.GET("/:id/analytics", middleware.RequireAnyRole(), buildingHandler.GetBuildingAnalytics) buildings.GET("/:id/units", middleware.RequireAnyRole(), buildingHandler.GetBuildingUnits) buildings.PUT("/:id/status", middleware.RequireAdminOrPropertyManager(), buildingHandler.UpdateBuildingStatus) - + // Building-unit relationship endpoints buildings.GET("/:id/units/list", middleware.RequireAnyRole(), unitHandler.GetUnitsByBuilding) } @@ -184,7 +187,7 @@ func (s *Server) setupRoutes() { tenants := protected.Group("/tenants") { tenants.GET("", s.handlePlaceholder("Get tenants")) - tenants.POST("", s.handlePlaceholder("Create tenant")) + tenants.POST("", middleware.RequireAdminOrPropertyManager(), tenantHandler.CreateTenant) tenants.GET("/:id", s.handlePlaceholder("Get tenant")) tenants.PUT("/:id", s.handlePlaceholder("Update tenant")) tenants.DELETE("/:id", s.handlePlaceholder("Delete tenant")) diff --git a/src/backend/api/internal/services/tenant_service.go b/src/backend/api/internal/services/tenant_service.go new file mode 100644 index 0000000..0e8acfa --- /dev/null +++ b/src/backend/api/internal/services/tenant_service.go @@ -0,0 +1,63 @@ +package services + +import ( + "fmt" + + "github.com/ysnarafat/tenantly/internal/interfaces" + "github.com/ysnarafat/tenantly/internal/models" +) + +type TenantService struct { + tenantRepo interfaces.TenantRepositoryInterface + auditService interfaces.AuditServiceInterface +} + +func NewTenantService( + tenantRepo interfaces.TenantRepositoryInterface, + auditService interfaces.AuditServiceInterface, +) *TenantService { + return &TenantService{ + tenantRepo: tenantRepo, + auditService: auditService, + } +} + +// CreateTenant creates a new tenant with validation +func (s *TenantService) CreateTenant(req *models.CreateTenantRequest, userID int) (*models.TenantResponse, error) { + // Validate email uniqueness if provided + if req.Email != "" { + exists, err := s.tenantRepo.CheckEmailExists(req.Email, 0) + if err != nil { + return nil, fmt.Errorf("failed to check email uniqueness: %w", err) + } + if exists { + return nil, fmt.Errorf("email already exists") + } + } + + // Validate NID uniqueness if provided + exists, err := s.tenantRepo.CheckNIDExists(req.NIDNumber, 0) + if err != nil { + return nil, fmt.Errorf("failed to check NID uniqueness: %w", err) + } + if exists { + return nil, fmt.Errorf("NID number already exists") + } + + // Create tenant + tenant, err := s.tenantRepo.Create(req) + if err != nil { + return nil, fmt.Errorf("failed to create tenant: %w", err) + } + + // Log audit + s.auditService.LogUserAction(userID, "CREATE", "tenants", &tenant.ID, nil, map[string]interface{}{ + "tenant_id": tenant.ID, + "name": tenant.Name, + "tenant_type": tenant.TenantType, + "email": tenant.Email, + "nid_number": tenant.NIDNumber, + }) + + return tenant.ToResponse(), nil +} diff --git a/src/backend/api/internal/services/tenant_service_test.go b/src/backend/api/internal/services/tenant_service_test.go new file mode 100644 index 0000000..8aa53f5 --- /dev/null +++ b/src/backend/api/internal/services/tenant_service_test.go @@ -0,0 +1,91 @@ +package services + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/ysnarafat/tenantly/internal/interfaces/mocks" + "github.com/ysnarafat/tenantly/internal/models" +) + +func TestTenantService_CreateTenant(t *testing.T) { + mockRepo := new(mocks.TenantRepositoryInterface) + mockAudit := new(mocks.AuditServiceInterface) + service := NewTenantService(mockRepo, mockAudit) + + req := &models.CreateTenantRequest{ + Name: "John Doe", + Email: "john@example.com", + NIDNumber: "NID123", + TenantType: models.TenantTypeIndividual, + PhoneNumber: "1234567890", + Address: "123 Main St", + } + + userID := 1 + + t.Run("success", func(t *testing.T) { + createdTenant := &models.Tenant{ + ID: 1, + Name: req.Name, + Email: req.Email, + NIDNumber: req.NIDNumber, + TenantType: req.TenantType, + PhoneNumber: req.PhoneNumber, + Address: req.Address, + Active: true, + } + + mockRepo.On("CheckEmailExists", req.Email, 0).Return(false, nil).Once() + mockRepo.On("CheckNIDExists", req.NIDNumber, 0).Return(false, nil).Once() + mockRepo.On("Create", req).Return(createdTenant, nil).Once() + mockAudit.On("LogUserAction", userID, "CREATE", "tenants", mock.AnythingOfType("*int"), mock.Anything, mock.Anything).Return(nil).Once() + + resp, err := service.CreateTenant(req, userID) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, createdTenant.ID, resp.ID) + assert.Equal(t, createdTenant.Name, resp.Name) + mockRepo.AssertExpectations(t) + mockAudit.AssertExpectations(t) + }) + + t.Run("email already exists", func(t *testing.T) { + mockRepo.On("CheckEmailExists", req.Email, 0).Return(true, nil).Once() + + resp, err := service.CreateTenant(req, userID) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "email already exists") + mockRepo.AssertExpectations(t) + }) + + t.Run("nid already exists", func(t *testing.T) { + mockRepo.On("CheckEmailExists", req.Email, 0).Return(false, nil).Once() + mockRepo.On("CheckNIDExists", req.NIDNumber, 0).Return(true, nil).Once() + + resp, err := service.CreateTenant(req, userID) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "NID number already exists") + mockRepo.AssertExpectations(t) + }) + + t.Run("repository error", func(t *testing.T) { + mockRepo.On("CheckEmailExists", req.Email, 0).Return(false, nil).Once() + mockRepo.On("CheckNIDExists", req.NIDNumber, 0).Return(false, nil).Once() + mockRepo.On("Create", req).Return((*models.Tenant)(nil), fmt.Errorf("db error")).Once() + + resp, err := service.CreateTenant(req, userID) + + assert.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "failed to create tenant") + mockRepo.AssertExpectations(t) + }) +} diff --git a/src/frontend/src/app/core/services/auth.service.spec.ts b/src/frontend/src/app/core/services/auth.service.spec.ts index 7fea5ef..c556386 100644 --- a/src/frontend/src/app/core/services/auth.service.spec.ts +++ b/src/frontend/src/app/core/services/auth.service.spec.ts @@ -1,11 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { AuthService, LoginRequest, ChangePasswordRequest, ResetPasswordRequest, + User, } from './auth.service'; import { AppState } from '../../store'; import * as AuthActions from '../../store/auth/auth.actions'; @@ -14,8 +15,10 @@ import * as AuthSelectors from '../../store/auth/auth.selectors'; describe('AuthService', () => { let service: AuthService; let store: jasmine.SpyObj>; + let loadingSubject: BehaviorSubject; + let errorSubject: BehaviorSubject; - const mockUser = { + const mockUser: User = { id: 1, username: 'testuser', email: 'test@example.com', @@ -26,27 +29,43 @@ describe('AuthService', () => { }; beforeEach(() => { + loadingSubject = new BehaviorSubject(false); + errorSubject = new BehaviorSubject(null); const storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']); - TestBed.configureTestingModule({ - providers: [AuthService, { provide: Store, useValue: storeSpy }], - }); - - service = TestBed.inject(AuthService); - store = TestBed.inject(Store) as jasmine.SpyObj>; - - // Setup default store selectors - store.select.and.callFake((selector: any) => { + // IMPORTANT: Set up the spy BEFORE creating the service + // because the service creates observables in its constructor + storeSpy.select.and.callFake((selector: any) => { + // Return appropriate observables based on selector if (selector === AuthSelectors.selectUser) return of(mockUser); if (selector === AuthSelectors.selectUserRole) return of('Admin'); if (selector === AuthSelectors.selectIsAuthenticated) return of(true); if (selector === AuthSelectors.selectIsAdmin) return of(true); if (selector === AuthSelectors.selectIsPropertyManager) return of(false); if (selector === AuthSelectors.selectIsAccountant) return of(false); - if (selector === AuthSelectors.selectAuthLoading) return of(false); - if (selector === AuthSelectors.selectAuthError) return of(null); + if (selector === AuthSelectors.selectAuthLoading) return loadingSubject.asObservable(); + if (selector === AuthSelectors.selectAuthError) return errorSubject.asObservable(); + // For parameterized selectors + if (typeof selector === 'function') { + return of(true); // Default for hasRole/hasAnyRole selectors + } return of(null); }); + + TestBed.configureTestingModule({ + providers: [AuthService, { provide: Store, useValue: storeSpy }], + }); + + service = TestBed.inject(AuthService); + store = TestBed.inject(Store) as jasmine.SpyObj>; + + // Clear localStorage before each test + localStorage.clear(); + }); + + afterEach(() => { + // Clean up localStorage after each test + localStorage.clear(); }); it('should be created', () => { @@ -61,6 +80,14 @@ describe('AuthService', () => { expect(store.dispatch).toHaveBeenCalledWith(AuthActions.login({ credentials })); }); + + it('should dispatch login action with empty credentials', () => { + const credentials: LoginRequest = { username: '', password: '' }; + + service.login(credentials); + + expect(store.dispatch).toHaveBeenCalledWith(AuthActions.login({ credentials })); + }); }); describe('logout', () => { @@ -69,6 +96,14 @@ describe('AuthService', () => { expect(store.dispatch).toHaveBeenCalledWith(AuthActions.logout()); }); + + it('should dispatch logout action only once when called multiple times', () => { + service.logout(); + service.logout(); + + expect(store.dispatch).toHaveBeenCalledTimes(2); + expect(store.dispatch).toHaveBeenCalledWith(AuthActions.logout()); + }); }); describe('refreshToken', () => { @@ -81,7 +116,21 @@ describe('AuthService', () => { describe('changePassword', () => { it('should dispatch changePassword action', () => { - const request: ChangePasswordRequest = { current_password: 'old', new_password: 'new' }; + const request: ChangePasswordRequest = { + current_password: 'old', + new_password: 'new', + }; + + service.changePassword(request); + + expect(store.dispatch).toHaveBeenCalledWith(AuthActions.changePassword({ request })); + }); + + it('should dispatch changePassword action with same passwords', () => { + const request: ChangePasswordRequest = { + current_password: 'same', + new_password: 'same', + }; service.changePassword(request); @@ -97,6 +146,14 @@ describe('AuthService', () => { expect(store.dispatch).toHaveBeenCalledWith(AuthActions.resetPassword({ request })); }); + + it('should dispatch resetPassword action with invalid email format', () => { + const request: ResetPasswordRequest = { email: 'invalid-email' }; + + service.resetPassword(request); + + expect(store.dispatch).toHaveBeenCalledWith(AuthActions.resetPassword({ request })); + }); }); describe('clearError', () => { @@ -115,12 +172,77 @@ describe('AuthService', () => { }); }); + describe('getToken', () => { + it('should return token from localStorage', () => { + spyOn(localStorage, 'getItem').and.returnValue('test-token'); + + const result = service.getToken(); + + expect(result).toBe('test-token'); + expect(localStorage.getItem).toHaveBeenCalledWith('tenantly_token'); + }); + + it('should return null when no token exists', () => { + spyOn(localStorage, 'getItem').and.returnValue(null); + + const result = service.getToken(); + + expect(result).toBeNull(); + }); + + it('should return empty string token', () => { + spyOn(localStorage, 'getItem').and.returnValue(''); + + const result = service.getToken(); + + expect(result).toBe(''); + }); + }); + + describe('getRefreshToken', () => { + it('should return refresh token from localStorage', () => { + spyOn(localStorage, 'getItem').and.returnValue('test-refresh-token'); + + const result = service.getRefreshToken(); + + expect(result).toBe('test-refresh-token'); + expect(localStorage.getItem).toHaveBeenCalledWith('tenantly_refresh_token'); + }); + + it('should return null when no refresh token exists', () => { + spyOn(localStorage, 'getItem').and.returnValue(null); + + const result = service.getRefreshToken(); + + expect(result).toBeNull(); + }); + }); + describe('getUser', () => { it('should return user from store', () => { const result = service.getUser(); expect(result).toEqual(mockUser); - expect(store.select).toHaveBeenCalledWith(AuthSelectors.selectUser); + }); + + it('should return null when no user in store', () => { + store.select.and.returnValue(of(null)); + + const result = service.getUser(); + + expect(result).toBeNull(); + }); + + it('should return user with different role', () => { + const propertyManagerUser: User = { + ...mockUser, + role: 'PropertyManager', + }; + store.select.and.returnValue(of(propertyManagerUser)); + + const result = service.getUser(); + + expect(result?.role).toBe('PropertyManager'); }); }); @@ -129,16 +251,91 @@ describe('AuthService', () => { const result = service.getUserRole(); expect(result).toBe('Admin'); - expect(store.select).toHaveBeenCalledWith(AuthSelectors.selectUserRole); + }); + + it('should return empty string when no role in store', () => { + store.select.and.returnValue(of('')); + + const result = service.getUserRole(); + + expect(result).toBe(''); + }); + + it('should return PropertyManager role', () => { + store.select.and.returnValue(of('PropertyManager')); + + const result = service.getUserRole(); + + expect(result).toBe('PropertyManager'); + }); + + it('should return Accountant role', () => { + store.select.and.returnValue(of('Accountant')); + + const result = service.getUserRole(); + + expect(result).toBe('Accountant'); }); }); describe('isAuthenticated', () => { - it('should return authentication status from store', () => { + it('should return true when authenticated', () => { const result = service.isAuthenticated(); expect(result).toBe(true); - expect(store.select).toHaveBeenCalledWith(AuthSelectors.selectIsAuthenticated); + }); + + it('should return false when not authenticated', () => { + store.select.and.returnValue(of(false)); + + const result = service.isAuthenticated(); + + expect(result).toBe(false); + }); + }); + + describe('isTokenExpired', () => { + it('should return true when no expiry date exists', () => { + const result = service.isTokenExpired(); + + expect(result).toBe(true); + }); + + it('should return true when token is expired', () => { + const pastDate = new Date(); + pastDate.setHours(pastDate.getHours() - 1); + localStorage.setItem('tenantly_expires_at', pastDate.toISOString()); + + const result = service.isTokenExpired(); + + expect(result).toBe(true); + }); + + it('should return false when token is not expired', () => { + const futureDate = new Date(); + futureDate.setHours(futureDate.getHours() + 1); + localStorage.setItem('tenantly_expires_at', futureDate.toISOString()); + + const result = service.isTokenExpired(); + + expect(result).toBe(false); + }); + + it('should return true when expiry date is exactly now', () => { + const now = new Date(); + localStorage.setItem('tenantly_expires_at', now.toISOString()); + + const result = service.isTokenExpired(); + + expect(result).toBe(true); + }); + + it('should return true when expiry date is invalid', () => { + localStorage.setItem('tenantly_expires_at', 'invalid-date'); + + const result = service.isTokenExpired(); + + expect(result).toBe(true); }); }); @@ -150,15 +347,28 @@ describe('AuthService', () => { }); it('should return false when user does not have the specified role', () => { - store.select.and.callFake((selector: any) => { - if (selector === AuthSelectors.selectUserRole) return of('PropertyManager'); - return of(null); - }); + store.select.and.returnValue(of('PropertyManager')); const result = service.hasRole('Admin'); expect(result).toBe(false); }); + + it('should return false when checking for empty role', () => { + store.select.and.returnValue(of('Admin')); + + const result = service.hasRole(''); + + expect(result).toBe(false); + }); + + it('should be case-sensitive', () => { + store.select.and.returnValue(of('Admin')); + + const result = service.hasRole('admin'); + + expect(result).toBe(false); + }); }); describe('hasAnyRole', () => { @@ -169,15 +379,34 @@ describe('AuthService', () => { }); it('should return false when user does not have any of the specified roles', () => { - store.select.and.callFake((selector: any) => { - if (selector === AuthSelectors.selectUserRole) return of('Accountant'); - return of(null); - }); + store.select.and.returnValue(of('Accountant')); const result = service.hasAnyRole(['Admin', 'PropertyManager']); expect(result).toBe(false); }); + + it('should return false when checking empty array', () => { + const result = service.hasAnyRole([]); + + expect(result).toBe(false); + }); + + it('should return true when user role is in a single-item array', () => { + store.select.and.returnValue(of('Admin')); + + const result = service.hasAnyRole(['Admin']); + + expect(result).toBe(true); + }); + + it('should return true when user has last role in array', () => { + store.select.and.returnValue(of('Accountant')); + + const result = service.hasAnyRole(['Admin', 'PropertyManager', 'Accountant']); + + expect(result).toBe(true); + }); }); describe('isAdmin', () => { @@ -185,80 +414,200 @@ describe('AuthService', () => { const result = service.isAdmin(); expect(result).toBe(true); - expect(store.select).toHaveBeenCalledWith(AuthSelectors.selectIsAdmin); + }); + + it('should return false for non-admin user', () => { + store.select.and.returnValue(of(false)); + + const result = service.isAdmin(); + + expect(result).toBe(false); }); }); describe('isPropertyManager', () => { - it('should return false for admin user', () => { + it('should return true for property manager user', () => { + store.select.and.returnValue(of(true)); + + const result = service.isPropertyManager(); + + expect(result).toBe(true); + }); + + it('should return false for non-property manager user', () => { const result = service.isPropertyManager(); expect(result).toBe(false); - expect(store.select).toHaveBeenCalledWith(AuthSelectors.selectIsPropertyManager); }); }); describe('isAccountant', () => { - it('should return false for admin user', () => { + it('should return true for accountant user', () => { + store.select.and.returnValue(of(true)); + + const result = service.isAccountant(); + + expect(result).toBe(true); + }); + + it('should return false for non-accountant user', () => { const result = service.isAccountant(); expect(result).toBe(false); - expect(store.select).toHaveBeenCalledWith(AuthSelectors.selectIsAccountant); }); }); describe('reactive methods', () => { - it('should provide reactive hasRole$', () => { + it('should provide reactive hasRole$', (done) => { const result$ = service.hasRole$('Admin'); - expect(result$).toBeDefined(); + result$.subscribe((hasRole) => { + expect(hasRole).toBe(true); + done(); + }); }); - it('should provide reactive hasAnyRole$', () => { + it('should provide reactive hasRole$ for non-matching role', (done) => { + store.select.and.returnValue(of(false)); + + const result$ = service.hasRole$('PropertyManager'); + + result$.subscribe((hasRole) => { + expect(hasRole).toBe(false); + done(); + }); + }); + + it('should provide reactive hasAnyRole$', (done) => { const roles = ['Admin', 'PropertyManager']; const result$ = service.hasAnyRole$(roles); - expect(result$).toBeDefined(); + result$.subscribe((hasAnyRole) => { + expect(hasAnyRole).toBe(true); + done(); + }); + }); + + it('should provide reactive hasAnyRole$ for empty array', (done) => { + store.select.and.returnValue(of(false)); + + const result$ = service.hasAnyRole$([]); + + result$.subscribe((hasAnyRole) => { + expect(hasAnyRole).toBe(false); + done(); + }); }); - it('should provide reactive isAdmin$', () => { + it('should provide reactive isAdmin$', (done) => { const result$ = service.isAdmin$(); - expect(result$).toBeDefined(); + result$.subscribe((isAdmin) => { + expect(isAdmin).toBe(true); + done(); + }); }); - it('should provide reactive isPropertyManager$', () => { + it('should provide reactive isPropertyManager$', (done) => { const result$ = service.isPropertyManager$(); - expect(result$).toBeDefined(); + result$.subscribe((isPM) => { + expect(isPM).toBe(false); + done(); + }); }); - it('should provide reactive isAccountant$', () => { + it('should provide reactive isAccountant$', (done) => { const result$ = service.isAccountant$(); - expect(result$).toBeDefined(); + result$.subscribe((isAccountant) => { + expect(isAccountant).toBe(false); + done(); + }); }); }); describe('observables', () => { - it('should expose isAuthenticated$ observable', () => { - expect(service.isAuthenticated$).toBeDefined(); + it('should expose isAuthenticated$ observable', (done) => { + service.isAuthenticated$.subscribe((isAuth) => { + expect(isAuth).toBe(true); + done(); + }); }); - it('should expose user$ observable', () => { - expect(service.user$).toBeDefined(); + it('should expose user$ observable', (done) => { + service.user$.subscribe((user) => { + expect(user).toEqual(mockUser); + done(); + }); }); - it('should expose loading$ observable', () => { - expect(service.loading$).toBeDefined(); + it('should expose loading$ observable', (done) => { + service.loading$.subscribe((loading) => { + expect(loading).toBe(false); + done(); + }); }); - it('should expose error$ observable', () => { - expect(service.error$).toBeDefined(); + it('should expose error$ observable', (done) => { + service.error$.subscribe((error) => { + expect(error).toBeNull(); + done(); + }); }); - it('should expose userRole$ observable', () => { - expect(service.userRole$).toBeDefined(); + it('should expose userRole$ observable', (done) => { + service.userRole$.subscribe((role) => { + expect(role).toBe('Admin'); + done(); + }); + }); + + it('should handle loading state changes', (done) => { + loadingSubject.next(true); + + service.loading$.subscribe((loading) => { + expect(loading).toBe(true); + done(); + }); + }); + + it('should handle error state changes', (done) => { + const error = 'Authentication failed'; + errorSubject.next(error); + + service.error$.subscribe((err) => { + expect(err).toBe(error); + done(); + }); + }); + }); + + describe('edge cases', () => { + it('should handle multiple consecutive calls to getUser', () => { + const result1 = service.getUser(); + const result2 = service.getUser(); + + expect(result1).toEqual(mockUser); + expect(result2).toEqual(mockUser); + }); + + it('should handle rapid role changes', () => { + store.select.and.returnValue(of('Admin')); + expect(service.getUserRole()).toBe('Admin'); + + store.select.and.returnValue(of('PropertyManager')); + expect(service.getUserRole()).toBe('PropertyManager'); + + store.select.and.returnValue(of('Accountant')); + expect(service.getUserRole()).toBe('Accountant'); + }); + + it('should handle null user gracefully', () => { + store.select.and.returnValue(of(null)); + + const user = service.getUser(); + expect(user).toBeNull(); }); }); }); diff --git a/src/frontend/src/app/core/services/auth.service.ts b/src/frontend/src/app/core/services/auth.service.ts index c4efdf9..7cab9e0 100644 --- a/src/frontend/src/app/core/services/auth.service.ts +++ b/src/frontend/src/app/core/services/auth.service.ts @@ -141,7 +141,10 @@ export class AuthService { const expiresAt = localStorage.getItem(this.EXPIRES_AT_KEY); if (!expiresAt) return true; - return new Date() >= new Date(expiresAt); + const expiryDate = new Date(expiresAt); + if (isNaN(expiryDate.getTime())) return true; + + return new Date() >= expiryDate; } hasRole(role: string): boolean { diff --git a/src/frontend/src/app/core/services/tenant.service.spec.ts b/src/frontend/src/app/core/services/tenant.service.spec.ts new file mode 100644 index 0000000..8c76399 --- /dev/null +++ b/src/frontend/src/app/core/services/tenant.service.spec.ts @@ -0,0 +1,76 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TenantService } from './tenant.service'; +import { environment } from '../../../environments/environment'; +import { CreateTenantRequest, Tenant } from '../models/tenant.model'; + +describe('TenantService', () => { + let service: TenantService; + let httpMock: HttpTestingController; + + const mockTenant: Tenant = { + id: 1, + name: 'John Doe', + tenant_type: 'Individual', + email: 'john@example.com', + phone_number: '1234567890', + nid_number: 'NID123', + address: '123 Main St', + active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + const createRequest: CreateTenantRequest = { + name: 'John Doe', + tenant_type: 'Individual', + email: 'john@example.com', + phone_number: '1234567890', + nid_number: 'NID123', + address: '123 Main St', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [TenantService], + }); + service = TestBed.inject(TenantService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should create a tenant', () => { + service.createTenant(createRequest).subscribe((tenant) => { + expect(tenant).toEqual(mockTenant); + }); + + const req = httpMock.expectOne(`${environment.apiUrl}/tenants`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(createRequest); + req.flush(mockTenant); + }); + + it('should handle error when creating tenant fails', () => { + const errorMsg = 'Email already exists'; + + service.createTenant(createRequest).subscribe({ + next: () => fail('should have failed with 409 error'), + error: (error) => { + expect(error.status).toBe(409); + expect(error.error).toBe(errorMsg); + }, + }); + + const req = httpMock.expectOne(`${environment.apiUrl}/tenants`); + expect(req.request.method).toBe('POST'); + req.flush(errorMsg, { status: 409, statusText: 'Conflict' }); + }); +}); diff --git a/src/frontend/src/app/core/services/tenant.service.ts b/src/frontend/src/app/core/services/tenant.service.ts new file mode 100644 index 0000000..20fd1e3 --- /dev/null +++ b/src/frontend/src/app/core/services/tenant.service.ts @@ -0,0 +1,17 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { CreateTenantRequest, Tenant } from '../models/tenant.model'; +import { environment } from '../../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class TenantService { + private http = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/tenants`; + + createTenant(tenant: CreateTenantRequest): Observable { + return this.http.post(this.apiUrl, tenant); + } +} diff --git a/src/frontend/src/app/features/auth/login/login.spec.ts b/src/frontend/src/app/features/auth/login/login.spec.ts index 286f6cc..41225ca 100644 --- a/src/frontend/src/app/features/auth/login/login.spec.ts +++ b/src/frontend/src/app/features/auth/login/login.spec.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Store } from '@ngrx/store'; -import { of, Subject } from 'rxjs'; +import { of, BehaviorSubject } from 'rxjs'; import { Login } from './login'; import { AuthService } from '../../../core/services/auth.service'; import { AppState } from '../../../store'; @@ -17,15 +17,20 @@ describe('Login Component', () => { let router: jasmine.SpyObj; let snackBar: jasmine.SpyObj; - const mockAuthService = { - login: jasmine.createSpy('login'), - clearError: jasmine.createSpy('clearError'), - loading$: of(false), - error$: of(null), - isAuthenticated$: of(false), - }; + let loadingSubject: BehaviorSubject; + let errorSubject: BehaviorSubject; + let isAuthenticatedSubject: BehaviorSubject; beforeEach(async () => { + loadingSubject = new BehaviorSubject(false); + errorSubject = new BehaviorSubject(null); + isAuthenticatedSubject = new BehaviorSubject(false); + + const authServiceSpy = jasmine.createSpyObj('AuthService', ['login', 'clearError']); + authServiceSpy.loading$ = loadingSubject.asObservable(); + authServiceSpy.error$ = errorSubject.asObservable(); + authServiceSpy.isAuthenticated$ = isAuthenticatedSubject.asObservable(); + const storeSpy = jasmine.createSpyObj('Store', ['select', 'dispatch']); const routerSpy = jasmine.createSpyObj('Router', ['navigate']); const snackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']); @@ -33,7 +38,7 @@ describe('Login Component', () => { await TestBed.configureTestingModule({ imports: [Login, ReactiveFormsModule, NoopAnimationsModule], providers: [ - { provide: AuthService, useValue: mockAuthService }, + { provide: AuthService, useValue: authServiceSpy }, { provide: Store, useValue: storeSpy }, { provide: Router, useValue: routerSpy }, { provide: MatSnackBar, useValue: snackBarSpy }, @@ -89,11 +94,6 @@ describe('Login Component', () => { }); it('should navigate to dashboard and show success message on successful authentication', () => { - const isAuthenticatedSubject = new Subject(); - authService.isAuthenticated$ = isAuthenticatedSubject.asObservable(); - - component.ngOnInit(); - isAuthenticatedSubject.next(true); expect(snackBar.open).toHaveBeenCalledWith('Login successful!', 'Close', { duration: 3000 }); @@ -101,11 +101,6 @@ describe('Login Component', () => { }); it('should show error message when authentication fails', () => { - const errorSubject = new Subject(); - authService.error$ = errorSubject.asObservable(); - - component.ngOnInit(); - const error = { error: { error: 'Invalid credentials' } }; errorSubject.next(error); @@ -113,11 +108,6 @@ describe('Login Component', () => { }); it('should show default error message when error has no specific message', () => { - const errorSubject = new Subject(); - authService.error$ = errorSubject.asObservable(); - - component.ngOnInit(); - const error = {}; errorSubject.next(error); @@ -128,21 +118,17 @@ describe('Login Component', () => { ); }); - it('should have loading$ observable from authService', () => { - expect(component.loading$).toBe(authService.loading$); - }); + it('should have loading signal updated from authService', () => { + loadingSubject.next(true); + expect(component.loading()).toBe(true); - it('should have error$ observable from authService', () => { - expect(component.error$).toBe(authService.error$); + loadingSubject.next(false); + expect(component.loading()).toBe(false); }); - it('should unsubscribe on destroy', () => { - spyOn(component['destroy$'], 'next'); - spyOn(component['destroy$'], 'complete'); - - component.ngOnDestroy(); - - expect(component['destroy$'].next).toHaveBeenCalled(); - expect(component['destroy$'].complete).toHaveBeenCalled(); + it('should have error signal updated from authService', () => { + const error = { message: 'Test error' }; + errorSubject.next(error); + expect(component.error()).toBe(error); }); }); diff --git a/src/frontend/src/app/features/properties/property-card/property-card.spec.ts b/src/frontend/src/app/features/properties/property-card/property-card.spec.ts index a43e47a..742712c 100644 --- a/src/frontend/src/app/features/properties/property-card/property-card.spec.ts +++ b/src/frontend/src/app/features/properties/property-card/property-card.spec.ts @@ -1,18 +1,25 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PropertyCard } from './property-card'; +import { PropertyCardComponent } from './property-card'; describe('PropertyCard', () => { - let component: PropertyCard; - let fixture: ComponentFixture; + let component: PropertyCardComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [PropertyCard], + imports: [PropertyCardComponent], }).compileComponents(); - fixture = TestBed.createComponent(PropertyCard); + fixture = TestBed.createComponent(PropertyCardComponent); component = fixture.componentInstance; + component.property = { + id: 1, + property_name: 'Test Property', + address: '123 Test St', + property_type: 'Residential', + expanded: false, + } as any; fixture.detectChanges(); }); diff --git a/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.html b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.html new file mode 100644 index 0000000..8b0c8e5 --- /dev/null +++ b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.html @@ -0,0 +1,86 @@ +

{{ dialogTitle }}

+ +
+ +
+ + + Full Name + + person + {{ getErrorMessage('name') }} + + +
+ + + Tenant Type + + @for (type of tenantTypes; track type) { + {{ type }} + } + + {{ getErrorMessage('tenant_type') }} + + + + + NID / Registry No. + + badge + {{ getErrorMessage('nid_number') }} + +
+ +
+ + + Phone Number + + phone + {{ getErrorMessage('phone_number') }} + +
+ +
+ + + Email Address + + email + {{ getErrorMessage('email') }} + +
+ + + + Address + + map + {{ getErrorMessage('address') }} + +
+
+ + + + + +
diff --git a/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.scss b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.scss new file mode 100644 index 0000000..d5ed9a1 --- /dev/null +++ b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.scss @@ -0,0 +1,36 @@ +.form-grid { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; + min-width: 500px; +} + +.full-width { + width: 100%; +} + +.row { + display: flex; + gap: 16px; + width: 100%; + + mat-form-field { + flex: 1; + } +} + +mat-dialog-content { + overflow-x: hidden; +} + +@media (max-width: 600px) { + .form-grid { + min-width: auto; + } + + .row { + flex-direction: column; + gap: 0; + } +} diff --git a/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.spec.ts b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.spec.ts new file mode 100644 index 0000000..76bd9a1 --- /dev/null +++ b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.spec.ts @@ -0,0 +1,127 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TenantFormDialogComponent } from './tenant-form-dialog'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Tenant } from '../../../core/models/tenant.model'; + +describe('TenantFormDialogComponent', () => { + let component: TenantFormDialogComponent; + let fixture: ComponentFixture; + let dialogRefSpy: jasmine.SpyObj>; + + const mockTenant: Tenant = { + id: 1, + name: 'John Doe', + tenant_type: 'Individual', + email: 'john@example.com', + phone_number: '1234567890', + nid_number: 'NID123', + address: '123 Main St', + active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + beforeEach(async () => { + dialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [ + TenantFormDialogComponent, + ReactiveFormsModule, + MatDialogModule, + NoopAnimationsModule, + ], + providers: [ + FormBuilder, + { provide: MatDialogRef, useValue: dialogRefSpy }, + { provide: MAT_DIALOG_DATA, useValue: { mode: 'create' } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TenantFormDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize form in create mode', () => { + expect(component.tenantForm.get('name')?.value).toBe(''); + expect(component.tenantForm.get('tenant_type')?.value).toBe('Individual'); + expect(component.isEditMode).toBeFalse(); + expect(component.dialogTitle).toBe('Add New Tenant'); + }); + + it('should initialize form in edit mode with data', () => { + // Re-configure for edit mode + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + TenantFormDialogComponent, + ReactiveFormsModule, + MatDialogModule, + NoopAnimationsModule, + ], + providers: [ + FormBuilder, + { provide: MatDialogRef, useValue: dialogRefSpy }, + { provide: MAT_DIALOG_DATA, useValue: { mode: 'edit', tenant: mockTenant } }, + ], + }); + + const editFixture = TestBed.createComponent(TenantFormDialogComponent); + const editComponent = editFixture.componentInstance; + editFixture.detectChanges(); + + expect(editComponent.tenantForm.get('name')?.value).toBe(mockTenant.name); + expect(editComponent.tenantForm.get('email')?.value).toBe(mockTenant.email); + expect(editComponent.isEditMode).toBeTrue(); + expect(editComponent.dialogTitle).toBe('Edit Tenant'); + }); + + it('should mark form as invalid when required fields are empty', () => { + component.tenantForm.patchValue({ + name: '', + tenant_type: '', + }); + expect(component.tenantForm.valid).toBeFalse(); + }); + + it('should validate email format', () => { + const emailControl = component.tenantForm.get('email'); + emailControl?.setValue('invalid-email'); + expect(emailControl?.valid).toBeFalse(); + expect(emailControl?.errors?.['email']).toBeTruthy(); + + emailControl?.setValue('valid@example.com'); + expect(emailControl?.valid).toBeTrue(); + }); + + it('should close dialog with form value on valid submit', () => { + const formData = { + name: 'Jane Doe', + tenant_type: 'Individual', + email: 'jane@example.com', + phone_number: '9876543210', + nid_number: 'NID456', + address: '456 Elm St', + }; + + component.tenantForm.patchValue(formData); + component.onSubmit(); + + expect(dialogRefSpy.close).toHaveBeenCalledWith(jasmine.objectContaining(formData)); + }); + + it('should not close dialog on invalid submit', () => { + component.tenantForm.patchValue({ name: '' }); + component.onSubmit(); + + expect(dialogRefSpy.close).not.toHaveBeenCalled(); + expect(component.tenantForm.get('name')?.touched).toBeTrue(); + }); +}); diff --git a/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.ts b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.ts new file mode 100644 index 0000000..95059b5 --- /dev/null +++ b/src/frontend/src/app/features/tenants/tenant-form-dialog/tenant-form-dialog.ts @@ -0,0 +1,109 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { CreateTenantRequest, Tenant, TenantType } from '../../../core/models/tenant.model'; + +export interface TenantFormDialogData { + tenant?: Tenant; + mode: 'create' | 'edit'; +} + +@Component({ + selector: 'app-tenant-form-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + ], + templateUrl: './tenant-form-dialog.html', + styleUrls: ['./tenant-form-dialog.scss'], +}) +export class TenantFormDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private dialogRef = inject(MatDialogRef); + public data = inject(MAT_DIALOG_DATA); + + tenantForm!: FormGroup; + tenantTypes: TenantType[] = ['Individual', 'Business']; + + ngOnInit() { + this.initializeForm(); + } + + private initializeForm() { + const tenant = this.data.tenant; + + this.tenantForm = this.fb.group({ + name: [tenant?.name || '', [Validators.required, Validators.maxLength(100)]], + tenant_type: [tenant?.tenant_type || 'Individual', [Validators.required]], + nid_number: [tenant?.nid_number || '', [Validators.required, Validators.maxLength(20)]], + phone_number: [tenant?.phone_number || '', [Validators.required, Validators.maxLength(20)]], + email: [tenant?.email || '', [Validators.email]], + address: [tenant?.address || ''], + }); + } + + onSubmit() { + if (this.tenantForm.valid) { + this.dialogRef.close(this.tenantForm.value); + } else { + Object.keys(this.tenantForm.controls).forEach((key) => { + this.tenantForm.get(key)?.markAsTouched(); + }); + } + } + + onCancel() { + this.dialogRef.close(); + } + + getErrorMessage(fieldName: string): string { + const control = this.tenantForm.get(fieldName); + if (!control || !control.errors || !control.touched) { + return ''; + } + + if (control.errors['required']) { + return `${this.getFieldLabel(fieldName)} is required`; + } + if (control.errors['email']) { + return 'Invalid email address'; + } + if (control.errors['maxlength']) { + return `Maximum length is ${control.errors['maxlength'].requiredLength}`; + } + return 'Invalid value'; + } + + private getFieldLabel(fieldName: string): string { + const labels: { [key: string]: string } = { + name: 'Full Name', + tenant_type: 'Tenant Type', + email: 'Email', + phone_number: 'Phone Number', + nid_number: 'NID/Registration No.', + address: 'Address', + }; + return labels[fieldName] || fieldName; + } + + get isEditMode(): boolean { + return this.data.mode === 'edit'; + } + + get dialogTitle(): string { + return this.isEditMode ? 'Edit Tenant' : 'Add New Tenant'; + } +} diff --git a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html index 7d3190a..163f686 100644 --- a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html +++ b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html @@ -1,5 +1,12 @@
-

Tenant Management

+
+

Tenants

+ +
+

Tenant management functionality will be implemented in later tasks

diff --git a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.scss b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.scss index 174063c..d45ffe5 100644 --- a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.scss +++ b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.scss @@ -1,3 +1,16 @@ .tenant-list-container { padding: 20px; + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + h1 { + margin: 0; + font-size: 24px; + font-weight: 500; + } + } } diff --git a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts index 80ac223..02bf9ce 100644 --- a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts +++ b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts @@ -1,12 +1,46 @@ -import { Component } from '@angular/core'; - +import { Component, inject } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { TenantService } from '../../../core/services/tenant.service'; +import { TenantFormDialogComponent } from '../tenant-form-dialog/tenant-form-dialog'; +import { AuthFacade } from '../../../store/auth/auth.facade'; +import { take } from 'rxjs'; @Component({ selector: 'app-tenant-list', standalone: true, - imports: [MatCardModule], + imports: [MatCardModule, MatButtonModule, MatIconModule, MatDialogModule], templateUrl: './tenant-list.html', styleUrls: ['./tenant-list.scss'], }) -export class TenantList {} +export class TenantList { + private dialog = inject(MatDialog); + private tenantService = inject(TenantService); + private authFacade = inject(AuthFacade); + + addTenant() { + const dialogRef = this.dialog.open(TenantFormDialogComponent, { + width: '600px', + data: { mode: 'create' }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.authFacade.user$.pipe(take(1)).subscribe((user) => { + const userID = user?.id || 0; + this.tenantService.createTenant(result).subscribe({ + next: (tenant) => { + console.log('Tenant created successfully:', tenant); + // In a real scenario, we might want to refresh the list or show a snackbar + }, + error: (err) => { + console.error('Error creating tenant:', err); + }, + }); + }); + } + }); + } +} diff --git a/src/frontend/src/app/store/auth/auth.effects.spec.ts b/src/frontend/src/app/store/auth/auth.effects.spec.ts index d6b2388..0029c41 100644 --- a/src/frontend/src/app/store/auth/auth.effects.spec.ts +++ b/src/frontend/src/app/store/auth/auth.effects.spec.ts @@ -56,6 +56,7 @@ describe('AuthEffects', () => { describe('login$', () => { it('should return loginSuccess action on successful demo login', (done) => { + spyOn(effects, 'isDemoMode').and.returnValue(true); const credentials = { username: 'demo', password: 'demo123' }; const action = AuthActions.login({ credentials }); @@ -69,6 +70,7 @@ describe('AuthEffects', () => { }); it('should return loginFailure action on invalid demo credentials', (done) => { + spyOn(effects, 'isDemoMode').and.returnValue(true); const credentials = { username: 'invalid', password: 'invalid' }; const action = AuthActions.login({ credentials }); @@ -102,6 +104,7 @@ describe('AuthEffects', () => { describe('logout$', () => { it('should return logoutSuccess action in demo mode', (done) => { + spyOn(effects, 'isDemoMode').and.returnValue(true); const action = AuthActions.logout(); actions$ = of(action); @@ -136,6 +139,7 @@ describe('AuthEffects', () => { describe('refreshToken$', () => { it('should return refreshTokenSuccess action in demo mode', (done) => { + spyOn(effects, 'isDemoMode').and.returnValue(true); localStorage.setItem('tenantly_refresh_token', 'test-refresh-token'); localStorage.setItem('tenantly_user', JSON.stringify(mockLoginResponse.user)); @@ -202,6 +206,7 @@ describe('AuthEffects', () => { describe('changePassword$', () => { it('should return changePasswordSuccess action in demo mode', (done) => { + spyOn(effects, 'isDemoMode').and.returnValue(true); const request = { current_password: 'old', new_password: 'new' }; const action = AuthActions.changePassword({ request }); actions$ = of(action); @@ -216,6 +221,7 @@ describe('AuthEffects', () => { describe('resetPassword$', () => { it('should return resetPasswordSuccess action in demo mode', (done) => { + spyOn(effects, 'isDemoMode').and.returnValue(true); const request = { email: 'test@example.com' }; const action = AuthActions.resetPassword({ request }); actions$ = of(action);