478 lines
18 KiB
Go
478 lines
18 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestRepresentativeLoadDefaultsAndValidation(t *testing.T) {
|
|
pipelinePath, sessionPath := writeConfigFiles(t, `whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`, `session_id: 2026-05-03
|
|
inputs:
|
|
audio_dir: ./audio
|
|
`)
|
|
|
|
cfg, err := Load(pipelinePath, sessionPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
if cfg.Pipeline.Workspace.Root != DefaultWorkspaceRoot {
|
|
t.Fatalf("workspace.root = %q, want %q", cfg.Pipeline.Workspace.Root, DefaultWorkspaceRoot)
|
|
}
|
|
if cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil {
|
|
t.Fatalf("storage = %#v, want local backend without S3 configuration", cfg.Pipeline.Storage)
|
|
}
|
|
if cfg.Pipeline.WhisperX.Timeout != DefaultWhisperXTimeout || cfg.Pipeline.WhisperX.RetryDelay != DefaultWhisperXRetryDelay {
|
|
t.Fatalf("whisperx defaults = %#v, want timeout and retry-delay defaults", cfg.Pipeline.WhisperX)
|
|
}
|
|
if err := Validate(cfg); err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMarshalEffectivePipelineRendersStablePublicConfiguration(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "pipeline.yml")
|
|
if err := os.WriteFile(path, []byte(`scriptorium:
|
|
artifacts:
|
|
zeta:
|
|
enabled: false
|
|
output_path: artifacts/zeta.md
|
|
alpha:
|
|
enabled: false
|
|
output_path: artifacts/alpha.md
|
|
workspace:
|
|
root: /srv/narratio
|
|
whisperx:
|
|
transcribe_url: https://transcription.example.com/transcribe
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
first, err := LoadPipeline(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := LoadPipeline(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstYAML, err := MarshalEffectivePipeline(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secondYAML, err := MarshalEffectivePipeline(second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(firstYAML) != string(secondYAML) || !strings.HasSuffix(string(firstYAML), "\n") {
|
|
t.Fatalf("effective YAML is not stable: first=%q second=%q", firstYAML, secondYAML)
|
|
}
|
|
output := string(firstYAML)
|
|
if strings.Contains(output, "artifact_families") || strings.Contains(output, "resolution") {
|
|
t.Fatalf("effective YAML leaked runtime fields: %q", output)
|
|
}
|
|
if strings.Index(output, "alpha:") > strings.Index(output, "zeta:") {
|
|
t.Fatalf("configured artifacts are not sorted: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) {
|
|
const firstCanary = "credential-canary-first"
|
|
const secondCanary = "credential-canary-second"
|
|
t.Setenv("NARRATIO_TEST_ACCESS_KEY", firstCanary)
|
|
t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary)
|
|
t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary)
|
|
pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + `
|
|
storage:
|
|
backend: s3
|
|
s3:
|
|
bucket: test-bucket
|
|
access_key_id: NARRATIO_TEST_ACCESS_KEY
|
|
secret_access_key: NARRATIO_TEST_SECRET_KEY
|
|
`
|
|
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
|
|
|
first, err := LoadPipeline(pipelinePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstYAML, err := MarshalEffectivePipeline(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstValues, err := EffectivePipelineValues(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
serializedValues := ""
|
|
for _, value := range firstValues {
|
|
serializedValues += value.Path + "=" + value.Value + "\n"
|
|
}
|
|
directYAML, err := yaml.Marshal(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
directJSON, err := json.Marshal(first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, output := range []string{string(firstYAML), serializedValues, string(directYAML), string(directJSON)} {
|
|
if strings.Contains(output, firstCanary) {
|
|
t.Fatalf("effective output exposed credential value: %q", output)
|
|
}
|
|
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY"} {
|
|
if !strings.Contains(output, reference) {
|
|
t.Fatalf("effective output omitted safe reference %q: %q", reference, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)
|
|
t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary)
|
|
t.Setenv("NARRATIO_TEST_AUDITA_KEY", secondCanary)
|
|
second, err := LoadPipeline(pipelinePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if EffectivePipelineDigest(first) != EffectivePipelineDigest(second) {
|
|
t.Fatal("credential value change altered effective digest")
|
|
}
|
|
t.Setenv("NARRATIO_TEST_AUDITA_KEY_ALTERNATE", secondCanary)
|
|
alternatePath, _ := writeConfigFiles(t, strings.Replace(pipelineYAML, "NARRATIO_TEST_AUDITA_KEY", "NARRATIO_TEST_AUDITA_KEY_ALTERNATE", 1), testSessionBaseYAML)
|
|
alternate, err := LoadPipeline(alternatePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternate) {
|
|
t.Fatal("credential reference-name change did not alter effective digest")
|
|
}
|
|
}
|
|
|
|
func TestEffectivePipelineValuesPreserveNilEmptyAndExactIntegers(t *testing.T) {
|
|
base := &PipelineConfig{Audita: AuditaConfig{Modules: nil}}
|
|
nilValues, err := EffectivePipelineValues(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base.Audita.Modules = []string{}
|
|
base.Scriptorium = &ScriptoriumConfig{Artifacts: map[string]ScriptoriumArtifactConfig{
|
|
"recap": {Vars: map[string]any{"exact": int64(9007199254740993)}},
|
|
}}
|
|
emptyValues, err := EffectivePipelineValues(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := effectiveValueForTest(t, nilValues, "audita.modules"); got != "null" {
|
|
t.Fatalf("nil sequence value = %q, want null", got)
|
|
}
|
|
if got := effectiveValueForTest(t, emptyValues, "audita.modules"); got != "[]" {
|
|
t.Fatalf("empty sequence value = %q, want []", got)
|
|
}
|
|
if got := effectiveValueForTest(t, emptyValues, "scriptorium.artifacts.recap.vars.exact"); got != "9007199254740993" {
|
|
t.Fatalf("exact integer value = %q", got)
|
|
}
|
|
encoded, err := MarshalEffectivePipeline(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(encoded), "9007199254740993") {
|
|
t.Fatalf("effective YAML lost exact integer: %q", encoded)
|
|
}
|
|
}
|
|
|
|
func effectiveValueForTest(t *testing.T, values []EffectivePipelineValueRecord, path string) string {
|
|
t.Helper()
|
|
for _, value := range values {
|
|
if value.Path == path {
|
|
return value.Value
|
|
}
|
|
}
|
|
t.Fatalf("effective value %q not found", path)
|
|
return ""
|
|
}
|
|
|
|
func TestValidateMissingAudioSource(t *testing.T) {
|
|
cfg := loadedValidConfig(t)
|
|
cfg.Session.Inputs.AudioDir = ""
|
|
cfg.Session.Inputs.AudioFiles = nil
|
|
cfg.Session.Inputs.AudioS3 = nil
|
|
|
|
err := Validate(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "audio_dir, at least one audio_files entry, or audio_s3") {
|
|
t.Fatalf("error = %q, want audio source guidance", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "session config") {
|
|
t.Fatalf("error = %q, want session config context", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestExamplesLoadAndValidate(t *testing.T) {
|
|
t.Setenv("OBJECT_STORAGE_KEY_ID", "synthetic-example-access-key")
|
|
t.Setenv("OBJECT_STORAGE_KEY", "synthetic-example-secret-key")
|
|
t.Setenv("AUDITA_LLM_API_KEY", "synthetic-example-audita-key")
|
|
examplesDir := filepath.Join("..", "..", "examples")
|
|
if got, want := maintainedPipelineRoots(t, examplesDir), []string{
|
|
"pipeline.extraction-subset.yml",
|
|
"pipeline.full.annotated.yml",
|
|
"pipeline.minimal.yml",
|
|
"pipeline.production.yml",
|
|
filepath.Join("production-testing", "pipeline.yml"),
|
|
}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("maintained pipeline roots = %#v, want %#v", got, want)
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
pipelineFile string
|
|
sessionFile string
|
|
}{
|
|
{
|
|
name: "minimal pipeline with local audio session",
|
|
pipelineFile: "pipeline.minimal.yml",
|
|
sessionFile: "session.local-audio.yml",
|
|
},
|
|
{
|
|
name: "production pipeline with s3 audio session",
|
|
pipelineFile: "pipeline.production.yml",
|
|
sessionFile: "session.s3-audio.yml",
|
|
},
|
|
{
|
|
name: "full annotated pipeline with local audio session",
|
|
pipelineFile: "pipeline.full.annotated.yml",
|
|
sessionFile: "session.local-audio.yml",
|
|
},
|
|
{
|
|
name: "extraction subset pipeline with local audio session",
|
|
pipelineFile: "pipeline.extraction-subset.yml",
|
|
sessionFile: "session.local-audio.yml",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile)
|
|
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
|
sessionPath := filepath.Join(examplesDir, tt.sessionFile)
|
|
|
|
cfg, err := Load(pipelinePath, campaignPath, sessionPath)
|
|
if err != nil {
|
|
t.Fatalf("load example config error = %v", err)
|
|
}
|
|
if err := Validate(cfg); err != nil {
|
|
t.Fatalf("validate example config error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("split production and testing bundle", func(t *testing.T) {
|
|
pipelinePath := filepath.Join(examplesDir, "production-testing", "pipeline.yml")
|
|
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
|
sessionPath := filepath.Join(examplesDir, "session.local-audio.yml")
|
|
campaign, err := LoadCampaign(campaignPath)
|
|
if err != nil {
|
|
t.Fatalf("load split bundle campaign: %v", err)
|
|
}
|
|
for _, profile := range []struct {
|
|
name string
|
|
model string
|
|
}{
|
|
{name: "production", model: "narratio-production-model-placeholder"},
|
|
{name: "testing", model: "narratio-testing-model-placeholder"},
|
|
} {
|
|
t.Run(profile.name, func(t *testing.T) {
|
|
selected := profile.name
|
|
pipeline, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: &selected})
|
|
if err != nil {
|
|
t.Fatalf("load split bundle pipeline: %v", err)
|
|
}
|
|
loaded, err := LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
|
if err != nil {
|
|
t.Fatalf("resolve split bundle campaign: %v", err)
|
|
}
|
|
cfg, err := LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, SessionLoadOptions{})
|
|
if err != nil {
|
|
t.Fatalf("load split bundle session: %v", err)
|
|
}
|
|
if err := Validate(cfg); err != nil {
|
|
t.Fatalf("validate split bundle: %v", err)
|
|
}
|
|
if cfg.Pipeline.Audita.Model != profile.model {
|
|
t.Fatalf("audita model = %q, want %q", cfg.Pipeline.Audita.Model, profile.model)
|
|
}
|
|
if cfg.Pipeline.Audita.LLMAPIKey != nil || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil {
|
|
t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage)
|
|
}
|
|
if selectedProfile, ok := SelectedPipelineProfile(cfg.Pipeline); !ok || selectedProfile.Name != profile.name {
|
|
t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.name)
|
|
}
|
|
for _, key := range []string{
|
|
"character_meta_arannis",
|
|
"character_meta_brenna",
|
|
"character_items_arannis",
|
|
"character_items_brenna",
|
|
} {
|
|
if _, exists := cfg.Pipeline.Scriptorium.Artifacts[key]; !exists {
|
|
t.Fatalf("expanded artifact %q is absent", key)
|
|
}
|
|
}
|
|
if got := cfg.Pipeline.Scriptorium.Artifacts["character_items_arannis"].Inputs["character_meta"].Source; got != "narratio.artifact.character_meta_arannis" {
|
|
t.Fatalf("same-member input source = %q", got)
|
|
}
|
|
if cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 {
|
|
t.Fatalf("canonical party = %#v", cfg.Party)
|
|
}
|
|
characters := cfg.Party.Canonical.Characters
|
|
if characters[0].Player.Name != characters[1].Player.Name || len(characters[0].Character.Aliases) != 2 || len(characters[1].Character.Classes) != 2 {
|
|
t.Fatalf("canonical party does not preserve repeated player, aliases, and multiclass data: %#v", characters)
|
|
}
|
|
if len(cfg.Pipeline.Publish.Outputs) != 3 {
|
|
t.Fatalf("publish outputs = %#v, want transcript plus two family outputs", cfg.Pipeline.Publish.Outputs)
|
|
}
|
|
if profile.name == "testing" {
|
|
artifact, exists := cfg.Pipeline.Scriptorium.Artifacts["testing_notes"]
|
|
if !exists || artifact.Enabled {
|
|
t.Fatalf("testing-only disabled artifact = %#v", artifact)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func maintainedPipelineRoots(t *testing.T, examplesDir string) []string {
|
|
t.Helper()
|
|
var roots []string
|
|
err := filepath.WalkDir(examplesDir, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
name := entry.Name()
|
|
if name != "pipeline.yml" && !(strings.HasPrefix(name, "pipeline.") && (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml"))) {
|
|
return nil
|
|
}
|
|
relative, err := filepath.Rel(examplesDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
roots = append(roots, relative)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("discover maintained pipeline roots: %v", err)
|
|
}
|
|
sort.Strings(roots)
|
|
return roots
|
|
}
|
|
|
|
func TestMaintainedExtractionExamplesPreservePublishedContracts(t *testing.T) {
|
|
t.Setenv("OBJECT_STORAGE_KEY_ID", "synthetic-example-access-key")
|
|
t.Setenv("OBJECT_STORAGE_KEY", "synthetic-example-secret-key")
|
|
t.Setenv("AUDITA_LLM_API_KEY", "synthetic-example-audita-key")
|
|
examplesDir := filepath.Join("..", "..", "examples")
|
|
full, err := LoadPipeline(filepath.Join(examplesDir, "pipeline.full.annotated.yml"))
|
|
if err != nil {
|
|
t.Fatalf("load full example error = %v", err)
|
|
}
|
|
want := map[string]NotariusOutputConfig{
|
|
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
|
|
"npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"},
|
|
"location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"},
|
|
"scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"},
|
|
"item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"},
|
|
"spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"},
|
|
"combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"},
|
|
"npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"},
|
|
"location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"},
|
|
"enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"},
|
|
}
|
|
if full.Notarius == nil || !reflect.DeepEqual(full.Notarius.Outputs, want) {
|
|
t.Fatalf("full example outputs = %#v, want %#v", full.Notarius, want)
|
|
}
|
|
|
|
subset, err := LoadPipeline(filepath.Join(examplesDir, "pipeline.extraction-subset.yml"))
|
|
if err != nil {
|
|
t.Fatalf("load subset example error = %v", err)
|
|
}
|
|
brief := subset.Scriptorium.Artifacts["session_brief"]
|
|
wantSources := map[string]string{
|
|
"npcs": "narratio.extraction.npc_registry",
|
|
"locations": "narratio.extraction.location_registry",
|
|
"scenes": "narratio.extraction.scene_descriptions",
|
|
}
|
|
gotSources := make(map[string]string, len(brief.Inputs))
|
|
for name, input := range brief.Inputs {
|
|
gotSources[name] = input.Source
|
|
}
|
|
if !reflect.DeepEqual(gotSources, wantSources) {
|
|
t.Fatalf("subset example sources = %#v, want %#v", gotSources, wantSources)
|
|
}
|
|
}
|
|
|
|
func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, string) {
|
|
t.Helper()
|
|
if !strings.Contains(pipelineYAML, "\naudita:") && !strings.HasPrefix(pipelineYAML, "audita:") {
|
|
if !strings.HasSuffix(pipelineYAML, "\n") {
|
|
pipelineYAML += "\n"
|
|
}
|
|
pipelineYAML += "audita:\n binary: audita\n"
|
|
}
|
|
if !strings.Contains(sessionYAML, "\ncampaign:") && !strings.HasPrefix(sessionYAML, "campaign:") {
|
|
if !strings.HasSuffix(sessionYAML, "\n") {
|
|
sessionYAML += "\n"
|
|
}
|
|
sessionYAML += "campaign: sample-campaign\n"
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
|
campaignPath := filepath.Join(dir, "campaign.yml")
|
|
sessionPath := filepath.Join(dir, "session.yml")
|
|
|
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
|
t.Fatalf("write pipeline.yml: %v", err)
|
|
}
|
|
campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + `
|
|
inputs:
|
|
speakers_file: ./speakers.yml
|
|
autocorrect_file: ./autocorrect.yml
|
|
glossary_file: ./glossary.yml
|
|
party_file: ./party.yml
|
|
`
|
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
|
t.Fatalf("write campaign.yml: %v", err)
|
|
}
|
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
|
t.Fatalf("write session.yml: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte(canonicalPartyFixture), 0o644); err != nil {
|
|
t.Fatalf("write party.yml: %v", err)
|
|
}
|
|
|
|
return pipelinePath, sessionPath
|
|
}
|
|
|
|
func campaignNameFromSessionYAML(sessionYAML string) string {
|
|
for _, line := range strings.Split(sessionYAML, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "campaign:") {
|
|
return strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "campaign:")), `"'`)
|
|
}
|
|
}
|
|
return "sample-campaign"
|
|
}
|