Skip to content

feat: limit model with WEEKLY, CUSTOM types and time window support#69

Open
alexgarzao wants to merge 1 commit intodevelopfrom
feature/limits-by-period-pr2-limit-model
Open

feat: limit model with WEEKLY, CUSTOM types and time window support#69
alexgarzao wants to merge 1 commit intodevelopfrom
feature/limits-by-period-pr2-limit-model

Conversation

@alexgarzao
Copy link
Collaborator

@alexgarzao alexgarzao commented Mar 16, 2026

PR Overview

This pull request extends the Limit model and its PostgreSQL repository to support new time window and custom period fields: ActiveTimeStart, ActiveTimeEnd, CustomStartDate, and CustomEndDate. These fields are now handled throughout the data access layer, including model conversion, repository CRUD operations, and related tests. The changes ensure that these new fields are properly persisted, queried, and tested.

Model and Data Layer Enhancements:

  • Added new fields (ActiveTimeStart, ActiveTimeEnd, CustomStartDate, CustomEndDate) to LimitPostgreSQLModel and updated its conversion methods to map these fields between the database and the domain model. [1] [2] [3] [4]
  • Updated repository methods (Create, Update, GetByID, List, and scan helpers) to include the new fields in SQL queries and mapping logic. [1] [2] [3] [4] [5] [6]

Testing Improvements:

Service Layer Update:

  • Updated the CreateLimitInput struct in the command service to accept the new fields, preparing for their use in business logic.

Other Minor Changes:

  • Added necessary imports for handling new field types in the service layer.

Add domain constructors and validation for new limit types:

  • NewLimitWithTimeWindow, NewLimitWithCustomPeriod, NewLimitWithCustomPeriodAndTimeWindow constructors
  • Time window validation (mismatch, zero-width, overnight support)
  • Custom period validation (dates required, order, max 5 years)
  • WEEKLY resetAt calculation (next Monday 00:00 UTC)
  • DB model conversion for new columns (active_time_start/end, custom_start_date/end_date)
  • Create/update command handlers for new limit types
  • MustNewTimeOfDay test helper in internal/testhelper

Note: CalculatePeriodKey for WEEKLY/CUSTOM will be added in PR 3 (validation pipeline) along with its tests.

Pull Request Checklist

Pull Request Type

  • Core
  • Pipeline
  • Tests
  • Documentation
  • Helm

Checklist

Please check each item after it's completed.

  • I have tested these changes locally.
  • I have updated the documentation accordingly.
  • I have added necessary comments to the code, especially in complex areas.
  • I have ensured that my changes adhere to the project's coding standards.
  • I have checked for any potential security issues.
  • I have ensured that all tests pass.
  • I have updated the version appropriately (if applicable).
  • I have confirmed this code is ready for review.

Additional Notes

Obs: Please, always remember to target your PR to develop branch instead of main.

Add domain constructors and validation for new limit types:
- NewLimitWithTimeWindow, NewLimitWithCustomPeriod,
  NewLimitWithCustomPeriodAndTimeWindow constructors
- Time window validation (mismatch, zero-width, overnight support)
- Custom period validation (dates required, order, max 5 years)
- WEEKLY resetAt calculation (next Monday 00:00 UTC)
- DB model conversion for new columns (active_time_start/end,
  custom_start_date/end_date)
- Create/update command handlers for new limit types
- MustNewTimeOfDay test helper in internal/testhelper

Note: CalculatePeriodKey for WEEKLY/CUSTOM will be added in PR 3
(validation pipeline) along with its tests.
@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

Walkthrough

This PR extends the limit model with time window and custom period support. Four new database fields (ActiveTimeStart, ActiveTimeEnd, CustomStartDate, CustomEndDate) are added to the PostgreSQL schema and repository queries. The domain model introduces WEEKLY and CUSTOM LimitType values, adds validation for time windows and custom periods, and provides methods to check if timestamps fall within these constraints. Limit creation now supports four constructor paths based on the presence of time window and/or custom period. The update flow is enhanced to modify these fields with proper validation and ResetAt recalculation. Comprehensive test coverage validates all combinations and edge cases.

Sequence Diagram

