package projects import ( "encoding/json" "fmt" "testing" "time" "github.com/stretchr/testify/require" "senlinai-agent/backend/internal/models" ) func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) { database := newTestDB(t) service := NewService() require.NoError(t, database.Create(&models.SaUser{Email: "david@example.com", DisplayName: "David", PasswordHash: "hash"}).Error) project, err := service.CreateProjectWithInput(1, CreateProjectRequest{ Name: "Project A1", Identifier: "A1", Icon: "folder", Background: "#165DFF", Description: "Client strategy workspace", }) require.NoError(t, err) other, err := service.CreateProject(1, "Project A2", "") require.NoError(t, err) projectTag := models.SaTag{ProjectID: project.ID, Name: "UI"} otherTag := models.SaTag{ProjectID: other.ID, Name: "UI"} require.NoError(t, database.Create(&projectTag).Error) require.NoError(t, database.Create(&otherTag).Error) localZone := time.FixedZone("UTC+8", 8*60*60) createdAt := time.Date(2026, 7, 20, 10, 30, 0, 0, localZone) updatedAt := createdAt.Add(90 * time.Minute) nextRun := time.Date(2026, 7, 21, 9, 0, 0, 0, localZone) inbox := models.SaInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn research into tasks.", Status: "open", CreatedAt: createdAt, UpdatedAt: updatedAt} task := models.SaTask{ProjectID: project.ID, CreatedBy: 1, TagID: &projectTag.ID, Title: "Confirm IA", Description: "Review channel layout.", Status: "done", DueAt: &nextRun, CreatedAt: createdAt, UpdatedAt: updatedAt} session := models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI critique", Context: "Discuss channel behavior.", CreatedAt: createdAt, UpdatedAt: updatedAt} firstDocument := models.SaDocumentTree{ProjectID: project.ID, OwnerID: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", MimeType: "text/markdown", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt} secondDocument := models.SaDocumentTree{ProjectID: project.ID, OwnerID: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", MimeType: "application/pdf", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt} channel := models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap", SortOrder: 7, CreatedAt: createdAt, UpdatedAt: updatedAt} plan := models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly review", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, CreatedAt: createdAt, UpdatedAt: updatedAt} for _, record := range []any{&inbox, &task, &session, &firstDocument, &secondDocument, &channel, &plan} { require.NoError(t, database.Create(record).Error) } workspace, err := service.Workspace(1, project.Identity) require.NoError(t, err) require.Equal(t, project.Identity, workspace.Project.ID) require.Equal(t, []WorkspaceTagDTO{{ID: projectTag.Identity, Name: "UI"}}, workspace.Tags) require.Len(t, workspace.Channels, 7) require.Equal(t, project.Identity, workspace.Channels[0].ProjectID) require.Equal(t, channel.Identity, workspace.Channels[6].ID) require.Equal(t, project.Identity, workspace.Channels[6].ProjectID) require.Len(t, workspace.Inbox, 1) require.Equal(t, inbox.Identity, workspace.Inbox[0].ID) require.Equal(t, project.Identity, workspace.Inbox[0].ProjectID) require.Equal(t, updatedAt.UTC(), workspace.Inbox[0].Time) require.Len(t, workspace.Tasks, 1) require.Equal(t, task.Identity, workspace.Tasks[0].ID) require.Equal(t, project.Identity, workspace.Tasks[0].ProjectID) require.Equal(t, projectTag.Identity, *workspace.Tasks[0].TagID) require.Equal(t, "UI", workspace.Tasks[0].Tag) require.Equal(t, nextRun.UTC(), *workspace.Tasks[0].Due) require.Equal(t, createdAt.UTC(), workspace.Tasks[0].CreatedAt) require.Nil(t, workspace.Tasks[0].CompletedAt) require.Len(t, workspace.AISessions, 1) require.Equal(t, session.Identity, workspace.AISessions[0].ID) require.Equal(t, project.Identity, workspace.AISessions[0].ProjectID) require.Equal(t, updatedAt.UTC(), workspace.AISessions[0].UpdatedAt) require.Equal(t, session.Identity, workspace.RecentSessions[0].ID) require.Len(t, workspace.Documents, 2) require.Equal(t, secondDocument.Identity, workspace.Documents[0].ID) require.Equal(t, firstDocument.Identity, workspace.Documents[1].ID) for _, item := range workspace.Documents { require.Equal(t, project.Identity, item.ProjectID) require.Equal(t, updatedAt.UTC(), item.UpdatedAt) } require.Len(t, workspace.CronPlans, 1) require.Equal(t, plan.Identity, workspace.CronPlans[0].ID) require.Equal(t, project.Identity, workspace.CronPlans[0].ProjectID) require.Equal(t, nextRun.UTC(), *workspace.CronPlans[0].NextRun) payload, err := json.Marshal(workspace) require.NoError(t, err) require.Contains(t, string(payload), `"createdAt":"2026-07-20T02:30:00Z"`) require.Contains(t, string(payload), `"updatedAt":"2026-07-20T04:00:00Z"`) require.Contains(t, string(payload), `"nextRun":"2026-07-21T01:00:00Z"`) require.NotContains(t, string(payload), fmt.Sprintf(`"id":%d`, task.ID)) require.NotContains(t, string(payload), "duration") } func TestWorkspaceChannelDTOSerializesRequiredCountAndURL(t *testing.T) { payload, err := json.Marshal(WorkspaceChannelDTO{}) require.NoError(t, err) require.Contains(t, string(payload), `"count":0`) require.Contains(t, string(payload), `"url":""`) } func TestWorkspaceDoneTaskDoesNotInventCompletedAt(t *testing.T) { database := newTestDB(t) service := NewService() require.NoError(t, database.Create(&models.SaUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error) project, err := service.CreateProject(1, "Alpha", "") require.NoError(t, err) createdAt := time.Date(2026, 7, 20, 1, 0, 0, 0, time.UTC) updatedAt := createdAt.Add(2 * time.Hour) require.NoError(t, database.Create(&models.SaTask{ ProjectID: project.ID, CreatedBy: 1, Title: "Done", Status: "done", CreatedAt: createdAt, UpdatedAt: updatedAt, }).Error) workspace, err := service.Workspace(1, project.Identity) require.NoError(t, err) require.Len(t, workspace.Tasks, 1) require.Nil(t, workspace.Tasks[0].CompletedAt) payload, err := json.Marshal(workspace.Tasks[0]) require.NoError(t, err) require.Contains(t, string(payload), `"completedAt":null`) } func TestWorkspaceRejectsProjectOwnedByAnotherUserByIdentity(t *testing.T) { newTestDB(t) service := NewService() project, err := service.CreateProject(2, "Beta", "") require.NoError(t, err) _, err = service.Workspace(1, project.Identity) require.Error(t, err) }