🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
459 lines
14 KiB
C#
459 lines
14 KiB
C#
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
|
using ColaFlow.Modules.ProjectManagement.Domain.Exceptions;
|
|
using FluentAssertions;
|
|
|
|
namespace ColaFlow.Domain.Tests.Aggregates;
|
|
|
|
/// <summary>
|
|
/// Unit tests for Story entity
|
|
/// </summary>
|
|
public class StoryTests
|
|
{
|
|
#region Create Tests
|
|
|
|
[Fact]
|
|
public void Create_WithValidData_ShouldCreateStory()
|
|
{
|
|
// Arrange
|
|
var title = "User Story 1";
|
|
var description = "Story Description";
|
|
var epicId = EpicId.Create();
|
|
var priority = TaskPriority.High;
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
var story = Story.Create(title, description, epicId, priority, createdBy);
|
|
|
|
// Assert
|
|
story.Should().NotBeNull();
|
|
story.Title.Should().Be(title);
|
|
story.Description.Should().Be(description);
|
|
story.EpicId.Should().Be(epicId);
|
|
story.Status.Should().Be(WorkItemStatus.ToDo);
|
|
story.Priority.Should().Be(priority);
|
|
story.CreatedBy.Should().Be(createdBy);
|
|
story.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
|
|
story.UpdatedAt.Should().BeNull();
|
|
story.EstimatedHours.Should().BeNull();
|
|
story.ActualHours.Should().BeNull();
|
|
story.AssigneeId.Should().BeNull();
|
|
story.Tasks.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_WithNullDescription_ShouldCreateStoryWithEmptyDescription()
|
|
{
|
|
// Arrange
|
|
var title = "User Story 1";
|
|
string? description = null;
|
|
var epicId = EpicId.Create();
|
|
var priority = TaskPriority.Medium;
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
var story = Story.Create(title, description!, epicId, priority, createdBy);
|
|
|
|
// Assert
|
|
story.Should().NotBeNull();
|
|
story.Description.Should().Be(string.Empty);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(null)]
|
|
public void Create_WithEmptyTitle_ShouldThrowDomainException(string invalidTitle)
|
|
{
|
|
// Arrange
|
|
var epicId = EpicId.Create();
|
|
var priority = TaskPriority.Medium;
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
Action act = () => Story.Create(invalidTitle, "Description", epicId, priority, createdBy);
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Story title cannot be empty");
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_WithTitleExceeding200Characters_ShouldThrowDomainException()
|
|
{
|
|
// Arrange
|
|
var title = new string('A', 201);
|
|
var epicId = EpicId.Create();
|
|
var priority = TaskPriority.Medium;
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
Action act = () => Story.Create(title, "Description", epicId, priority, createdBy);
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Story title cannot exceed 200 characters");
|
|
}
|
|
|
|
[Fact]
|
|
public void Create_WithTitleExactly200Characters_ShouldSucceed()
|
|
{
|
|
// Arrange
|
|
var title = new string('A', 200);
|
|
var epicId = EpicId.Create();
|
|
var priority = TaskPriority.Medium;
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
var story = Story.Create(title, "Description", epicId, priority, createdBy);
|
|
|
|
// Assert
|
|
story.Should().NotBeNull();
|
|
story.Title.Should().Be(title);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region CreateTask Tests
|
|
|
|
[Fact]
|
|
public void CreateTask_WithValidData_ShouldCreateTask()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var taskTitle = "Task 1";
|
|
var taskDescription = "Task Description";
|
|
var priority = TaskPriority.Urgent;
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
var task = story.CreateTask(taskTitle, taskDescription, priority, createdBy);
|
|
|
|
// Assert
|
|
task.Should().NotBeNull();
|
|
task.Title.Should().Be(taskTitle);
|
|
task.Description.Should().Be(taskDescription);
|
|
task.StoryId.Should().Be(story.Id);
|
|
task.Priority.Should().Be(priority);
|
|
task.CreatedBy.Should().Be(createdBy);
|
|
story.Tasks.Should().ContainSingle();
|
|
story.Tasks.Should().Contain(task);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateTask_MultipleTasks_ShouldAddToCollection()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var createdBy = UserId.Create();
|
|
|
|
// Act
|
|
var task1 = story.CreateTask("Task 1", "Desc 1", TaskPriority.Low, createdBy);
|
|
var task2 = story.CreateTask("Task 2", "Desc 2", TaskPriority.Medium, createdBy);
|
|
var task3 = story.CreateTask("Task 3", "Desc 3", TaskPriority.High, createdBy);
|
|
|
|
// Assert
|
|
story.Tasks.Should().HaveCount(3);
|
|
story.Tasks.Should().Contain(new[] { task1, task2, task3 });
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region UpdateDetails Tests
|
|
|
|
[Fact]
|
|
public void UpdateDetails_WithValidData_ShouldUpdateStory()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Original Title", "Original Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var originalCreatedAt = story.CreatedAt;
|
|
var newTitle = "Updated Title";
|
|
var newDescription = "Updated Description";
|
|
|
|
// Act
|
|
story.UpdateDetails(newTitle, newDescription);
|
|
|
|
// Assert
|
|
story.Title.Should().Be(newTitle);
|
|
story.Description.Should().Be(newDescription);
|
|
story.CreatedAt.Should().Be(originalCreatedAt);
|
|
story.UpdatedAt.Should().NotBeNull();
|
|
story.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateDetails_WithNullDescription_ShouldSetEmptyDescription()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Original Title", "Original Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
story.UpdateDetails("Updated Title", null!);
|
|
|
|
// Assert
|
|
story.Description.Should().Be(string.Empty);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(null)]
|
|
public void UpdateDetails_WithEmptyTitle_ShouldThrowDomainException(string invalidTitle)
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Original Title", "Original Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
Action act = () => story.UpdateDetails(invalidTitle, "Updated Description");
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Story title cannot be empty");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateDetails_WithTitleExceeding200Characters_ShouldThrowDomainException()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Original Title", "Original Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var title = new string('A', 201);
|
|
|
|
// Act
|
|
Action act = () => story.UpdateDetails(title, "Updated Description");
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Story title cannot exceed 200 characters");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region UpdateStatus Tests
|
|
|
|
[Fact]
|
|
public void UpdateStatus_WithValidStatus_ShouldUpdateStatus()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var newStatus = WorkItemStatus.InProgress;
|
|
|
|
// Act
|
|
story.UpdateStatus(newStatus);
|
|
|
|
// Assert
|
|
story.Status.Should().Be(newStatus);
|
|
story.UpdatedAt.Should().NotBeNull();
|
|
story.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateStatus_ToAllStatuses_ShouldSucceed()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act & Assert
|
|
story.UpdateStatus(WorkItemStatus.InProgress);
|
|
story.Status.Should().Be(WorkItemStatus.InProgress);
|
|
|
|
story.UpdateStatus(WorkItemStatus.InReview);
|
|
story.Status.Should().Be(WorkItemStatus.InReview);
|
|
|
|
story.UpdateStatus(WorkItemStatus.Done);
|
|
story.Status.Should().Be(WorkItemStatus.Done);
|
|
|
|
story.UpdateStatus(WorkItemStatus.Blocked);
|
|
story.Status.Should().Be(WorkItemStatus.Blocked);
|
|
|
|
story.UpdateStatus(WorkItemStatus.ToDo);
|
|
story.Status.Should().Be(WorkItemStatus.ToDo);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region AssignTo Tests
|
|
|
|
[Fact]
|
|
public void AssignTo_WithValidUserId_ShouldAssignStory()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var assigneeId = UserId.Create();
|
|
|
|
// Act
|
|
story.AssignTo(assigneeId);
|
|
|
|
// Assert
|
|
story.AssigneeId.Should().Be(assigneeId);
|
|
story.UpdatedAt.Should().NotBeNull();
|
|
story.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void AssignTo_ReassignToDifferentUser_ShouldUpdateAssignee()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var firstAssignee = UserId.Create();
|
|
var secondAssignee = UserId.Create();
|
|
|
|
// Act
|
|
story.AssignTo(firstAssignee);
|
|
story.AssignTo(secondAssignee);
|
|
|
|
// Assert
|
|
story.AssigneeId.Should().Be(secondAssignee);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region UpdateEstimate Tests
|
|
|
|
[Fact]
|
|
public void UpdateEstimate_WithValidHours_ShouldUpdateEstimate()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var hours = 8.5m;
|
|
|
|
// Act
|
|
story.UpdateEstimate(hours);
|
|
|
|
// Assert
|
|
story.EstimatedHours.Should().Be(hours);
|
|
story.UpdatedAt.Should().NotBeNull();
|
|
story.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateEstimate_WithZeroHours_ShouldSucceed()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
story.UpdateEstimate(0);
|
|
|
|
// Assert
|
|
story.EstimatedHours.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateEstimate_WithNegativeHours_ShouldThrowDomainException()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
Action act = () => story.UpdateEstimate(-1);
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Estimated hours cannot be negative");
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateEstimate_MultipleUpdates_ShouldOverwritePreviousValue()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
story.UpdateEstimate(8);
|
|
story.UpdateEstimate(16);
|
|
|
|
// Assert
|
|
story.EstimatedHours.Should().Be(16);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region LogActualHours Tests
|
|
|
|
[Fact]
|
|
public void LogActualHours_WithValidHours_ShouldLogHours()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
var hours = 10.5m;
|
|
|
|
// Act
|
|
story.LogActualHours(hours);
|
|
|
|
// Assert
|
|
story.ActualHours.Should().Be(hours);
|
|
story.UpdatedAt.Should().NotBeNull();
|
|
story.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
[Fact]
|
|
public void LogActualHours_WithZeroHours_ShouldSucceed()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
story.LogActualHours(0);
|
|
|
|
// Assert
|
|
story.ActualHours.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void LogActualHours_WithNegativeHours_ShouldThrowDomainException()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
Action act = () => story.LogActualHours(-1);
|
|
|
|
// Assert
|
|
act.Should().Throw<DomainException>()
|
|
.WithMessage("Actual hours cannot be negative");
|
|
}
|
|
|
|
[Fact]
|
|
public void LogActualHours_MultipleUpdates_ShouldOverwritePreviousValue()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act
|
|
story.LogActualHours(8);
|
|
story.LogActualHours(12);
|
|
|
|
// Assert
|
|
story.ActualHours.Should().Be(12);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Entity Characteristics Tests
|
|
|
|
[Fact]
|
|
public void Tasks_Collection_ShouldBeReadOnly()
|
|
{
|
|
// Arrange
|
|
var story = Story.Create("Story 1", "Description", EpicId.Create(), TaskPriority.Medium, UserId.Create());
|
|
|
|
// Act & Assert
|
|
story.Tasks.Should().BeAssignableTo<IReadOnlyCollection<WorkTask>>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Story_ShouldHaveUniqueId()
|
|
{
|
|
// Arrange & Act
|
|
var epicId = EpicId.Create();
|
|
var createdBy = UserId.Create();
|
|
var story1 = Story.Create("Story 1", "Description", epicId, TaskPriority.Medium, createdBy);
|
|
var story2 = Story.Create("Story 2", "Description", epicId, TaskPriority.Medium, createdBy);
|
|
|
|
// Assert
|
|
story1.Id.Should().NotBe(story2.Id);
|
|
}
|
|
|
|
#endregion
|
|
}
|