sequenceDiagram
    participant Client
    participant CreateCmd as CreateLimitCommand
    participant Validator as Validation
    participant Constructor as Model Constructor
    participant Repository
    participant Database

    Client->>CreateCmd: Execute(CreateLimitInput)
    CreateCmd->>Validator: Check time window/custom period presence
    
    alt Partial time window
        Validator-->>CreateCmd: ErrLimitTimeWindowMismatch
        CreateCmd-->>Client: Error
    else Partial custom period
        Validator-->>CreateCmd: ErrLimitCustomDatesRequired
        CreateCmd-->>Client: Error
    else Standard limit
        Validator->>Constructor: NewLimit()
        Constructor->>Constructor: Initialize core fields
        Constructor-->>CreateCmd: *Limit
    else Time window only
        Validator->>Constructor: NewLimitWithTimeWindow()
        Constructor->>Constructor: Validate & set ActiveTimeStart/End
        Constructor-->>CreateCmd: *Limit
    else Custom period only
        Validator->>Constructor: NewLimitWithCustomPeriod()
        Constructor->>Constructor: Parse & validate CustomStartDate/End
        Constructor->>Constructor: Calculate ResetAt
        Constructor-->>CreateCmd: *Limit
    else Custom period + time window
        Validator->>Constructor: NewLimitWithCustomPeriodAndTimeWindow()
        Constructor->>Constructor: Validate both time window & custom period
        Constructor->>Constructor: Calculate ResetAt for CUSTOM
        Constructor-->>CreateCmd: *Limit
    end
    
    CreateCmd->>Repository: Create(limit)
    Repository->>Database: INSERT with all fields
    Database-->>Repository: Success
    Repository-->>CreateCmd: Limit with ID
    CreateCmd-->>Client: Created limit
Loading

Suggested reviewers

  • gandalf-at-lerian
  • marcelo-lerian
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: adding WEEKLY and CUSTOM limit types with time window support to the limit model.
Description check ✅ Passed The description follows the template structure with completed checklist items, provides comprehensive context about the changes across data layer, service layer, and tests, and includes helpful notes about future work.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can generate a title for your PR based on the changes with custom instructions.

Set the reviews.auto_title_instructions setting to generate a title for your PR based on the changes in the PR with custom instructions.

Copy link

@gandalf-at-lerian gandalf-at-lerian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid PR. The model decomposition is clean — newLimitBase + specialized constructors avoids repetition without over-abstracting. A few observations:

Half-open interval consistency — both IsWithinTimeWindow and IsWithinCustomPeriod use [start, end) semantics, well-documented and well-tested. The overnight window logic (startMins >= endMins path) is correct. Good.

Update partial semantics — if someone sends only customEndDate (without customStartDate), ValidateCustomPeriod catches it as ErrLimitCustomDatesRequired. Same for time windows. This means partial updates require both fields in the pair, which is the right call for data integrity. Worth a note in the API docs (PR 6) that these are atomic pairs.

Minor asymmetry — CreateLimitInput takes *model.TimeOfDay for time window fields (pre-parsed) but *string for custom dates (parsed in command layer). Not a problem — TimeOfDay is a value object, custom dates are standard RFC3339 — but worth noting for consistency if the team prefers one pattern.

Validate() skips expiry check (comment says "done in constructors") — correct, avoids breaking reads of historical data from DB. Clean separation of creation-time vs read-time validation.

Test coverage is thorough — boundary conditions, overnight windows, partial inputs, defensive nil checks. LGTM 👍

@lerian-studio
Copy link

This PR is very large (11 files, 2243 lines changed). Consider breaking it into smaller PRs for easier review.

@github-actions
Copy link

🔒 Security Scan Results — tracer

Filesystem Scan

✅ No vulnerabilities or secrets found.

Docker Image Scan

✅ No vulnerabilities found.

All security checks passed.

@lerian-studio
Copy link

📊 Unit Test Coverage Report: tracer

Metric Value
Overall Coverage 83.1% ⚠️ BELOW THRESHOLD
Threshold 85%

Coverage by Package

