@@ -737,6 +737,105 @@ func TestNotifications(t *testing.T) {
737737require .Contains (t ,sent [i ].Targets ,dormantWs .OwnerID )
738738}
739739})
740+
741+ // Regression test for https://github.com/coder/coder/issues/20913
742+ // Deleted workspaces should not receive dormancy notifications.
743+ t .Run ("DeletedWorkspacesNotNotified" ,func (t * testing.T ) {
744+ t .Parallel ()
745+
746+ var (
747+ db ,_ = dbtestutil .NewDB (t )
748+ ctx = testutil .Context (t ,testutil .WaitLong )
749+ user = dbgen .User (t ,db , database.User {})
750+ file = dbgen .File (t ,db , database.File {
751+ CreatedBy :user .ID ,
752+ })
753+ templateJob = dbgen .ProvisionerJob (t ,db ,nil , database.ProvisionerJob {
754+ FileID :file .ID ,
755+ InitiatorID :user .ID ,
756+ Tags : database.StringMap {
757+ "foo" :"bar" ,
758+ },
759+ })
760+ timeTilDormant = time .Minute * 2
761+ templateVersion = dbgen .TemplateVersion (t ,db , database.TemplateVersion {
762+ CreatedBy :user .ID ,
763+ JobID :templateJob .ID ,
764+ OrganizationID :templateJob .OrganizationID ,
765+ })
766+ template = dbgen .Template (t ,db , database.Template {
767+ ActiveVersionID :templateVersion .ID ,
768+ CreatedBy :user .ID ,
769+ OrganizationID :templateJob .OrganizationID ,
770+ TimeTilDormant :int64 (timeTilDormant ),
771+ TimeTilDormantAutoDelete :int64 (timeTilDormant ),
772+ })
773+ )
774+
775+ // Create a dormant workspace that is NOT deleted.
776+ activeDormantWorkspace := dbgen .Workspace (t ,db , database.WorkspaceTable {
777+ OwnerID :user .ID ,
778+ TemplateID :template .ID ,
779+ OrganizationID :templateJob .OrganizationID ,
780+ LastUsedAt :time .Now ().Add (- time .Hour ),
781+ })
782+ _ ,err := db .UpdateWorkspaceDormantDeletingAt (ctx , database.UpdateWorkspaceDormantDeletingAtParams {
783+ ID :activeDormantWorkspace .ID ,
784+ DormantAt : sql.NullTime {
785+ Time :activeDormantWorkspace .LastUsedAt .Add (timeTilDormant ),
786+ Valid :true ,
787+ },
788+ })
789+ require .NoError (t ,err )
790+
791+ // Create a dormant workspace that IS deleted.
792+ deletedDormantWorkspace := dbgen .Workspace (t ,db , database.WorkspaceTable {
793+ OwnerID :user .ID ,
794+ TemplateID :template .ID ,
795+ OrganizationID :templateJob .OrganizationID ,
796+ LastUsedAt :time .Now ().Add (- time .Hour ),
797+ Deleted :true ,// Mark as deleted
798+ })
799+ _ ,err = db .UpdateWorkspaceDormantDeletingAt (ctx , database.UpdateWorkspaceDormantDeletingAtParams {
800+ ID :deletedDormantWorkspace .ID ,
801+ DormantAt : sql.NullTime {
802+ Time :deletedDormantWorkspace .LastUsedAt .Add (timeTilDormant ),
803+ Valid :true ,
804+ },
805+ })
806+ require .NoError (t ,err )
807+
808+ // Setup dependencies
809+ notifyEnq := notificationstest .NewFakeEnqueuer ()
810+ logger := slogtest .Make (t ,& slogtest.Options {IgnoreErrors :true }).Leveled (slog .LevelDebug )
811+ const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
812+ userQuietHoursStore ,err := schedule .NewEnterpriseUserQuietHoursScheduleStore (userQuietHoursSchedule ,true )
813+ require .NoError (t ,err )
814+ userQuietHoursStorePtr := & atomic.Pointer [agplschedule.UserQuietHoursScheduleStore ]{}
815+ userQuietHoursStorePtr .Store (& userQuietHoursStore )
816+ templateScheduleStore := schedule .NewEnterpriseTemplateScheduleStore (userQuietHoursStorePtr ,notifyEnq ,logger ,nil )
817+
818+ // Lower the dormancy TTL to ensure the schedule recalculates deadlines and
819+ // triggers notifications.
820+ _ ,err = templateScheduleStore .Set (dbauthz .AsNotifier (ctx ),db ,template , agplschedule.TemplateScheduleOptions {
821+ TimeTilDormant :timeTilDormant / 2 ,
822+ TimeTilDormantAutoDelete :timeTilDormant / 2 ,
823+ })
824+ require .NoError (t ,err )
825+
826+ // We should only receive a notification for the non-deleted dormant workspace.
827+ sent := notifyEnq .Sent ()
828+ require .Len (t ,sent ,1 ,"expected exactly 1 notification for the non-deleted workspace" )
829+ require .Equal (t ,sent [0 ].UserID ,activeDormantWorkspace .OwnerID )
830+ require .Equal (t ,sent [0 ].TemplateID ,notifications .TemplateWorkspaceMarkedForDeletion )
831+ require .Contains (t ,sent [0 ].Targets ,activeDormantWorkspace .ID )
832+
833+ // Ensure the deleted workspace was NOT notified
834+ for _ ,notification := range sent {
835+ require .NotContains (t ,notification .Targets ,deletedDormantWorkspace .ID ,
836+ "deleted workspace should not receive notifications" )
837+ }
838+ })
740839}
741840
742841func TestTemplateTTL (t * testing.T ) {