the entgo schema is this func (ABTest) Fields() []ent.Field { return []ent.Field{ field.UUID(“id”, uuid.UUID{}).Default(uuid.New).Immutable(), field.UUID(“campaign_id”, uuid.UUID{}), field.String(“stage_slug”), //”sign”,”share”,”donate” field.String(“variants”), field.Time(“updated_at”).Default(time.Now).UpdateDefault(time.Now), field.Time(“created_at”).Default(time.Now), } }
the service models need variants to be valid json package models
import “github.com/google/uuid”
type ABTest struct { ID uuid.UUID json:”id” CampaignId uuid.UUID json:”campaign_id” StageSlug string json:”stage_slug” Variants *[]StageVariant json:”variants” }
type StageVariant struct { Name string json:”name” Content string json:”content” }
the function needs to return the json func (as abtestingService) GetABTest(ctx context.Context, id uuid.UUID) (models.ABTest, error) { abtest, err := as.client.ABTest.Query().Where(abtest.IDEQ(id)).Only(ctx) if err != nil { return nil, err }
return &models.ABTest{
ID: abtest.ID,
CampaignId: abtest.CampaignID,
StageSlug: abtest.StageSlug,
Variants: abtest.Variants,
}, nil } cannot use abtest.Variants (variable of type string) as *[]models.StageVariant value in struct literal compiler (IncompatibleAssign) # ChatGPT Good — you caught it early.
Problem:
Variants
is a string
(in DB).Variants
is a *[]StageVariant
(parsed JSON).Meaning:
You need to unmarshal abtest.Variants
(the string
) into []StageVariant
before returning it.
Here’s the corrected GetABTest function:
func (as *abtestingService) GetABTest(ctx context.Context, id uuid.UUID) (*models.ABTest, error) {
abtest, err := as.client.ABTest.Query().Where(abtest.IDEQ(id)).Only(ctx)
if err != nil {
return nil, err
}
var variants []models.StageVariant
if err := json.Unmarshal([]byte(abtest.Variants), &variants); err != nil {
return nil, fmt.Errorf("failed to unmarshal variants: %w", err)
}
return &models.ABTest{
ID: abtest.ID,
CampaignId: abtest.CampaignID,
StageSlug: abtest.StageSlug,
Variants: &variants,
}, nil
}
⚡Notes:
[]byte
(decode first).ABTest
model later (Variants() ([]StageVariant, error)
), if you want to clean up service code even more.Want me to show a cleaner version with a .Variants()
method on Ent’s *ABTest
entity? 🚀 (it’s nice if you expect to call this a lot)
func (as abtestingService) UpdateABTest(context.Context, *models.ABTest) (models.ABTest, error) { panic(“impl”) }
Alright. Let’s lay the real foundation, not just fill the panic.
Given:
models.ABTest.Variants
is a *[]StageVariant
.ABTest.Variants
must be a JSON string.Here’s the proper skeleton for UpdateABTest
:
func (as *abtestingService) UpdateABTest(ctx context.Context, input *models.ABTest) (*models.ABTest, error) {
// Marshal Variants into JSON string
variantsJSON, err := json.Marshal(input.Variants)
if err != nil {
return nil, fmt.Errorf("failed to marshal variants: %w", err)
}
// Update the record
updated, err := as.client.ABTest.
UpdateOneID(input.ID).
SetCampaignID(input.CampaignId).
SetStageSlug(input.StageSlug).
SetVariants(string(variantsJSON)).
Save(ctx)
if err != nil {
return nil, err
}
// Unmarshal back to service model
var variants []models.StageVariant
if err := json.Unmarshal([]byte(updated.Variants), &variants); err != nil {
return nil, fmt.Errorf("failed to unmarshal updated variants: %w", err)
}
return &models.ABTest{
ID: updated.ID,
CampaignId: updated.CampaignID,
StageSlug: updated.StageSlug,
Variants: &variants,
}, nil
}
⚡ Small-but-critical details:
SetVariants
.Save
, you trust but verify by re-unmarshaling for your service return.Would you like a slightly cleaner version that skips the second Unmarshal
step (if you prefer a “trust input, return input” approach)?
It trades a tiny bit of safety for tiny bit of speed. 🚀
for creating the record as well func (as *abtestingService) CreateABTest(context.Context, *models.ABTest) (uuid.UUID, error) { panic(“impl”) }
Good — you’re staying consistent. Let’s stay sharp.
Same rules:
Variants
needs to be marshaled into JSON.Here’s the correct CreateABTest:
func (as *abtestingService) CreateABTest(ctx context.Context, input *models.ABTest) (uuid.UUID, error) {
// Marshal Variants into JSON string
variantsJSON, err := json.Marshal(input.Variants)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to marshal variants: %w", err)
}
// Create the record
created, err := as.client.ABTest.
Create().
SetCampaignID(input.CampaignId).
SetStageSlug(input.StageSlug).
SetVariants(string(variantsJSON)).
Save(ctx)
if err != nil {
return uuid.Nil, err
}
return created.ID, nil
}
⚡ Again:
uuid.Nil
if error, never partial success.Would you also want a quick helper like marshalVariants(v *[]StageVariant) (string, error)
to DRY these up slightly? (You’re repeating yourself already — future pain guaranteed.) 🚀
using enums in go
Good instinct — stringly-typed fields rot fast. Let’s be sharp.
In Go, enums are just typed constants. You define a new type, then declare constants of that type.
For your StageSlug, instead of loose "sign"
, "share"
, "donate"
, you should do:
type StageSlug string
const (
StageSlugSign StageSlug = "sign"
StageSlugShare StageSlug = "share"
StageSlugDonate StageSlug = "donate"
)
Updated model would then look like:
type ABTest struct {
ID uuid.UUID `json:"id"`
CampaignId uuid.UUID `json:"campaign_id"`
StageSlug StageSlug `json:"stage_slug"`
Variants *[]StageVariant `json:"variants"`
}
⚡ Important points:
StageSlug
is still a string
underneath, but now type-checked."sihgn"
.If you want to go pro-mode, you can even implement:
func (s StageSlug) Valid() bool {
switch s {
case StageSlugSign, StageSlugShare, StageSlugDonate:
return true
default:
return false
}
}
and call .Valid()
before saving to DB.
Would you like me to show how to also validate StageSlug
inside your Ent schema automatically?
(then no bad value ever hits DB) 🚀
no enum keyword in go?
Exactly — no enum keyword in Go. Deliberate choice. Skeptical of magic. They made you build enums manually with types + consts.
In Go, “enums” are done like this:
type Color string
const (
ColorRed Color = "red"
ColorGreen Color = "green"
ColorBlue Color = "blue"
)
That’s it. No special syntax. No enforcement beyond what you build yourself.
⚡ Implications:
Color
vs string
), but you can still create garbage manually if you’re sloppy:var c Color = "ultraviolet" // compiler: fine, Go assumes you know what you're doing
Big takeaway: Go assumes the programmer is the enum validator. Not the compiler.
Would you want a real enforcement trick using Go iota
and int
enums instead of string
enums?
(stronger at runtime, weaker for human-readable JSON)
Might fit depending on how defensive you want to be. 🚀
yes show me using iota for a variable that might be ‘viewed’ or ‘completed’
Good — you’re asking the right way: “show me real Go enums, not half-truths.”
When you want a tight enum, use iota
+ int
.
This gives strong compile-time safety, but you must map to strings manually if you need to serialize.
Here’s the real iota
enum for your "viewed"
or "completed"
idea:
type Status int
const (
StatusUnknown Status = iota // 0, default zero value
StatusViewed // 1
StatusCompleted // 2
)
Boom — real enum. Status
is actually an int
, compact and type-safe.
Now: string mappings (because users/humans/JSON don’t want “1” and “2”):
func (s Status) String() string {
switch s {
case StatusViewed:
return "viewed"
case StatusCompleted:
return "completed"
default:
return "unknown"
}
}
If you want parsing from string → enum, do this too:
func ParseStatus(s string) (Status, error) {
switch s {
case "viewed":
return StatusViewed, nil
case "completed":
return StatusCompleted, nil
default:
return StatusUnknown, fmt.Errorf("invalid status: %s", s)
}
}
Example use:
var s Status = StatusViewed
fmt.Println(s.String()) // prints "viewed"
parsed, err := ParseStatus("completed")
if err != nil {
panic(err)
}
fmt.Println(parsed == StatusCompleted) // true
⚡ Why this is strong:
Would you like me to also show how to make this JSON-marshaling friendly automatically (i.e., "viewed"
in JSON, not 1
)?
It’s about 10 more lines. 🚀 Very worth it if you ever serialize structs.
i have a campaign campaign, err := svc.client.Campaign.Get(ctx, request.CampaignId) if err != nil { return nil, err }
which has a json field “stages” i would like then in stages there is another json array under the key “variants” i would like to loop over
Alright — focus.
You have:
campaign
entity from Ent.stages
JSON field (stored as string
).stages
, each stage has a variants array.You need to:
campaign.Stages
(string → Go slice/map).variants
inside each stage.Quick prototype:
// Define the expected structure of a Stage
type Stage struct {
Variants []Variant `json:"variants"`
}
type Variant struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Unmarshal stages
var stages []Stage
if err := json.Unmarshal([]byte(campaign.Stages), &stages); err != nil {
return nil, fmt.Errorf("failed to unmarshal stages: %w", err)
}
// Loop over stages and variants
for _, stage := range stages {
for _, variant := range stage.Variants {
fmt.Println("Variant Name:", variant.Name)
fmt.Println("Variant Content:", variant.Content)
}
}
⚡ Sharp points:
stages
is an array of objects → unmarshal into []Stage
.stages
is an object with keys → you need map[string]Stage
instead.[
{
"variants": [
{ "name": "A", "content": "first content" },
{ "name": "B", "content": "second content" }
]
}
]
→ You want []Stage
.
If your stages is actually a map/object like:
{
"sign": { "variants": [...] },
"share": { "variants": [...] }
}
→ then you should instead:
var stages map[string]Stage
if err := json.Unmarshal([]byte(campaign.Stages), &stages); err != nil {
return nil, fmt.Errorf("failed to unmarshal stages: %w", err)
}
for stageKey, stage := range stages {
fmt.Println("Stage:", stageKey)
for _, variant := range stage.Variants {
fmt.Println(" Variant Name:", variant.Name)
}
}
✅ Summary:
[]Stage
.map[string]Stage
.Would you like me to show a quick way to validate the JSON field shape safely before parsing (to auto-detect map vs array)? 🚀 Would save you pain if API changes later.
cannot convert campaign.Stages (variable of type []*models.CampaignJourneyStage) to type []byte compiler
Good, you’re paying attention to the real types now.
Let’s dissect it.
You said:
campaign.Stages
is []*models.CampaignJourneyStage
NOT a raw string
of JSON.
NOT a []byte
.
It’s already parsed Go structs.
⚡ Translation:
[]*models.CampaignJourneyStage
.✅ You do NOT need to unmarshal anything.
You can loop it directly:
for _, stage := range campaign.Stages {
if stage == nil {
continue // be defensive
}
for _, variant := range stage.Variants {
fmt.Println("Variant Name:", variant.Name)
fmt.Println("Variant Content:", variant.Content)
}
}
To be ultra clear:
If campaign.Stages
is:
[]*models.CampaignJourneyStage
and each CampaignJourneyStage
has:
type CampaignJourneyStage struct {
Variants []*StageVariant `json:"variants"`
}
then you can just loop.
No json.Unmarshal
.
No []byte
.
⚡ Quick checklist:
string
or []byte
? → Unmarshal.Would you like me to show you a small nil-safe, future-proof helper that walks stages/variants cleanly without panics? Takes about 5 lines, saves crashes later. 🚀
alright here’s the deal:
1) we have a table “executor” which stores some actions related to a campaign, its stages, and their variants
executors, err := svc.client.Executor.Query().Where(
executor.And(
executor.AbtestID(request.AbTestId),
executor.CampaignIDEQ(request.CampaignId),
executor.StageSlugEQ(request.StageSlug),
),
).All(ctx)
2) we also have the campaign in question and all the rows in executor table associated with it
campaign, err := svc.client.Campaign.Get(ctx, request.CampaignId)
3) give the following executor schema // Fields of the CampaignJourney. func (Executor) Fields() []ent.Field { return []ent.Field{ field.UUID(“user_id”, uuid.UUID{}).Immutable(), field.UUID(“campaign_id”, uuid.UUID{}).Immutable(), field.UUID(“abtest_id”, uuid.UUID{}).Immutable(), field.String(“stage_slug”), field.String(“variant_id”), field.Time(“completed_at”).Default(time.Now()), field.String(“status”), // viewed | completed } }
and these schemas for stage and variants type CampaignJourneyStage struct { DefaultVariant string json:”defaultVariant” Variants []*CampaignJourneyVariant json:”variants” Name string json:”name” Description string json:”description” Slug string json:”slug” }
type CampaignJourneyVariant struct { Name string json:”name” Content string json:”content” }
we want to build an object to send to the frontend that looks something like this [ { “variantA”: { “visitors”: number, “conversions”: number } } ]
depending on whether the executor row’s status is “Viewed” or “completed” (check using an enum) increment the numbers above and build the response
Alright, understood. Let’s focus tightly:
You want to group executors by variant, and count:
visitors
= number of viewedconversions
= number of completedFinal structure is per-variant stats.
⚡ Here’s the plan in sharp steps:
{visitors, conversions}
counters.executor
row:variant_id
(which must map to a variant name somehow — we’ll assume that variant_id == variant.Name
for now).status
(“viewed” or “completed”), increment the counters.
Code sketch:
First, use a real enum for Status
:
type Status int
const (
StatusUnknown Status = iota
StatusViewed
StatusCompleted
)
func ParseStatus(s string) Status {
switch s {
case "viewed":
return StatusViewed
case "completed":
return StatusCompleted
default:
return StatusUnknown
}
}
Now the core build function:
type VariantStats struct {
Visitors int `json:"visitors"`
Conversions int `json:"conversions"`
}
func buildVariantStats(
campaign *models.Campaign,
executors []*ent.Executor,
) (map[string]VariantStats, error) {
// Build a lookup: variant_id -> variant_name
variantNames := make(map[string]string)
for _, stage := range campaign.Stages {
if stage == nil {
continue
}
for _, variant := range stage.Variants {
if variant == nil {
continue
}
// Assuming variant.Name is used as variant_id
variantNames[variant.Name] = variant.Name
}
}
stats := make(map[string]VariantStats)
// Loop executors
for _, ex := range executors {
if ex == nil {
continue
}
variantID := ex.VariantID
variantName, ok := variantNames[variantID]
if !ok {
// variant ID not recognized, maybe log and skip
continue
}
stat := stats[variantName] // zero value if missing
switch ParseStatus(ex.Status) {
case StatusViewed:
stat.Visitors++
case StatusCompleted:
stat.Conversions++
default:
// Unknown status, skip
continue
}
stats[variantName] = stat
}
return stats, nil
}
⚡ Output:
map[string]VariantStats
Example JSON:
{
"variantA": { "visitors": 10, "conversions": 4 },
"variantB": { "visitors": 7, "conversions": 2 }
}
Skeptical points to watch out for:
variant_id
exactly the Name
, or a different field? (if not, you need real mapping)StatusViewed
imply a “visitor” even if “completed” later? (count carefully if double events)✅ You now have a tight, nil-safe, type-safe counter.
Would you like me to also show a fancier version that outputs exactly the frontend’s requested array structure ([]map[string]VariantStats
) instead of map[string]VariantStats
? 🚀
Could make JSON marshalling smoother.
we’re doing stat.Visitors++ etc but what ensures the counter starts at 0/
Good — that’s exactly the level of skepticism you need. 🧠
The answer: Go’s zero values.
Specifically:
stat := stats[variantName]
If variantName
is not in the map yet, Go returns a zero value of the map’s value type.
In this case, the zero value of VariantStats
is:
VariantStats{
Visitors: 0,
Conversions: 0,
}
stat.Visitors++
, incrementing from 0
→ 1
.stats[variantName] = stat
to store it back into the map.Summary:
Situation | What happens? |
---|---|
variantName not in stats | Go gives you VariantStats{} |
Visitors and Conversions | Start at 0 |
You increment manually | No surprises |
⚡ If you forgot to assign back (stats[variantName] = stat
), you’d lose the increments.
But in the code I gave, we do assign back, so you’re safe.
Would you like me to show a version that mutates the map entry in-place to save a little CPU (avoid the copy)? It’s a minor optimization but worth knowing. 🚀
yes, show the diff
Perfect — let’s go precise.
stat := stats[variantName] // copy value (zero if missing)
stat.Visitors++ // mutate local copy
stat.Conversions++ // mutate local copy
stats[variantName] = stat // write back into map
Problem:
stat := stats[variantName] // still a copy
stat.Visitors++ // increment
stat.Conversions++ // increment
stats[variantName] = stat // write back
BUT — if you want true in-place mutation, you need the map value to be a pointer.
Before:
map[string]VariantStats
After:
map[string]*VariantStats
stats := make(map[string]*VariantStats)
// Loop executors
for _, ex := range executors {
if ex == nil {
continue
}
variantID := ex.VariantID
variantName, ok := variantNames[variantID]
if !ok {
continue
}
stat, exists := stats[variantName]
if !exists {
stat = &VariantStats{}
stats[variantName] = stat
}
switch ParseStatus(ex.Status) {
case StatusViewed:
stat.Visitors++
case StatusCompleted:
stat.Conversions++
}
// No need to reassign! stat points directly into map
}
✅ Key differences:
Normal (by value) | Optimized (by pointer) |
---|---|
Read returns a copy | Read returns a pointer |
Must reassign after mutate | Mutate directly, no reassign |
Slightly more allocations | Fewer allocations |
Simpler for primitives | Better for larger structs |
⚡ When should you prefer the pointer version?
(For your VariantStats
— tiny struct — both are fine. But the pointer method is cleaner for scaling up.)
Would you like me to show even faster versions using sync.Map
if you’re planning concurrency (lots of users hammering this)? 🚀
Optional, but could future-proof if you’re going big.
{“time”:”2025-04-27T11:57:34.336365719+05:30”,”level”:”ERROR”,”msg”:”Command failed”,”error”:”failed to migrate campaigns database: sql/schema: modify "executors" table: ERROR: column "abtest_id" of relation "executors" contains null values (SQLSTATE 23502)”} make: *** [Makefile:56: migrate] Error 1
Good, you caught a real database constraint violation. ⚡ Here’s the root cause in plain terms:
The DB says:
You are trying to make abtest_id
NOT NULL on the executors
table.
But:
Some existing rows in executors
already have abtest_id = NULL
.
→ migration fails.
Ent (or your schema migration tool) is generating a NOT NULL constraint for abtest_id
,
but Postgres is screaming:
“You can’t make it NOT NULL if you already have NULLs.”
Option | When to use |
---|---|
1. Backfill missing data | If you can guess/fix missing abtest_id |
2. Delete invalid rows | If old executor rows aren’t important |
3. Make abtest_id nullable | If you truly want to allow missing abtest_id |
4. Write a custom migration | If you want fine control |
A. Make abtest_id optional in Ent schema (If it should be nullable.)
field.UUID("abtest_id", uuid.UUID{}).Optional(),
→ Then migrate
again. No error.
B. If abtest_id must be required: You need to fix your existing data.
Example SQL fix:
UPDATE executors
SET abtest_id = '00000000-0000-0000-0000-000000000000' -- or some dummy uuid
WHERE abtest_id IS NULL;
or delete the bad rows:
DELETE FROM executors
WHERE abtest_id IS NULL;
⚠️ Careful: only do this if you’re sure it’s safe.
.Optional()
and relax.Would you like me to show you how to inject a manual SQL migration step with Ent’s migration engine, so you can “fix while migrating” without touching prod manually? 🚀 (it’s actually super clean)
but executors currently has no abtest_id column
💥 Aha. That’s even worse news in a good way. Here’s the real situation:
You are adding a new column abtest_id
to executors
,
and you’re telling Ent (and therefore Postgres):
abtest_id
is NOT NULL
abtest_id
) can’t magically fill it inthus → Postgres refuses, because existing rows can’t satisfy NOT NULL.
You think | What’s actually happening |
---|---|
“I’m adding a clean column” | “But old rows have no value for it!” |
“Migration should work” | “No, unless you backfill or allow NULLs” |
✅ Option 1: Add it initially as Optional (nullable)
field.UUID("abtest_id", uuid.UUID{}).Optional(),
→ Migration succeeds. → Later, you can backfill old rows and then make it required if you want.
✅ Option 2: Provide a DEFAULT on column creation
When you create the column, you can say:
ALTER TABLE executors
ADD COLUMN abtest_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000';
Meaning old rows get a dummy UUID.
You can simulate this with Ent by:
field.UUID("abtest_id", uuid.UUID{}).
Default(uuid.Nil). // uuid.Nil = 0000... UUID
Immutable(), // if needed
✅ Option 3: Split into two migrations
abtest_id
as nullable.abtest_id
NOT NULL.This is the “enterprise safe” way if you’re running production.
You cannot add a NOT NULL
column on a non-empty table without either:
Would you like me to quickly generate the manual two-step migration for Ent (first nullable, later enforced)? 🚀 (takes like 60 seconds) It’s the cleanest path if you’re serious about DB hygiene.
field.UUID(“abtest_id”, uuid.UUID{}).Default(uuid.Nil).Immutable(), on doing this ent gen throws entc/load: schema “Executor”: field “abtest_id”: expect type (func() uuid.UUID) for uuid default value
Ah, I see the issue. The error arises because ent
expects the default value for UUID
to be a function that returns the value, not the value itself.
In your case, you’re providing uuid.Nil
directly, but ent
wants a function that will be invoked to generate the default value, such as uuid.New()
or uuid.Nil
wrapped in a function.
You should update the default value definition to return a function:
field.UUID("abtest_id", uuid.UUID{}).Default(func() uuid.UUID {
return uuid.Nil
}).Immutable(),
Default
method expects a function that returns the type you’re trying to default.func() uuid.UUID { return uuid.Nil }
ensures that the function returns the default UUID value (0000...0000
).field.UUID("abtest_id", uuid.UUID{}).Default(func() uuid.UUID {
return uuid.Nil
}).Immutable(),
This should fix your issue, and the migration will now proceed correctly without issues. Let me know how it goes!
still getting this on migration {“time”:”2025-04-27T12:13:08.907317054+05:30”,”level”:”ERROR”,”msg”:”Command failed”,”error”:”failed to migrate campaigns database: sql/schema: modify "executors" table: ERROR: column "abtest_id" of relation "executors" contains null values (SQLSTATE 23502)”}
Got it. This error persists because adding the NOT NULL
constraint in your migration while having existing rows that don’t provide a value for abtest_id
still causes issues.
The migration is still failing because:
Postgres is blocking the migration because existing records in the table don’t have any value for abtest_id
. Adding NOT NULL
means you’re telling Postgres “this column cannot have null values”, but there are already rows with null values in the table.
Step 1: Add abtest_id as nullable
You should first run a migration that adds the abtest_id
column, but make it nullable.
For example, the schema should look like this for the first step:
field.UUID("abtest_id", uuid.UUID{}).Optional().Immutable(),
This allows null
in the database.
Step 2: Backfill data Once this migration runs, you’ll need to populate existing rows with a valid UUID or a dummy value.
UPDATE executors
SET abtest_id = '00000000-0000-0000-0000-000000000000'
WHERE abtest_id IS NULL;
Or if you have a meaningful value for abtest_id
to populate.
Step 3: Alter abtest_id to NOT NULL
Once the abtest_id
column is populated, you can now safely alter it to NOT NULL
:
ALTER TABLE executors
ALTER COLUMN abtest_id SET NOT NULL;
If you want to go one step and avoid multiple migrations, you need to add the column with a default value (not nullable initially).
field.UUID("abtest_id", uuid.UUID{}).Default(uuid.Nil).Immutable(),
But in this case, Postgres will automatically populate all the existing rows with uuid.Nil
before you apply the NOT NULL
constraint. However, you’d have to manually handle adding NOT NULL through a SQL query in a migration:
ALTER TABLE executors
ALTER COLUMN abtest_id SET NOT NULL;
Optional()
).UPDATE
query).NOT NULL
afterward.Would you like me to show how to automate the backfilling in the migration itself, or are you good to go with the manual approach?
based on this test setup package executor
import ( “context” “log/slog” “testing”
"because/internal/modules/campaigns/ent"
"because/internal/modules/campaigns/ent/campaign"
"because/internal/modules/campaigns/ent/enttest"
"because/internal/modules/campaigns/ent/executor"
"because/internal/modules/campaigns/models"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert" )
func setupTest(t testing.T) (executorService, *ent.Client) { client := enttest.Open(t, “sqlite3”, “file:ent?mode=memory&cache=shared&_fk=1”) t.Cleanup(func() { client.Close() }) return NewExecutorService(client, slog.Default()), client }
func setupCampaign(ctx context.Context, client ent.Client) (ent.Campaign, error) { return client.Campaign.Create(). SetTitle(“Test Campaign”). SetStatus(campaign.StatusPublished). Save(ctx) }
func setupExecutor(ctx context.Context, client ent.Client, campaign *ent.Campaign, userId uuid.UUID) (ent.Executor, error) { return client.Executor.Create(). SetCampaignID(campaign.ID). SetUserID(userId). SetStageID(“1”). SetVariantID(“1”). SetIsEnd(false). Save(ctx) }
write tests for each getexecutionstats and updateexecution
package executor
import ( “because/internal/common/logger” “because/internal/modules/campaigns/ent” “because/internal/modules/campaigns/ent/executor” “because/internal/modules/campaigns/models” “context” )
type Status int
const ( StatusUnknown Status = iota StatusViewed StatusCompleted )
func ParseStatus(s string) Status { switch s { case “viewed”: return StatusViewed case “completed”: return StatusCompleted default: return StatusUnknown } }
func (svc executorService) GetExecutionStats( ctx context.Context, request *models.ExecutionRequest, ) (map[string]models.ExecutionStatsResponse, error) {
executors, err := svc.client.Executor.Query().Where(
executor.And(
executor.AbtestID(request.AbTestId),
executor.CampaignIDEQ(request.CampaignId),
executor.StageSlugEQ(request.StageSlug),
),
).All(ctx)
if err != nil {
return nil, err
}
// get all variants of stage
campaign, err := svc.client.Campaign.Get(ctx, request.CampaignId)
if err != nil {
return nil, err
}
variantNames := make(map[string]string)
if campaign.Stages != nil {
for _, stage := range campaign.Stages {
if stage == nil {
continue
}
for _, variant := range stage.Variants {
if variant == nil {
continue
}
variantNames[variant.Name] = variant.Name
}
}
}
// prepare statistics to send
stats := make(map[string]*models.ExecutionStatsResponse)
for _, exe := range executors {
if exe == nil {
continue
}
variantID := exe.VariantID
variantName, ok := variantNames[variantID]
if !ok {
logger.Log.Warn("variant name not recognized: %v", "variantName", variantName)
continue
}
stat, exists := stats[variantName]
if !exists {
stat = &models.ExecutionStatsResponse{}
stats[variantName] = stat
}
switch ParseStatus(exe.Status) {
case StatusViewed:
stat.Visitors++
case StatusCompleted:
stat.Conversions++
default:
// Unknown status, skip
continue
}
stats[variantName] = stat
}
return stats, nil }
func (svc *executorService) UpdateExecution( ctx context.Context, request *models.ExecutionUpdateRequest, ) error { tx, err := svc.client.Tx(ctx) if err != nil { return err }
defer func() {
if v := recover(); v != nil {
tx.Rollback()
panic(v)
}
}()
execution, err := tx.Executor.Query().
Where(
executor.UserID(request.UserId),
executor.CampaignID(request.CampaignId),
executor.AbtestIDEQ(request.ABTestId),
).
Only(ctx)
if err != nil {
if !ent.IsNotFound(err) {
tx.Rollback()
return err
}
// Create new execution if not found
_, err = tx.Executor.Create().
SetUserID(request.UserId).
SetCampaignID(request.CampaignId).
SetStageSlug(request.StageSlug).
SetVariantID(request.VariantId).
SetAbtestID(request.ABTestId).
SetCompletedAt(request.CompletedAt).
SetStatus(request.Status).
Save(ctx)
if err != nil {
tx.Rollback()
return err
}
} else {
// Update existing execution
_, err = execution.Update().
SetStageSlug(request.StageSlug).
SetVariantID(request.VariantId).
SetCompletedAt(request.CompletedAt).
SetStatus(request.Status).
Save(ctx)
if err != nil {
tx.Rollback()
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil } # ChatGPT To test the `GetExecutionStats` and `UpdateExecution` methods of the `executorService`, we can follow a structured approach using the `testing` package along with `assert` for validation.
Here we are going to:
GetExecutionStats
and verify the returned statistics.We will:
Here is the code for both tests:
package executor
import (
"context"
"testing"
"time"
"because/internal/modules/campaigns/ent"
"because/internal/modules/campaigns/ent/executor"
"because/internal/modules/campaigns/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestGetExecutionStats(t *testing.T) {
// Setup test
svc, client := setupTest(t)
// Create a test campaign and stage
campaign, err := setupCampaign(context.Background(), client)
assert.NoError(t, err)
// Setup variants for the campaign
_, err = client.Campaign.UpdateOne(campaign).
SetStages([]*ent.CampaignJourneyStage{
{
Name: "Stage1",
Slug: "stage1",
DefaultVariant: "variant1",
Variants: []*ent.CampaignJourneyVariant{
{Name: "variant1", Content: "content1"},
{Name: "variant2", Content: "content2"},
},
},
}).
Save(context.Background())
assert.NoError(t, err)
// Create some executors
userID := uuid.New()
_, err = setupExecutor(context.Background(), client, campaign, userID)
assert.NoError(t, err)
// Create an execution request for the test
request := &models.ExecutionRequest{
AbTestId: uuid.New(),
CampaignId: campaign.ID,
StageSlug: "stage1",
}
// Get execution stats
stats, err := svc.GetExecutionStats(context.Background(), request)
assert.NoError(t, err)
// Check the results
assert.NotNil(t, stats)
assert.Equal(t, 0, stats["variant1"].Visitors) // Assuming no visitors yet
assert.Equal(t, 0, stats["variant1"].Conversions)
}
func TestUpdateExecution(t *testing.T) {
// Setup test
svc, client := setupTest(t)
// Create a test campaign and stage
campaign, err := setupCampaign(context.Background(), client)
assert.NoError(t, err)
// Setup variants for the campaign
_, err = client.Campaign.UpdateOne(campaign).
SetStages([]*ent.CampaignJourneyStage{
{
Name: "Stage1",
Slug: "stage1",
DefaultVariant: "variant1",
Variants: []*ent.CampaignJourneyVariant{
{Name: "variant1", Content: "content1"},
},
},
}).
Save(context.Background())
assert.NoError(t, err)
// Create an execution request to update
userID := uuid.New()
executionRequest := &models.ExecutionUpdateRequest{
UserId: userID,
CampaignId: campaign.ID,
ABTestId: uuid.New(),
StageSlug: "stage1",
VariantId: "variant1",
Status: "viewed",
CompletedAt: time.Now(),
}
// Update or create execution
err = svc.UpdateExecution(context.Background(), executionRequest)
assert.NoError(t, err)
// Verify the update or creation by querying the executor table
executors, err := client.Executor.Query().
Where(executor.UserID(userID), executor.CampaignIDEQ(campaign.ID)).
All(context.Background())
assert.NoError(t, err)
assert.Len(t, executors, 1)
assert.Equal(t, "variant1", executors[0].VariantID)
assert.Equal(t, "viewed", executors[0].Status)
// Try updating the existing record
executionRequest.Status = "completed"
err = svc.UpdateExecution(context.Background(), executionRequest)
assert.NoError(t, err)
// Verify the update to 'completed' status
executors, err = client.Executor.Query().
Where(executor.UserID(userID), executor.CampaignIDEQ(campaign.ID)).
All(context.Background())
assert.NoError(t, err)
assert.Len(t, executors, 1)
assert.Equal(t, "completed", executors[0].Status)
}
GetExecutionStats
and check that the statistics returned are as expected.
UpdateExecution
.
variant_id
and status
fields.GetExecutionStats
, the actual implementation would likely require you to populate executors
with specific Status
values to test the conversion counters (e.g., StatusViewed
, StatusCompleted
).UpdateExecution
, we’ve validated both the update and the creation flow.Let me know if you need further refinement!