Package Coverage
tracer/internal/adapters/cel 81.9%
tracer/internal/adapters/http/in/middleware 62.0%
tracer/internal/adapters/http/in 81.8%
tracer/internal/adapters/postgres/db 0.0%
tracer/internal/adapters/postgres 74.9%
tracer/internal/services/cache 95.6%
tracer/internal/services/command 81.5%
tracer/internal/services/query 78.3%
tracer/internal/services/workers 79.7%
tracer/internal/services 40.2%
tracer/internal/testhelper 0.0%
tracer/pkg/clock 50.0%
tracer/pkg/contextutil 100.0%
tracer/pkg/logging 100.0%
tracer/pkg/migration 89.0%
tracer/pkg/model 96.1%
tracer/pkg/net/http 88.3%
tracer/pkg/resilience 93.2%
tracer/pkg/sanitize 87.1%
tracer/pkg/validation 50.0%
tracer/pkg 96.6%

Generated by Go PR Analysis workflow

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/adapters/postgres/limit_repository.go (1)

321-331: ⚠️ Potential issue | 🟠 Major

Persist reset_at when these fields change.

limit.Update(...) can recalculate ResetAt for custom-period edits, but this UPDATE never writes that field back. After a successful update, the in-memory entity and stored row can diverge, leaving the database with a stale reset boundary.

💡 Suggested fix
 	query := sq.Update(r.tableName).
 		Set("name", dbModel.Name).
 		Set("description", dbModel.Description).
 		Set("max_amount", dbModel.MaxAmount).
 		Set("scopes", dbModel.Scopes).
 		Set("status", dbModel.Status).
+		Set("reset_at", dbModel.ResetAt).
 		Set("active_time_start", dbModel.ActiveTimeStart).
 		Set("active_time_end", dbModel.ActiveTimeEnd).
 		Set("custom_start_date", dbModel.CustomStartDate).
 		Set("custom_end_date", dbModel.CustomEndDate).
 		Set("updated_at", dbModel.UpdatedAt).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapters/postgres/limit_repository.go` around lines 321 - 331, The
UPDATE builder for r.tableName is missing the reset_at column, so when
limit.Update(...) recalculates dbModel.ResetAt for custom-period edits the
database isn't updated; add Set("reset_at", dbModel.ResetAt) to the
sq.Update(...) chain (where query is built using r.tableName and dbModel) so the
recalculated ResetAt is persisted alongside name, description, max_amount,
scopes, status, active_time_start/end, custom_start/end and updated_at.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/adapters/postgres/limit_postgresql_model.go`:
- Around line 98-146: After constructing ActiveTimeStart/ActiveTimeEnd and
CustomStartDate/CustomEndDate but before returning the &model.Limit, validate
the paired-field invariants: ensure ActiveTimeStart and ActiveTimeEnd are either
both non-nil or both nil, and likewise CustomStartDate and CustomEndDate; if one
side is set without its pair return a descriptive error. Also validate ordering
(ActiveTimeStart <= ActiveTimeEnd and CustomStartDate <= CustomEndDate) and any
domain span limits your domain requires (e.g., max date span or max time
window); if any check fails return an error instead of the model. Use the
existing variables ActiveTimeStart, ActiveTimeEnd, customStartDate,
customEndDate and the surrounding mapper function that builds and returns
*model.Limit to locate where to insert these checks.

In `@internal/services/command/create_limit_test.go`:
- Around line 495-620: Add a happy-path test that exercises non-nil
ActiveTimeStart/ActiveTimeEnd and CustomStartDate/CustomEndDate to verify
command-layer parsing and repository handoff: in a new subtest (alongside
TestCreateLimitCommand_Execute_PartialCustomPeriod and
TestCreateLimitCommand_Execute_PartialTimeWindow) create a CreateLimitInput with
both ActiveTimeStart and ActiveTimeEnd set and both CustomStartDate and
CustomEndDate set, construct NewCreateLimitCommand with NewMockLimitRepository
and NewMockAuditWriter, set expectations that the repository's create/save
method (the method on NewMockLimitRepository used by Execute) is called once
with a limit containing the supplied times/dates and that
auditWriter.RecordLimitEvent is called once, call
cmd.Execute(context.Background(), input) and assert no error and non-nil result;
ensure you reference CreateLimitInput, ActiveTimeStart, ActiveTimeEnd,
CustomStartDate, CustomEndDate and Execute to locate where to hook the
expectations.

