Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ jobs:
name: Build and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2

- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v6.2.0
with:
go-version: '1.24'

Expand All @@ -31,11 +31,14 @@ jobs:
- name: Test
run: go test -v ./...

- name: Run Integration Tests
run: bash tests/integration_test.sh

docker:
name: Docker Build and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6.0.2

- name: Build Docker Image
run: docker build -t quirm-app .
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/mirror.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Mirror to Codeberg

on:
push:
branches:
- main
schedule:
# Runs at 00:00 UTC every day
- cron: '0 0 * * *'
workflow_dispatch:

jobs:
mirror_to_codeberg:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.2
with:
# Fetch all history for all branches and tags
fetch-depth: 0

- name: Mirror to Codeberg
run: |
REMOTE_URL="https://${{ secrets.CODEBERG_USERNAME }}:${{ secrets.CODEBERG_TOKEN }}@codeberg.org/${{ secrets.CODEBERG_ORG }}/${{ github.event.repository.name }}.git"

git remote add codeberg "$REMOTE_URL"
git push --mirror codeberg
Comment thread Dismissed
17 changes: 12 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,19 @@ cache_data/
# Application
quirm

# Vegh files (CodeTease's Snapshot Tool)
# Vegh Snapshot Tool. A CodeTease project
*.vegh
# Vegh cache folder
.veghcache/
# PyVegh's Vegh Hooks (Optional)
.veghhooks.json
# Old Vegh files extension, kept for backward compatibility
*.snap
# Not a branding: Get PyVegh in https://pypi.org/project/pyvegh/ (probably just `pip install pyvegh`)
# .veghhooks.json

# Pavidi Task Runner. A CodeTease project
.p/
# Test binaries and artifacts
tests/mock_s3_bin
tests/sign_url_bin
tests/test_image.jpg
tests/fallback.jpg
tests/cache/
tests/.env.test
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
)

var (
Version = "0.5.0"
Version = "0.5.1"
)

func main() {
Expand Down
10 changes: 0 additions & 10 deletions pkg/cache/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,6 @@ func NewMemoryCache(size int, limitBytes int64, defaultTTL time.Duration) *Memor
if limitBytes > 0 {
// Capacity-based limit
maxCost = limitBytes
// NumCounters should be approx 10x the number of items.
// Since we don't know the item count, we assume an average item size.
// Let's assume average 50KB image/data size as a heuristic?
// Or just set a safe high number. Ristretto counters are small (4 bits).
// 100MB cache -> 2000 items (50KB each). 10x -> 20,000 counters.
// If limitBytes is small (10MB), 200 items.
// Let's estimate avg item size 10KB to be safe?
estimatedItems := limitBytes / 10240
if estimatedItems < 100 {
estimatedItems = 100
Expand Down Expand Up @@ -69,9 +62,6 @@ func NewMemoryCache(size int, limitBytes int64, defaultTTL time.Duration) *Memor

cache, err := ristretto.NewCache(config)
if err != nil {
// Fallback or panic? A panic here means bad config usually.
// Given startup phase, panic is acceptable or return nil/log fatal.
// We'll panic to be noticed immediately.
panic(err)
}

Expand Down
182 changes: 182 additions & 0 deletions pkg/cache/tiered_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package cache

import (
"context"
"testing"
"time"
)

// MockCacheProvider
type MockCacheProvider struct {
GetFunc func(ctx context.Context, key string) ([]byte, bool)
SetFunc func(ctx context.Context, key string, value []byte, ttl time.Duration) error
DeleteFunc func(ctx context.Context, key string) error
HealthFunc func(ctx context.Context) error
}

func (m *MockCacheProvider) Get(ctx context.Context, key string) ([]byte, bool) {
if m.GetFunc != nil {
return m.GetFunc(ctx, key)
}
return nil, false
}

func (m *MockCacheProvider) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if m.SetFunc != nil {
return m.SetFunc(ctx, key, value, ttl)
}
return nil
}

func (m *MockCacheProvider) Delete(ctx context.Context, key string) error {
if m.DeleteFunc != nil {
return m.DeleteFunc(ctx, key)
}
return nil
}

func (m *MockCacheProvider) Health(ctx context.Context) error {
if m.HealthFunc != nil {
return m.HealthFunc(ctx)
}
return nil
}

func TestTieredCache_Get_HitL1(t *testing.T) {
ctx := context.Background()
key := "test-key"
val := []byte("value")

l1 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
if k == key {
return val, true
}
return nil, false
},
}
l2 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
t.Error("L2 should not be called")
return nil, false
},
}

c := NewTieredCache(l1, l2)

