Skip to content

Commit 8bcae24

Browse files
authored
Merge pull request #60 from LerianStudio/feature/limits-by-period-pr1-migrations-model-foundation
feat: database migrations and model foundation for limits by period
2 parents b5142d2 + de3a156 commit 8bcae24

12 files changed

+961
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- ============================================
2+
-- Migration: 000006_add_limit_type_enum_values (DOWN)
3+
-- Description: Note about enum value removal
4+
-- Date: 2026-03-10
5+
-- ============================================
6+
-- Note: PostgreSQL does not support removing enum values directly.
7+
-- Removing WEEKLY and CUSTOM would require:
8+
-- 1. Creating a new enum without these values
9+
-- 2. Updating all tables using the enum
10+
-- 3. Dropping the old enum
11+
-- 4. Renaming the new enum
12+
--
13+
-- This is intentionally left as a no-op because:
14+
-- - Removing enum values is a breaking change
15+
-- - Any existing limits using WEEKLY/CUSTOM would become invalid
16+
-- - The risk of data loss outweighs the benefit of a clean rollback
17+
--
18+
-- If rollback is truly needed, manual intervention is required.
19+
DO $$ BEGIN
20+
RAISE NOTICE 'Enum values WEEKLY and CUSTOM cannot be automatically removed from limit_type_enum';
21+
END $$;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- ============================================
2+
-- Migration: 000006_add_limit_type_enum_values
3+
-- Description: Add WEEKLY and CUSTOM values to limit_type_enum
4+
-- Date: 2026-03-10
5+
-- ============================================
6+
-- Note: Adding enum values must be in a separate migration from column changes
7+
-- PostgreSQL requires ADD VALUE to be the only statement in a transaction when
8+
-- NOT using IF NOT EXISTS (prior to PG 10), and golang-migrate handles each file
9+
-- as a separate transaction.
10+
11+
-- Add WEEKLY type for limits that reset every Monday at 00:00 UTC
12+
ALTER TYPE limit_type_enum ADD VALUE IF NOT EXISTS 'WEEKLY';
13+
14+
-- Add CUSTOM type for limits with user-defined periods
15+
ALTER TYPE limit_type_enum ADD VALUE IF NOT EXISTS 'CUSTOM';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- ============================================
2+
-- Migration: 000007_add_limit_period_columns (DOWN)
3+
-- Description: Remove time window and custom period columns from limits table
4+
-- Date: 2026-03-10
5+
-- ============================================
6+
7+
-- Drop index first
8+
DROP INDEX IF EXISTS idx_limits_custom_period;
9+
10+
-- Drop constraints
11+
ALTER TABLE limits DROP CONSTRAINT IF EXISTS chk_limits_time_format_end;
12+
ALTER TABLE limits DROP CONSTRAINT IF EXISTS chk_limits_time_format_start;
13+
ALTER TABLE limits DROP CONSTRAINT IF EXISTS chk_limits_custom_dates_order;
14+
ALTER TABLE limits DROP CONSTRAINT IF EXISTS chk_limits_custom_dates_forbidden;
15+
ALTER TABLE limits DROP CONSTRAINT IF EXISTS chk_limits_custom_dates_required;
16+
ALTER TABLE limits DROP CONSTRAINT IF EXISTS chk_limits_time_window_pair;
17+
18+
-- Drop columns
19+
ALTER TABLE limits DROP COLUMN IF EXISTS custom_end_date;
20+
ALTER TABLE limits DROP COLUMN IF EXISTS custom_start_date;
21+
ALTER TABLE limits DROP COLUMN IF EXISTS active_time_end;
22+
ALTER TABLE limits DROP COLUMN IF EXISTS active_time_start;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- ============================================
2+
-- Migration: 000007_add_limit_period_columns
3+
-- Description: Add time window and custom period columns to limits table
4+
-- Date: 2026-03-10
5+
-- ============================================
6+
7+
-- Add time window columns for daily time-of-day restrictions
8+
-- Format: "HH:MM" stored as VARCHAR(5) to preserve the exact format
9+
ALTER TABLE limits ADD COLUMN IF NOT EXISTS active_time_start VARCHAR(5);
10+
ALTER TABLE limits ADD COLUMN IF NOT EXISTS active_time_end VARCHAR(5);
11+
12+
-- Add custom period columns for CUSTOM limit type
13+
ALTER TABLE limits ADD COLUMN IF NOT EXISTS custom_start_date TIMESTAMP WITH TIME ZONE;
14+
ALTER TABLE limits ADD COLUMN IF NOT EXISTS custom_end_date TIMESTAMP WITH TIME ZONE;
15+
16+
-- Add constraint: time window must have both or neither
17+
ALTER TABLE limits ADD CONSTRAINT chk_limits_time_window_pair
18+
CHECK (
19+
(active_time_start IS NULL AND active_time_end IS NULL) OR
20+
(active_time_start IS NOT NULL AND active_time_end IS NOT NULL)
21+
);
22+
23+
-- Add constraint: CUSTOM type requires custom dates
24+
ALTER TABLE limits ADD CONSTRAINT chk_limits_custom_dates_required
25+
CHECK (
26+
limit_type != 'CUSTOM' OR
27+
(custom_start_date IS NOT NULL AND custom_end_date IS NOT NULL)
28+
);
29+
30+
-- Add constraint: non-CUSTOM types must not have custom dates
31+
ALTER TABLE limits ADD CONSTRAINT chk_limits_custom_dates_forbidden
32+
CHECK (
33+
limit_type = 'CUSTOM' OR
34+
(custom_start_date IS NULL AND custom_end_date IS NULL)
35+
);
36+
37+
-- Add constraint: custom_start_date must be before custom_end_date
38+
ALTER TABLE limits ADD CONSTRAINT chk_limits_custom_dates_order
39+
CHECK (
40+
custom_start_date IS NULL OR custom_end_date IS NULL OR
41+
custom_start_date < custom_end_date
42+
);
43+
44+
-- Add constraint: time window format validation (HH:MM pattern)
45+
ALTER TABLE limits ADD CONSTRAINT chk_limits_time_format_start
46+
CHECK (
47+
active_time_start IS NULL OR
48+
active_time_start ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
49+
);
50+
51+
ALTER TABLE limits ADD CONSTRAINT chk_limits_time_format_end
52+
CHECK (
53+
active_time_end IS NULL OR
54+
active_time_end ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
55+
);
56+
57+
-- Index for custom period queries (finding limits active during a date range)
58+
CREATE INDEX IF NOT EXISTS idx_limits_custom_period
59+
ON limits(custom_start_date, custom_end_date)
60+
WHERE limit_type = 'CUSTOM' AND status = 'ACTIVE';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- ============================================
2+
-- Migration: 000008_add_counter_expires_at (DOWN)
3+
-- Description: Remove expires_at column from usage_counters
4+
-- Date: 2026-03-10
5+
-- ============================================
6+
7+
-- Drop index first
8+
DROP INDEX IF EXISTS idx_usage_counters_expires_at;
9+
10+
-- Drop column
11+
ALTER TABLE usage_counters DROP COLUMN IF EXISTS expires_at;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- ============================================
2+
-- Migration: 000008_add_counter_expires_at
3+
-- Description: Add expires_at column to usage_counters for efficient cleanup
4+
-- Date: 2026-03-10
5+
-- ============================================
6+
7+
-- Add expires_at column to store when the counter should be cleaned up
8+
-- This enables efficient batch cleanup of expired counters
9+
ALTER TABLE usage_counters ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE;
10+
11+
-- Index for cleanup queries (finding expired counters)
12+
-- Partial index only includes rows that have an expiration set
13+
CREATE INDEX IF NOT EXISTS idx_usage_counters_expires_at
14+
ON usage_counters(expires_at)
15+
WHERE expires_at IS NOT NULL;