In `@internal/testhelper/time_of_day.go`:
- Around line 5-21: Move this helper into the internal/testutil package and
replace the panic-only API with a non-panicking constructor plus a thin Must
wrapper: implement a function (e.g. TimeOfDayFromString or NewTimeOfDayForTest)
that calls model.NewTimeOfDay and returns (model.TimeOfDay, error), then
reimplement MustNewTimeOfDay to call that function and panic only on error;
update package name from testhelper to testutil and update call sites to use the
new package and the non-panicking variant where appropriate.

In `@pkg/model/limit_weekly_custom_test.go`:
- Around line 540-604: The subtests in TestLimit_GetAPIResponse only inspect the
in-memory Limit fields and do not verify the serialized API contract; update
each subtest to produce and assert the actual response payload by either (a)
calling the model's response mapper (e.g., a function like Limit.ToAPIResponse /
BuildLimitResponse if one exists) and asserting the returned structure contains
the expected keys/values for LimitType (LimitTypeWeekly, LimitTypeCustom),
CustomStartDate, CustomEndDate, ResetAt, ActiveTimeStart, ActiveTimeEnd, etc.,
or (b) json.Marshal the response object and assert the resulting JSON
string/object contains the expected serialized keys and formatted values (e.g.,
"limit_type", "custom_start_date", "custom_end_date", "active_time_start",
"active_time_end", "reset_at") instead of only checking the in-memory fields on
the Limit struct.
- Around line 375-382: Add a table test that verifies the inclusive 5-year
boundary: insert a case (e.g. name "accepts period equal to 5 years") using
LimitTypeCustom where endDate is startDate.AddDate(5,0,0) (use testutil.Ptr for
both dates), set now to a date within the range, and assert expectError: false;
keep the existing failing case that uses constant.ErrLimitCustomPeriodTooLong to
ensure one-over-limit still fails; reference the
MaxCustomPeriodYears/LimitTypeCustom logic and the ErrLimitCustomPeriodTooLong
constant when locating where to add the new row.

In `@pkg/model/limit.go`:
- Around line 574-583: The Update branch never clears an existing window because
it only runs when at least one of activeTimeStart/activeTimeEnd is non-nil; add
an explicit clear flag (e.g., clearActiveWindow) to the Limit.Update signature
and handle it before the existing branch: if clearActiveWindow is true set
l.ActiveTimeStart = nil and l.ActiveTimeEnd = nil and mark updated = true;
otherwise keep the current logic (call ValidateTimeWindow when one or both
bounds provided and assign l.ActiveTimeStart/l.ActiveTimeEnd). Ensure
ValidateTimeWindow is still used when assigning new bounds and update any
callers to pass the new clear flag where a caller intends to remove the time
window.
- Around line 173-179: The CUSTOM period handling is inconsistent:
IsWithinCustomPeriod uses the full timestamp while ValidateCustomPeriod and
CalculateCustomResetAt round to day; pick a single behavior—normalize
CustomEndDate to UTC midnight everywhere. Update ValidateCustomPeriod to
truncate CustomEndDate.UTC() to 24*time.Hour (zeroing time) when validating,
update IsWithinCustomPeriod to compare against that truncated date (so the
period is inclusive/exclusive consistently), and change CalculateCustomResetAt
to return truncatedCustomEndDate.AddDate(0,0,1) (midnight UTC of the next day).
Apply the same truncation logic to any other uses (e.g., the other functions
noted) and adjust tests accordingly.

---

Outside diff comments:
In `@internal/adapters/postgres/limit_repository.go`:
- Around line 321-331: The UPDATE builder for r.tableName is missing the
reset_at column, so when limit.Update(...) recalculates dbModel.ResetAt for
custom-period edits the database isn't updated; add Set("reset_at",
dbModel.ResetAt) to the sq.Update(...) chain (where query is built using
r.tableName and dbModel) so the recalculated ResetAt is persisted alongside
name, description, max_amount, scopes, status, active_time_start/end,
custom_start/end and updated_at.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 150ce703-d7cf-4c97-ad1d-8411c45429a2

📥 Commits

Reviewing files that changed from the base of the PR and between 8bcae24 and 72fd22f.

📒 Files selected for processing (11)
  • internal/adapters/postgres/limit_postgresql_model.go
  • internal/adapters/postgres/limit_repository.go
  • internal/adapters/postgres/limit_repository_test.go
  • internal/services/command/create_limit.go
  • internal/services/command/create_limit_test.go
  • internal/services/command/update_limit.go
  • internal/services/command/update_limit_timewindow_test.go
  • internal/testhelper/time_of_day.go
  • pkg/model/limit.go
  • pkg/model/limit_test.go
  • pkg/model/limit_weekly_custom_test.go