got, found := c.Get(ctx, key)
if !found {
t.Error("Expected found")
}
if string(got) != string(val) {
t.Errorf("Expected %s, got %s", val, got)
}
}

func TestTieredCache_Get_HitL2(t *testing.T) {
ctx := context.Background()
key := "test-key"
val := []byte("value")

l1SetCalled := false
l1 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
return nil, false
},
SetFunc: func(ctx context.Context, k string, v []byte, ttl time.Duration) error {
if k == key && string(v) == string(val) {
l1SetCalled = true
}
return nil
},
}
l2 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
if k == key {
return val, true
}
return nil, false
},
}

c := NewTieredCache(l1, l2)

got, found := c.Get(ctx, key)
if !found {
t.Error("Expected found")
}
if string(got) != string(val) {
t.Errorf("Expected %s, got %s", val, got)
}
if !l1SetCalled {
t.Error("Expected L1 Set to be called")
}
}

func TestTieredCache_Get_MissAll(t *testing.T) {
ctx := context.Background()
key := "test-key"

l1 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
return nil, false
},
}
l2 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
return nil, false
},
}

c := NewTieredCache(l1, l2)

_, found := c.Get(ctx, key)
if found {
t.Error("Expected not found")
}
}

func TestTieredCache_Promote_L2_to_L1(t *testing.T) {
ctx := context.Background()
key := "promo-key"
val := []byte("promo-value")

l1SetCalled := false
l1 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
return nil, false // Miss
},
SetFunc: func(ctx context.Context, k string, v []byte, ttl time.Duration) error {
if k != key {
t.Errorf("L1 Set called with wrong key: %s", k)
}
if string(v) != string(val) {
t.Errorf("L1 Set called with wrong value: %s", v)
}
l1SetCalled = true
return nil
},
}
l2 := &MockCacheProvider{
GetFunc: func(ctx context.Context, k string) ([]byte, bool) {
if k == key {
return val, true // Hit
}
return nil, false
},
}

c := NewTieredCache(l1, l2)

got, found := c.Get(ctx, key)
if !found {
t.Error("Expected found")
}
if string(got) != string(val) {
t.Errorf("Expected %s, got %s", val, got)
}

if !l1SetCalled {
t.Error("Expected L1 Set to be called to promote value from L2")
}
}
65 changes: 65 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package config

import (
"os"
"testing"
"time"
)

func TestLoadConfig_AllowedCIDRs(t *testing.T) {
// Setup env
os.Setenv("ALLOWED_CIDRS", "192.168.1.0/24,10.0.0.1/32,invalid-ip")
defer os.Unsetenv("ALLOWED_CIDRS")

cfg := LoadConfig()

if len(cfg.AllowedCIDRs) != 3 {
t.Errorf("Expected 3 raw CIDR strings, got %d", len(cfg.AllowedCIDRs))
}

// Check AllowedCIDRNets
if len(cfg.AllowedCIDRNets) != 2 {
t.Errorf("Expected 2 valid IPNets, got %d", len(cfg.AllowedCIDRNets))
}

// Verify the parsed nets
// 192.168.1.0/24
if cfg.AllowedCIDRNets[0].String() != "192.168.1.0/24" {
t.Errorf("Expected first CIDR to be 192.168.1.0/24, got %s", cfg.AllowedCIDRNets[0].String())
}
// 10.0.0.1/32
if cfg.AllowedCIDRNets[1].String() != "10.0.0.1/32" {
t.Errorf("Expected second CIDR to be 10.0.0.1/32, got %s", cfg.AllowedCIDRNets[1].String())
}
}

func TestLoadConfig_Defaults(t *testing.T) {
// Unset relevant env vars to test defaults
vars := []string{"RATE_LIMIT", "CACHE_TTL_HOURS", "S3_FORCE_PATH_STYLE"}
for _, v := range vars {
// Store old value to restore
oldVal, exists := os.LookupEnv(v)
if exists {
os.Unsetenv(v)
defer os.Setenv(v, oldVal)
} else {
// Ensure it stays unset?
// Defer nothing if it didn't exist
}
}

cfg := LoadConfig()

if cfg.RateLimit != 10 {
t.Errorf("Expected default RateLimit 10, got %d", cfg.RateLimit)
}

expectedTTL := 24 * time.Hour
if cfg.CacheTTL != expectedTTL {
t.Errorf("Expected default CacheTTL %v, got %v", expectedTTL, cfg.CacheTTL)
}

if cfg.S3ForcePathStyle != false {
t.Errorf("Expected default S3ForcePathStyle false, got %v", cfg.S3ForcePathStyle)
}
}
Loading
Loading