pkg/clock/clock.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,26 @@ func (RealClock) NewTicker(d time.Duration) (<-chan time.Time, func()) {
3333
func New() Clock {
3434
return RealClock{}
3535
}
36+
37+
// FixedClock implements Clock with a fixed time. Used for MOCK_TIME support.
38+
type FixedClock struct {
39+
fixedTime time.Time
40+
}
41+
42+
// Now returns the fixed time.
43+
func (c FixedClock) Now() time.Time {
44+
return c.fixedTime
45+
}
46+
47+
// NewTicker returns a channel that never fires (fixed clock has no real ticks).
48+
// Stop is a no-op, matching time.Ticker.Stop() which does not close its channel.
49+
func (c FixedClock) NewTicker(_ time.Duration) (<-chan time.Time, func()) {
50+
ch := make(chan time.Time)
51+
52+
return ch, func() {}
53+
}
54+
55+
// NewFixedClock creates a FixedClock that always returns the given time.
56+
func NewFixedClock(t time.Time) Clock {
57+
return FixedClock{fixedTime: t}
58+
}

pkg/constant/errors.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,21 @@ var (
194194
// =============================================================================
195195
ErrRuleCacheWarmUpFailed = errors.New("TRC-0280") // rule cache warm-up failed
196196
ErrRuleCacheNotReady = errors.New("TRC-0281") // rule cache is not ready
197+
198+
// =============================================================================
199+
// Limit Extended Errors - Time Window & Custom Period (TRC-0300 to TRC-0319)
200+
// =============================================================================
201+
ErrLimitTimeWindowMismatch = errors.New("TRC-0300") // activeTimeStart and activeTimeEnd must both be set or both be nil
202+
ErrLimitTimeWindowZeroWidth = errors.New("TRC-0301") // activeTimeStart cannot equal activeTimeEnd
203+
ErrTimeOfDayInvalidFormat = errors.New("TRC-0302") // invalid time of day format, expected HH:MM
204+
ErrLimitCustomDatesRequired = errors.New("TRC-0303") // customStartDate and customEndDate required for CUSTOM limitType
205+
ErrLimitCustomDatesOrder = errors.New("TRC-0304") // customStartDate must be before customEndDate
206+
ErrLimitCustomDatesNotAllowed = errors.New("TRC-0305") // customStartDate/customEndDate only allowed for CUSTOM limitType
207+
ErrLimitUnknownType = errors.New("TRC-0306") // unknown limit type
208+
ErrLimitCustomPeriodTooLong = errors.New("TRC-0307") // custom period cannot exceed 5 years
209+
ErrLimitCustomPeriodExpired = errors.New("TRC-0308") // custom period end date must be in the future
210+
ErrLimitInvalidCustomStartFormat = errors.New("TRC-0309") // invalid customStartDate format, expected RFC3339
211+
ErrLimitInvalidCustomEndFormat = errors.New("TRC-0310") // invalid customEndDate format, expected RFC3339
197212
)
198213

199214
// Error code constants for HTTP responses.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 Lerian Studio. All rights reserved.
2+
// Use of this source code is governed by the Elastic License 2.0
3+
// that can be found in the LICENSE file.
4+
5+
package constant
6+
7+
import (
8+
"errors"
9+
"fmt"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
// TestErrorConstants_LimitExtended tests that the new limit-related error constants exist.
16+
// These are required for WEEKLY, CUSTOM limit types and time window validation.
17+
func TestErrorConstants_LimitExtended(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
err error
21+
code string
22+
category string
23+
}{
24+
{
25+
name: "ErrLimitTimeWindowMismatch exists with code TRC-0300",
26+
err: ErrLimitTimeWindowMismatch,
27+
code: "TRC-0300",
28+
category: "time window validation",
29+
},
30+
{
31+
name: "ErrLimitTimeWindowZeroWidth exists with code TRC-0301",
32+
err: ErrLimitTimeWindowZeroWidth,
33+
code: "TRC-0301",
34+
category: "time window validation",
35+
},
36+
{
37+
name: "ErrTimeOfDayInvalidFormat exists with code TRC-0302",
38+
err: ErrTimeOfDayInvalidFormat,
39+
code: "TRC-0302",
40+
category: "time of day parsing",
41+
},
42+
{
43+
name: "ErrLimitCustomDatesRequired exists with code TRC-0303",
44+
err: ErrLimitCustomDatesRequired,
45+
code: "TRC-0303",
46+
category: "custom period validation",
47+
},
48+
{
49+
name: "ErrLimitCustomDatesOrder exists with code TRC-0304",
50+
err: ErrLimitCustomDatesOrder,
51+
code: "TRC-0304",
52+
category: "custom period validation",
53+
},
54+
{
55+
name: "ErrLimitCustomDatesNotAllowed exists with code TRC-0305",
56+
err: ErrLimitCustomDatesNotAllowed,
57+
code: "TRC-0305",
58+
category: "custom period validation",
59+
},
60+
{
61+
name: "ErrLimitUnknownType exists with code TRC-0306",
62+
err: ErrLimitUnknownType,
63+
code: "TRC-0306",
64+
category: "limit type validation",
65+
},
66+
{
67+
name: "ErrLimitCustomPeriodTooLong exists with code TRC-0307",
68+
err: ErrLimitCustomPeriodTooLong,
69+
code: "TRC-0307",
70+
category: "custom period validation",
71+
},
72+
{
73+
name: "ErrLimitCustomPeriodExpired exists with code TRC-0308",
74+
err: ErrLimitCustomPeriodExpired,
75+
code: "TRC-0308",
76+
category: "custom period validation",
77+
},
78+
{
79+
name: "ErrLimitInvalidCustomStartFormat exists with code TRC-0309",
80+
err: ErrLimitInvalidCustomStartFormat,
81+
code: "TRC-0309",
82+
category: "custom period validation",
83+
},
84+
{
85+
name: "ErrLimitInvalidCustomEndFormat exists with code TRC-0310",
86+
err: ErrLimitInvalidCustomEndFormat,
87+
code: "TRC-0310",
88+
category: "custom period validation",
89+
},
90+
}
91+
92+
for _, tc := range tests {
93+
t.Run(tc.name, func(t *testing.T) {
94+
// Verify error is not nil
95+
assert.NotNil(t, tc.err, "error constant should exist")
96+
97+
// Verify error message contains the expected code
98+
assert.Contains(t, tc.err.Error(), tc.code,
99+
"error message should contain code %s for %s", tc.code, tc.category)
100+
101+
// Verify it's a proper error type
102+
wrapped := fmt.Errorf("wrapped: %w", tc.err)
103+
assert.True(t, errors.Is(wrapped, tc.err),
104+
"error should be unwrappable with errors.Is")
105+
})
106+
}
107+
}
108+
109+
// TestErrorConstants_UniquenessTRC0300Range tests that error codes in TRC-0300 range are unique.
110+
func TestErrorConstants_UniquenessTRC0300Range(t *testing.T) {
111+
// All TRC-0300 range errors that should be unique
112+
errorConstants := []error{
113+
ErrLimitTimeWindowMismatch, // TRC-0300
114+
ErrLimitTimeWindowZeroWidth, // TRC-0301
115+
ErrTimeOfDayInvalidFormat, // TRC-0302
116+
ErrLimitCustomDatesRequired, // TRC-0303
117+
ErrLimitCustomDatesOrder, // TRC-0304
118+
ErrLimitCustomDatesNotAllowed, // TRC-0305
119+
ErrLimitUnknownType, // TRC-0306
120+
ErrLimitCustomPeriodTooLong, // TRC-0307
121+
ErrLimitCustomPeriodExpired, // TRC-0308
122+
ErrLimitInvalidCustomStartFormat, // TRC-0309
123+
ErrLimitInvalidCustomEndFormat, // TRC-0310
124+
}
125+
126+
// Check for duplicates using error messages
127+
seen := make(map[string]int)
128+
for i, err := range errorConstants {
129+
msg := err.Error()
130+
if prev, exists := seen[msg]; exists {
131+
t.Errorf("duplicate error code: index %d and %d both have message %q", prev, i, msg)
132+
}
133+
seen[msg] = i
134+
}
135+
}
136+
137+
// TestErrorConstants_NonEmptyMessages tests that error constants have non-empty error text.
138+
func TestErrorConstants_NonEmptyMessages(t *testing.T) {
139+
tests := []struct {
140+
name string
141+
err error
142+
}{
143+
{
144+
name: "time window mismatch has non-empty message",
145+
err: ErrLimitTimeWindowMismatch,
146+
},
147+
{
148+
name: "zero width window has non-empty message",
149+
err: ErrLimitTimeWindowZeroWidth,
150+
},
151+
{
152+
name: "custom dates required has non-empty message",
153+
err: ErrLimitCustomDatesRequired,
154+
},
155+
{
156+
name: "custom dates order has non-empty message",
157+
err: ErrLimitCustomDatesOrder,
158+
},
159+
{
160+
name: "custom dates not allowed has non-empty message",
161+
err: ErrLimitCustomDatesNotAllowed,
162+
},
163+
{
164+
name: "custom period too long has non-empty message",
165+
err: ErrLimitCustomPeriodTooLong,
166+
},
167+
{
168+
name: "invalid time of day format has non-empty message",
169+
err: ErrTimeOfDayInvalidFormat,
170+
},
171+
{
172+
name: "unknown limit type has non-empty message",
173+
err: ErrLimitUnknownType,
174+
},
175+
{
176+
name: "custom period expired has non-empty message",
177+
err: ErrLimitCustomPeriodExpired,
178+
},
179+
{
180+
name: "invalid custom start format has non-empty message",
181+
err: ErrLimitInvalidCustomStartFormat,
182+
},
183+
{
184+
name: "invalid custom end format has non-empty message",
185+
err: ErrLimitInvalidCustomEndFormat,
186+
},
187+
}
188+
189+
for _, tc := range tests {
190+
t.Run(tc.name, func(t *testing.T) {
191+
assert.NotEmpty(t, tc.err.Error(), "error should have a non-empty message")
192+
})
193+
}
194+
}

0 commit comments

Comments
 (0)