Comment on lines +98 to 146
// Convert time windows from database strings to TimeOfDay
var activeTimeStart, activeTimeEnd *model.TimeOfDay

if m.ActiveTimeStart.Valid {
parsed, err := model.NewTimeOfDay(m.ActiveTimeStart.String)
if err != nil {
return nil, fmt.Errorf("invalid active_time_start in database: %w", err)
}

activeTimeStart = &parsed
}

if m.ActiveTimeEnd.Valid {
parsed, err := model.NewTimeOfDay(m.ActiveTimeEnd.String)
if err != nil {
return nil, fmt.Errorf("invalid active_time_end in database: %w", err)
}

activeTimeEnd = &parsed
}

// Convert custom period dates
var customStartDate, customEndDate *time.Time
if m.CustomStartDate.Valid {
customStartDate = &m.CustomStartDate.Time
}

if m.CustomEndDate.Valid {
customEndDate = &m.CustomEndDate.Time
}

return &model.Limit{
ID: id,
Name: m.Name,
Description: description,
LimitType: limitType,
MaxAmount: m.MaxAmount,
Currency: m.Currency,
Scopes: scopes,
Status: status,
ResetAt: resetAt,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
DeletedAt: deletedAt,
ID: id,
Name: m.Name,
Description: description,
LimitType: limitType,
MaxAmount: m.MaxAmount,
Currency: m.Currency,
Scopes: scopes,
Status: status,
ResetAt: resetAt,
ActiveTimeStart: activeTimeStart,
ActiveTimeEnd: activeTimeEnd,
CustomStartDate: customStartDate,
CustomEndDate: customEndDate,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
DeletedAt: deletedAt,
}, nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Revalidate the new period/window invariants before returning the entity.

This mapper now rebuilds ActiveTime* and Custom*Date independently, but it never checks paired-field requirements, date ordering, or span limits. A row with only one side set will still deserialize into a model.Limit and leak invalid state past the adapter.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapters/postgres/limit_postgresql_model.go` around lines 98 - 146,
After constructing ActiveTimeStart/ActiveTimeEnd and
CustomStartDate/CustomEndDate but before returning the &model.Limit, validate
the paired-field invariants: ensure ActiveTimeStart and ActiveTimeEnd are either
both non-nil or both nil, and likewise CustomStartDate and CustomEndDate; if one
side is set without its pair return a descriptive error. Also validate ordering
(ActiveTimeStart <= ActiveTimeEnd and CustomStartDate <= CustomEndDate) and any
domain span limits your domain requires (e.g., max date span or max time
window); if any check fails return an error instead of the model. Use the
existing variables ActiveTimeStart, ActiveTimeEnd, customStartDate,
customEndDate and the surrounding mapper function that builds and returns
*model.Limit to locate where to insert these checks.

Comment on lines +495 to +620
func TestCreateLimitCommand_Execute_PartialCustomPeriod(t *testing.T) {
validScope := model.Scope{
AccountID: testutil.UUIDPtr(testutil.MustDeterministicUUID(1)),
}

startDate := "2026-11-27T00:00:00Z"

tests := []struct {
name string
limitType model.LimitType
customStartDate *string
customEndDate *string
errorIs error
}{
{
name: "CUSTOM with only customStartDate",
limitType: model.LimitTypeCustom,
customStartDate: &startDate,
customEndDate: nil,
errorIs: constant.ErrLimitCustomDatesRequired,
},
{
name: "CUSTOM with only customEndDate",
limitType: model.LimitTypeCustom,
customStartDate: nil,
customEndDate: &startDate,
errorIs: constant.ErrLimitCustomDatesRequired,
},
{
name: "DAILY with only customStartDate rejected",
limitType: model.LimitTypeDaily,
customStartDate: &startDate,
customEndDate: nil,
errorIs: constant.ErrLimitCustomDatesRequired,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)

mockRepo := NewMockLimitRepository(ctrl)
auditWriter := NewMockAuditWriter(ctrl)

auditWriter.EXPECT().
RecordLimitEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Times(0)

cmd, cmdErr := NewCreateLimitCommand(mockRepo, testutil.NewDefaultMockClock(), auditWriter)
require.NoError(t, cmdErr)

input := &CreateLimitInput{
Name: "Test Partial Custom",
LimitType: tc.limitType,
MaxAmount: decimal.RequireFromString("1000"),
Currency: "USD",
Scopes: []model.Scope{validScope},
CustomStartDate: tc.customStartDate,
CustomEndDate: tc.customEndDate,
}

result, err := cmd.Execute(context.Background(), input)

require.Error(t, err, "Partial custom period should be rejected")
assert.ErrorIs(t, err, tc.errorIs)
assert.Nil(t, result)
})
}
}

func TestCreateLimitCommand_Execute_PartialTimeWindow(t *testing.T) {
validScope := model.Scope{
AccountID: testutil.UUIDPtr(testutil.MustDeterministicUUID(1)),
}

startTime := testhelper.MustNewTimeOfDay("09:00")

tests := []struct {
name string
activeTimeStart *model.TimeOfDay
activeTimeEnd *model.TimeOfDay
}{
{
name: "only activeTimeStart provided",
activeTimeStart: &startTime,
activeTimeEnd: nil,
},
{
name: "only activeTimeEnd provided",
activeTimeStart: nil,
activeTimeEnd: &startTime,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)

mockRepo := NewMockLimitRepository(ctrl)
auditWriter := NewMockAuditWriter(ctrl)

auditWriter.EXPECT().
RecordLimitEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Times(0)

cmd, cmdErr := NewCreateLimitCommand(mockRepo, testutil.NewDefaultMockClock(), auditWriter)
require.NoError(t, cmdErr)

input := &CreateLimitInput{
Name: "Test Partial TimeWindow",
LimitType: model.LimitTypeDaily,
MaxAmount: decimal.RequireFromString("1000"),
Currency: "USD",
Scopes: []model.Scope{validScope},
ActiveTimeStart: tc.activeTimeStart,
ActiveTimeEnd: tc.activeTimeEnd,
}

result, err := cmd.Execute(context.Background(), input)

require.Error(t, err, "Partial time window should be rejected")
assert.ErrorIs(t, err, constant.ErrLimitTimeWindowMismatch)
assert.Nil(t, result)
})
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add a happy-path create case for the new fields.

These new tests only exercise mismatch validation. The command-layer parsing and repository handoff for non-nil ActiveTime* / Custom*Date values is still unverified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/command/create_limit_test.go` around lines 495 - 620, Add a
happy-path test that exercises non-nil ActiveTimeStart/ActiveTimeEnd and
CustomStartDate/CustomEndDate to verify command-layer parsing and repository
handoff: in a new subtest (alongside
TestCreateLimitCommand_Execute_PartialCustomPeriod and
TestCreateLimitCommand_Execute_PartialTimeWindow) create a CreateLimitInput with
both ActiveTimeStart and ActiveTimeEnd set and both CustomStartDate and
CustomEndDate set, construct NewCreateLimitCommand with NewMockLimitRepository
and NewMockAuditWriter, set expectations that the repository's create/save
method (the method on NewMockLimitRepository used by Execute) is called once
with a limit containing the supplied times/dates and that
auditWriter.RecordLimitEvent is called once, call
cmd.Execute(context.Background(), input) and assert no error and non-nil result;
ensure you reference CreateLimitInput, ActiveTimeStart, ActiveTimeEnd,
CustomStartDate, CustomEndDate and Execute to locate where to hook the
expectations.

Comment on lines +5 to +21
package testhelper

import (
"fmt"

"tracer/pkg/model"
)

// MustNewTimeOfDay creates a model.TimeOfDay from a string or panics.
// Use only in tests where invalid input indicates a bug.
func MustNewTimeOfDay(s string) model.TimeOfDay {
tod, err := model.NewTimeOfDay(s)
if err != nil {
panic(fmt.Sprintf("MustNewTimeOfDay(%q): %v", s, err))
}

return tod
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Move this helper into internal/testutil and add a non-panicking variant.

Shared test helpers here are centralized under internal/testutil, and helpers that can fail should expose (model.TimeOfDay, error) with Must* as a thin wrapper. The current shape forks the helper namespace and forces panic-only call sites.

Based on learnings: Place shared test helpers in internal/testutil package; test helper functions that can fail should return errors instead of panicking, with Must* wrapper for test setup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/testhelper/time_of_day.go` around lines 5 - 21, Move this helper
into the internal/testutil package and replace the panic-only API with a
non-panicking constructor plus a thin Must wrapper: implement a function (e.g.
TimeOfDayFromString or NewTimeOfDayForTest) that calls model.NewTimeOfDay and
returns (model.TimeOfDay, error), then reimplement MustNewTimeOfDay to call that
function and panic only on error; update package name from testhelper to
testutil and update call sites to use the new package and the non-panicking
variant where appropriate.

Comment on lines +375 to +382
name: "rejects period exceeding 5 years",
limitType: LimitTypeCustom,
startDate: testutil.Ptr(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
endDate: testutil.Ptr(time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC)), // > 5 years
now: time.Date(2025, 2, 15, 0, 0, 0, 0, time.UTC),
expectError: true,
errorIs: constant.ErrLimitCustomPeriodTooLong,
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add the exact 5-year pass case.

This table locks down the failing side of MaxCustomPeriodYears, but not the inclusive boundary. Please add a case where endDate == startDate.AddDate(5, 0, 0) so a future change cannot accidentally make the limit exclusive. Based on learnings: Test boundary conditions: exactly at limit (pass), one over limit (fail), zero/empty values, negative values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/model/limit_weekly_custom_test.go` around lines 375 - 382, Add a table
test that verifies the inclusive 5-year boundary: insert a case (e.g. name
"accepts period equal to 5 years") using LimitTypeCustom where endDate is
startDate.AddDate(5,0,0) (use testutil.Ptr for both dates), set now to a date
within the range, and assert expectError: false; keep the existing failing case
that uses constant.ErrLimitCustomPeriodTooLong to ensure one-over-limit still
fails; reference the MaxCustomPeriodYears/LimitTypeCustom logic and the
ErrLimitCustomPeriodTooLong constant when locating where to add the new row.

Comment on lines +540 to +604
// TestLimit_GetAPIResponse tests that new fields are included in API response.
func TestLimit_GetAPIResponse(t *testing.T) {
validScope := Scope{AccountID: testutil.UUIDPtr(testutil.MustDeterministicUUID(120))}
fixedTime := testutil.FixedTime()

t.Run("includes WEEKLY type in response", func(t *testing.T) {
limit := &Limit{
ID: testutil.MustDeterministicUUID(121),
Name: "Weekly Limit",
LimitType: LimitTypeWeekly,
MaxAmount: decimal.RequireFromString("5000"),
Currency: "USD",
Scopes: []Scope{validScope},
Status: LimitStatusActive,
ResetAt: testutil.Ptr(fixedTime.AddDate(0, 0, 7)),
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
}

assert.Equal(t, LimitTypeWeekly, limit.LimitType)
})

t.Run("includes CUSTOM type with dates in response", func(t *testing.T) {
limit := &Limit{
ID: testutil.MustDeterministicUUID(122),
Name: "Custom Limit",
LimitType: LimitTypeCustom,
MaxAmount: decimal.RequireFromString("100000"),
Currency: "USD",
Scopes: []Scope{validScope},
Status: LimitStatusActive,
CustomStartDate: testutil.Ptr(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)),
CustomEndDate: testutil.Ptr(time.Date(2025, 3, 31, 0, 0, 0, 0, time.UTC)),
ResetAt: testutil.Ptr(time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC)),
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
}

assert.Equal(t, LimitTypeCustom, limit.LimitType)
require.NotNil(t, limit.CustomStartDate)
require.NotNil(t, limit.CustomEndDate)
})

t.Run("includes time window fields in response", func(t *testing.T) {
limit := &Limit{
ID: testutil.MustDeterministicUUID(123),
Name: "Time Window Limit",
LimitType: LimitTypeDaily,
MaxAmount: decimal.RequireFromString("1000"),
Currency: "USD",
Scopes: []Scope{validScope},
Status: LimitStatusActive,
ActiveTimeStart: testutil.Ptr(mustNewTimeOfDay("20:00")),
ActiveTimeEnd: testutil.Ptr(mustNewTimeOfDay("06:00")),
ResetAt: testutil.Ptr(fixedTime.AddDate(0, 0, 1)),
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
}

require.NotNil(t, limit.ActiveTimeStart)
require.NotNil(t, limit.ActiveTimeEnd)
assert.Equal(t, "20:00", limit.ActiveTimeStart.String())
assert.Equal(t, "06:00", limit.ActiveTimeEnd.String())
})
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

This test never exercises the response contract.

These subtests only re-read fields from the in-memory Limit, so broken JSON tags or a broken response mapper would still pass. Marshal the payload or call the actual response builder and assert the serialized keys/values for the new fields.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/model/limit_weekly_custom_test.go` around lines 540 - 604, The subtests
in TestLimit_GetAPIResponse only inspect the in-memory Limit fields and do not
verify the serialized API contract; update each subtest to produce and assert
the actual response payload by either (a) calling the model's response mapper
(e.g., a function like Limit.ToAPIResponse / BuildLimitResponse if one exists)
and asserting the returned structure contains the expected keys/values for
LimitType (LimitTypeWeekly, LimitTypeCustom), CustomStartDate, CustomEndDate,
ResetAt, ActiveTimeStart, ActiveTimeEnd, etc., or (b) json.Marshal the response
object and assert the resulting JSON string/object contains the expected
serialized keys and formatted values (e.g., "limit_type", "custom_start_date",
"custom_end_date", "active_time_start", "active_time_end", "reset_at") instead
of only checking the in-memory fields on the Limit struct.

Comment on lines +173 to +179
// CalculateCustomResetAt computes reset time for CUSTOM limits.
// Returns customEndDate + 1 day at midnight UTC.
func CalculateCustomResetAt(customEndDate time.Time) *time.Time {
resetAt := customEndDate.UTC().Truncate(24*time.Hour).AddDate(0, 0, 1)

return &resetAt
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make CUSTOM period handling consistent.

IsWithinCustomPeriod treats CustomEndDate as an exact exclusive instant, but ValidateCustomPeriod and CalculateCustomResetAt round it to the day. A limit ending at 2025-06-15T08:00:00Z still validates at noon and will not reset until midnight, even though it stopped matching at 08:00. Either normalize custom periods to date-only boundaries everywhere or compare/reset against the full timestamp everywhere.

Also applies to: 478-505, 703-715

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/model/limit.go` around lines 173 - 179, The CUSTOM period handling is
inconsistent: IsWithinCustomPeriod uses the full timestamp while
ValidateCustomPeriod and CalculateCustomResetAt round to day; pick a single
behavior—normalize CustomEndDate to UTC midnight everywhere. Update
ValidateCustomPeriod to truncate CustomEndDate.UTC() to 24*time.Hour (zeroing
time) when validating, update IsWithinCustomPeriod to compare against that
truncated date (so the period is inclusive/exclusive consistently), and change
CalculateCustomResetAt to return truncatedCustomEndDate.AddDate(0,0,1) (midnight
UTC of the next day). Apply the same truncation logic to any other uses (e.g.,
the other functions noted) and adjust tests accordingly.

Comment on lines +574 to +583
// Update time window if both fields provided (must be together or both nil)
if activeTimeStart != nil || activeTimeEnd != nil {
if err := ValidateTimeWindow(activeTimeStart, activeTimeEnd); err != nil {
return err
}

l.ActiveTimeStart = activeTimeStart
l.ActiveTimeEnd = activeTimeEnd
updated = true
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update can't remove an existing time window.

This branch only runs when at least one bound is non-nil, so passing both activeTimeStart and activeTimeEnd as nil leaves the old window in place. Once a limit has a window, Limit.Update has no way to return it to 24/7 behavior without a tri-state field or an explicit clear flag.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/model/limit.go` around lines 574 - 583, The Update branch never clears an
existing window because it only runs when at least one of
activeTimeStart/activeTimeEnd is non-nil; add an explicit clear flag (e.g.,
clearActiveWindow) to the Limit.Update signature and handle it before the
existing branch: if clearActiveWindow is true set l.ActiveTimeStart = nil and
l.ActiveTimeEnd = nil and mark updated = true; otherwise keep the current logic
(call ValidateTimeWindow when one or both bounds provided and assign
l.ActiveTimeStart/l.ActiveTimeEnd). Ensure ValidateTimeWindow is still used when
assigning new bounds and update any callers to pass the new clear flag where a
caller intends to remove the time window.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants