diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2b32d9edd..12ba18f10 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,7 +1,7 @@
name: CI
on: [push, pull_request]
env:
- go-version: '1.14.x'
+ go-version: '1.16.x'
postgis-version: '2.5'
jobs:
test:
@@ -51,7 +51,7 @@ jobs:
if: success()
uses: codecov/codecov-action@v1
with:
- fail_ci_if_error: true
+ fail_ci_if_error: false
release:
name: Release
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49dc437cc..e0faa9643 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,168 @@
+v6.5.6
+----------
+ * Update to latest goflow and add parse_only as param to parse_query to allow us to extract field dependencies even when they don't yet exist in the database
+
+v6.5.5
+----------
+ * Fix tests broken by recent db changes to msgs and broadcasts
+ * Populate ticket_count when creating new contacts
+
+v6.5.4
+----------
+ * Actually save IVR messages with sent_on set
+
+v6.5.3
+----------
+ * Update contact modified_on after populate dynamic group task
+ * Update to latest goflow
+
+v6.5.2
+----------
+ * Set sent_on for outgoing IVR messages
+
+v6.5.1
+----------
+ * Support flow config ivr_retry values of -1 meaning no retry
+ * Log error if marking event fire as fired fails
+
+v6.5.0
+----------
+ * Update to latest goflow and gocommon
+
+v6.4.3
+----------
+ * Fix triggering new IVR flow from a simulation resume so that it includes connection to test channel
+
+v6.4.2
+----------
+ * Latest goflow with latest localization
+
+v6.4.1
+----------
+ * Update to latest goflow to get fixes for nulls in webhook responses
+ * Add new error type for failed SQL queries
+
+v6.4.0
+----------
+ * move s3 session config error to a warning for the time being since not strictly required yet
+
+v6.3.31
+----------
+ * Support ticket open events with assignees
+ * Add endpoints for ticket assignment and adding notes
+
+v6.3.30
+----------
+ * Update to latest goflow
+
+v6.3.29
+----------
+ * Include args in BulkQuery error output
+
+v6.3.28
+----------
+ * Return more SQL when BulkQuery errors
+ * Update to latest goflow/gocommon
+
+v6.3.27
+----------
+ * Fix handling of inbox messages to also update open tickets
+
+v6.3.26
+----------
+ * Stop writing broadcast.is_active which is now nullable
+
+v6.3.25
+----------
+ * Update to latest goflow
+
+v6.3.24
+----------
+ * Update to latest goflow
+ * Load org users as assets and use for ticket assignees and manual trigger users
+ * Add ticket to broadcasts and update last_activity_on after creating messages for a broadcast with a ticket
+
+v6.3.23
+----------
+ * Add support for exclusion groups on scheduled triggers
+
+v6.3.22
+----------
+ * Update ticket last_activity_on when opening/closing and for incoming messages
+ * Set contact_id when creating new tickets events
+
+v6.3.21
+----------
+ * Update to latest goflow and which no longer takes default_language
+
+v6.3.20
+----------
+ * Have our session filename lead with timestamp so other objects can exist in contact dirs
+
+v6.3.19
+----------
+ * Parse URL to get path out for sessions
+
+v6.3.18
+----------
+ * Use s3 session prefix when building s3 paths, default to /
+ * Throw error upwards if we have no DB backdown
+ * Read session files from storage when org configured to do so
+
+v6.3.17
+----------
+ * Ignore contact tickets on ticketers which have been deleted
+
+v6.3.16
+----------
+ * Add ticket closed triggers and use to handle close ticket events
+ * Add ticket events and insert when opening/closing/reopening tickets
+
+v6.3.15
+----------
+ * Fix test which modifies org
+ * Update to latest goflow as last release was broken
+
+v6.3.14
+----------
+ * Update to latest goflow
+ * Write sessions to s3 on resumes (optionally)
+ * Add support for exclusion groups on triggers and generalize trigger matching
+
+v6.3.13
+----------
+ * Introduce runtime.Runtime
+ * Simplify testdata functions
+ * Various fixes from linter
+ * Simplify use of test contacts in handler tests
+ * Move test constants out of models package
+ * Remove reduntant resend_msgs task
+
+v6.3.12
+----------
+ * Update to latest goflow (legacy_extra is no longer an issue)
+ * Make Msg.next_attempt nullable
+ * Add web endpoint for msg resends so they can be a synchronous operation
+
+v6.3.11
+----------
+ * Expose open tickets as @contact.tickets
+
+v6.3.9
+----------
+ * Fix queueing of resent messages to courier and improve testing of queueing
+ * Update to latest goflow
+ * Add WA template translation namespace
+
+v6.3.8
+----------
+ * Add task to resend messages
+
+v6.3.7
+----------
+ * Update to latest goflow
+ * Update test database and rename Nexmo to Vonage
+
v6.3.6
----------
* Update to latest goflow
diff --git a/README.md b/README.md
index f512c01fd..70be6174e 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
-# mailroom
+# mailroom
-[![Build Status](https://github.com/nyaruka/mailroom/workflows/CI/badge.svg)](https://github.com/nyaruka/mailroom/actions?query=workflow%3ACI)
-[![codecov](https://codecov.io/gh/nyaruka/mailroom/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/mailroom)
+[![Build Status](https://github.com/nyaruka/mailroom/workflows/CI/badge.svg)](https://github.com/nyaruka/mailroom/actions?query=workflow%3ACI)
+[![codecov](https://codecov.io/gh/nyaruka/mailroom/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/mailroom)
-# About
+# About
Mailroom is the [RapidPro](https://github.com/rapidpro/rapidpro) component responsible for the execution of
flows. It interacts directly with the RapidPro database and sends and receives messages with [Courier](https://github.com/nyaruka/courier) for handling via Redis.
@@ -18,9 +18,10 @@ behind a reverse proxy such as nginx or Elastic Load Balancer that provides HTTP
# Configuration
Mailroom uses a tiered configuration system, each option takes precendence over the ones above it:
- 1. The configuration file
- 2. Environment variables starting with `MAILROOM_`
- 3. Command line parameters
+
+1. The configuration file
+2. Environment variables starting with `MAILROOM_`
+3. Command line parameters
We recommend running Mailroom with no changes to the configuration and no parameters, using only
environment variables to configure it. You can use `% mailroom --help` to see a list of the
@@ -30,29 +31,34 @@ environment variables and parameters and for more details on each option.
For use with RapidPro, you will want to configure these settings:
- * `MAILROOM_ADDRESS`: the address to bind our web server to (default "localhost")
- * `MAILROOM_DOMAIN`: the domain that mailroom is listening on
- * `MAILROOM_AUTH_TOKEN`: the token clients will need to authenticate web requests (should match setting in RapidPro)
- * `MAILROOM_ATTACHMENT_DOMAIN`: the domain that will be used for relative attachments in flows
- * `MAILROOM_DB`: URL describing how to connect to the RapidPro database (default "postgres://temba:temba@localhost/temba?sslmode=disable")
- * `MAILROOM_REDIS`: URL describing how to connect to Redis (default "redis://localhost:6379/15")
- * `MAILROOM_ELASTIC`: URL describing how to connect to ElasticSearch (default "http://localhost:9200")
- * `MAILROOM_SMTP_SERVER`: the smtp configuration for sending emails ex: smtp://user%40password@server:port/?from=foo%40gmail.com
-
+- `MAILROOM_ADDRESS`: the address to bind our web server to (default "localhost")
+- `MAILROOM_DOMAIN`: the domain that mailroom is listening on
+- `MAILROOM_AUTH_TOKEN`: the token clients will need to authenticate web requests (should match setting in RapidPro)
+- `MAILROOM_ATTACHMENT_DOMAIN`: the domain that will be used for relative attachments in flows
+- `MAILROOM_DB`: URL describing how to connect to the RapidPro database (default "postgres://temba:temba@localhost/temba?sslmode=disable")
+- `MAILROOM_REDIS`: URL describing how to connect to Redis (default "redis://localhost:6379/15")
+- `MAILROOM_ELASTIC`: URL describing how to connect to ElasticSearch (default "http://localhost:9200")
+- `MAILROOM_SMTP_SERVER`: the smtp configuration for sending emails ex: smtp://user%40password@server:port/?from=foo%40gmail.com
+
For writing of message attachments, Mailroom needs access to an S3 bucket, you can configure access to your bucket via:
- * `MAILROOM_S3_REGION`: The region for your S3 bucket (ex: `eu-west-1`)
- * `MAILROOM_S3_MEDIA_BUCKET`: The name of your S3 bucket (ex: `dl-mailroom`)
- * `MAILROOM_S3_MEDIA_PREFIX`: The prefix to use for filenames of attachments added to your bucket (ex: `attachments`)
- * `MAILROOM_AWS_ACCESS_KEY_ID`: The AWS access key id used to authenticate to AWS
- * `MAILROOM_AWS_SECRET_ACCESS_KEY` The AWS secret access key used to authenticate to AWS
+- `MAILROOM_S3_REGION`: The region for your S3 bucket (ex: `eu-west-1`)
+- `MAILROOM_S3_MEDIA_BUCKET`: The name of your S3 bucket (ex: `dl-mailroom`)
+- `MAILROOM_S3_MEDIA_PREFIX`: The prefix to use for filenames of attachments added to your bucket (ex: `attachments`)
+- `MAILROOM_AWS_ACCESS_KEY_ID`: The AWS access key id used to authenticate to AWS
+- `MAILROOM_AWS_SECRET_ACCESS_KEY` The AWS secret access key used to authenticate to AWS
+
+While still in beta, Mailroom will move to writing session data to S3 in 6.6, you can configure those buckets using:
+
+- `MAILROOM_S3_SESSION_BUCKET`: The name of your S3 bucket (ex: `rp-sessions`)
+- `MAILROOM_S3_SESSION_PREFIX`: The prefix to use for filenames of sessions added to your bucket (ex: ``)
Recommended settings for error and performance monitoring:
- * `MAILROOM_LIBRATO_USERNAME`: The username to use for logging of events to Librato
- * `MAILROOM_LIBRATO_TOKEN`: The token to use for logging of events to Librato
- * `MAILROOM_SENTRY_DSN`: The DSN to use when logging errors to Sentry
- * `MAILROOM_LOG_LEVEL`: the logging level mailroom should use (default "error", use "debug" for more)
+- `MAILROOM_LIBRATO_USERNAME`: The username to use for logging of events to Librato
+- `MAILROOM_LIBRATO_TOKEN`: The token to use for logging of events to Librato
+- `MAILROOM_SENTRY_DSN`: The DSN to use when logging errors to Sentry
+- `MAILROOM_LOG_LEVEL`: the logging level mailroom should use (default "error", use "debug" for more)
# Development
diff --git a/cmd/mailroom/main.go b/cmd/mailroom/main.go
index a063f4cfa..f187e2138 100644
--- a/cmd/mailroom/main.go
+++ b/cmd/mailroom/main.go
@@ -20,7 +20,6 @@ import (
_ "github.com/nyaruka/mailroom/core/tasks/campaigns"
_ "github.com/nyaruka/mailroom/core/tasks/contacts"
_ "github.com/nyaruka/mailroom/core/tasks/expirations"
- _ "github.com/nyaruka/mailroom/core/tasks/groups"
_ "github.com/nyaruka/mailroom/core/tasks/interrupts"
_ "github.com/nyaruka/mailroom/core/tasks/ivr"
_ "github.com/nyaruka/mailroom/core/tasks/schedules"
@@ -36,6 +35,7 @@ import (
_ "github.com/nyaruka/mailroom/web/expression"
_ "github.com/nyaruka/mailroom/web/flow"
_ "github.com/nyaruka/mailroom/web/ivr"
+ _ "github.com/nyaruka/mailroom/web/msg"
_ "github.com/nyaruka/mailroom/web/org"
_ "github.com/nyaruka/mailroom/web/po"
_ "github.com/nyaruka/mailroom/web/simulation"
diff --git a/config/config.go b/config/config.go
index 45f74aa01..dd952ef01 100644
--- a/config/config.go
+++ b/config/config.go
@@ -47,12 +47,18 @@ type Config struct {
Domain string `help:"the domain that mailroom is listening on"`
AttachmentDomain string `help:"the domain that will be used for relative attachment"`
- S3Endpoint string `help:"the S3 endpoint we will write attachments to"`
- S3Region string `help:"the S3 region we will write attachments to"`
- S3MediaBucket string `help:"the S3 bucket we will write attachments to"`
- S3MediaPrefix string `help:"the prefix that will be added to attachment filenames"`
- S3DisableSSL bool `help:"whether we disable SSL when accessing S3. Should always be set to False unless you're hosting an S3 compatible service within a secure internal network"`
- S3ForcePathStyle bool `help:"whether we force S3 path style. Should generally need to default to False unless you're hosting an S3 compatible service"`
+ S3Endpoint string `help:"the S3 endpoint we will write attachments to"`
+ S3Region string `help:"the S3 region we will write attachments to"`
+
+ S3MediaBucket string `help:"the S3 bucket we will write attachments to"`
+ S3MediaPrefix string `help:"the prefix that will be added to attachment filenames"`
+
+ S3SessionBucket string `help:"the S3 bucket we will write attachments to"`
+ S3SessionPrefix string `help:"the prefix that will be added to attachment filenames"`
+
+ S3DisableSSL bool `help:"whether we disable SSL when accessing S3. Should always be set to False unless you're hosting an S3 compatible service within a secure internal network"`
+ S3ForcePathStyle bool `help:"whether we force S3 path style. Should generally need to default to False unless you're hosting an S3 compatible service"`
+
AWSAccessKeyID string `help:"the access key id to use when authenticating S3"`
AWSSecretAccessKey string `help:"the secret access key id to use when authenticating S3"`
@@ -93,6 +99,8 @@ func NewMailroomConfig() *Config {
S3Region: "us-east-1",
S3MediaBucket: "mailroom-media",
S3MediaPrefix: "/media/",
+ S3SessionBucket: "mailroom-sessions",
+ S3SessionPrefix: "/",
S3DisableSSL: false,
S3ForcePathStyle: false,
AWSAccessKeyID: "",
diff --git a/config/config_test.go b/config/config_test.go
index 4bb476ba2..7ab175e34 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -5,11 +5,15 @@ import (
"testing"
"github.com/nyaruka/mailroom/config"
+ "github.com/nyaruka/mailroom/testsuite"
"github.com/stretchr/testify/assert"
)
func TestParseDisallowedNetworks(t *testing.T) {
+ // this is only here because this is the first test run.. should find a better way to ensure DB is in correct state for first test that needs it
+ testsuite.Reset()
+
cfg := config.NewMailroomConfig()
privateNetwork1 := &net.IPNet{IP: net.IPv4(10, 0, 0, 0).To4(), Mask: net.CIDRMask(8, 32)}
diff --git a/core/goflow/engine.go b/core/goflow/engine.go
index 1d750d015..816b83dba 100644
--- a/core/goflow/engine.go
+++ b/core/goflow/engine.go
@@ -4,7 +4,6 @@ import (
"sync"
"github.com/nyaruka/gocommon/urns"
- "github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/engine"
"github.com/nyaruka/goflow/services/webhooks"
@@ -46,22 +45,22 @@ func RegisterAirtimeServiceFactory(factory engine.AirtimeServiceFactory) {
}
// Engine returns the global engine instance for use with real sessions
-func Engine() flows.Engine {
+func Engine(cfg *config.Config) flows.Engine {
engInit.Do(func() {
webhookHeaders := map[string]string{
- "User-Agent": "RapidProMailroom/" + config.Mailroom.Version,
+ "User-Agent": "RapidProMailroom/" + cfg.Version,
"X-Mailroom-Mode": "normal",
}
- httpClient, httpRetries, httpAccess := HTTP()
+ httpClient, httpRetries, httpAccess := HTTP(cfg)
eng = engine.NewBuilder().
- WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, httpRetries, httpAccess, webhookHeaders, config.Mailroom.WebhooksMaxBodyBytes)).
+ WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, httpRetries, httpAccess, webhookHeaders, cfg.WebhooksMaxBodyBytes)).
WithClassificationServiceFactory(classificationFactory).
WithEmailServiceFactory(emailFactory).
WithTicketServiceFactory(ticketFactory).
WithAirtimeServiceFactory(airtimeFactory).
- WithMaxStepsPerSprint(config.Mailroom.MaxStepsPerSprint).
+ WithMaxStepsPerSprint(cfg.MaxStepsPerSprint).
Build()
})
@@ -69,22 +68,22 @@ func Engine() flows.Engine {
}
// Simulator returns the global engine instance for use with simulated sessions
-func Simulator() flows.Engine {
+func Simulator(cfg *config.Config) flows.Engine {
simulatorInit.Do(func() {
webhookHeaders := map[string]string{
- "User-Agent": "RapidProMailroom/" + config.Mailroom.Version,
+ "User-Agent": "RapidProMailroom/" + cfg.Version,
"X-Mailroom-Mode": "simulation",
}
- httpClient, _, httpAccess := HTTP() // don't do retries in simulator
+ httpClient, _, httpAccess := HTTP(cfg) // don't do retries in simulator
simulator = engine.NewBuilder().
- WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, nil, httpAccess, webhookHeaders, config.Mailroom.WebhooksMaxBodyBytes)).
+ WithWebhookServiceFactory(webhooks.NewServiceFactory(httpClient, nil, httpAccess, webhookHeaders, cfg.WebhooksMaxBodyBytes)).
WithClassificationServiceFactory(classificationFactory). // simulated sessions do real classification
WithEmailServiceFactory(simulatorEmailServiceFactory). // but faked emails
WithTicketServiceFactory(simulatorTicketServiceFactory). // and faked tickets
WithAirtimeServiceFactory(simulatorAirtimeServiceFactory). // and faked airtime transfers
- WithMaxStepsPerSprint(config.Mailroom.MaxStepsPerSprint).
+ WithMaxStepsPerSprint(cfg.MaxStepsPerSprint).
Build()
})
@@ -110,7 +109,7 @@ type simulatorTicketService struct {
}
func (s *simulatorTicketService) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) {
- return flows.NewTicket(flows.TicketUUID(uuids.New()), s.ticketer.Reference(), subject, body, ""), nil
+ return flows.OpenTicket(s.ticketer, subject, body), nil
}
func simulatorAirtimeServiceFactory(session flows.Session) (flows.AirtimeService, error) {
diff --git a/core/goflow/engine_test.go b/core/goflow/engine_test.go
index 31b650924..aa33562e3 100644
--- a/core/goflow/engine_test.go
+++ b/core/goflow/engine_test.go
@@ -10,6 +10,7 @@ import (
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
@@ -17,7 +18,9 @@ import (
)
func TestEngineWebhook(t *testing.T) {
- svc, err := goflow.Engine().Services().Webhook(nil)
+ _, rt, _, _ := testsuite.Get()
+
+ svc, err := goflow.Engine(rt.Config).Services().Webhook(nil)
assert.NoError(t, err)
defer httpx.SetRequestor(httpx.DefaultRequestor)
@@ -37,7 +40,9 @@ func TestEngineWebhook(t *testing.T) {
}
func TestSimulatorAirtime(t *testing.T) {
- svc, err := goflow.Simulator().Services().Airtime(nil)
+ _, rt, _, _ := testsuite.Get()
+
+ svc, err := goflow.Simulator(rt.Config).Services().Airtime(nil)
assert.NoError(t, err)
amounts := map[string]decimal.Decimal{"USD": decimal.RequireFromString(`1.50`)}
@@ -55,25 +60,25 @@ func TestSimulatorAirtime(t *testing.T) {
}
func TestSimulatorTicket(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.ResetDB()
+ ctx, rt, db, _ := testsuite.Get()
- ticketer, err := models.LookupTicketerByUUID(ctx, db, models.MailgunUUID)
+ ticketer, err := models.LookupTicketerByUUID(ctx, db, testdata.Mailgun.UUID)
require.NoError(t, err)
- svc, err := goflow.Simulator().Services().Ticket(nil, flows.NewTicketer(ticketer))
+ svc, err := goflow.Simulator(rt.Config).Services().Ticket(nil, flows.NewTicketer(ticketer))
assert.NoError(t, err)
ticket, err := svc.Open(nil, "New ticket", "Where are my cookies?", nil)
assert.NoError(t, err)
- assert.Equal(t, models.MailgunUUID, ticket.Ticketer.UUID)
- assert.Equal(t, "New ticket", ticket.Subject)
- assert.Equal(t, "Where are my cookies?", ticket.Body)
+ assert.Equal(t, testdata.Mailgun.UUID, ticket.Ticketer().UUID())
+ assert.Equal(t, "New ticket", ticket.Subject())
+ assert.Equal(t, "Where are my cookies?", ticket.Body())
}
func TestSimulatorWebhook(t *testing.T) {
- svc, err := goflow.Simulator().Services().Webhook(nil)
+ _, rt, _, _ := testsuite.Get()
+
+ svc, err := goflow.Simulator(rt.Config).Services().Webhook(nil)
assert.NoError(t, err)
defer httpx.SetRequestor(httpx.DefaultRequestor)
diff --git a/core/goflow/flows.go b/core/goflow/flows.go
index 7d1b56619..91d217d98 100644
--- a/core/goflow/flows.go
+++ b/core/goflow/flows.go
@@ -22,8 +22,8 @@ func SpecVersion() *semver.Version {
}
// ReadFlow reads a flow from the given JSON definition, migrating it if necessary
-func ReadFlow(data json.RawMessage) (flows.Flow, error) {
- return definition.ReadFlow(data, MigrationConfig())
+func ReadFlow(cfg *config.Config, data json.RawMessage) (flows.Flow, error) {
+ return definition.ReadFlow(data, MigrationConfig(cfg))
}
// CloneDefinition clones the given flow definition
@@ -32,14 +32,14 @@ func CloneDefinition(data json.RawMessage, depMapping map[uuids.UUID]uuids.UUID)
}
// MigrateDefinition migrates the given flow definition to the specified version
-func MigrateDefinition(data json.RawMessage, toVersion *semver.Version) (json.RawMessage, error) {
- return migrations.MigrateToVersion(data, toVersion, MigrationConfig())
+func MigrateDefinition(cfg *config.Config, data json.RawMessage, toVersion *semver.Version) (json.RawMessage, error) {
+ return migrations.MigrateToVersion(data, toVersion, MigrationConfig(cfg))
}
// MigrationConfig returns the migration configuration for flows
-func MigrationConfig() *migrations.Config {
+func MigrationConfig(cfg *config.Config) *migrations.Config {
migConfInit.Do(func() {
- migConf = &migrations.Config{BaseMediaURL: "https://" + config.Mailroom.AttachmentDomain}
+ migConf = &migrations.Config{BaseMediaURL: "https://" + cfg.AttachmentDomain}
})
return migConf
diff --git a/core/goflow/flows_test.go b/core/goflow/flows_test.go
index 71fb5cd71..22f3772fe 100644
--- a/core/goflow/flows_test.go
+++ b/core/goflow/flows_test.go
@@ -9,6 +9,7 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/test"
"github.com/nyaruka/mailroom/core/goflow"
+ "github.com/nyaruka/mailroom/testsuite"
"github.com/Masterminds/semver"
"github.com/stretchr/testify/assert"
@@ -19,13 +20,15 @@ func TestSpecVersion(t *testing.T) {
}
func TestReadFlow(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
// try to read empty definition
- flow, err := goflow.ReadFlow([]byte(`{}`))
+ flow, err := goflow.ReadFlow(rt.Config, []byte(`{}`))
assert.Nil(t, flow)
assert.EqualError(t, err, "unable to read flow header: field 'uuid' is required, field 'spec_version' is required")
// read legacy definition
- flow, err = goflow.ReadFlow([]byte(`{"flow_type": "M", "base_language": "eng", "action_sets": [], "metadata": {"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "Legacy"}}`))
+ flow, err = goflow.ReadFlow(rt.Config, []byte(`{"flow_type": "M", "base_language": "eng", "action_sets": [], "metadata": {"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "Legacy"}}`))
assert.Nil(t, err)
assert.Equal(t, assets.FlowUUID("502c3ee4-3249-4dee-8e71-c62070667d52"), flow.UUID())
assert.Equal(t, "Legacy", flow.Name())
@@ -33,7 +36,7 @@ func TestReadFlow(t *testing.T) {
assert.Equal(t, flows.FlowTypeMessaging, flow.Type())
// read new definition
- flow, err = goflow.ReadFlow([]byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`))
+ flow, err = goflow.ReadFlow(rt.Config, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`))
assert.Nil(t, err)
assert.Equal(t, assets.FlowUUID("502c3ee4-3249-4dee-8e71-c62070667d52"), flow.UUID())
assert.Equal(t, "New", flow.Name())
@@ -46,12 +49,14 @@ func TestCloneDefinition(t *testing.T) {
cloned, err := goflow.CloneDefinition([]byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), nil)
assert.NoError(t, err)
- test.AssertEqualJSON(t, []byte(`{"uuid": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), cloned, "cloned flow mismatch")
+ test.AssertEqualJSON(t, []byte(`{"uuid": "1ae96956-4b34-433e-8d1a-f05fe6923d6d", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), cloned)
}
func TestMigrateDefinition(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
// 13.0 > 13.1
- migrated, err := goflow.MigrateDefinition([]byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), semver.MustParse("13.1.0"))
+ migrated, err := goflow.MigrateDefinition(rt.Config, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.0.0", "type": "messaging", "language": "eng", "nodes": []}`), semver.MustParse("13.1.0"))
assert.NoError(t, err)
- test.AssertEqualJSON(t, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.1.0", "type": "messaging", "language": "eng", "nodes": []}`), migrated, "migrated flow mismatch")
+ test.AssertEqualJSON(t, []byte(`{"uuid": "502c3ee4-3249-4dee-8e71-c62070667d52", "name": "New", "spec_version": "13.1.0", "type": "messaging", "language": "eng", "nodes": []}`), migrated)
}
diff --git a/core/goflow/http.go b/core/goflow/http.go
index 7e907a522..8f0fc7c35 100644
--- a/core/goflow/http.go
+++ b/core/goflow/http.go
@@ -17,7 +17,7 @@ var httpRetries *httpx.RetryConfig
var httpAccess *httpx.AccessConfig
// HTTP returns the configuration objects for HTTP calls from the engine and its services
-func HTTP() (*http.Client, *httpx.RetryConfig, *httpx.AccessConfig) {
+func HTTP(cfg *config.Config) (*http.Client, *httpx.RetryConfig, *httpx.AccessConfig) {
httpInit.Do(func() {
// customize the default golang transport
t := http.DefaultTransport.(*http.Transport).Clone()
@@ -30,16 +30,16 @@ func HTTP() (*http.Client, *httpx.RetryConfig, *httpx.AccessConfig) {
httpClient = &http.Client{
Transport: t,
- Timeout: time.Duration(config.Mailroom.WebhooksTimeout) * time.Millisecond,
+ Timeout: time.Duration(cfg.WebhooksTimeout) * time.Millisecond,
}
httpRetries = httpx.NewExponentialRetries(
- time.Duration(config.Mailroom.WebhooksInitialBackoff)*time.Millisecond,
- config.Mailroom.WebhooksMaxRetries,
- config.Mailroom.WebhooksBackoffJitter,
+ time.Duration(cfg.WebhooksInitialBackoff)*time.Millisecond,
+ cfg.WebhooksMaxRetries,
+ cfg.WebhooksBackoffJitter,
)
- disallowedIPs, disallowedNets, _ := config.Mailroom.ParseDisallowedNetworks()
+ disallowedIPs, disallowedNets, _ := cfg.ParseDisallowedNetworks()
httpAccess = httpx.NewAccessConfig(10*time.Second, disallowedIPs, disallowedNets)
})
return httpClient, httpRetries, httpAccess
diff --git a/core/goflow/modifiers_test.go b/core/goflow/modifiers_test.go
index 7830f99ad..1acbeb258 100644
--- a/core/goflow/modifiers_test.go
+++ b/core/goflow/modifiers_test.go
@@ -7,15 +7,15 @@ import (
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
)
func TestReadModifiers(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
// can read empty list
@@ -47,7 +47,7 @@ func TestReadModifiers(t *testing.T) {
assert.Equal(t, "language", mods[1].Type())
// modifier with missing asset or an error if allowMissing is false
- mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{
+ _, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{
[]byte(`{"type": "name", "name": "Bob"}`),
[]byte(`{"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}`),
[]byte(`{"type": "language", "language": "spa"}`),
@@ -55,7 +55,7 @@ func TestReadModifiers(t *testing.T) {
assert.EqualError(t, err, `error reading modifier: {"type": "field", "field": {"key": "blood_type", "name": "Blood Type"}, "value": "O"}: no modifier to return because of missing assets`)
// error if any modifier structurally invalid
- mods, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{
+ _, err = goflow.ReadModifiers(oa.SessionAssets(), []json.RawMessage{
[]byte(`{"type": "field", "value": "O"}`),
[]byte(`{"type": "language", "language": "spa"}`),
}, goflow.ErrorOnMissing)
diff --git a/core/handlers/airtime_transferred_test.go b/core/handlers/airtime_transferred_test.go
index 4dd52b5a8..285a11e6a 100644
--- a/core/handlers/airtime_transferred_test.go
+++ b/core/handlers/airtime_transferred_test.go
@@ -7,8 +7,8 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/shopspring/decimal"
)
@@ -193,79 +193,6 @@ var productsResponse = `[
}
]`
-var transactionConfirmedResponse = `{
- "benefits": [
- {
- "additional_information": null,
- "amount": {
- "base": 3,
- "promotion_bonus": 0,
- "total_excluding_tax": 3
- },
- "type": "CREDITS",
- "unit": "USD",
- "unit_type": "CURRENCY"
- }
- ],
- "confirmation_date": "2021-03-24T20:05:06.111631000Z",
- "confirmation_expiration_date": "2021-03-24T21:05:05.883561000Z",
- "creation_date": "2021-03-24T20:05:05.883561000Z",
- "credit_party_identifier": {
- "mobile_number": "+593979123456"
- },
- "external_id": "EX12345",
- "id": 2237512891,
- "prices": {
- "retail": {
- "amount": 4,
- "fee": 0,
- "unit": "USD",
- "unit_type": "CURRENCY"
- },
- "wholesale": {
- "amount": 3.6,
- "fee": 0,
- "unit": "USD",
- "unit_type": "CURRENCY"
- }
- },
- "product": {
- "description": "",
- "id": 6035,
- "name": "3 USD",
- "operator": {
- "country": {
- "iso_code": "ECU",
- "name": "Ecuador",
- "regions": null
- },
- "id": 1596,
- "name": "Claro Ecuador",
- "regions": null
- },
- "regions": null,
- "service": {
- "id": 1,
- "name": "Mobile"
- },
- "type": "FIXED_VALUE_RECHARGE"
- },
- "promotions": null,
- "rates": {
- "base": 0.833333333333333,
- "retail": 0.75,
- "wholesale": 0.833333333333333
- },
- "status": {
- "class": {
- "id": 2,
- "message": "CONFIRMED"
- },
- "id": 20000,
- "message": "CONFIRMED"
- }
-}`
-
var transactionRejectedResponse = `{
"benefits": [
{
@@ -340,8 +267,9 @@ var transactionRejectedResponse = `{
}`
func TestAirtimeTransferred(t *testing.T) {
- testsuite.Reset()
+ _, _, db, _ := testsuite.Get()
+ defer testsuite.Reset()
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -361,43 +289,43 @@ func TestAirtimeTransferred(t *testing.T) {
},
}))
- testsuite.DB().MustExec(`UPDATE orgs_org SET config = '{"dtone_key": "key123", "dtone_secret": "sesame"}'::jsonb WHERE id = $1`, models.Org1)
+ db.MustExec(`UPDATE orgs_org SET config = '{"dtone_key": "key123", "dtone_secret": "sesame"}'::jsonb WHERE id = $1`, testdata.Org1.ID)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewTransferAirtime(handlers.NewActionUUID(), map[string]decimal.Decimal{"USD": decimal.RequireFromString(`3.50`)}, "Transfer"),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from airtime_airtimetransfer where org_id = $1 AND contact_id = $2 AND status = 'S'`,
- Args: []interface{}{models.Org1, models.CathyID},
+ Args: []interface{}{testdata.Org1.ID, testdata.Cathy.ID},
Count: 1,
},
{
SQL: `select count(*) from request_logs_httplog where org_id = $1 AND airtime_transfer_id IS NOT NULL AND is_error = FALSE AND url LIKE 'https://dvs-api.dtone.com/v1/%'`,
- Args: []interface{}{models.Org1},
+ Args: []interface{}{testdata.Org1.ID},
Count: 3,
},
},
},
{
Actions: handlers.ContactActionMap{
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewTransferAirtime(handlers.NewActionUUID(), map[string]decimal.Decimal{"USD": decimal.RequireFromString(`3.50`)}, "Transfer"),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from airtime_airtimetransfer where org_id = $1 AND contact_id = $2 AND status = 'F'`,
- Args: []interface{}{models.Org1, models.GeorgeID},
+ Args: []interface{}{testdata.Org1.ID, testdata.George.ID},
Count: 1,
},
{
SQL: `select count(*) from request_logs_httplog where org_id = $1 AND airtime_transfer_id IS NOT NULL AND is_error = TRUE AND url LIKE 'https://dvs-api.dtone.com/v1/%'`,
- Args: []interface{}{models.Org1},
+ Args: []interface{}{testdata.Org1.ID},
Count: 1,
},
},
diff --git a/core/handlers/base_test.go b/core/handlers/base_test.go
index 4dc1c5de3..e19104caf 100644
--- a/core/handlers/base_test.go
+++ b/core/handlers/base_test.go
@@ -17,7 +17,9 @@ import (
"github.com/nyaruka/goflow/flows/triggers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/runner"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/gomodule/redigo/redis"
"github.com/jmoiron/sqlx"
@@ -25,9 +27,9 @@ import (
"github.com/stretchr/testify/require"
)
-type ContactActionMap map[models.ContactID][]flows.Action
-type ContactMsgMap map[models.ContactID]*flows.MsgIn
-type ContactModifierMap map[models.ContactID][]flows.Modifier
+type ContactActionMap map[*testdata.Contact][]flows.Action
+type ContactMsgMap map[*testdata.Contact]*flows.MsgIn
+type ContactModifierMap map[*testdata.Contact][]flows.Modifier
type modifyResult struct {
Contact *flows.Contact `json:"contact"`
@@ -43,7 +45,7 @@ type TestCase struct {
SQLAssertions []SQLAssertion
}
-type Assertion func(t *testing.T, db *sqlx.DB, rc redis.Conn) error
+type Assertion func(t *testing.T, rt *runtime.Runtime) error
type SQLAssertion struct {
SQL string
@@ -82,11 +84,11 @@ func createTestFlow(t *testing.T, uuid assets.FlowUUID, tc TestCase) flows.Flow
exits := make([]flows.Exit, len(tc.Actions))
exitNodes := make([]flows.Node, len(tc.Actions))
i = 0
- for cid, actions := range tc.Actions {
+ for contact, actions := range tc.Actions {
cases[i] = routers.NewCase(
uuids.New(),
"has_any_word",
- []string{fmt.Sprintf("%d", cid)},
+ []string{fmt.Sprintf("%d", contact.ID)},
categoryUUIDs[i],
)
@@ -99,7 +101,7 @@ func createTestFlow(t *testing.T, uuid assets.FlowUUID, tc TestCase) flows.Flow
categories[i] = routers.NewCategory(
categoryUUIDs[i],
- fmt.Sprintf("Contact %d", cid),
+ fmt.Sprintf("Contact %d", contact.ID),
exitUUIDs[i],
)
@@ -157,19 +159,22 @@ func createTestFlow(t *testing.T, uuid assets.FlowUUID, tc TestCase) flows.Flow
}
func RunTestCases(t *testing.T, tcs []TestCase) {
- models.FlushCache()
+ ctx, rt, db, _ := testsuite.Get()
- db := testsuite.DB()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
+ models.FlushCache()
oa, err := models.GetOrgAssets(ctx, db, models.OrgID(1))
assert.NoError(t, err)
// reuse id from one of our real flows
- flowUUID := models.FavoritesFlowUUID
+ flowUUID := testdata.Favorites.UUID
for i, tc := range tcs {
+ msgsByContactID := make(map[models.ContactID]*flows.MsgIn)
+ for contact, msg := range tc.Msgs {
+ msgsByContactID[contact.ID] = msg
+ }
+
// build our flow for this test case
testFlow := createTestFlow(t, flowUUID, tc)
flowDef, err := json.Marshal(testFlow)
@@ -184,7 +189,7 @@ func RunTestCases(t *testing.T, tcs []TestCase) {
options := runner.NewStartOptions()
options.CommitHook = func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, session []*models.Session) error {
for _, s := range session {
- msg := tc.Msgs[s.ContactID()]
+ msg := msgsByContactID[s.ContactID()]
if msg != nil {
s.SetIncomingMsg(msg.ID(), "")
}
@@ -192,23 +197,24 @@ func RunTestCases(t *testing.T, tcs []TestCase) {
return nil
}
options.TriggerBuilder = func(contact *flows.Contact) flows.Trigger {
- msg := tc.Msgs[models.ContactID(contact.ID())]
+ msg := msgsByContactID[models.ContactID(contact.ID())]
if msg == nil {
return triggers.NewBuilder(oa.Env(), testFlow.Reference(), contact).Manual().Build()
}
return triggers.NewBuilder(oa.Env(), testFlow.Reference(), contact).Msg(msg).Build()
}
- _, err = runner.StartFlow(ctx, db, rp, oa, flow.(*models.Flow), []models.ContactID{models.CathyID, models.BobID, models.GeorgeID, models.AlexandriaID}, options)
- assert.NoError(t, err)
+ for _, c := range []*testdata.Contact{testdata.Cathy, testdata.Bob, testdata.George, testdata.Alexandria} {
+ _, err := runner.StartFlow(ctx, rt, oa, flow.(*models.Flow), []models.ContactID{c.ID}, options)
+ require.NoError(t, err)
+ }
results := make(map[models.ContactID]modifyResult)
// create scenes for our contacts
scenes := make([]*models.Scene, 0, len(tc.Modifiers))
- for contactID, mods := range tc.Modifiers {
-
- contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{contactID})
+ for contact, mods := range tc.Modifiers {
+ contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{contact.ID})
assert.NoError(t, err)
contact := contacts[0]
@@ -236,11 +242,11 @@ func RunTestCases(t *testing.T, tcs []TestCase) {
assert.NoError(t, err)
for _, scene := range scenes {
- err := models.HandleEvents(ctx, tx, rp, oa, scene, results[scene.ContactID()].Events)
+ err := models.HandleEvents(ctx, tx, rt.RP, oa, scene, results[scene.ContactID()].Events)
assert.NoError(t, err)
}
- err = models.ApplyEventPreCommitHooks(ctx, tx, rp, oa, scenes)
+ err = models.ApplyEventPreCommitHooks(ctx, tx, rt.RP, oa, scenes)
assert.NoError(t, err)
err = tx.Commit()
@@ -249,23 +255,22 @@ func RunTestCases(t *testing.T, tcs []TestCase) {
tx, err = db.BeginTxx(ctx, nil)
assert.NoError(t, err)
- err = models.ApplyEventPostCommitHooks(ctx, tx, rp, oa, scenes)
+ err = models.ApplyEventPostCommitHooks(ctx, tx, rt.RP, oa, scenes)
assert.NoError(t, err)
err = tx.Commit()
assert.NoError(t, err)
+ time.Sleep(500 * time.Millisecond)
+
// now check our assertions
- time.Sleep(1 * time.Second)
- for ii, a := range tc.SQLAssertions {
- testsuite.AssertQueryCount(t, db, a.SQL, a.Args, a.Count, "%d:%d: mismatch in expected count for query: %s", i, ii, a.SQL)
+ for j, a := range tc.SQLAssertions {
+ testsuite.AssertQuery(t, db, a.SQL, a.Args...).Returns(a.Count, "%d:%d: mismatch in expected count for query: %s", i, j, a.SQL)
}
- rc := rp.Get()
- for ii, a := range tc.Assertions {
- err := a(t, db, rc)
- assert.NoError(t, err, "%d: %d error checking assertion", i, ii)
+ for j, a := range tc.Assertions {
+ err := a(t, rt)
+ assert.NoError(t, err, "%d:%d error checking assertion", i, j)
}
- rc.Close()
}
}
diff --git a/core/handlers/broadcast_created_test.go b/core/handlers/broadcast_created_test.go
index a3fa521e2..456b487d8 100644
--- a/core/handlers/broadcast_created_test.go
+++ b/core/handlers/broadcast_created_test.go
@@ -10,34 +10,37 @@ import (
"github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
- "github.com/gomodule/redigo/redis"
- "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
)
func TestBroadcastCreated(t *testing.T) {
- testsuite.Reset()
+ defer testsuite.Reset()
// TODO: test contacts, groups
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSendBroadcast(handlers.NewActionUUID(), "hello world", nil, nil, []urns.URN{urns.URN("tel:+12065551212")}, nil, nil, nil),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "select count(*) from flows_flowrun where contact_id = $1 AND is_active = FALSE",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
},
Assertions: []handlers.Assertion{
- func(t *testing.T, db *sqlx.DB, rc redis.Conn) error {
+ func(t *testing.T, rt *runtime.Runtime) error {
+ rc := rt.RP.Get()
+ defer rc.Close()
+
task, err := queue.PopNextTask(rc, queue.HandlerQueue)
assert.NoError(t, err)
assert.NotNil(t, task)
diff --git a/core/handlers/campaigns_test.go b/core/handlers/campaigns_test.go
index 2c58a0426..ed28d533d 100644
--- a/core/handlers/campaigns_test.go
+++ b/core/handlers/campaigns_test.go
@@ -8,14 +8,14 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestCampaigns(t *testing.T) {
testsuite.Reset()
- doctors := assets.NewGroupReference(models.DoctorsGroupUUID, "Doctors")
+ doctors := assets.NewGroupReference(testdata.DoctorsGroup.UUID, "Doctors")
joined := assets.NewFieldReference("joined", "Joined")
// insert an event on our campaign that is based on created_on
@@ -23,43 +23,43 @@ func TestCampaigns(t *testing.T) {
`INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour,
campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode)
VALUES(TRUE, NOW(), NOW(), $1, 1000, 'W', 'F', -1, $2, 1, 1, $3, $4, 'I')`,
- uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.CreatedOnFieldID)
+ uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.CreatedOnField.ID)
// insert an event on our campaign that is based on last_seen_on
testsuite.DB().MustExec(
`INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour,
campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode)
VALUES(TRUE, NOW(), NOW(), $1, 2, 'D', 'F', -1, $2, 1, 1, $3, $4, 'I')`,
- uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.LastSeenOnFieldID)
+ uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.LastSeenOnField.ID)
// init their values
testsuite.DB().MustExec(
`update contacts_contact set fields = fields - '8c1c1256-78d6-4a5b-9f1c-1761d5728251'
- WHERE id = $1`, models.CathyID)
+ WHERE id = $1`, testdata.Cathy.ID)
testsuite.DB().MustExec(
`update contacts_contact set fields = fields ||
'{"8c1c1256-78d6-4a5b-9f1c-1761d5728251": { "text": "2029-09-15T12:00:00+00:00", "datetime": "2029-09-15T12:00:00+00:00" }}'::jsonb
- WHERE id = $1`, models.BobID)
+ WHERE id = $1`, testdata.Bob.ID)
tcs := []handlers.TestCase{
{
Msgs: handlers.ContactMsgMap{
- models.CathyID: flows.NewMsgIn(flows.MsgUUID(uuids.New()), models.CathyURN, nil, "Hi there", nil),
+ testdata.Cathy: flows.NewMsgIn(flows.MsgUUID(uuids.New()), testdata.Cathy.URN, nil, "Hi there", nil),
},
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false),
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}),
actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"),
actions.NewSetContactField(handlers.NewActionUUID(), joined, ""),
},
- models.BobID: []flows.Action{
+ testdata.Bob: []flows.Action{
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}),
actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"),
actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}),
actions.NewSetContactField(handlers.NewActionUUID(), joined, "2029-09-15T12:00:00+00:00"),
actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false),
@@ -68,17 +68,17 @@ func TestCampaigns(t *testing.T) {
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) FROM campaigns_eventfire WHERE contact_id = $1`,
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 2,
},
{
SQL: `select count(*) FROM campaigns_eventfire WHERE contact_id = $1`,
- Args: []interface{}{models.BobID},
+ Args: []interface{}{testdata.Bob.ID},
Count: 3,
},
{
SQL: `select count(*) FROM campaigns_eventfire WHERE contact_id = $1`,
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 0,
},
},
diff --git a/core/handlers/contact_field_changed_test.go b/core/handlers/contact_field_changed_test.go
index 5b208bf3a..000806e6f 100644
--- a/core/handlers/contact_field_changed_test.go
+++ b/core/handlers/contact_field_changed_test.go
@@ -7,38 +7,40 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestContactFieldChanged(t *testing.T) {
+ _, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
+
gender := assets.NewFieldReference("gender", "Gender")
age := assets.NewFieldReference("age", "Age")
- db := testsuite.DB()
-
// populate some field values on alexandria
- db.Exec(`UPDATE contacts_contact SET fields = '{"903f51da-2717-47c7-a0d3-f2f32877013d": {"text":"34"}, "3a5891e4-756e-4dc9-8e12-b7a766168824": {"text":"female"}}' WHERE id = $1`, models.AlexandriaID)
+ db.MustExec(`UPDATE contacts_contact SET fields = '{"903f51da-2717-47c7-a0d3-f2f32877013d": {"text":"34"}, "3a5891e4-756e-4dc9-8e12-b7a766168824": {"text":"female"}}' WHERE id = $1`, testdata.Alexandria.ID)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSetContactField(handlers.NewActionUUID(), gender, "Male"),
actions.NewSetContactField(handlers.NewActionUUID(), gender, "Female"),
actions.NewSetContactField(handlers.NewActionUUID(), age, ""),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewSetContactField(handlers.NewActionUUID(), gender, "Male"),
actions.NewSetContactField(handlers.NewActionUUID(), gender, ""),
actions.NewSetContactField(handlers.NewActionUUID(), age, "40"),
},
- models.BobID: []flows.Action{
+ testdata.Bob: []flows.Action{
actions.NewSetContactField(handlers.NewActionUUID(), gender, ""),
actions.NewSetContactField(handlers.NewActionUUID(), gender, "Male"),
actions.NewSetContactField(handlers.NewActionUUID(), age, "Old"),
},
- models.AlexandriaID: []flows.Action{
+ testdata.Alexandria: []flows.Action{
actions.NewSetContactField(handlers.NewActionUUID(), age, ""),
actions.NewSetContactField(handlers.NewActionUUID(), gender, ""),
},
@@ -46,42 +48,42 @@ func TestContactFieldChanged(t *testing.T) {
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"Female"}'::jsonb`,
- Args: []interface{}{models.CathyID, models.GenderFieldUUID},
+ Args: []interface{}{testdata.Cathy.ID, testdata.GenderField.UUID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND NOT fields?$2`,
- Args: []interface{}{models.CathyID, models.AgeFieldUUID},
+ Args: []interface{}{testdata.Cathy.ID, testdata.AgeField.UUID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND NOT fields?$2`,
- Args: []interface{}{models.GeorgeID, models.GenderFieldUUID},
+ Args: []interface{}{testdata.George.ID, testdata.GenderField.UUID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"40", "number": 40}'::jsonb`,
- Args: []interface{}{models.GeorgeID, models.AgeFieldUUID},
+ Args: []interface{}{testdata.George.ID, testdata.AgeField.UUID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"Male"}'::jsonb`,
- Args: []interface{}{models.BobID, models.GenderFieldUUID},
+ Args: []interface{}{testdata.Bob.ID, testdata.GenderField.UUID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND fields->$2 = '{"text":"Old"}'::jsonb`,
- Args: []interface{}{models.BobID, models.AgeFieldUUID},
+ Args: []interface{}{testdata.Bob.ID, testdata.AgeField.UUID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND NOT fields?$2`,
- Args: []interface{}{models.BobID, "unknown"},
+ Args: []interface{}{testdata.Bob.ID, "unknown"},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND fields = '{}'`,
- Args: []interface{}{models.AlexandriaID},
+ Args: []interface{}{testdata.Alexandria.ID},
Count: 1,
},
},
diff --git a/core/handlers/contact_groups_changed_test.go b/core/handlers/contact_groups_changed_test.go
index ed70bdea3..70e84cb39 100644
--- a/core/handlers/contact_groups_changed_test.go
+++ b/core/handlers/contact_groups_changed_test.go
@@ -7,23 +7,26 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestContactGroupsChanged(t *testing.T) {
- doctors := assets.NewGroupReference(models.DoctorsGroupUUID, "Doctors")
- testers := assets.NewGroupReference(models.TestersGroupUUID, "Testers")
+ defer testsuite.Reset()
+
+ doctors := assets.NewGroupReference(testdata.DoctorsGroup.UUID, "Doctors")
+ testers := assets.NewGroupReference(testdata.TestersGroup.UUID, "Testers")
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}),
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}),
actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false),
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{testers}),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewRemoveContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{doctors}, false),
actions.NewAddContactGroups(handlers.NewActionUUID(), []*assets.GroupReference{testers}),
},
@@ -31,22 +34,22 @@ func TestContactGroupsChanged(t *testing.T) {
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2",
- Args: []interface{}{models.CathyID, models.DoctorsGroupID},
+ Args: []interface{}{testdata.Cathy.ID, testdata.DoctorsGroup.ID},
Count: 0,
},
{
SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2",
- Args: []interface{}{models.CathyID, models.TestersGroupID},
+ Args: []interface{}{testdata.Cathy.ID, testdata.TestersGroup.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2",
- Args: []interface{}{models.GeorgeID, models.TestersGroupID},
+ Args: []interface{}{testdata.George.ID, testdata.TestersGroup.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contactgroup_contacts where contact_id = $1 and contactgroup_id = $2",
- Args: []interface{}{models.BobID, models.TestersGroupID},
+ Args: []interface{}{testdata.Bob.ID, testdata.TestersGroup.ID},
Count: 0,
},
},
diff --git a/core/handlers/contact_language_changed_test.go b/core/handlers/contact_language_changed_test.go
index ad1340f5d..f04703ba0 100644
--- a/core/handlers/contact_language_changed_test.go
+++ b/core/handlers/contact_language_changed_test.go
@@ -6,43 +6,46 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestContactLanguageChanged(t *testing.T) {
+ defer testsuite.Reset()
+
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSetContactLanguage(handlers.NewActionUUID(), "fra"),
actions.NewSetContactLanguage(handlers.NewActionUUID(), "eng"),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewSetContactLanguage(handlers.NewActionUUID(), "spa"),
},
- models.AlexandriaID: []flows.Action{
+ testdata.Alexandria: []flows.Action{
actions.NewSetContactLanguage(handlers.NewActionUUID(), ""),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "select count(*) from contacts_contact where id = $1 and language = 'eng'",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contact where id = $1 and language = 'spa'",
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contact where id = $1 and language is NULL;",
- Args: []interface{}{models.BobID},
+ Args: []interface{}{testdata.Bob.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contact where id = $1 and language is NULL;",
- Args: []interface{}{models.AlexandriaID},
+ Args: []interface{}{testdata.Alexandria.ID},
Count: 1,
},
},
diff --git a/core/handlers/contact_name_changed_test.go b/core/handlers/contact_name_changed_test.go
index a298ca537..169e01dff 100644
--- a/core/handlers/contact_name_changed_test.go
+++ b/core/handlers/contact_name_changed_test.go
@@ -6,31 +6,34 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestContactNameChanged(t *testing.T) {
+ defer testsuite.Reset()
+
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSetContactName(handlers.NewActionUUID(), "Fred"),
actions.NewSetContactName(handlers.NewActionUUID(), "Tarzan"),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewSetContactName(handlers.NewActionUUID(), "Geoff Newman"),
},
- models.BobID: []flows.Action{
+ testdata.Bob: []flows.Action{
actions.NewSetContactName(handlers.NewActionUUID(), ""),
},
- models.AlexandriaID: []flows.Action{
+ testdata.Alexandria: []flows.Action{
actions.NewSetContactName(handlers.NewActionUUID(), "😃234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "select count(*) from contacts_contact where name = 'Tarzan' and id = $1",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
@@ -39,17 +42,17 @@ func TestContactNameChanged(t *testing.T) {
},
{
SQL: "select count(*) from contacts_contact where name IS NULL and id = $1",
- Args: []interface{}{models.BobID},
+ Args: []interface{}{testdata.Bob.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contact where name = 'Geoff Newman' and id = $1",
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contact where name = '😃2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678' and id = $1",
- Args: []interface{}{models.AlexandriaID},
+ Args: []interface{}{testdata.Alexandria.ID},
Count: 1,
},
},
diff --git a/core/handlers/contact_status_changed_test.go b/core/handlers/contact_status_changed_test.go
index d5831f1a5..a2d3cafa5 100644
--- a/core/handlers/contact_status_changed_test.go
+++ b/core/handlers/contact_status_changed_test.go
@@ -6,55 +6,56 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/modifiers"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestContactStatusChanged(t *testing.T) {
+ _, _, db, _ := testsuite.Get()
- db := testsuite.DB()
+ defer testsuite.Reset()
// make sure cathyID contact is active
- db.Exec(`UPDATE contacts_contact SET status = 'A' WHERE id = $1`, models.CathyID)
+ db.Exec(`UPDATE contacts_contact SET status = 'A' WHERE id = $1`, testdata.Cathy.ID)
tcs := []handlers.TestCase{
{
Modifiers: handlers.ContactModifierMap{
- models.CathyID: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusBlocked)},
+ testdata.Cathy: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusBlocked)},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from contacts_contact where id = $1 AND status = 'B'`,
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
},
},
{
Modifiers: handlers.ContactModifierMap{
- models.CathyID: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusStopped)},
+ testdata.Cathy: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusStopped)},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from contacts_contact where id = $1 AND status = 'S'`,
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
},
},
{
Modifiers: handlers.ContactModifierMap{
- models.CathyID: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusActive)},
+ testdata.Cathy: []flows.Modifier{modifiers.NewStatus(flows.ContactStatusActive)},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from contacts_contact where id = $1 AND status = 'A'`,
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: `select count(*) from contacts_contact where id = $1 AND status = 'A'`,
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
},
diff --git a/core/handlers/contact_urns_changed_test.go b/core/handlers/contact_urns_changed_test.go
index e2edae4e9..285e6e5f3 100644
--- a/core/handlers/contact_urns_changed_test.go
+++ b/core/handlers/contact_urns_changed_test.go
@@ -8,50 +8,51 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestContactURNsChanged(t *testing.T) {
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
// add a URN to george that cathy will steal
- testdata.InsertContactURN(t, db, models.Org1, models.GeorgeID, urns.URN("tel:+12065551212"), 100)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.George, urns.URN("tel:+12065551212"), 100)
now := time.Now()
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewAddContactURN(handlers.NewActionUUID(), "tel", "12065551212"),
actions.NewAddContactURN(handlers.NewActionUUID(), "tel", "12065551212"),
actions.NewAddContactURN(handlers.NewActionUUID(), "telegram", "11551"),
actions.NewAddContactURN(handlers.NewActionUUID(), "tel", "+16055741111"),
},
- models.GeorgeID: []flows.Action{},
+ testdata.George: []flows.Action{},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "select count(*) from contacts_contacturn where contact_id = $1 and scheme = 'telegram' and path = '11551' and priority = 998",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contacturn where contact_id = $1 and scheme = 'tel' and path = '+12065551212' and priority = 999 and identity = 'tel:+12065551212'",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: "select count(*) from contacts_contacturn where contact_id = $1 and scheme = 'tel' and path = '+16055741111' and priority = 1000",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
// evan lost his 206 URN
{
SQL: "select count(*) from contacts_contacturn where contact_id = $1",
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 1,
},
// two contacts updated, both cathy and evan since their URNs changed
diff --git a/core/handlers/input_labels_added_test.go b/core/handlers/input_labels_added_test.go
index 94428d3f8..06e098c9a 100644
--- a/core/handlers/input_labels_added_test.go
+++ b/core/handlers/input_labels_added_test.go
@@ -14,31 +14,33 @@ import (
)
func TestInputLabelsAdded(t *testing.T) {
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
reporting := assets.NewLabelReference(assets.LabelUUID("ebc4dedc-91c4-4ed4-9dd6-daa05ea82698"), "Reporting")
testing := assets.NewLabelReference(assets.LabelUUID("a6338cdc-7938-4437-8b05-2d5d785e3a08"), "Testing")
- msg1 := testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "start")
- msg2 := testdata.InsertIncomingMsg(t, db, models.Org1, models.BobID, models.BobURN, models.BobURNID, "start")
+ msg1 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "start", models.MsgStatusHandled)
+ msg2 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Bob, "start", models.MsgStatusHandled)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{reporting}),
actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{testing}),
actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{reporting}),
},
- models.BobID: []flows.Action{},
- models.GeorgeID: []flows.Action{
+ testdata.Bob: []flows.Action{},
+ testdata.George: []flows.Action{
actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{testing}),
actions.NewAddInputLabels(handlers.NewActionUUID(), []*assets.LabelReference{reporting}),
},
},
Msgs: handlers.ContactMsgMap{
- models.CathyID: msg1,
- models.BobID: msg2,
+ testdata.Cathy: msg1,
+ testdata.Bob: msg2,
},
SQLAssertions: []handlers.SQLAssertion{
{
@@ -53,7 +55,7 @@ func TestInputLabelsAdded(t *testing.T) {
},
{
SQL: "select count(*) from msgs_msg_labels l JOIN msgs_msg m ON l.msg_id = m.id WHERE m.contact_id = $1",
- Args: []interface{}{models.BobID},
+ Args: []interface{}{testdata.Bob.ID},
Count: 0,
},
},
diff --git a/core/handlers/ivr_created.go b/core/handlers/ivr_created.go
index 93d434fa2..4c2f63fa0 100644
--- a/core/handlers/ivr_created.go
+++ b/core/handlers/ivr_created.go
@@ -38,10 +38,7 @@ func handleIVRCreated(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *mode
return nil
}
- msg, err := models.NewOutgoingIVR(oa.OrgID(), conn, event.Msg, event.CreatedOn())
- if err != nil {
- return errors.Wrapf(err, "error creating outgoing ivr say: %s", event.Msg.Text())
- }
+ msg := models.NewOutgoingIVR(oa.OrgID(), conn, event.Msg, event.CreatedOn())
// register to have this message committed
scene.AppendToEventPreCommitHook(hooks.CommitIVRHook, msg)
diff --git a/core/handlers/msg_created_test.go b/core/handlers/msg_created_test.go
index 7a1b2ab5b..fcfb22ff0 100644
--- a/core/handlers/msg_created_test.go
+++ b/core/handlers/msg_created_test.go
@@ -20,23 +20,24 @@ import (
)
func TestMsgCreated(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
config.Mailroom.AttachmentDomain = "foo.bar.com"
defer func() { config.Mailroom.AttachmentDomain = "" }()
// add a URN for cathy so we can test all urn sends
- testdata.InsertContactURN(t, db, models.Org1, models.CathyID, urns.URN("tel:+12065551212"), 10)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.Cathy, urns.URN("tel:+12065551212"), 10)
// delete all URNs for bob
- db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, models.BobID)
+ db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, testdata.Bob.ID)
// change alexandrias URN to a twitter URN and set her language to eng so that a template gets used for her
- db.MustExec(`UPDATE contacts_contacturn SET identity = 'twitter:12345', path='12345', scheme='twitter' WHERE contact_id = $1`, models.AlexandriaID)
- db.MustExec(`UPDATE contacts_contact SET language='eng' WHERE id = $1`, models.AlexandriaID)
+ db.MustExec(`UPDATE contacts_contacturn SET identity = 'twitter:12345', path='12345', scheme='twitter' WHERE contact_id = $1`, testdata.Alexandria.ID)
+ db.MustExec(`UPDATE contacts_contact SET language='eng' WHERE id = $1`, testdata.Alexandria.ID)
- msg1 := testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "start")
+ msg1 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "start", models.MsgStatusHandled)
templateAction := actions.NewSendMsg(handlers.NewActionUUID(), "Template time", nil, nil, false)
templateAction.Templating = &actions.Templating{
@@ -48,45 +49,45 @@ func TestMsgCreated(t *testing.T) {
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "Hello World", nil, []string{"yes", "no"}, true),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "Hello Attachments", []string{"image/png:/images/image1.png"}, nil, true),
},
- models.BobID: []flows.Action{
+ testdata.Bob: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "No URNs", nil, nil, false),
},
- models.AlexandriaID: []flows.Action{
+ testdata.Alexandria: []flows.Action{
templateAction,
},
},
Msgs: handlers.ContactMsgMap{
- models.CathyID: msg1,
+ testdata.Cathy: msg1,
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='Hello World' AND contact_id = $1 AND metadata = $2 AND response_to_id = $3 AND high_priority = TRUE",
- Args: []interface{}{models.CathyID, `{"quick_replies":["yes","no"]}`, msg1.ID()},
+ Args: []interface{}{testdata.Cathy.ID, `{"quick_replies":["yes","no"]}`, msg1.ID()},
Count: 2,
},
{
SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='Hello Attachments' AND contact_id = $1 AND attachments[1] = $2 AND status = 'Q' AND high_priority = FALSE",
- Args: []interface{}{models.GeorgeID, "image/png:https://foo.bar.com/images/image1.png"},
+ Args: []interface{}{testdata.George.ID, "image/png:https://foo.bar.com/images/image1.png"},
Count: 1,
},
{
SQL: "SELECT COUNT(*) FROM msgs_msg WHERE contact_id=$1;",
- Args: []interface{}{models.BobID},
+ Args: []interface{}{testdata.Bob.ID},
Count: 0,
},
{
SQL: "SELECT COUNT(*) FROM msgs_msg WHERE contact_id = $1 AND text = $2 AND metadata = $3 AND direction = 'O' AND status = 'Q' AND channel_id = $4",
Args: []interface{}{
- models.AlexandriaID,
+ testdata.Alexandria.ID,
`Hi Alexandia, are you still experiencing problems with tooth?`,
- `{"templating":{"template":{"uuid":"9c22b594-fcab-4b29-9bcb-ce4404894a80","name":"revive_issue"},"language":"eng","country":"US","variables":["Alexandia","tooth"]}}`,
- models.TwitterChannelID,
+ `{"templating":{"template":{"uuid":"9c22b594-fcab-4b29-9bcb-ce4404894a80","name":"revive_issue"},"language":"eng","country":"US","variables":["Alexandia","tooth"],"namespace":"2d40b45c_25cd_4965_9019_f05d0124c5fa"}}`,
+ testdata.TwitterChannel.ID,
},
Count: 1,
},
@@ -100,12 +101,12 @@ func TestMsgCreated(t *testing.T) {
defer rc.Close()
// Cathy should have 1 batch of queued messages at high priority
- count, err := redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/1", models.TwilioChannelUUID)))
+ count, err := redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/1", testdata.TwilioChannel.UUID)))
assert.NoError(t, err)
assert.Equal(t, 1, count)
// One bulk for George
- count, err = redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/0", models.TwilioChannelUUID)))
+ count, err = redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/0", testdata.TwilioChannel.UUID)))
assert.NoError(t, err)
assert.Equal(t, 1, count)
}
@@ -115,19 +116,19 @@ func TestNoTopup(t *testing.T) {
db := testsuite.DB()
// no more credits
- db.MustExec(`UPDATE orgs_topup SET credits = 0 WHERE org_id = $1`, models.Org1)
+ db.MustExec(`UPDATE orgs_topup SET credits = 0 WHERE org_id = $1`, testdata.Org1.ID)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "No Topup", nil, nil, false),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "SELECT COUNT(*) FROM msgs_msg WHERE text='No Topup' AND contact_id = $1 AND status = 'Q'",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
},
@@ -142,28 +143,28 @@ func TestNewURN(t *testing.T) {
db := testsuite.DB()
// switch our twitter channel to telegram
- telegramUUID := models.TwitterChannelUUID
- telegramID := models.TwitterChannelID
+ telegramUUID := testdata.TwitterChannel.UUID
+ telegramID := testdata.TwitterChannel.ID
db.MustExec(
`UPDATE channels_channel SET channel_type = 'TG', name = 'Telegram', schemes = ARRAY['telegram'] WHERE uuid = $1`,
telegramUUID,
)
// give George a URN that Bob will steal
- testdata.InsertContactURN(t, db, models.Org1, models.GeorgeID, urns.URN("telegram:67890"), 1)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.George, urns.URN("telegram:67890"), 1)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
// brand new URN on Cathy
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewAddContactURN(handlers.NewActionUUID(), "telegram", "12345"),
actions.NewSetContactChannel(handlers.NewActionUUID(), assets.NewChannelReference(telegramUUID, "telegram")),
actions.NewSendMsg(handlers.NewActionUUID(), "Cathy Message", nil, nil, false),
},
// Bob is stealing a URN previously assigned to George
- models.BobID: []flows.Action{
+ testdata.Bob: []flows.Action{
actions.NewAddContactURN(handlers.NewActionUUID(), "telegram", "67890"),
actions.NewSetContactChannel(handlers.NewActionUUID(), assets.NewChannelReference(telegramUUID, "telegram")),
actions.NewSendMsg(handlers.NewActionUUID(), "Bob Message", nil, nil, false),
@@ -184,7 +185,7 @@ func TestNewURN(t *testing.T) {
u.identity = $2 AND
m.channel_id = $3 AND
u.channel_id IS NULL`,
- Args: []interface{}{models.CathyID, "telegram:12345", telegramID},
+ Args: []interface{}{testdata.Cathy.ID, "telegram:12345", telegramID},
Count: 1,
},
{
@@ -201,7 +202,7 @@ func TestNewURN(t *testing.T) {
u.identity = $2 AND
m.channel_id = $3 AND
u.channel_id IS NULL`,
- Args: []interface{}{models.BobID, "telegram:67890", telegramID},
+ Args: []interface{}{testdata.Bob.ID, "telegram:67890", telegramID},
Count: 1,
},
},
diff --git a/core/handlers/msg_received_test.go b/core/handlers/msg_received_test.go
index 60a1736a2..d83f9e790 100644
--- a/core/handlers/msg_received_test.go
+++ b/core/handlers/msg_received_test.go
@@ -15,33 +15,34 @@ import (
)
func TestMsgReceived(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
now := time.Now()
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "Hello World", nil, nil, false),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "Hello world", nil, nil, false),
},
},
Msgs: handlers.ContactMsgMap{
- models.CathyID: testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "start"),
+ testdata.Cathy: testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "start", models.MsgStatusHandled),
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "SELECT COUNT(*) FROM contacts_contact WHERE id = $1 AND last_seen_on > $2",
- Args: []interface{}{models.CathyID, now},
+ Args: []interface{}{testdata.Cathy.ID, now},
Count: 1,
},
{
SQL: "SELECT COUNT(*) FROM contacts_contact WHERE id = $1 AND last_seen_on IS NULL",
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 1,
},
},
@@ -49,17 +50,17 @@ func TestMsgReceived(t *testing.T) {
{
FlowType: flows.FlowTypeMessagingOffline,
Actions: handlers.ContactActionMap{
- models.BobID: []flows.Action{
+ testdata.Bob: []flows.Action{
actions.NewSendMsg(handlers.NewActionUUID(), "Hello World", nil, nil, false),
},
},
Msgs: handlers.ContactMsgMap{
- models.BobID: flows.NewMsgIn(flows.MsgUUID(uuids.New()), urns.NilURN, nil, "Hi offline", nil),
+ testdata.Bob: flows.NewMsgIn(flows.MsgUUID(uuids.New()), urns.NilURN, nil, "Hi offline", nil),
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "SELECT COUNT(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'I'",
- Args: []interface{}{models.BobID},
+ Args: []interface{}{testdata.Bob.ID},
Count: 1,
},
},
diff --git a/core/handlers/service_called_test.go b/core/handlers/service_called_test.go
index b0f36c20e..03e8443ac 100644
--- a/core/handlers/service_called_test.go
+++ b/core/handlers/service_called_test.go
@@ -8,10 +8,12 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/actions"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
)
func TestServiceCalled(t *testing.T) {
+ defer testsuite.Reset()
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -29,19 +31,19 @@ func TestServiceCalled(t *testing.T) {
},
}))
- wit := assets.NewClassifierReference(models.WitUUID, "Wit Classifier")
+ wit := assets.NewClassifierReference(testdata.Wit.UUID, "Wit Classifier")
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewCallClassifier(handlers.NewActionUUID(), wit, "book me a flight", "flight"),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from request_logs_httplog where org_id = $1 AND is_error = FALSE AND classifier_id = $2 AND url = 'https://api.wit.ai/message?v=20200513&q=book+me+a+flight'`,
- Args: []interface{}{models.Org1, models.WitID},
+ Args: []interface{}{testdata.Org1.ID, testdata.Wit.ID},
Count: 1,
},
},
diff --git a/core/handlers/session_triggered_test.go b/core/handlers/session_triggered_test.go
index 795df4b67..19524c5b4 100644
--- a/core/handlers/session_triggered_test.go
+++ b/core/handlers/session_triggered_test.go
@@ -11,32 +11,30 @@ import (
"github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
- "github.com/gomodule/redigo/redis"
- "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
)
func TestSessionTriggered(t *testing.T) {
- testsuite.Reset()
- testsuite.ResetRP()
- models.FlushCache()
- db := testsuite.DB()
- ctx := testsuite.CTX()
+ ctx, _, db, _ := testsuite.Get()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ defer testsuite.Reset()
+
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
- simpleFlow, err := oa.FlowByID(models.SingleMessageFlowID)
+ simpleFlow, err := oa.FlowByID(testdata.SingleMessage.ID)
assert.NoError(t, err)
contactRef := &flows.ContactReference{
- UUID: models.GeorgeUUID,
+ UUID: testdata.George.UUID,
}
groupRef := &assets.GroupReference{
- UUID: models.TestersGroupUUID,
+ UUID: testdata.TestersGroup.UUID,
}
uuids.SetGenerator(uuids.NewSeededGenerator(1234567))
@@ -45,34 +43,37 @@ func TestSessionTriggered(t *testing.T) {
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewStartSession(handlers.NewActionUUID(), simpleFlow.FlowReference(), nil, []*flows.ContactReference{contactRef}, []*assets.GroupReference{groupRef}, nil, true),
},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: "select count(*) from flows_flowrun where contact_id = $1 AND is_active = FALSE",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: "select count(*) from flows_flowstart where org_id = 1 AND start_type = 'F' AND flow_id = $1 AND status = 'P' AND parent_summary IS NOT NULL AND session_history IS NOT NULL;",
- Args: []interface{}{models.SingleMessageFlowID},
+ Args: []interface{}{testdata.SingleMessage.ID},
Count: 1,
},
{
SQL: "select count(*) from flows_flowstart_contacts where id = 1 AND contact_id = $1",
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 1,
},
{
SQL: "select count(*) from flows_flowstart_groups where id = 1 AND contactgroup_id = $1",
- Args: []interface{}{models.TestersGroupID},
+ Args: []interface{}{testdata.TestersGroup.ID},
Count: 1,
},
},
Assertions: []handlers.Assertion{
- func(t *testing.T, db *sqlx.DB, rc redis.Conn) error {
+ func(t *testing.T, rt *runtime.Runtime) error {
+ rc := rt.RP.Get()
+ defer rc.Close()
+
task, err := queue.PopNextTask(rc, queue.BatchQueue)
assert.NoError(t, err)
assert.NotNil(t, task)
@@ -80,8 +81,8 @@ func TestSessionTriggered(t *testing.T) {
err = json.Unmarshal(task.Task, &start)
assert.NoError(t, err)
assert.True(t, start.CreateContact())
- assert.Equal(t, []models.ContactID{models.GeorgeID}, start.ContactIDs())
- assert.Equal(t, []models.GroupID{models.TestersGroupID}, start.GroupIDs())
+ assert.Equal(t, []models.ContactID{testdata.George.ID}, start.ContactIDs())
+ assert.Equal(t, []models.GroupID{testdata.TestersGroup.ID}, start.GroupIDs())
assert.Equal(t, simpleFlow.ID(), start.FlowID())
assert.JSONEq(t, `{"parent_uuid":"39a9f95e-3641-4d19-95e0-ed866f27c829", "ancestors":1, "ancestors_since_input":1}`, string(start.SessionHistory()))
return nil
@@ -94,16 +95,12 @@ func TestSessionTriggered(t *testing.T) {
}
func TestQuerySessionTriggered(t *testing.T) {
- testsuite.Reset()
- testsuite.ResetRP()
- models.FlushCache()
- db := testsuite.DB()
- ctx := testsuite.CTX()
+ ctx, _, db, rp := testsuite.Reset()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
- favoriteFlow, err := oa.FlowByID(models.FavoritesFlowID)
+ favoriteFlow, err := oa.FlowByID(testdata.Favorites.ID)
assert.NoError(t, err)
sessionAction := actions.NewStartSession(handlers.NewActionUUID(), favoriteFlow.FlowReference(), nil, nil, nil, nil, true)
@@ -112,17 +109,20 @@ func TestQuerySessionTriggered(t *testing.T) {
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{sessionAction},
+ testdata.Cathy: []flows.Action{sessionAction},
},
SQLAssertions: []handlers.SQLAssertion{
{
SQL: `select count(*) from flows_flowstart where flow_id = $1 AND start_type = 'F' AND status = 'P' AND query = 'name ~ "Cathy"' AND parent_summary IS NOT NULL;`,
- Args: []interface{}{models.FavoritesFlowID},
+ Args: []interface{}{testdata.Favorites.ID},
Count: 1,
},
},
Assertions: []handlers.Assertion{
- func(t *testing.T, db *sqlx.DB, rc redis.Conn) error {
+ func(t *testing.T, rt *runtime.Runtime) error {
+ rc := rp.Get()
+ defer rc.Close()
+
task, err := queue.PopNextTask(rc, queue.BatchQueue)
assert.NoError(t, err)
assert.NotNil(t, task)
diff --git a/core/handlers/ticket_opened.go b/core/handlers/ticket_opened.go
index 03f244ba2..6a62f5494 100644
--- a/core/handlers/ticket_opened.go
+++ b/core/handlers/ticket_opened.go
@@ -28,6 +28,15 @@ func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *mo
return errors.Errorf("unable to find ticketer with UUID: %s", event.Ticket.Ticketer.UUID)
}
+ var assigneeID models.UserID
+ if event.Ticket.Assignee != nil {
+ assignee := oa.UserByEmail(event.Ticket.Assignee.Email)
+ if assignee == nil {
+ return errors.Errorf("unable to find user with email: %s", event.Ticket.Assignee.Email)
+ }
+ assigneeID = assignee.ID()
+ }
+
ticket := models.NewTicket(
event.Ticket.UUID,
oa.OrgID(),
@@ -36,6 +45,7 @@ func handleTicketOpened(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *mo
event.Ticket.ExternalID,
event.Ticket.Subject,
event.Ticket.Body,
+ assigneeID,
map[string]interface{}{
"contact-uuid": scene.Contact().UUID(),
"contact-display": tickets.GetContactDisplay(oa.Env(), scene.Contact()),
diff --git a/core/handlers/ticket_opened_test.go b/core/handlers/ticket_opened_test.go
index 8d1e48d4e..54585e745 100644
--- a/core/handlers/ticket_opened_test.go
+++ b/core/handlers/ticket_opened_test.go
@@ -11,6 +11,7 @@ import (
"github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
_ "github.com/nyaruka/mailroom/services/tickets/mailgun"
_ "github.com/nyaruka/mailroom/services/tickets/zendesk"
@@ -19,10 +20,9 @@ import (
)
func TestTicketOpened(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
+ ctx, _, db, _ := testsuite.Get()
+ defer testsuite.Reset()
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -45,51 +45,55 @@ func TestTicketOpened(t *testing.T) {
}))
// an existing ticket
- cathyTicket := models.NewTicket(flows.TicketUUID(uuids.New()), models.Org1, models.CathyID, models.MailgunID, "748363", "Old Question", "Who?", nil)
+ cathyTicket := models.NewTicket(flows.TicketUUID(uuids.New()), testdata.Org1.ID, testdata.Cathy.ID, testdata.Mailgun.ID, "748363", "Old Question", "Who?", models.NilUserID, nil)
err := models.InsertTickets(ctx, db, []*models.Ticket{cathyTicket})
require.NoError(t, err)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
- actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(models.MailgunUUID, "Mailgun (IT Support)"), "Need help", "Where are my cookies?", "Email Ticket"),
+ testdata.Cathy: []flows.Action{
+ actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(testdata.Mailgun.UUID, "Mailgun (IT Support)"), "Need help", "Where are my cookies?", "Email Ticket"),
},
- models.BobID: []flows.Action{
- actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(models.ZendeskUUID, "Zendesk (Nyaruka)"), "Interesting", "I've found some cookies", "Zen Ticket"),
+ testdata.Bob: []flows.Action{
+ actions.NewOpenTicket(handlers.NewActionUUID(), assets.NewTicketerReference(testdata.Zendesk.UUID, "Zendesk (Nyaruka)"), "Interesting", "I've found some cookies", "Zen Ticket"),
},
},
SQLAssertions: []handlers.SQLAssertion{
{ // cathy's old ticket will still be open and cathy's new ticket will have been created
SQL: "select count(*) from tickets_ticket where contact_id = $1 AND status = 'O' AND ticketer_id = $2",
- Args: []interface{}{models.CathyID, models.MailgunID},
+ Args: []interface{}{testdata.Cathy.ID, testdata.Mailgun.ID},
Count: 2,
},
{ // and there's an HTTP log for that
SQL: "select count(*) from request_logs_httplog where ticketer_id = $1",
- Args: []interface{}{models.MailgunID},
+ Args: []interface{}{testdata.Mailgun.ID},
Count: 1,
},
{ // which doesn't include our API token
SQL: "select count(*) from request_logs_httplog where ticketer_id = $1 AND request like '%sesame%'",
- Args: []interface{}{models.MailgunID},
+ Args: []interface{}{testdata.Mailgun.ID},
Count: 0,
},
{ // bob's ticket will have been created too
SQL: "select count(*) from tickets_ticket where contact_id = $1 AND status = 'O' AND ticketer_id = $2",
- Args: []interface{}{models.BobID, models.ZendeskID},
+ Args: []interface{}{testdata.Bob.ID, testdata.Zendesk.ID},
Count: 1,
},
{ // and there's an HTTP log for that
SQL: "select count(*) from request_logs_httplog where ticketer_id = $1",
- Args: []interface{}{models.ZendeskID},
+ Args: []interface{}{testdata.Zendesk.ID},
Count: 1,
},
{ // which doesn't include our API token
SQL: "select count(*) from request_logs_httplog where ticketer_id = $1 AND request like '%523562%'",
- Args: []interface{}{models.ZendeskID},
+ Args: []interface{}{testdata.Zendesk.ID},
Count: 0,
},
+ { // and we have 2 ticket opened events for the 2 tickets opened
+ SQL: "select count(*) from tickets_ticketevent where event_type = 'O'",
+ Count: 2,
+ },
},
},
}
diff --git a/core/handlers/webhook_called_test.go b/core/handlers/webhook_called_test.go
index f9d97212d..9fda89ef6 100644
--- a/core/handlers/webhook_called_test.go
+++ b/core/handlers/webhook_called_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/goflow/flows"
@@ -13,8 +13,9 @@ import (
)
func TestWebhookCalled(t *testing.T) {
- testsuite.Reset()
+ _, _, db, _ := testsuite.Get()
+ defer testsuite.Reset()
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -30,21 +31,21 @@ func TestWebhookCalled(t *testing.T) {
}))
// add a few resthooks
- testsuite.DB().MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'foo', 1, NOW(), NOW(), 1, 1);`)
- testsuite.DB().MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'bar', 1, NOW(), NOW(), 1, 1);`)
+ db.MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'foo', 1, NOW(), NOW(), 1, 1);`)
+ db.MustExec(`INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'bar', 1, NOW(), NOW(), 1, 1);`)
// and a few targets
- testsuite.DB().MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/', 1, 1, 1);`)
- testsuite.DB().MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 2);`)
- testsuite.DB().MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 1);`)
+ db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/', 1, 1, 1);`)
+ db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 2);`)
+ db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id) VALUES(TRUE, NOW(), NOW(), 'http://rapidpro.io/?unsub=1', 1, 1, 1);`)
tcs := []handlers.TestCase{
{
Actions: handlers.ContactActionMap{
- models.CathyID: []flows.Action{
+ testdata.Cathy: []flows.Action{
actions.NewCallResthook(handlers.NewActionUUID(), "foo", "foo"),
},
- models.GeorgeID: []flows.Action{
+ testdata.George: []flows.Action{
actions.NewCallResthook(handlers.NewActionUUID(), "foo", "foo"),
actions.NewCallWebhook(handlers.NewActionUUID(), "GET", "http://rapidpro.io/?unsub=1", nil, "", ""),
},
@@ -67,22 +68,22 @@ func TestWebhookCalled(t *testing.T) {
},
{
SQL: "select count(*) from api_webhookresult where contact_id = $1 AND status_code = 200",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: "select count(*) from api_webhookresult where contact_id = $1 AND status_code = 410",
- Args: []interface{}{models.CathyID},
+ Args: []interface{}{testdata.Cathy.ID},
Count: 1,
},
{
SQL: "select count(*) from api_webhookresult where contact_id = $1",
- Args: []interface{}{models.GeorgeID},
+ Args: []interface{}{testdata.George.ID},
Count: 3,
},
{
SQL: "select count(*) from api_webhookevent where org_id = $1",
- Args: []interface{}{models.Org1},
+ Args: []interface{}{testdata.Org1.ID},
Count: 2,
},
},
diff --git a/core/hooks/insert_tickets.go b/core/hooks/insert_tickets.go
index b6bf164bf..b0c009c64 100644
--- a/core/hooks/insert_tickets.go
+++ b/core/hooks/insert_tickets.go
@@ -32,5 +32,17 @@ func (h *insertTicketsHook) Apply(ctx context.Context, tx *sqlx.Tx, rp *redis.Po
return errors.Wrapf(err, "error inserting tickets")
}
+ // generate opened events for each ticket
+ openEvents := make([]*models.TicketEvent, len(tickets))
+ for i, ticket := range tickets {
+ openEvents[i] = models.NewTicketOpenedEvent(ticket, models.NilUserID, ticket.AssigneeID())
+ }
+
+ // and insert those too
+ err = models.InsertTicketEvents(ctx, tx, openEvents)
+ if err != nil {
+ return errors.Wrapf(err, "error inserting ticket opened events")
+ }
+
return nil
}
diff --git a/core/ivr/ivr.go b/core/ivr/ivr.go
index fcbc0fb09..3738497dd 100644
--- a/core/ivr/ivr.go
+++ b/core/ivr/ivr.go
@@ -22,6 +22,7 @@ import (
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/runner"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/gomodule/redigo/redis"
"github.com/jmoiron/sqlx"
@@ -35,9 +36,6 @@ const (
NilCallID = CallID("")
NilAttachment = utils.Attachment("")
- // Our user agent
- userAgent = "Mailroom/"
-
// ErrorMessage that is spoken to an IVR user if an error occurs
ErrorMessage = "An error has occurred, please try again later."
)
@@ -298,17 +296,17 @@ func WriteErrorResponse(ctx context.Context, db *sqlx.DB, client Client, conn *m
// StartIVRFlow takes care of starting the flow in the passed in start for the passed in contact and URN
func StartIVRFlow(
- ctx context.Context, db *sqlx.DB, rp *redis.Pool, client Client, resumeURL string, oa *models.OrgAssets,
+ ctx context.Context, rt *runtime.Runtime, client Client, resumeURL string, oa *models.OrgAssets,
channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN, startID models.StartID,
r *http.Request, w http.ResponseWriter) error {
// connection isn't in a wired status, that's an error
if conn.Status() != models.ConnectionStatusWired && conn.Status() != models.ConnectionStatusInProgress {
- return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("connection in invalid state: %s", conn.Status()))
+ return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("connection in invalid state: %s", conn.Status()))
}
// get the flow for our start
- start, err := models.GetFlowStartAttributes(ctx, db, startID)
+ start, err := models.GetFlowStartAttributes(ctx, rt.DB, startID)
if err != nil {
return errors.Wrapf(err, "unable to load start: %d", startID)
}
@@ -358,7 +356,7 @@ func StartIVRFlow(
}
// mark our connection as started
- err = conn.MarkStarted(ctx, db, time.Now())
+ err = conn.MarkStarted(ctx, rt.DB, time.Now())
if err != nil {
return errors.Wrapf(err, "error updating call status")
}
@@ -372,7 +370,7 @@ func StartIVRFlow(
}
// start our flow
- sessions, err := runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, hook, true)
+ sessions, err := runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{trigger}, hook, true)
if err != nil {
return errors.Wrapf(err, "error starting flow")
}
@@ -382,7 +380,7 @@ func StartIVRFlow(
}
// have our client output our session status
- err = client.WriteSessionResponse(ctx, rp, channel, conn, sessions[0], urn, resumeURL, r, w)
+ err = client.WriteSessionResponse(ctx, rt.RP, channel, conn, sessions[0], urn, resumeURL, r, w)
if err != nil {
return errors.Wrapf(err, "error writing ivr response for start")
}
@@ -392,7 +390,7 @@ func StartIVRFlow(
// ResumeIVRFlow takes care of resuming the flow in the passed in start for the passed in contact and URN
func ResumeIVRFlow(
- ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, store storage.Storage,
+ ctx context.Context, rt *runtime.Runtime,
resumeURL string, client Client,
oa *models.OrgAssets, channel *models.Channel, conn *models.ChannelConnection, c *models.Contact, urn urns.URN,
r *http.Request, w http.ResponseWriter) error {
@@ -402,25 +400,25 @@ func ResumeIVRFlow(
return errors.Wrapf(err, "error creating flow contact")
}
- session, err := models.ActiveSessionForContact(ctx, db, oa, models.FlowTypeVoice, contact)
+ session, err := models.ActiveSessionForContact(ctx, rt.DB, rt.SessionStorage, oa, models.FlowTypeVoice, contact)
if err != nil {
return errors.Wrapf(err, "error loading session for contact")
}
if session == nil {
- return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("no active IVR session for contact"))
+ return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("no active IVR session for contact"))
}
if session.ConnectionID() == nil {
- return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("active session: %d has no connection", session.ID()))
+ return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("active session: %d has no connection", session.ID()))
}
if *session.ConnectionID() != conn.ID() {
- return WriteErrorResponse(ctx, db, client, conn, w, errors.Errorf("active session: %d does not match connection: %d", session.ID(), *session.ConnectionID()))
+ return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Errorf("active session: %d does not match connection: %d", session.ID(), *session.ConnectionID()))
}
// preprocess this request
- body, err := client.PreprocessResume(ctx, db, rp, conn, r)
+ body, err := client.PreprocessResume(ctx, rt.DB, rt.RP, conn, r)
if err != nil {
return errors.Wrapf(err, "error preprocessing resume")
}
@@ -444,7 +442,7 @@ func ResumeIVRFlow(
// make sure our call is still happening
status, _ := client.StatusForRequest(r)
if status != models.ConnectionStatusInProgress {
- err := conn.UpdateStatus(ctx, db, status, 0, time.Now())
+ err := conn.UpdateStatus(ctx, rt.DB, status, 0, time.Now())
if err != nil {
return errors.Wrapf(err, "error updating status")
}
@@ -455,17 +453,17 @@ func ResumeIVRFlow(
if err != nil {
// call has ended, so will our session
if err == CallEndedError {
- WriteErrorResponse(ctx, db, client, conn, w, errors.Wrapf(err, "call already ended"))
+ WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Wrapf(err, "call already ended"))
}
- return WriteErrorResponse(ctx, db, client, conn, w, errors.Wrapf(err, "error finding input for request"))
+ return WriteErrorResponse(ctx, rt.DB, client, conn, w, errors.Wrapf(err, "error finding input for request"))
}
var resume flows.Resume
var clientErr error
switch res := ivrResume.(type) {
case InputResume:
- resume, clientErr, err = buildMsgResume(ctx, config, db, rp, store, client, channel, contact, urn, conn, oa, r, res)
+ resume, clientErr, err = buildMsgResume(ctx, rt.Config, rt.DB, rt.RP, rt.MediaStorage, client, channel, contact, urn, conn, oa, r, res)
case DialResume:
resume, clientErr, err = buildDialResume(oa, contact, res)
@@ -484,19 +482,19 @@ func ResumeIVRFlow(
return client.WriteErrorResponse(w, fmt.Errorf("no resume found, ending call"))
}
- session, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, hook)
+ session, err = runner.ResumeFlow(ctx, rt, oa, session, resume, hook)
if err != nil {
return errors.Wrapf(err, "error resuming ivr flow")
}
// if still active, write out our response
if status == models.ConnectionStatusInProgress {
- err = client.WriteSessionResponse(ctx, rp, channel, conn, session, urn, resumeURL, r, w)
+ err = client.WriteSessionResponse(ctx, rt.RP, channel, conn, session, urn, resumeURL, r, w)
if err != nil {
return errors.Wrapf(err, "error writing ivr response for resume")
}
} else {
- err = models.ExitSessions(ctx, db, []models.SessionID{session.ID()}, models.ExitCompleted, time.Now())
+ err = models.ExitSessions(ctx, rt.DB, []models.SessionID{session.ID()}, models.ExitCompleted, time.Now())
if err != nil {
logrus.WithError(err).Error("error closing session")
}
@@ -547,7 +545,7 @@ func buildMsgResume(
// filename is based on our org id and msg UUID
filename := string(msgUUID) + path.Ext(resume.Attachment.URL())
- resume.Attachment, err = oa.Org().StoreAttachment(store, filename, resume.Attachment.ContentType(), resp.Body)
+ resume.Attachment, err = oa.Org().StoreAttachment(ctx, store, filename, resume.Attachment.ContentType(), resp.Body)
if err != nil {
return nil, errors.Wrapf(err, "unable to download and store attachment, ending call"), nil
}
@@ -583,7 +581,7 @@ func buildMsgResume(
// HandleIVRStatus is called on status callbacks for an IVR call. We let the client decide whether the call has
// ended for some reason and update the state of the call and session if so
-func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, client Client, conn *models.ChannelConnection, r *http.Request, w http.ResponseWriter) error {
+func HandleIVRStatus(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, client Client, conn *models.ChannelConnection, r *http.Request, w http.ResponseWriter) error {
// read our status and duration from our client
status, duration := client.StatusForRequest(r)
@@ -591,12 +589,12 @@ func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *model
if status == models.ConnectionStatusErrored {
// no associated start? this is a permanent failure
if conn.StartID() == models.NilStartID {
- conn.MarkFailed(ctx, db, time.Now())
- return client.WriteEmptyResponse(w, fmt.Sprintf("status updated: F"))
+ conn.MarkFailed(ctx, rt.DB, time.Now())
+ return client.WriteEmptyResponse(w, "status updated: F")
}
// on errors we need to look up the flow to know how long to wait before retrying
- start, err := models.GetFlowStartAttributes(ctx, db, conn.StartID())
+ start, err := models.GetFlowStartAttributes(ctx, rt.DB, conn.StartID())
if err != nil {
return errors.Wrapf(err, "unable to load start: %d", conn.StartID())
}
@@ -606,16 +604,16 @@ func HandleIVRStatus(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *model
return errors.Wrapf(err, "unable to load flow: %d", start.FlowID())
}
- conn.MarkErrored(ctx, db, time.Now(), flow.IVRRetryWait())
+ conn.MarkErrored(ctx, rt.DB, time.Now(), flow.IVRRetryWait())
if conn.Status() == models.ConnectionStatusErrored {
return client.WriteEmptyResponse(w, fmt.Sprintf("status updated: %s next_attempt: %s", conn.Status(), conn.NextAttempt()))
}
} else if status == models.ConnectionStatusFailed {
- conn.MarkFailed(ctx, db, time.Now())
+ conn.MarkFailed(ctx, rt.DB, time.Now())
} else {
if status != conn.Status() || duration > 0 {
- err := conn.UpdateStatus(ctx, db, status, duration, time.Now())
+ err := conn.UpdateStatus(ctx, rt.DB, status, duration, time.Now())
if err != nil {
return errors.Wrapf(err, "error updating call status")
}
diff --git a/core/ivr/twiml/twiml.go b/core/ivr/twiml/twiml.go
index a3477ce9e..6cb0969d2 100644
--- a/core/ivr/twiml/twiml.go
+++ b/core/ivr/twiml/twiml.go
@@ -23,6 +23,7 @@ import (
"github.com/nyaruka/goflow/flows/routers/waits"
"github.com/nyaruka/goflow/flows/routers/waits/hints"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/ivr"
"github.com/nyaruka/mailroom/core/models"
@@ -67,13 +68,6 @@ const (
sendURLConfig = "send_url"
baseURLConfig = "base_url"
-
- errorBody = `
-
- An error was encountered. Goodbye.
-
-
- `
)
var validLanguageCodes = map[string]bool{
@@ -501,7 +495,7 @@ func responseForSprint(number urns.URN, resumeURL string, w flows.ActivatedWait,
if len(event.Msg.Attachments()) == 0 {
country := envs.DeriveCountryFromTel(number.Path())
locale := envs.NewLocale(event.Msg.TextLanguage, country)
- languageCode := locale.ToISO639_2()
+ languageCode := locale.ToBCP47()
if _, valid := validLanguageCodes[languageCode]; !valid {
languageCode = ""
@@ -509,7 +503,7 @@ func responseForSprint(number urns.URN, resumeURL string, w flows.ActivatedWait,
commands = append(commands, Say{Text: event.Msg.Text(), Language: languageCode})
} else {
for _, a := range event.Msg.Attachments() {
- a = models.NormalizeAttachment(a)
+ a = models.NormalizeAttachment(config.Mailroom, a)
commands = append(commands, Play{URL: a.URL()})
}
}
diff --git a/core/ivr/vonage/vonage.go b/core/ivr/vonage/vonage.go
index 74d65aee3..4cd5e3826 100644
--- a/core/ivr/vonage/vonage.go
+++ b/core/ivr/vonage/vonage.go
@@ -62,13 +62,6 @@ const (
appIDConfig = "nexmo_app_id"
privateKeyConfig = "nexmo_app_private_key"
- errorBody = `
-
- An error was encountered. Goodbye.
-
-
- `
-
statusFailed = "failed"
)
@@ -192,7 +185,7 @@ func (c *client) PreprocessStatus(ctx context.Context, db *sqlx.DB, rp *redis.Po
}
if nxType == "transfer" {
- return c.MakeEmptyResponseBody(fmt.Sprintf("ignoring conversation callback")), nil
+ return c.MakeEmptyResponseBody("ignoring conversation callback"), nil
}
// grab our uuid out
@@ -284,7 +277,7 @@ func (c *client) PreprocessStatus(ctx context.Context, db *sqlx.DB, rp *redis.Po
return c.MakeEmptyResponseBody(fmt.Sprintf("updated status for call: %s to: %s", callUUID, status)), nil
}
- return c.MakeEmptyResponseBody(fmt.Sprintf("ignoring non final status for tranfer leg")), nil
+ return c.MakeEmptyResponseBody("ignoring non final status for tranfer leg"), nil
}
func (c *client) PreprocessResume(ctx context.Context, db *sqlx.DB, rp *redis.Pool, conn *models.ChannelConnection, r *http.Request) ([]byte, error) {
@@ -961,9 +954,7 @@ func (c *client) responseForSprint(ctx context.Context, rp *redis.Pool, channel
}
}
- for _, w := range waitActions {
- actions = append(actions, w)
- }
+ actions = append(actions, waitActions...)
var body []byte
var err error
diff --git a/core/ivr/vonage/vonage_test.go b/core/ivr/vonage/vonage_test.go
index 7a4a520cf..06618dda5 100644
--- a/core/ivr/vonage/vonage_test.go
+++ b/core/ivr/vonage/vonage_test.go
@@ -15,26 +15,29 @@ import (
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
)
func TestResponseForSprint(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
+ ctx, _, db, rp := testsuite.Get()
+
+ defer testsuite.Reset()
+
rc := rp.Get()
defer rc.Close()
- models.FlushCache()
urn := urns.URN("tel:+12067799294")
- channelRef := assets.NewChannelReference(models.VonageChannelUUID, "Vonage Channel")
+ channelRef := assets.NewChannelReference(testdata.VonageChannel.UUID, "Vonage Channel")
resumeURL := "http://temba.io/resume?session=1"
// deactivate our twilio channel
- db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, testdata.TwilioChannel.ID)
// add auth tokens
- db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, models.VonageChannelID)
+ db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, testdata.VonageChannel.ID)
// set our UUID generator
uuids.SetGenerator(uuids.NewSeededGenerator(0))
@@ -43,10 +46,10 @@ func TestResponseForSprint(t *testing.T) {
config.Mailroom.AttachmentDomain = "mailroom.io"
defer func() { config.Mailroom.AttachmentDomain = "" }()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
- channel := oa.ChannelByUUID(models.VonageChannelUUID)
+ channel := oa.ChannelByUUID(testdata.VonageChannel.UUID)
assert.NotNil(t, channel)
c, err := NewClientFromChannel(http.DefaultClient, channel)
diff --git a/core/models/airtime_test.go b/core/models/airtime_test.go
index 102fa5b8c..7794ac41d 100644
--- a/core/models/airtime_test.go
+++ b/core/models/airtime_test.go
@@ -1,25 +1,28 @@
-package models
+package models_test
import (
"testing"
"time"
"github.com/nyaruka/gocommon/urns"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
)
func TestAirtimeTransfers(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer db.MustExec(`DELETE FROM airtime_airtimetransfer`)
// insert a transfer
- transfer := NewAirtimeTransfer(
- Org1,
- AirtimeTransferStatusSuccess,
- CathyID,
+ transfer := models.NewAirtimeTransfer(
+ testdata.Org1.ID,
+ models.AirtimeTransferStatusSuccess,
+ testdata.Cathy.ID,
urns.URN("tel:+250700000001"),
urns.URN("tel:+250700000002"),
"RWF",
@@ -27,18 +30,16 @@ func TestAirtimeTransfers(t *testing.T) {
decimal.RequireFromString(`1000`),
time.Now(),
)
- err := InsertAirtimeTransfers(ctx, db, []*AirtimeTransfer{transfer})
+ err := models.InsertAirtimeTransfers(ctx, db, []*models.AirtimeTransfer{transfer})
assert.Nil(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from airtime_airtimetransfer WHERE org_id = $1 AND status = $2`,
- []interface{}{Org1, AirtimeTransferStatusSuccess}, 1)
+ testsuite.AssertQuery(t, db, `SELECT org_id, status from airtime_airtimetransfer`).Columns(map[string]interface{}{"org_id": int64(1), "status": "S"})
// insert a failed transfer with nil sender, empty currency
- transfer = NewAirtimeTransfer(
- Org1,
- AirtimeTransferStatusFailed,
- CathyID,
+ transfer = models.NewAirtimeTransfer(
+ testdata.Org1.ID,
+ models.AirtimeTransferStatusFailed,
+ testdata.Cathy.ID,
urns.NilURN,
urns.URN("tel:+250700000002"),
"",
@@ -46,10 +47,8 @@ func TestAirtimeTransfers(t *testing.T) {
decimal.Zero,
time.Now(),
)
- err = InsertAirtimeTransfers(ctx, db, []*AirtimeTransfer{transfer})
+ err = models.InsertAirtimeTransfers(ctx, db, []*models.AirtimeTransfer{transfer})
assert.Nil(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from airtime_airtimetransfer WHERE org_id = $1 AND status = $2`,
- []interface{}{Org1, AirtimeTransferStatusFailed}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from airtime_airtimetransfer WHERE org_id = $1 AND status = $2`, testdata.Org1.ID, models.AirtimeTransferStatusFailed).Returns(1)
}
diff --git a/core/models/assets.go b/core/models/assets.go
index f1edea222..b5b4527b1 100644
--- a/core/models/assets.go
+++ b/core/models/assets.go
@@ -12,6 +12,7 @@ import (
"github.com/nyaruka/goflow/envs"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/engine"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/goflow"
cache "github.com/patrickmn/go-cache"
"github.com/pkg/errors"
@@ -67,6 +68,10 @@ type OrgAssets struct {
locations []assets.LocationHierarchy
locationsBuiltAt time.Time
+
+ users []assets.User
+ usersByID map[UserID]*User
+ usersByEmail map[string]*User
}
var ErrNotFound = errors.New("not found")
@@ -125,7 +130,7 @@ func NewOrgAssets(ctx context.Context, db *sqlx.DB, orgID OrgID, prev *OrgAssets
var err error
if prev == nil || refresh&RefreshOrg > 0 {
- oa.org, err = loadOrg(ctx, db, orgID)
+ oa.org, err = LoadOrg(ctx, config.Mailroom, db, orgID)
if err != nil {
return nil, errors.Wrapf(err, "error loading environment for org %d", orgID)
}
@@ -315,8 +320,25 @@ func NewOrgAssets(ctx context.Context, db *sqlx.DB, orgID OrgID, prev *OrgAssets
oa.ticketersByUUID = prev.ticketersByUUID
}
+ if prev == nil || refresh&RefreshUsers > 0 {
+ oa.users, err = loadUsers(ctx, db, orgID)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error loading user assets for org %d", orgID)
+ }
+ oa.usersByID = make(map[UserID]*User)
+ oa.usersByEmail = make(map[string]*User)
+ for _, u := range oa.users {
+ oa.usersByID[u.(*User).ID()] = u.(*User)
+ oa.usersByEmail[u.Email()] = u.(*User)
+ }
+ } else {
+ oa.users = prev.users
+ oa.usersByID = prev.usersByID
+ oa.usersByEmail = prev.usersByEmail
+ }
+
// intialize our session assets
- oa.sessionAssets, err = engine.NewSessionAssets(oa.Env(), oa, goflow.MigrationConfig())
+ oa.sessionAssets, err = engine.NewSessionAssets(oa.Env(), oa, goflow.MigrationConfig(config.Mailroom))
if err != nil {
return nil, errors.Wrapf(err, "error build session assets for org: %d", orgID)
}
@@ -345,6 +367,7 @@ const (
RefreshLabels = Refresh(1 << 12)
RefreshFlows = Refresh(1 << 13)
RefreshTicketers = Refresh(1 << 14)
+ RefreshUsers = Refresh(1 << 15)
)
// GetOrgAssets creates or gets org assets for the passed in org
@@ -439,6 +462,9 @@ func (a *OrgAssets) FieldByKey(key string) *Field {
func (a *OrgAssets) CloneForSimulation(ctx context.Context, db *sqlx.DB, newDefs map[assets.FlowUUID]json.RawMessage, testChannels []assets.Channel) (*OrgAssets, error) {
// only channels and flows can be modified so only refresh those
clone, err := NewOrgAssets(context.Background(), a.db, a.OrgID(), a, RefreshFlows)
+ if err != nil {
+ return nil, err
+ }
for flowUUID, newDef := range newDefs {
// get the original flow
@@ -455,13 +481,10 @@ func (a *OrgAssets) CloneForSimulation(ctx context.Context, db *sqlx.DB, newDefs
clone.flowByID[cf.ID()] = cf
}
- for _, channel := range testChannels {
- // we don't populate our maps for uuid or id, shouldn't be used in any hook anyways
- clone.channels = append(clone.channels, channel)
- }
+ clone.channels = append(clone.channels, testChannels...)
// rebuild our session assets with our new items
- clone.sessionAssets, err = engine.NewSessionAssets(a.Env(), clone, goflow.MigrationConfig())
+ clone.sessionAssets, err = engine.NewSessionAssets(a.Env(), clone, goflow.MigrationConfig(config.Mailroom))
if err != nil {
return nil, errors.Wrapf(err, "error build session assets for org: %d", clone.OrgID())
}
@@ -482,7 +505,7 @@ func (a *OrgAssets) Flow(flowUUID assets.FlowUUID) (assets.Flow, error) {
return flow, nil
}
- dbFlow, err := loadFlowByUUID(ctx, a.db, a.orgID, flowUUID)
+ dbFlow, err := LoadFlowByUUID(ctx, a.db, a.orgID, flowUUID)
if err != nil {
return nil, errors.Wrapf(err, "error loading flow: %s", flowUUID)
}
@@ -512,7 +535,7 @@ func (a *OrgAssets) FlowByID(flowID FlowID) (*Flow, error) {
return flow.(*Flow), nil
}
- dbFlow, err := loadFlowByID(ctx, a.db, a.orgID, flowID)
+ dbFlow, err := LoadFlowByID(ctx, a.db, a.orgID, flowID)
if err != nil {
return nil, errors.Wrapf(err, "error loading flow: %d", flowID)
}
@@ -605,3 +628,15 @@ func (a *OrgAssets) TicketerByID(id TicketerID) *Ticketer {
func (a *OrgAssets) TicketerByUUID(uuid assets.TicketerUUID) *Ticketer {
return a.ticketersByUUID[uuid]
}
+
+func (a *OrgAssets) Users() ([]assets.User, error) {
+ return a.users, nil
+}
+
+func (a *OrgAssets) UserByID(id UserID) *User {
+ return a.usersByID[id]
+}
+
+func (a *OrgAssets) UserByEmail(email string) *User {
+ return a.usersByEmail[email]
+}
diff --git a/core/models/assets_test.go b/core/models/assets_test.go
index 260ba527e..440449752 100644
--- a/core/models/assets_test.go
+++ b/core/models/assets_test.go
@@ -8,17 +8,18 @@ import (
"github.com/nyaruka/goflow/assets/static/types"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCloneForSimulation(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
models.FlushCache()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
newFavoritesDef := `{
@@ -28,7 +29,7 @@ func TestCloneForSimulation(t *testing.T) {
}`
newDefs := map[assets.FlowUUID]json.RawMessage{
- models.FavoritesFlowUUID: []byte(newFavoritesDef),
+ testdata.Favorites.UUID: []byte(newFavoritesDef),
}
testChannels := []assets.Channel{
@@ -40,7 +41,7 @@ func TestCloneForSimulation(t *testing.T) {
require.NoError(t, err)
// should get new definition
- flow, err := clone.Flow(models.FavoritesFlowUUID)
+ flow, err := clone.Flow(testdata.Favorites.UUID)
require.NoError(t, err)
assert.Equal(t, newFavoritesDef, string(flow.Definition()))
@@ -51,11 +52,11 @@ func TestCloneForSimulation(t *testing.T) {
assert.Equal(t, "Test Channel 2", testChannel2.Name())
// as well as the regular channels
- vonage := clone.SessionAssets().Channels().Get(models.VonageChannelUUID)
+ vonage := clone.SessionAssets().Channels().Get(testdata.VonageChannel.UUID)
assert.Equal(t, "Vonage", vonage.Name())
// original assets still has original flow definition
- flow, err = oa.Flow(models.FavoritesFlowUUID)
+ flow, err = oa.Flow(testdata.Favorites.UUID)
require.NoError(t, err)
assert.Equal(t, "{\"_ui\": {\"nodes\": {\"10c9c241-777f-4010-a841-6e87abed8520\": {\"typ", string(flow.Definition())[:64])
@@ -64,6 +65,6 @@ func TestCloneForSimulation(t *testing.T) {
assert.Nil(t, testChannel1)
// can't override definition for a non-existent flow
- oa, err = oa.CloneForSimulation(ctx, db, map[assets.FlowUUID]json.RawMessage{"a121f1af-7dfa-47af-9d22-9726372e2daa": []byte(newFavoritesDef)}, nil)
+ _, err = oa.CloneForSimulation(ctx, db, map[assets.FlowUUID]json.RawMessage{"a121f1af-7dfa-47af-9d22-9726372e2daa": []byte(newFavoritesDef)}, nil)
assert.EqualError(t, err, "unable to find flow with UUID 'a121f1af-7dfa-47af-9d22-9726372e2daa': not found")
}
diff --git a/core/models/campaigns_test.go b/core/models/campaigns_test.go
index 087d8871b..3781f74b7 100644
--- a/core/models/campaigns_test.go
+++ b/core/models/campaigns_test.go
@@ -8,6 +8,7 @@ import (
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -81,35 +82,35 @@ func TestCampaignSchedule(t *testing.T) {
}
func TestAddEventFires(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer db.MustExec(`DELETE FROM campaigns_eventfire`)
scheduled1 := time.Date(2020, 9, 8, 14, 38, 30, 123456789, time.UTC)
err := models.AddEventFires(ctx, db, []*models.FireAdd{
- {ContactID: models.CathyID, EventID: models.RemindersEvent1ID, Scheduled: scheduled1},
- {ContactID: models.BobID, EventID: models.RemindersEvent1ID, Scheduled: scheduled1},
- {ContactID: models.BobID, EventID: models.RemindersEvent2ID, Scheduled: scheduled1},
+ {ContactID: testdata.Cathy.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled1},
+ {ContactID: testdata.Bob.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled1},
+ {ContactID: testdata.Bob.ID, EventID: testdata.RemindersEvent2.ID, Scheduled: scheduled1},
})
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire`, nil, 3)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.CathyID, models.RemindersEvent1ID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.BobID, models.RemindersEvent1ID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.BobID, models.RemindersEvent2ID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire`).Returns(3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Cathy.ID, testdata.RemindersEvent1.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Bob.ID, testdata.RemindersEvent1.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Bob.ID, testdata.RemindersEvent2.ID).Returns(1)
- db.MustExec(`UPDATE campaigns_eventfire SET fired = NOW() WHERE contact_id = $1`, models.CathyID)
+ db.MustExec(`UPDATE campaigns_eventfire SET fired = NOW() WHERE contact_id = $1`, testdata.Cathy.ID)
scheduled2 := time.Date(2020, 9, 8, 14, 38, 30, 123456789, time.UTC)
err = models.AddEventFires(ctx, db, []*models.FireAdd{
- {ContactID: models.CathyID, EventID: models.RemindersEvent1ID, Scheduled: scheduled2}, // fine because previous one now has non-null fired
- {ContactID: models.BobID, EventID: models.RemindersEvent1ID, Scheduled: scheduled2}, // won't be added due to conflict
+ {ContactID: testdata.Cathy.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled2}, // fine because previous one now has non-null fired
+ {ContactID: testdata.Bob.ID, EventID: testdata.RemindersEvent1.ID, Scheduled: scheduled2}, // won't be added due to conflict
})
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire`, nil, 4)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, []interface{}{models.CathyID, models.RemindersEvent1ID}, 2)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, []interface{}{models.BobID}, 2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire`).Returns(4)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1 AND event_id = $2`, testdata.Cathy.ID, testdata.RemindersEvent1.ID).Returns(2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, testdata.Bob.ID).Returns(2)
}
diff --git a/core/models/channel_connection.go b/core/models/channel_connection.go
index e2ec1f31e..97b2dad15 100644
--- a/core/models/channel_connection.go
+++ b/core/models/channel_connection.go
@@ -353,13 +353,13 @@ func (c *ChannelConnection) MarkStarted(ctx context.Context, db Queryer, now tim
}
// MarkErrored updates the status for this connection to errored and schedules a retry if appropriate
-func (c *ChannelConnection) MarkErrored(ctx context.Context, db Queryer, now time.Time, wait time.Duration) error {
+func (c *ChannelConnection) MarkErrored(ctx context.Context, db Queryer, now time.Time, retryWait *time.Duration) error {
c.c.Status = ConnectionStatusErrored
c.c.EndedOn = &now
- if c.c.RetryCount < ConnectionMaxRetries {
+ if c.c.RetryCount < ConnectionMaxRetries && retryWait != nil {
c.c.RetryCount++
- next := now.Add(wait)
+ next := now.Add(*retryWait)
c.c.NextAttempt = &next
} else {
c.c.Status = ConnectionStatusFailed
diff --git a/core/models/channel_connection_test.go b/core/models/channel_connection_test.go
index 0ca721f9f..60e28a2ce 100644
--- a/core/models/channel_connection_test.go
+++ b/core/models/channel_connection_test.go
@@ -1,27 +1,31 @@
-package models
+package models_test
import (
"testing"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestChannelConnections(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer db.MustExec(`DELETE FROM channels_channelconnection`)
- conn, err := InsertIVRConnection(ctx, db, Org1, TwilioChannelID, NilStartID, CathyID, CathyURNID, ConnectionDirectionOut, ConnectionStatusPending, "")
+ conn, err := models.InsertIVRConnection(ctx, db, testdata.Org1.ID, testdata.TwilioChannel.ID, models.NilStartID, testdata.Cathy.ID, testdata.Cathy.URNID, models.ConnectionDirectionOut, models.ConnectionStatusPending, "")
assert.NoError(t, err)
- assert.NotEqual(t, ConnectionID(0), conn.ID())
+ assert.NotEqual(t, models.ConnectionID(0), conn.ID())
err = conn.UpdateExternalID(ctx, db, "test1")
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from channels_channelconnection where external_id = 'test1' AND id = $1`, []interface{}{conn.ID()}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from channels_channelconnection where external_id = 'test1' AND id = $1`, conn.ID()).Returns(1)
- conn2, err := SelectChannelConnection(ctx, db, conn.ID())
+ conn2, err := models.SelectChannelConnection(ctx, db, conn.ID())
assert.NoError(t, err)
assert.Equal(t, "test1", conn2.ExternalID())
}
diff --git a/core/models/channel_event_test.go b/core/models/channel_event_test.go
index d8962b2ca..c7bccaf47 100644
--- a/core/models/channel_event_test.go
+++ b/core/models/channel_event_test.go
@@ -1,22 +1,26 @@
-package models
+package models_test
import (
"encoding/json"
"testing"
"time"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestChannelEvents(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer db.MustExec(`DELETE FROM channels_channelevent`)
start := time.Now()
// no extra
- e := NewChannelEvent(MOMissEventType, Org1, TwilioChannelID, CathyID, CathyURNID, nil, false)
+ e := models.NewChannelEvent(models.MOMissEventType, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, nil, false)
err := e.Insert(ctx, db)
assert.NoError(t, err)
assert.NotZero(t, e.ID())
@@ -24,7 +28,7 @@ func TestChannelEvents(t *testing.T) {
assert.True(t, e.OccurredOn().After(start))
// with extra
- e2 := NewChannelEvent(MOMissEventType, Org1, TwilioChannelID, CathyID, CathyURNID, map[string]interface{}{"referral_id": "foobar"}, false)
+ e2 := models.NewChannelEvent(models.MOMissEventType, testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, map[string]interface{}{"referral_id": "foobar"}, false)
err = e2.Insert(ctx, db)
assert.NoError(t, err)
assert.NotZero(t, e2.ID())
@@ -33,7 +37,7 @@ func TestChannelEvents(t *testing.T) {
asJSON, err := json.Marshal(e2)
assert.NoError(t, err)
- e3 := &ChannelEvent{}
+ e3 := &models.ChannelEvent{}
err = json.Unmarshal(asJSON, e3)
assert.NoError(t, err)
assert.Equal(t, e2.Extra(), e3.Extra())
diff --git a/core/models/channel_logs.go b/core/models/channel_logs.go
index 359ae67a4..a0ff14b9c 100644
--- a/core/models/channel_logs.go
+++ b/core/models/channel_logs.go
@@ -61,7 +61,7 @@ func NewChannelLog(trace *httpx.Trace, isError bool, desc string, channel *Chann
l.URL = url
l.Method = trace.Request.Method
l.Request = string(trace.RequestTrace)
- l.Response = trace.ResponseTraceUTF8("...")
+ l.Response = string(trace.SanitizedResponse("..."))
l.Status = statusCode
l.CreatedOn = trace.StartTime
l.RequestTime = int((trace.EndTime.Sub(trace.StartTime)) / time.Millisecond)
diff --git a/core/models/channel_logs_test.go b/core/models/channel_logs_test.go
index f91e85bf9..09dacfa1d 100644
--- a/core/models/channel_logs_test.go
+++ b/core/models/channel_logs_test.go
@@ -7,15 +7,15 @@ import (
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/require"
)
func TestChannelLogs(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- db.MustExec(`DELETE FROM channels_channellog;`)
+ defer db.MustExec(`DELETE FROM channels_channellog`)
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -24,28 +24,32 @@ func TestChannelLogs(t *testing.T) {
"http://rapidpro.io/new": {httpx.NewMockResponse(200, nil, "OK")},
}))
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
- channel := oa.ChannelByID(models.TwilioChannelID)
+ channel := oa.ChannelByID(testdata.TwilioChannel.ID)
+ require.NotNil(t, channel)
req1, _ := httpx.NewRequest("GET", "http://rapidpro.io", nil, nil)
trace1, err := httpx.DoTrace(http.DefaultClient, req1, nil, nil, -1)
+ require.NoError(t, err)
log1 := models.NewChannelLog(trace1, false, "test request", channel, nil)
req2, _ := httpx.NewRequest("GET", "http://rapidpro.io/bad", nil, nil)
trace2, err := httpx.DoTrace(http.DefaultClient, req2, nil, nil, -1)
+ require.NoError(t, err)
log2 := models.NewChannelLog(trace2, true, "test request", channel, nil)
req3, _ := httpx.NewRequest("GET", "http://rapidpro.io/new", nil, map[string]string{"X-Forwarded-Path": "/old"})
trace3, err := httpx.DoTrace(http.DefaultClient, req3, nil, nil, -1)
+ require.NoError(t, err)
log3 := models.NewChannelLog(trace3, false, "test request", channel, nil)
err = models.InsertChannelLogs(ctx, db, []*models.ChannelLog{log1, log2, log3})
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog`, nil, 3)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io' AND is_error = FALSE AND channel_id = $1`, []interface{}{channel.ID()}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io/bad' AND is_error = TRUE AND channel_id = $1`, []interface{}{channel.ID()}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'https://rapidpro.io/old' AND is_error = FALSE AND channel_id = $1`, []interface{}{channel.ID()}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog`).Returns(3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io' AND is_error = FALSE AND channel_id = $1`, channel.ID()).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'http://rapidpro.io/bad' AND is_error = TRUE AND channel_id = $1`, channel.ID()).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE url = 'https://rapidpro.io/old' AND is_error = FALSE AND channel_id = $1`, channel.ID()).Returns(1)
}
diff --git a/core/models/channels_test.go b/core/models/channels_test.go
index 0f02df02a..9f86e6678 100644
--- a/core/models/channels_test.go
+++ b/core/models/channels_test.go
@@ -1,28 +1,36 @@
-package models
+package models_test
import (
"testing"
"github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestChannels(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
// add some tel specific config to channel 2
- db.MustExec(`UPDATE channels_channel SET config = '{"matching_prefixes": ["250", "251"], "allow_international": true}' WHERE id = $1`, VonageChannelID)
+ db.MustExec(`UPDATE channels_channel SET config = '{"matching_prefixes": ["250", "251"], "allow_international": true}' WHERE id = $1`, testdata.VonageChannel.ID)
// make twitter channel have a parent of twilio channel
- db.MustExec(`UPDATE channels_channel SET parent_id = $1 WHERE id = $2`, TwilioChannelID, TwitterChannelID)
+ db.MustExec(`UPDATE channels_channel SET parent_id = $1 WHERE id = $2`, testdata.TwilioChannel.ID, testdata.TwitterChannel.ID)
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, 1, models.RefreshChannels)
+ require.NoError(t, err)
- channels, err := loadChannels(ctx, db, 1)
- assert.NoError(t, err)
+ channels, err := oa.Channels()
+ require.NoError(t, err)
tcs := []struct {
- ID ChannelID
+ ID models.ChannelID
UUID assets.ChannelUUID
Name string
Address string
@@ -33,8 +41,8 @@ func TestChannels(t *testing.T) {
Parent *assets.ChannelReference
}{
{
- TwilioChannelID,
- TwilioChannelUUID,
+ testdata.TwilioChannel.ID,
+ testdata.TwilioChannel.UUID,
"Twilio",
"+13605551212",
[]string{"tel"},
@@ -44,8 +52,8 @@ func TestChannels(t *testing.T) {
nil,
},
{
- VonageChannelID,
- VonageChannelUUID,
+ testdata.VonageChannel.ID,
+ testdata.VonageChannel.UUID,
"Vonage",
"5789",
[]string{"tel"},
@@ -55,21 +63,21 @@ func TestChannels(t *testing.T) {
nil,
},
{
- TwitterChannelID,
- TwitterChannelUUID,
+ testdata.TwitterChannel.ID,
+ testdata.TwitterChannel.UUID,
"Twitter",
"ureport",
[]string{"twitter"},
[]assets.ChannelRole{"send", "receive"},
nil,
false,
- assets.NewChannelReference(TwilioChannelUUID, "Twilio"),
+ assets.NewChannelReference(testdata.TwilioChannel.UUID, "Twilio"),
},
}
assert.Equal(t, len(tcs), len(channels))
for i, tc := range tcs {
- channel := channels[i].(*Channel)
+ channel := channels[i].(*models.Channel)
assert.Equal(t, tc.UUID, channel.UUID())
assert.Equal(t, tc.ID, channel.ID())
assert.Equal(t, tc.Name, channel.Name())
diff --git a/core/models/classifiers.go b/core/models/classifiers.go
index e30d0fc7f..a6b860ca5 100644
--- a/core/models/classifiers.go
+++ b/core/models/classifiers.go
@@ -10,6 +10,7 @@ import (
"github.com/nyaruka/goflow/services/classification/bothub"
"github.com/nyaruka/goflow/services/classification/luis"
"github.com/nyaruka/goflow/services/classification/wit"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/utils/dbutil"
"github.com/nyaruka/null"
@@ -90,7 +91,7 @@ func (c *Classifier) Type() string { return c.c.Type }
// AsService builds the corresponding ClassificationService for the passed in Classifier
func (c *Classifier) AsService(classifier *flows.Classifier) (flows.ClassificationService, error) {
- httpClient, httpRetries, httpAccess := goflow.HTTP()
+ httpClient, httpRetries, httpAccess := goflow.HTTP(config.Mailroom)
switch c.Type() {
case ClassifierTypeWit:
diff --git a/core/models/classifiers_test.go b/core/models/classifiers_test.go
index ab7fa71d9..3d1f447c4 100644
--- a/core/models/classifiers_test.go
+++ b/core/models/classifiers_test.go
@@ -1,34 +1,40 @@
-package models
+package models_test
import (
"testing"
"github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestClassifiers(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshClassifiers)
+ require.NoError(t, err)
- classifiers, err := loadClassifiers(ctx, db, 1)
- assert.NoError(t, err)
+ classifiers, err := oa.Classifiers()
+ require.NoError(t, err)
tcs := []struct {
- ID ClassifierID
+ ID models.ClassifierID
UUID assets.ClassifierUUID
Name string
Intents []string
}{
- {LuisID, LuisUUID, "LUIS", []string{"book_flight", "book_car"}},
- {WitID, WitUUID, "Wit.ai", []string{"register"}},
- {BothubID, BothubUUID, "BotHub", []string{"intent"}},
+ {testdata.Luis.ID, testdata.Luis.UUID, "LUIS", []string{"book_flight", "book_car"}},
+ {testdata.Wit.ID, testdata.Wit.UUID, "Wit.ai", []string{"register"}},
+ {testdata.Bothub.ID, testdata.Bothub.UUID, "BotHub", []string{"intent"}},
}
assert.Equal(t, len(tcs), len(classifiers))
for i, tc := range tcs {
- c := classifiers[i].(*Classifier)
+ c := classifiers[i].(*models.Classifier)
assert.Equal(t, tc.UUID, c.UUID())
assert.Equal(t, tc.ID, c.ID())
assert.Equal(t, tc.Name, c.Name())
diff --git a/core/models/contacts.go b/core/models/contacts.go
index 46f8d9108..fc73178a2 100644
--- a/core/models/contacts.go
+++ b/core/models/contacts.go
@@ -80,6 +80,7 @@ type Contact struct {
fields map[string]*flows.Value
groups []*Group
urns []urns.URN
+ tickets []*Ticket
createdOn time.Time
modifiedOn time.Time
lastSeenOn *time.Time
@@ -191,27 +192,38 @@ func (c *Contact) UpdatePreferredURN(ctx context.Context, db Queryer, org *OrgAs
}
// FlowContact converts our mailroom contact into a flow contact for use in the engine
-func (c *Contact) FlowContact(org *OrgAssets) (*flows.Contact, error) {
+func (c *Contact) FlowContact(oa *OrgAssets) (*flows.Contact, error) {
// convert our groups to a list of references
groups := make([]*assets.GroupReference, len(c.groups))
for i, g := range c.groups {
groups[i] = assets.NewGroupReference(g.UUID(), g.Name())
}
+ // convert our tickets to flow tickets
+ tickets := make([]*flows.Ticket, len(c.tickets))
+ var err error
+ for i, t := range c.tickets {
+ tickets[i], err = t.FlowTicket(oa)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error creating flow ticket")
+ }
+ }
+
// create our flow contact
contact, err := flows.NewContact(
- org.SessionAssets(),
+ oa.SessionAssets(),
c.uuid,
flows.ContactID(c.id),
c.name,
c.language,
contactToFlowStatus[c.Status()],
- org.Env().Timezone(),
+ oa.Env().Timezone(),
c.createdOn,
c.lastSeenOn,
c.urns,
groups,
c.fields,
+ tickets,
assets.IgnoreMissing,
)
if err != nil {
@@ -305,6 +317,16 @@ func LoadContacts(ctx context.Context, db Queryer, org *OrgAssets, ids []Contact
}
contact.urns = contactURNs
+ // initialize our tickets
+ tickets := make([]*Ticket, 0, len(e.Tickets))
+ for _, t := range e.Tickets {
+ ticketer := org.TicketerByID(t.TicketerID)
+ if ticketer != nil {
+ tickets = append(tickets, NewTicket(t.UUID, org.OrgID(), contact.ID(), ticketer.ID(), t.ExternalID, t.Subject, t.Body, t.AssigneeID, nil))
+ }
+ }
+ contact.tickets = tickets
+
contacts = append(contacts, contact)
}
@@ -384,16 +406,6 @@ func queryContactIDs(ctx context.Context, db Queryer, query string, args ...inte
return ids, nil
}
-// fieldValueEnvelope is our utility struct for the value of a field
-type fieldValueEnvelope struct {
- Text types.XText `json:"text"`
- Datetime *types.XDateTime `json:"datetime,omitempty"`
- Number *types.XNumber `json:"number,omitempty"`
- State envs.LocationPath `json:"state,omitempty"`
- District envs.LocationPath `json:"district,omitempty"`
- Ward envs.LocationPath `json:"ward,omitempty"`
-}
-
type ContactURN struct {
ID URNID `json:"id" db:"id"`
Priority int `json:"priority" db:"priority"`
@@ -435,17 +447,32 @@ func (u *ContactURN) AsURN(org *OrgAssets) (urns.URN, error) {
// contactEnvelope is our JSON structure for a contact as read from the database
type contactEnvelope struct {
- ID ContactID `json:"id"`
- UUID flows.ContactUUID `json:"uuid"`
- Name string `json:"name"`
- Language envs.Language `json:"language"`
- Status ContactStatus `json:"status"`
- Fields map[assets.FieldUUID]*fieldValueEnvelope `json:"fields"`
- GroupIDs []GroupID `json:"group_ids"`
- URNs []ContactURN `json:"urns"`
- CreatedOn time.Time `json:"created_on"`
- ModifiedOn time.Time `json:"modified_on"`
- LastSeenOn *time.Time `json:"last_seen_on"`
+ ID ContactID `json:"id"`
+ UUID flows.ContactUUID `json:"uuid"`
+ Name string `json:"name"`
+ Language envs.Language `json:"language"`
+ Status ContactStatus `json:"status"`
+ Fields map[assets.FieldUUID]struct {
+ Text types.XText `json:"text"`
+ Datetime *types.XDateTime `json:"datetime,omitempty"`
+ Number *types.XNumber `json:"number,omitempty"`
+ State envs.LocationPath `json:"state,omitempty"`
+ District envs.LocationPath `json:"district,omitempty"`
+ Ward envs.LocationPath `json:"ward,omitempty"`
+ } `json:"fields"`
+ GroupIDs []GroupID `json:"group_ids"`
+ URNs []ContactURN `json:"urns"`
+ Tickets []struct {
+ UUID flows.TicketUUID `json:"uuid"`
+ TicketerID TicketerID `json:"ticketer_id"`
+ ExternalID string `json:"external_id"`
+ Subject string `json:"subject"`
+ Body string `json:"body"`
+ AssigneeID UserID `json:"assignee_id"`
+ } `json:"tickets"`
+ CreatedOn time.Time `json:"created_on"`
+ ModifiedOn time.Time `json:"modified_on"`
+ LastSeenOn *time.Time `json:"last_seen_on"`
}
const selectContactSQL = `
@@ -462,7 +489,8 @@ SELECT ROW_TO_JSON(r) FROM (SELECT
last_seen_on,
fields,
g.groups AS group_ids,
- u.urns AS urns
+ u.urns AS urns,
+ t.tickets AS tickets
FROM
contacts_contact c
LEFT JOIN (
@@ -497,6 +525,26 @@ LEFT JOIN (
GROUP BY
contact_id
) u ON c.id = u.contact_id
+LEFT JOIN (
+ SELECT
+ contact_id,
+ array_agg(
+ json_build_object(
+ 'uuid', t.uuid,
+ 'subject', t.subject,
+ 'body', t.body,
+ 'external_id', t.external_id,
+ 'ticketer_id', t.ticketer_id,
+ 'assignee_id', t.assignee_id
+ ) ORDER BY t.opened_on ASC, t.id ASC
+ ) as tickets
+ FROM
+ tickets_ticket t
+ WHERE
+ t.status = 'O' AND t.contact_id = ANY($1)
+ GROUP BY
+ contact_id
+) t ON c.id = t.contact_id
WHERE
c.id = ANY($1) AND
is_active = TRUE AND
@@ -740,8 +788,8 @@ func insertContactAndURNs(ctx context.Context, db Queryer, orgID OrgID, userID U
// first insert our contact
var contactID ContactID
err := db.GetContext(ctx, &contactID,
- `INSERT INTO contacts_contact (org_id, is_active, status, uuid, name, language, created_on, modified_on, created_by_id, modified_by_id)
- VALUES($1, TRUE, 'A', $2, $3, $4, $5, $5, $6, $6)
+ `INSERT INTO contacts_contact (org_id, is_active, status, uuid, name, language, ticket_count, created_on, modified_on, created_by_id, modified_by_id)
+ VALUES($1, TRUE, 'A', $2, $3, $4, 0, $5, $5, $6, $6)
RETURNING id`,
orgID, uuids.New(), null.String(name), null.String(string(language)), dates.Now(), userID,
)
@@ -869,10 +917,7 @@ func URNForID(ctx context.Context, db Queryer, org *OrgAssets, urnID URNID) (urn
// CalculateDynamicGroups recalculates all the dynamic groups for the passed in contact, recalculating
// campaigns as necessary based on those group changes.
func CalculateDynamicGroups(ctx context.Context, db Queryer, org *OrgAssets, contact *flows.Contact) error {
- added, removed, errs := contact.ReevaluateQueryBasedGroups(org.Env())
- if len(errs) > 0 {
- return errors.Wrapf(errs[0], "error calculating dynamic groups")
- }
+ added, removed := contact.ReevaluateQueryBasedGroups(org.Env())
campaigns := make(map[CampaignID]*Campaign)
diff --git a/core/models/contacts_test.go b/core/models/contacts_test.go
index a2c90e103..f915acbd2 100644
--- a/core/models/contacts_test.go
+++ b/core/models/contacts_test.go
@@ -2,6 +2,7 @@ package models_test
import (
"fmt"
+ "sort"
"testing"
"time"
@@ -20,22 +21,31 @@ import (
)
func TestContacts(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- org, err := models.GetOrgAssets(ctx, db, 1)
- assert.NoError(t, err)
+ defer testsuite.Reset()
- testdata.InsertContactURN(t, db, models.Org1, models.BobID, urns.URN("whatsapp:250788373373"), 999)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.Bob, "whatsapp:250788373373", 999)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Problem!", "Where are my shoes?", "1234", testdata.Agent)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Another Problem!", "Where are my pants?", "2345", nil)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Bob, testdata.Mailgun, "Urgent", "His name is Bob", "", testdata.Editor)
- db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, models.GeorgeID)
- db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.GeorgeID)
- db.MustExec(`UPDATE contacts_contact SET is_active = FALSE WHERE id = $1`, models.AlexandriaID)
+ // delete mailgun ticketer
+ db.MustExec(`UPDATE tickets_ticketer SET is_active = false WHERE id = $1`, testdata.Mailgun.ID)
- modelContacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{models.CathyID, models.GeorgeID, models.BobID, models.AlexandriaID})
+ org, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshAll)
assert.NoError(t, err)
- assert.Equal(t, 3, len(modelContacts))
+
+ db.MustExec(`DELETE FROM contacts_contacturn WHERE contact_id = $1`, testdata.George.ID)
+ db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.George.ID)
+ db.MustExec(`UPDATE contacts_contact SET is_active = FALSE WHERE id = $1`, testdata.Alexandria.ID)
+
+ modelContacts, err := models.LoadContacts(ctx, db, org, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID, testdata.George.ID, testdata.Alexandria.ID})
+ require.NoError(t, err)
+ require.Equal(t, 3, len(modelContacts))
+
+ // LoadContacts doesn't guarantee returned order of contacts
+ sort.Slice(modelContacts, func(i, j int) bool { return modelContacts[i].ID() < modelContacts[j].ID() })
// convert to goflow contacts
contacts := make([]*flows.Contact, len(modelContacts))
@@ -44,45 +54,54 @@ func TestContacts(t *testing.T) {
assert.NoError(t, err)
}
- if len(contacts) == 3 {
- assert.Equal(t, "Cathy", contacts[0].Name())
- assert.Equal(t, len(contacts[0].URNs()), 1)
- assert.Equal(t, contacts[0].URNs()[0].String(), "tel:+16055741111?id=10000&priority=1000")
- assert.Equal(t, 1, contacts[0].Groups().Count())
-
- assert.Equal(t, "Yobe", contacts[0].Fields()["state"].QueryValue())
- assert.Equal(t, "Dokshi", contacts[0].Fields()["ward"].QueryValue())
- assert.Equal(t, "F", contacts[0].Fields()["gender"].QueryValue())
- assert.Equal(t, (*flows.FieldValue)(nil), contacts[0].Fields()["age"])
-
- assert.Equal(t, "Bob", contacts[1].Name())
- assert.NotNil(t, contacts[1].Fields()["joined"].QueryValue())
- assert.Equal(t, 2, len(contacts[1].URNs()))
- assert.Equal(t, contacts[1].URNs()[0].String(), "tel:+16055742222?id=10001&priority=1000")
- assert.Equal(t, contacts[1].URNs()[1].String(), "whatsapp:250788373373?id=20121&priority=999")
- assert.Equal(t, 0, contacts[1].Groups().Count())
-
- assert.Equal(t, "George", contacts[2].Name())
- assert.Equal(t, decimal.RequireFromString("30"), contacts[2].Fields()["age"].QueryValue())
- assert.Equal(t, 0, len(contacts[2].URNs()))
- assert.Equal(t, 0, contacts[2].Groups().Count())
- }
+ cathy, bob, george := contacts[0], contacts[1], contacts[2]
+
+ assert.Equal(t, "Cathy", cathy.Name())
+ assert.Equal(t, len(cathy.URNs()), 1)
+ assert.Equal(t, cathy.URNs()[0].String(), "tel:+16055741111?id=10000&priority=1000")
+ assert.Equal(t, 1, cathy.Groups().Count())
+ assert.Equal(t, 2, cathy.Tickets().Count())
+
+ cathyTickets := cathy.Tickets().All()
+ assert.Equal(t, "Problem!", cathyTickets[0].Subject())
+ assert.Equal(t, "agent1@nyaruka.com", cathyTickets[0].Assignee().Email())
+ assert.Equal(t, "Another Problem!", cathyTickets[1].Subject())
+ assert.Nil(t, cathyTickets[1].Assignee())
+
+ assert.Equal(t, "Yobe", cathy.Fields()["state"].QueryValue())
+ assert.Equal(t, "Dokshi", cathy.Fields()["ward"].QueryValue())
+ assert.Equal(t, "F", cathy.Fields()["gender"].QueryValue())
+ assert.Equal(t, (*flows.FieldValue)(nil), cathy.Fields()["age"])
+
+ assert.Equal(t, "Bob", bob.Name())
+ assert.NotNil(t, bob.Fields()["joined"].QueryValue())
+ assert.Equal(t, 2, len(bob.URNs()))
+ assert.Equal(t, "tel:+16055742222?id=10001&priority=1000", bob.URNs()[0].String())
+ assert.Equal(t, "whatsapp:250788373373?id=20121&priority=999", bob.URNs()[1].String())
+ assert.Equal(t, 0, bob.Groups().Count())
+ assert.Equal(t, 0, bob.Tickets().Count()) // because ticketer no longer exists
+
+ assert.Equal(t, "George", george.Name())
+ assert.Equal(t, decimal.RequireFromString("30"), george.Fields()["age"].QueryValue())
+ assert.Equal(t, 0, len(george.URNs()))
+ assert.Equal(t, 0, george.Groups().Count())
+ assert.Equal(t, 0, george.Tickets().Count())
// change bob to have a preferred URN and channel of our telephone
- channel := org.ChannelByID(models.TwilioChannelID)
- err = modelContacts[1].UpdatePreferredURN(ctx, db, org, models.BobURNID, channel)
+ channel := org.ChannelByID(testdata.TwilioChannel.ID)
+ err = modelContacts[1].UpdatePreferredURN(ctx, db, org, testdata.Bob.URNID, channel)
assert.NoError(t, err)
- bob, err := modelContacts[1].FlowContact(org)
+ bob, err = modelContacts[1].FlowContact(org)
assert.NoError(t, err)
assert.Equal(t, "tel:+16055742222?channel=74729f45-7f29-4868-9dc4-90e491e3c7d8&id=10001&priority=1000", bob.URNs()[0].String())
assert.Equal(t, "whatsapp:250788373373?id=20121&priority=999", bob.URNs()[1].String())
// add another tel urn to bob
- testdata.InsertContactURN(t, db, models.Org1, models.BobID, urns.URN("tel:+250788373373"), 10)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.Bob, urns.URN("tel:+250788373373"), 10)
// reload the contact
- modelContacts, err = models.LoadContacts(ctx, db, org, []models.ContactID{models.BobID})
+ modelContacts, err = models.LoadContacts(ctx, db, org, []models.ContactID{testdata.Bob.ID})
assert.NoError(t, err)
// set our preferred channel again
@@ -117,17 +136,16 @@ func TestContacts(t *testing.T) {
}
func TestCreateContact(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
- models.FlushCache()
+ ctx, _, db, _ := testsuite.Get()
- testdata.InsertContactGroup(t, db, models.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Kinyarwanda", "language = kin")
+ defer testsuite.Reset()
+
+ testdata.InsertContactGroup(db, testdata.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Kinyarwanda", "language = kin")
// add an orphaned URN
- testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200002"), 100)
+ testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200002"), 100)
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
contact, flowContact, err := models.CreateContact(ctx, db, oa, models.UserID(1), "Rich", envs.Language(`kin`), []urns.URN{urns.URN("telegram:200001"), urns.URN("telegram:200002")})
@@ -148,10 +166,11 @@ func TestCreateContact(t *testing.T) {
}
func TestCreateContactRace(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
mdb := testsuite.NewMockDB(db, func(funcName string, call int) error {
@@ -176,23 +195,22 @@ func TestCreateContactRace(t *testing.T) {
}
func TestGetOrCreateContact(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
- testdata.InsertContactGroup(t, db, models.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Telegrammer", `telegram = 100001`)
+ defer testsuite.Reset()
+
+ testdata.InsertContactGroup(db, testdata.Org1, "d636c966-79c1-4417-9f1c-82ad629773a2", "Telegrammer", `telegram = 100001`)
// add some orphaned URNs
- testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200001"), 100)
- testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200002"), 100)
+ testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200001"), 100)
+ testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200002"), 100)
var maxContactID models.ContactID
db.Get(&maxContactID, `SELECT max(id) FROM contacts_contact`)
newContact := func() models.ContactID { maxContactID++; return maxContactID }
prevContact := func() models.ContactID { return maxContactID }
- models.FlushCache()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
tcs := []struct {
@@ -205,34 +223,34 @@ func TestGetOrCreateContact(t *testing.T) {
GroupsUUIDs []assets.GroupUUID
}{
{
- models.Org1,
- []urns.URN{models.CathyURN},
- models.CathyID,
+ testdata.Org1.ID,
+ []urns.URN{testdata.Cathy.URN},
+ testdata.Cathy.ID,
false,
[]urns.URN{"tel:+16055741111?id=10000&priority=1000"},
models.NilChannelID,
- []assets.GroupUUID{models.DoctorsGroupUUID},
+ []assets.GroupUUID{testdata.DoctorsGroup.UUID},
},
{
- models.Org1,
- []urns.URN{urns.URN(models.CathyURN.String() + "?foo=bar")},
- models.CathyID, // only URN identity is considered
+ testdata.Org1.ID,
+ []urns.URN{urns.URN(testdata.Cathy.URN.String() + "?foo=bar")},
+ testdata.Cathy.ID, // only URN identity is considered
false,
[]urns.URN{"tel:+16055741111?id=10000&priority=1000"},
models.NilChannelID,
- []assets.GroupUUID{models.DoctorsGroupUUID},
+ []assets.GroupUUID{testdata.DoctorsGroup.UUID},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100001")},
newContact(), // creates new contact
true,
[]urns.URN{"telegram:100001?channel=74729f45-7f29-4868-9dc4-90e491e3c7d8&id=20123&priority=1000"},
- models.TwilioChannelID,
+ testdata.TwilioChannel.ID,
[]assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100001")},
prevContact(), // returns the same created contact
false,
@@ -241,7 +259,7 @@ func TestGetOrCreateContact(t *testing.T) {
[]assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100001"), urns.URN("telegram:100002")},
prevContact(), // same again as other URNs don't exist
false,
@@ -250,7 +268,7 @@ func TestGetOrCreateContact(t *testing.T) {
[]assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100002"), urns.URN("telegram:100001")},
prevContact(), // same again as other URNs don't exist
false,
@@ -259,7 +277,7 @@ func TestGetOrCreateContact(t *testing.T) {
[]assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:200001"), urns.URN("telegram:100001")},
prevContact(), // same again as other URNs are orphaned
false,
@@ -268,7 +286,7 @@ func TestGetOrCreateContact(t *testing.T) {
[]assets.GroupUUID{"d636c966-79c1-4417-9f1c-82ad629773a2"},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100003"), urns.URN("telegram:100004")}, // 2 new URNs
newContact(),
true,
@@ -277,7 +295,7 @@ func TestGetOrCreateContact(t *testing.T) {
[]assets.GroupUUID{},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100005"), urns.URN("telegram:200002")}, // 1 new, 1 orphaned
newContact(),
true,
@@ -305,10 +323,11 @@ func TestGetOrCreateContact(t *testing.T) {
}
func TestGetOrCreateContactRace(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
mdb := testsuite.NewMockDB(db, func(funcName string, call int) error {
@@ -333,20 +352,19 @@ func TestGetOrCreateContactRace(t *testing.T) {
}
func TestGetOrCreateContactIDsFromURNs(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
// add an orphaned URN
- testdata.InsertContactURN(t, db, models.Org1, models.NilContactID, urns.URN("telegram:200001"), 100)
+ testdata.InsertContactURN(db, testdata.Org1, nil, urns.URN("telegram:200001"), 100)
var maxContactID models.ContactID
db.Get(&maxContactID, `SELECT max(id) FROM contacts_contact`)
newContact := func() models.ContactID { maxContactID++; return maxContactID }
prevContact := func() models.ContactID { return maxContactID }
- models.FlushCache()
- org, err := models.GetOrgAssets(ctx, db, models.Org1)
+ org, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
tcs := []struct {
@@ -355,30 +373,30 @@ func TestGetOrCreateContactIDsFromURNs(t *testing.T) {
ContactIDs map[urns.URN]models.ContactID
}{
{
- models.Org1,
- []urns.URN{models.CathyURN},
- map[urns.URN]models.ContactID{models.CathyURN: models.CathyID},
+ testdata.Org1.ID,
+ []urns.URN{testdata.Cathy.URN},
+ map[urns.URN]models.ContactID{testdata.Cathy.URN: testdata.Cathy.ID},
},
{
- models.Org1,
- []urns.URN{urns.URN(models.CathyURN.String() + "?foo=bar")},
- map[urns.URN]models.ContactID{urns.URN(models.CathyURN.String() + "?foo=bar"): models.CathyID},
+ testdata.Org1.ID,
+ []urns.URN{urns.URN(testdata.Cathy.URN.String() + "?foo=bar")},
+ map[urns.URN]models.ContactID{urns.URN(testdata.Cathy.URN.String() + "?foo=bar"): testdata.Cathy.ID},
},
{
- models.Org1,
- []urns.URN{models.CathyURN, urns.URN("telegram:100001")},
+ testdata.Org1.ID,
+ []urns.URN{testdata.Cathy.URN, urns.URN("telegram:100001")},
map[urns.URN]models.ContactID{
- models.CathyURN: models.CathyID,
+ testdata.Cathy.URN: testdata.Cathy.ID,
urns.URN("telegram:100001"): newContact(),
},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:100001")},
map[urns.URN]models.ContactID{urns.URN("telegram:100001"): prevContact()},
},
{
- models.Org1,
+ testdata.Org1.ID,
[]urns.URN{urns.URN("telegram:200001")},
map[urns.URN]models.ContactID{urns.URN("telegram:200001"): newContact()}, // new contact assigned orphaned URN
},
@@ -392,11 +410,9 @@ func TestGetOrCreateContactIDsFromURNs(t *testing.T) {
}
func TestGetOrCreateContactIDsFromURNsRace(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- models.FlushCache()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
mdb := testsuite.NewMockDB(db, func(funcName string, call int) error {
@@ -423,56 +439,56 @@ func TestGetOrCreateContactIDsFromURNsRace(t *testing.T) {
}
func TestGetContactIDsFromReferences(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- ids, err := models.GetContactIDsFromReferences(ctx, db, models.Org1, []*flows.ContactReference{
- flows.NewContactReference(models.CathyUUID, "Cathy"),
- flows.NewContactReference(models.BobUUID, "Bob"),
+ ids, err := models.GetContactIDsFromReferences(ctx, db, testdata.Org1.ID, []*flows.ContactReference{
+ flows.NewContactReference(testdata.Cathy.UUID, "Cathy"),
+ flows.NewContactReference(testdata.Bob.UUID, "Bob"),
})
require.NoError(t, err)
- assert.ElementsMatch(t, []models.ContactID{models.CathyID, models.BobID}, ids)
+ assert.ElementsMatch(t, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, ids)
}
func TestStopContact(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
// stop kathy
- err := models.StopContact(ctx, db, models.Org1, models.CathyID)
+ err := models.StopContact(ctx, db, testdata.Org1.ID, testdata.Cathy.ID)
assert.NoError(t, err)
// verify she's only in the stopped group
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = $1`, []interface{}{models.CathyID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.Cathy.ID).Returns(1)
// verify she's stopped
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S' AND is_active = TRUE`, []interface{}{models.CathyID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S' AND is_active = TRUE`, testdata.Cathy.ID).Returns(1)
}
func TestUpdateContactLastSeenAndModifiedOn(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
t0 := time.Now()
- err = models.UpdateContactModifiedOn(ctx, db, []models.ContactID{models.CathyID})
+ err = models.UpdateContactModifiedOn(ctx, db, []models.ContactID{testdata.Cathy.ID})
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on IS NULL`, []interface{}{t0}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on IS NULL`, t0).Returns(1)
t1 := time.Now().Truncate(time.Millisecond)
time.Sleep(time.Millisecond * 5)
- err = models.UpdateContactLastSeenOn(ctx, db, models.CathyID, t1)
+ err = models.UpdateContactLastSeenOn(ctx, db, testdata.Cathy.ID, t1)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on = $1`, []interface{}{t1}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE modified_on > $1 AND last_seen_on = $1`, t1).Returns(1)
- cathy, err := models.LoadContact(ctx, db, oa, models.CathyID)
+ cathy, err := models.LoadContact(ctx, db, oa, testdata.Cathy.ID)
require.NoError(t, err)
assert.NotNil(t, cathy.LastSeenOn())
assert.True(t, t1.Equal(*cathy.LastSeenOn()))
@@ -487,69 +503,70 @@ func TestUpdateContactLastSeenAndModifiedOn(t *testing.T) {
assert.True(t, t2.Equal(*cathy.LastSeenOn()))
// and that also updates the database
- cathy, err = models.LoadContact(ctx, db, oa, models.CathyID)
+ cathy, err = models.LoadContact(ctx, db, oa, testdata.Cathy.ID)
require.NoError(t, err)
assert.True(t, t2.Equal(*cathy.LastSeenOn()))
assert.True(t, cathy.ModifiedOn().After(t2))
}
func TestUpdateContactModifiedBy(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
err := models.UpdateContactModifiedBy(ctx, db, []models.ContactID{}, models.UserID(0))
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = $2`, []interface{}{models.CathyID, models.UserID(0)}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = NULL`, testdata.Cathy.ID).Returns(0)
- err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{models.CathyID}, models.UserID(0))
+ err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{testdata.Cathy.ID}, models.UserID(0))
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = $2`, []interface{}{models.CathyID, models.UserID(0)}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = NULL`, testdata.Cathy.ID).Returns(0)
- err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{models.CathyID}, models.UserID(1))
+ err = models.UpdateContactModifiedBy(ctx, db, []models.ContactID{testdata.Cathy.ID}, models.UserID(1))
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = $2`, []interface{}{models.CathyID, models.UserID(1)}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_by_id = 1`, testdata.Cathy.ID).Returns(1)
}
func TestUpdateContactStatus(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
err := models.UpdateContactStatus(ctx, db, []*models.ContactStatusChange{})
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, []interface{}{models.CathyID}, 0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, testdata.Cathy.ID).Returns(0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(0)
changes := make([]*models.ContactStatusChange, 0, 1)
- changes = append(changes, &models.ContactStatusChange{models.CathyID, flows.ContactStatusBlocked})
+ changes = append(changes, &models.ContactStatusChange{testdata.Cathy.ID, flows.ContactStatusBlocked})
err = models.UpdateContactStatus(ctx, db, changes)
+ assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, []interface{}{models.CathyID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, testdata.Cathy.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(0)
changes = make([]*models.ContactStatusChange, 0, 1)
- changes = append(changes, &models.ContactStatusChange{models.CathyID, flows.ContactStatusStopped})
+ changes = append(changes, &models.ContactStatusChange{testdata.Cathy.ID, flows.ContactStatusStopped})
err = models.UpdateContactStatus(ctx, db, changes)
+ assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, []interface{}{models.CathyID}, 0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'B'`, testdata.Cathy.ID).Returns(0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(1)
}
func TestUpdateContactURNs(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
+ ctx, _, db, _ := testsuite.Get()
+
+ defer testsuite.Reset()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
numInitialURNs := 0
@@ -562,52 +579,52 @@ func TestUpdateContactURNs(t *testing.T) {
assert.Equal(t, expected, actual, "URNs mismatch for contact %d", contactID)
}
- assertContactURNs(models.CathyID, []string{"tel:+16055741111"})
- assertContactURNs(models.BobID, []string{"tel:+16055742222"})
- assertContactURNs(models.GeorgeID, []string{"tel:+16055743333"})
+ assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055741111"})
+ assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222"})
+ assertContactURNs(testdata.George.ID, []string{"tel:+16055743333"})
- cathyURN := urns.URN(fmt.Sprintf("tel:+16055741111?id=%d", models.CathyURNID))
- bobURN := urns.URN(fmt.Sprintf("tel:+16055742222?id=%d", models.BobURNID))
+ cathyURN := urns.URN(fmt.Sprintf("tel:+16055741111?id=%d", testdata.Cathy.URNID))
+ bobURN := urns.URN(fmt.Sprintf("tel:+16055742222?id=%d", testdata.Bob.URNID))
// give Cathy a new higher priority URN
- err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.CathyID, models.Org1, []urns.URN{"tel:+16055700001", cathyURN}}})
+ err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001", cathyURN}}})
assert.NoError(t, err)
- assertContactURNs(models.CathyID, []string{"tel:+16055700001", "tel:+16055741111"})
+ assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001", "tel:+16055741111"})
// give Bob a new lower priority URN
- err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.BobID, models.Org1, []urns.URN{bobURN, "tel:+16055700002"}}})
+ err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Bob.ID, testdata.Org1.ID, []urns.URN{bobURN, "tel:+16055700002"}}})
assert.NoError(t, err)
- assertContactURNs(models.BobID, []string{"tel:+16055742222", "tel:+16055700002"})
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`, nil, 0) // shouldn't be any orphan URNs
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn`, nil, numInitialURNs+2) // but 2 new URNs
+ assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222", "tel:+16055700002"})
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`).Returns(0) // shouldn't be any orphan URNs
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn`).Returns(numInitialURNs + 2) // but 2 new URNs
// remove a URN from Cathy
- err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.CathyID, models.Org1, []urns.URN{"tel:+16055700001"}}})
+ err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001"}}})
assert.NoError(t, err)
- assertContactURNs(models.CathyID, []string{"tel:+16055700001"})
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`, nil, 1) // now orphaned
+ assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001"})
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn WHERE contact_id IS NULL`).Returns(1) // now orphaned
// steal a URN from Bob
- err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{models.CathyID, models.Org1, []urns.URN{"tel:+16055700001", "tel:+16055700002"}}})
+ err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{{testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001", "tel:+16055700002"}}})
assert.NoError(t, err)
- assertContactURNs(models.CathyID, []string{"tel:+16055700001", "tel:+16055700002"})
- assertContactURNs(models.BobID, []string{"tel:+16055742222"})
+ assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001", "tel:+16055700002"})
+ assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222"})
// steal the URN back from Cathy whilst simulataneously adding new URN to Cathy and not-changing anything for George
err = models.UpdateContactURNs(ctx, db, oa, []*models.ContactURNsChanged{
- {models.BobID, models.Org1, []urns.URN{"tel:+16055742222", "tel:+16055700002"}},
- {models.CathyID, models.Org1, []urns.URN{"tel:+16055700001", "tel:+16055700003"}},
- {models.GeorgeID, models.Org1, []urns.URN{"tel:+16055743333"}},
+ {testdata.Bob.ID, testdata.Org1.ID, []urns.URN{"tel:+16055742222", "tel:+16055700002"}},
+ {testdata.Cathy.ID, testdata.Org1.ID, []urns.URN{"tel:+16055700001", "tel:+16055700003"}},
+ {testdata.George.ID, testdata.Org1.ID, []urns.URN{"tel:+16055743333"}},
})
assert.NoError(t, err)
- assertContactURNs(models.CathyID, []string{"tel:+16055700001", "tel:+16055700003"})
- assertContactURNs(models.BobID, []string{"tel:+16055742222", "tel:+16055700002"})
- assertContactURNs(models.GeorgeID, []string{"tel:+16055743333"})
+ assertContactURNs(testdata.Cathy.ID, []string{"tel:+16055700001", "tel:+16055700003"})
+ assertContactURNs(testdata.Bob.ID, []string{"tel:+16055742222", "tel:+16055700002"})
+ assertContactURNs(testdata.George.ID, []string{"tel:+16055743333"})
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contacturn`, nil, numInitialURNs+3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contacturn`).Returns(numInitialURNs + 3)
}
diff --git a/core/models/fields_test.go b/core/models/fields_test.go
index 4595aa417..edab85c78 100644
--- a/core/models/fields_test.go
+++ b/core/models/fields_test.go
@@ -1,56 +1,43 @@
-package models
+package models_test
import (
"testing"
"github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestFields(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- userFields, systemFields, err := loadFields(ctx, db, 1)
- assert.NoError(t, err)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshFields)
+ require.NoError(t, err)
- expectedUserFields := []struct {
- Key string
- Name string
- ValueType assets.FieldType
+ expectedFields := []struct {
+ field testdata.Field
+ key string
+ name string
+ valueType assets.FieldType
}{
- {"age", "Age", assets.FieldTypeNumber},
- {"district", "District", assets.FieldTypeDistrict},
- {"gender", "Gender", assets.FieldTypeText},
- {"joined", "Joined", assets.FieldTypeDatetime},
- {"state", "State", assets.FieldTypeState},
- {"ward", "Ward", assets.FieldTypeWard},
+ {*testdata.GenderField, "gender", "Gender", assets.FieldTypeText},
+ {*testdata.AgeField, "age", "Age", assets.FieldTypeNumber},
+ {*testdata.CreatedOnField, "created_on", "Created On", assets.FieldTypeDatetime},
+ {*testdata.LastSeenOnField, "last_seen_on", "Last Seen On", assets.FieldTypeDatetime},
}
+ for _, tc := range expectedFields {
+ field := oa.FieldByUUID(tc.field.UUID)
+ require.NotNil(t, field, "no such field: %s", tc.field.UUID)
- assert.Equal(t, len(expectedUserFields), len(userFields))
- for i, tc := range expectedUserFields {
- assert.Equal(t, tc.Key, userFields[i].Key())
- assert.Equal(t, tc.Name, userFields[i].Name())
- assert.Equal(t, tc.ValueType, userFields[i].Type())
- }
-
- expectedSystemFields := []struct {
- Key string
- Name string
- ValueType assets.FieldType
- }{
- {"created_on", "Created On", assets.FieldTypeDatetime},
- {"id", "ID", assets.FieldTypeNumber},
- {"language", "Language", assets.FieldTypeText},
- {"last_seen_on", "Last Seen On", assets.FieldTypeDatetime},
- {"name", "Name", assets.FieldTypeText},
- }
+ fieldByKey := oa.FieldByKey(tc.key)
+ assert.Equal(t, field, fieldByKey)
- assert.Equal(t, len(expectedSystemFields), len(systemFields))
- for i, tc := range expectedSystemFields {
- assert.Equal(t, tc.Key, systemFields[i].Key())
- assert.Equal(t, tc.Name, systemFields[i].Name())
- assert.Equal(t, tc.ValueType, systemFields[i].Type())
+ assert.Equal(t, tc.field.UUID, field.UUID(), "uuid mismatch for field %s", tc.field.ID)
+ assert.Equal(t, tc.key, field.Key())
+ assert.Equal(t, tc.name, field.Name())
+ assert.Equal(t, tc.valueType, field.Type())
}
}
diff --git a/core/models/flows.go b/core/models/flows.go
index b3bc3dfca..ebc65be60 100644
--- a/core/models/flows.go
+++ b/core/models/flows.go
@@ -81,14 +81,22 @@ func (f *Flow) FlowType() FlowType { return f.f.FlowType }
// Version returns the version this flow was authored in
func (f *Flow) Version() string { return f.f.Version }
-// IVRRetryWait returns the wait before retrying a failed IVR call
-func (f *Flow) IVRRetryWait() time.Duration {
+// IVRRetryWait returns the wait before retrying a failed IVR call (nil means no retry)
+func (f *Flow) IVRRetryWait() *time.Duration {
+ wait := ConnectionRetryWait
+
value := f.f.Config.Get(flowConfigIVRRetryMinutes, nil)
fv, isFloat := value.(float64)
if isFloat {
- return time.Minute * time.Duration(int(fv))
+ minutes := int(fv)
+ if minutes >= 0 {
+ wait = time.Minute * time.Duration(minutes)
+ } else {
+ return nil // ivr_retry -1 means no retry
+ }
}
- return ConnectionRetryWait
+
+ return &wait
}
// IgnoreTriggers returns whether this flow ignores triggers
@@ -106,7 +114,7 @@ func (f *Flow) cloneWithNewDefinition(def []byte) *Flow {
return &c
}
-func flowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID assets.FlowUUID) (FlowID, error) {
+func FlowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID assets.FlowUUID) (FlowID, error) {
// first try to look up in our assets
flow, _ := oa.Flow(flowUUID)
if flow != nil {
@@ -119,11 +127,11 @@ func flowIDForUUID(ctx context.Context, tx *sqlx.Tx, oa *OrgAssets, flowUUID ass
return flowID, err
}
-func loadFlowByUUID(ctx context.Context, db Queryer, orgID OrgID, flowUUID assets.FlowUUID) (*Flow, error) {
+func LoadFlowByUUID(ctx context.Context, db Queryer, orgID OrgID, flowUUID assets.FlowUUID) (*Flow, error) {
return loadFlow(ctx, db, selectFlowByUUIDSQL, orgID, flowUUID)
}
-func loadFlowByID(ctx context.Context, db Queryer, orgID OrgID, flowID FlowID) (*Flow, error) {
+func LoadFlowByID(ctx context.Context, db Queryer, orgID OrgID, flowID FlowID) (*Flow, error) {
return loadFlow(ctx, db, selectFlowByIDSQL, orgID, flowID)
}
diff --git a/core/models/flows_test.go b/core/models/flows_test.go
index 3ffce2c3e..a668aeefd 100644
--- a/core/models/flows_test.go
+++ b/core/models/flows_test.go
@@ -1,4 +1,4 @@
-package models
+package models_test
import (
"testing"
@@ -6,72 +6,80 @@ import (
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/mailroom/core/goflow"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
)
-func TestFlows(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+func TestLoadFlows(t *testing.T) {
+ ctx, rt, db, _ := testsuite.Get()
- db.MustExec(`UPDATE flows_flow SET metadata = '{"ivr_retry": 30}'::json WHERE id = $1`, IVRFlowID)
+ db.MustExec(`UPDATE flows_flow SET metadata = '{"ivr_retry": 30}'::json WHERE id = $1`, testdata.IVRFlow.ID)
+ db.MustExec(`UPDATE flows_flow SET metadata = '{"ivr_retry": -1}'::json WHERE id = $1`, testdata.SurveyorFlow.ID)
+
+ sixtyMinutes := 60 * time.Minute
+ thirtyMinutes := 30 * time.Minute
tcs := []struct {
- OrgID OrgID
- FlowID FlowID
- FlowUUID assets.FlowUUID
- Name string
- IVRRetry time.Duration
- Found bool
+ org *testdata.Org
+ flowID models.FlowID
+ flowUUID assets.FlowUUID
+ expectedName string
+ expectedIVRRetry *time.Duration
}{
- {Org1, FavoritesFlowID, FavoritesFlowUUID, "Favorites", 60 * time.Minute, true},
- {Org1, IVRFlowID, IVRFlowUUID, "IVR Flow", 30 * time.Minute, true},
- {Org2, FlowID(0), assets.FlowUUID("51e3c67d-8483-449c-abf7-25e50686f0db"), "", 0, false},
+ {testdata.Org1, testdata.Favorites.ID, testdata.Favorites.UUID, "Favorites", &sixtyMinutes}, // will use default IVR retry
+ {testdata.Org1, testdata.IVRFlow.ID, testdata.IVRFlow.UUID, "IVR Flow", &thirtyMinutes}, // will have explicit IVR retry
+ {testdata.Org1, testdata.SurveyorFlow.ID, testdata.SurveyorFlow.UUID, "Contact Surveyor", nil}, // will have no IVR retry
+ {testdata.Org2, models.FlowID(0), assets.FlowUUID("51e3c67d-8483-449c-abf7-25e50686f0db"), "", nil},
}
- for _, tc := range tcs {
- flow, err := loadFlowByUUID(ctx, db, tc.OrgID, tc.FlowUUID)
+ for i, tc := range tcs {
+ // test loading by UUID
+ flow, err := models.LoadFlowByUUID(ctx, db, tc.org.ID, tc.flowUUID)
assert.NoError(t, err)
- if tc.Found {
- assert.Equal(t, tc.Name, flow.Name())
- assert.Equal(t, tc.FlowID, flow.ID())
- assert.Equal(t, tc.FlowUUID, flow.UUID())
- assert.Equal(t, tc.IVRRetry, flow.IVRRetryWait())
+ if tc.expectedName != "" {
+ assert.Equal(t, tc.flowID, flow.ID())
+ assert.Equal(t, tc.flowUUID, flow.UUID())
+ assert.Equal(t, tc.expectedName, flow.Name(), "%d: name mismatch", i)
+ assert.Equal(t, tc.expectedIVRRetry, flow.IVRRetryWait(), "%d: IVR retry mismatch", i)
- _, err := goflow.ReadFlow(flow.Definition())
+ _, err := goflow.ReadFlow(rt.Config, flow.Definition())
assert.NoError(t, err)
} else {
assert.Nil(t, flow)
}
- flow, err = loadFlowByID(ctx, db, tc.OrgID, tc.FlowID)
+ // test loading by ID
+ flow, err = models.LoadFlowByID(ctx, db, tc.org.ID, tc.flowID)
assert.NoError(t, err)
- if tc.Found {
- assert.Equal(t, tc.Name, flow.Name())
- assert.Equal(t, tc.FlowID, flow.ID())
- assert.Equal(t, tc.FlowUUID, flow.UUID())
+ if tc.expectedName != "" {
+ assert.Equal(t, tc.flowID, flow.ID())
+ assert.Equal(t, tc.flowUUID, flow.UUID())
+ assert.Equal(t, tc.expectedName, flow.Name(), "%d: name mismatch", i)
+ assert.Equal(t, tc.expectedIVRRetry, flow.IVRRetryWait(), "%d: IVR retry mismatch", i)
} else {
assert.Nil(t, flow)
}
}
}
-func TestGetFlowUUID(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- org, _ := GetOrgAssets(ctx, db, Org1)
+func TestFlowIDForUUID(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ org, _ := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
tx, err := db.BeginTxx(ctx, nil)
assert.NoError(t, err)
- id, err := flowIDForUUID(ctx, tx, org, FavoritesFlowUUID)
+ id, err := models.FlowIDForUUID(ctx, tx, org, testdata.Favorites.UUID)
assert.NoError(t, err)
- assert.Equal(t, FavoritesFlowID, id)
+ assert.Equal(t, testdata.Favorites.ID, id)
// make favorite inactive
- tx.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, FavoritesFlowID)
+ tx.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, testdata.Favorites.ID)
tx.Commit()
tx, err = db.BeginTxx(ctx, nil)
@@ -79,10 +87,10 @@ func TestGetFlowUUID(t *testing.T) {
defer tx.Rollback()
// clear our assets so it isn't cached
- FlushCache()
- org, _ = GetOrgAssets(ctx, db, Org1)
+ models.FlushCache()
+ org, _ = models.GetOrgAssets(ctx, db, testdata.Org1.ID)
- id, err = flowIDForUUID(ctx, tx, org, FavoritesFlowUUID)
+ id, err = models.FlowIDForUUID(ctx, tx, org, testdata.Favorites.UUID)
assert.NoError(t, err)
- assert.Equal(t, FavoritesFlowID, id)
+ assert.Equal(t, testdata.Favorites.ID, id)
}
diff --git a/core/models/globals_test.go b/core/models/globals_test.go
index 598ba88eb..cc5c9cb90 100644
--- a/core/models/globals_test.go
+++ b/core/models/globals_test.go
@@ -1,25 +1,26 @@
-package models
+package models_test
import (
"testing"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
-func TestGlobals(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+func TestLoadGlobals(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
// set one of our global values to empty
- db.MustExec(`UPDATE globals_global SET value = '' WHERE org_id = $1 AND key = $2`, Org1, "org_name")
+ db.MustExec(`UPDATE globals_global SET value = '' WHERE org_id = $1 AND key = $2`, testdata.Org1.ID, "org_name")
- tx, err := db.BeginTxx(ctx, nil)
- assert.NoError(t, err)
- defer tx.Rollback()
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshGlobals)
+ require.NoError(t, err)
- globals, err := loadGlobals(ctx, tx, 1)
- assert.NoError(t, err)
+ globals, err := oa.Globals()
+ require.NoError(t, err)
assert.Equal(t, 2, len(globals))
assert.Equal(t, "access_token", globals[0].Key())
diff --git a/core/models/groups.go b/core/models/groups.go
index 5a391c60d..ce8ab590b 100644
--- a/core/models/groups.go
+++ b/core/models/groups.go
@@ -376,7 +376,7 @@ func PopulateDynamicGroup(ctx context.Context, db *sqlx.DB, es *elastic.Client,
return 0, errors.Wrapf(err, "error performing query: %s for group: %d", query, groupID)
}
- // find which need to be added or removed
+ // find which contacts need to be added or removed
adds := make([]ContactID, 0, 100)
for _, id := range new {
if !present[id] {
@@ -409,5 +409,15 @@ func PopulateDynamicGroup(ctx context.Context, db *sqlx.DB, es *elastic.Client,
return 0, errors.Wrapf(err, "error marking dynamic group as ready")
}
+ // finally update modified_on for all affected contacts to ensure these changes are seen by rp-indexer
+ changed := make([]ContactID, 0, len(adds))
+ changed = append(changed, adds...)
+ changed = append(changed, removals...)
+
+ err = UpdateContactModifiedOn(ctx, db, changed)
+ if err != nil {
+ return 0, errors.Wrapf(err, "error updating contact modified_on after group population")
+ }
+
return len(new), nil
}
diff --git a/core/models/groups_test.go b/core/models/groups_test.go
index 1a214b2e6..e5751c76e 100644
--- a/core/models/groups_test.go
+++ b/core/models/groups_test.go
@@ -9,6 +9,7 @@ import (
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/lib/pq"
"github.com/olivere/elastic/v7"
@@ -17,8 +18,9 @@ import (
)
func TestLoadGroups(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.NewMockDB(testsuite.DB(), func(funcName string, call int) error {
+ ctx, _, db0, _ := testsuite.Get()
+
+ db := testsuite.NewMockDB(db0, func(funcName string, call int) error {
// fail first query for groups
if funcName == "QueryxContext" && call == 0 {
return errors.New("boom")
@@ -26,10 +28,10 @@ func TestLoadGroups(t *testing.T) {
return nil
})
- groups, err := models.LoadGroups(ctx, db, 1)
+ _, err := models.LoadGroups(ctx, db, testdata.Org1.ID)
require.EqualError(t, err, "error querying groups for org: 1: boom")
- groups, err = models.LoadGroups(ctx, db, 1)
+ groups, err := models.LoadGroups(ctx, db, 1)
require.NoError(t, err)
tcs := []struct {
@@ -38,8 +40,8 @@ func TestLoadGroups(t *testing.T) {
Name string
Query string
}{
- {models.DoctorsGroupID, models.DoctorsGroupUUID, "Doctors", ""},
- {models.TestersGroupID, models.TestersGroupUUID, "Testers", ""},
+ {testdata.DoctorsGroup.ID, testdata.DoctorsGroup.UUID, "Doctors", ""},
+ {testdata.TestersGroup.ID, testdata.TestersGroup.UUID, "Testers", ""},
}
assert.Equal(t, 2, len(groups))
@@ -53,8 +55,7 @@ func TestLoadGroups(t *testing.T) {
}
func TestDynamicGroups(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
// insert an event on our campaign
var eventID models.CampaignEventID
@@ -62,22 +63,20 @@ func TestDynamicGroups(t *testing.T) {
`INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour,
campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode)
VALUES(TRUE, NOW(), NOW(), $1, 1000, 'W', 'F', -1, $2, 1, 1, $3, $4, 'I') RETURNING id`,
- uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.JoinedFieldID)
+ uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.JoinedField.ID)
// clear Cathy's value
testsuite.DB().MustExec(
`update contacts_contact set fields = fields - $2
- WHERE id = $1`, models.CathyID, models.JoinedFieldUUID)
+ WHERE id = $1`, testdata.Cathy.ID, testdata.JoinedField.UUID)
// and populate Bob's
testsuite.DB().MustExec(
fmt.Sprintf(`update contacts_contact set fields = fields ||
'{"%s": { "text": "2029-09-15T12:00:00+00:00", "datetime": "2029-09-15T12:00:00+00:00" }}'::jsonb
- WHERE id = $1`, models.JoinedFieldUUID), models.BobID)
+ WHERE id = $1`, testdata.JoinedField.UUID), testdata.Bob.ID)
- // clear our org cache so we reload org campaigns and events
- models.FlushCache()
- org, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshCampaigns|models.RefreshGroups)
assert.NoError(t, err)
esServer := testsuite.NewMockElasticServer()
@@ -118,8 +117,8 @@ func TestDynamicGroups(t *testing.T) {
}
}`
- cathyHit := fmt.Sprintf(contactHit, models.CathyID)
- bobHit := fmt.Sprintf(contactHit, models.BobID)
+ cathyHit := fmt.Sprintf(contactHit, testdata.Cathy.ID)
+ bobHit := fmt.Sprintf(contactHit, testdata.Bob.ID)
tcs := []struct {
Query string
@@ -130,47 +129,45 @@ func TestDynamicGroups(t *testing.T) {
{
"cathy",
cathyHit,
- []models.ContactID{models.CathyID},
+ []models.ContactID{testdata.Cathy.ID},
[]models.ContactID{},
},
{
"bob",
bobHit,
- []models.ContactID{models.BobID},
- []models.ContactID{models.BobID},
+ []models.ContactID{testdata.Bob.ID},
+ []models.ContactID{testdata.Bob.ID},
},
{
"unchanged",
bobHit,
- []models.ContactID{models.BobID},
- []models.ContactID{models.BobID},
+ []models.ContactID{testdata.Bob.ID},
+ []models.ContactID{testdata.Bob.ID},
},
}
for _, tc := range tcs {
- err := models.UpdateGroupStatus(ctx, db, models.DoctorsGroupID, models.GroupStatusInitializing)
+ err := models.UpdateGroupStatus(ctx, db, testdata.DoctorsGroup.ID, models.GroupStatusInitializing)
assert.NoError(t, err)
esServer.NextResponse = tc.ESResponse
- count, err := models.PopulateDynamicGroup(ctx, db, es, org, models.DoctorsGroupID, tc.Query)
+ count, err := models.PopulateDynamicGroup(ctx, db, es, oa, testdata.DoctorsGroup.ID, tc.Query)
assert.NoError(t, err, "error populating dynamic group for: %s", tc.Query)
assert.Equal(t, count, len(tc.ContactIDs))
// assert the current group membership
- contactIDs, err := models.ContactIDsForGroupIDs(ctx, db, []models.GroupID{models.DoctorsGroupID})
+ contactIDs, err := models.ContactIDsForGroupIDs(ctx, db, []models.GroupID{testdata.DoctorsGroup.ID})
+ assert.NoError(t, err)
assert.Equal(t, tc.ContactIDs, contactIDs)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from contacts_contactgroup WHERE id = $1 AND status = 'R'`,
- []interface{}{models.DoctorsGroupID}, 1, "wrong number of contacts in group for query: %s", tc.Query)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from contacts_contactgroup WHERE id = $1 AND status = 'R'`, testdata.DoctorsGroup.ID).
+ Returns(1, "wrong number of contacts in group for query: %s", tc.Query)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from campaigns_eventfire WHERE event_id = $1`,
- []interface{}{eventID}, len(tc.EventContactIDs), "wrong number of contacts with events for query: %s", tc.Query)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from campaigns_eventfire WHERE event_id = $1`, eventID).
+ Returns(len(tc.EventContactIDs), "wrong number of contacts with events for query: %s", tc.Query)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from campaigns_eventfire WHERE event_id = $1 AND contact_id = ANY($2)`,
- []interface{}{eventID, pq.Array(tc.EventContactIDs)}, len(tc.EventContactIDs), "wrong contacts with events for query: %s", tc.Query)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from campaigns_eventfire WHERE event_id = $1 AND contact_id = ANY($2)`, eventID, pq.Array(tc.EventContactIDs)).
+ Returns(len(tc.EventContactIDs), "wrong contacts with events for query: %s", tc.Query)
}
}
diff --git a/core/models/http_logs_test.go b/core/models/http_logs_test.go
index c134bd183..48e52a910 100644
--- a/core/models/http_logs_test.go
+++ b/core/models/http_logs_test.go
@@ -9,37 +9,32 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTPLogs(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
// insert a log
- log := models.NewClassifierCalledLog(models.Org1, models.WitID, "http://foo.bar", "GET /", "STATUS 200", false, time.Second, time.Now())
+ log := models.NewClassifierCalledLog(testdata.Org1.ID, testdata.Wit.ID, "http://foo.bar", "GET /", "STATUS 200", false, time.Second, time.Now())
err := models.InsertHTTPLogs(ctx, db, []*models.HTTPLog{log})
assert.Nil(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = FALSE`,
- []interface{}{models.Org1, models.WitID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = FALSE`, testdata.Org1.ID, testdata.Wit.ID).Returns(1)
// insert a log with nil response
- log = models.NewClassifierCalledLog(models.Org1, models.WitID, "http://foo.bar", "GET /", "", true, time.Second, time.Now())
+ log = models.NewClassifierCalledLog(testdata.Org1.ID, testdata.Wit.ID, "http://foo.bar", "GET /", "", true, time.Second, time.Now())
err = models.InsertHTTPLogs(ctx, db, []*models.HTTPLog{log})
assert.Nil(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = TRUE AND response IS NULL`,
- []interface{}{models.Org1, models.WitID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND classifier_id = $2 AND is_error = TRUE AND response IS NULL`, testdata.Org1.ID, testdata.Wit.ID).Returns(1)
}
func TestHTTPLogger(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -49,7 +44,7 @@ func TestHTTPLogger(t *testing.T) {
},
}))
- mailgun, err := models.LookupTicketerByUUID(ctx, db, models.MailgunUUID)
+ mailgun, err := models.LookupTicketerByUUID(ctx, db, testdata.Mailgun.UUID)
require.NoError(t, err)
logger := &models.HTTPLogger{}
@@ -71,7 +66,5 @@ func TestHTTPLogger(t *testing.T) {
err = logger.Insert(ctx, db)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND ticketer_id = $2`,
- []interface{}{models.Org1, models.MailgunID}, 2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from request_logs_httplog WHERE org_id = $1 AND ticketer_id = $2`, testdata.Org1.ID, testdata.Mailgun.ID).Returns(2)
}
diff --git a/core/models/imports_test.go b/core/models/imports_test.go
index cba7b865b..a079506ac 100644
--- a/core/models/imports_test.go
+++ b/core/models/imports_test.go
@@ -26,23 +26,20 @@ import (
)
func TestContactImports(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- testsuite.Reset()
- defer testsuite.Reset()
+ ctx, _, db, _ := testsuite.Reset()
- models.FlushCache()
+ defer testsuite.Reset()
- testdata.DeleteContactsAndURNs(t, db)
+ testdata.DeleteContactsAndURNs(db)
// add contact in other org to make sure we can't update it
- testdata.InsertContact(t, db, models.Org2, "f7a8016d-69a6-434b-aae7-5142ce4a98ba", "Xavier", "spa")
+ testdata.InsertContact(db, testdata.Org2, "f7a8016d-69a6-434b-aae7-5142ce4a98ba", "Xavier", "spa")
// add dynamic group to test imported contacts are added to it
- testdata.InsertContactGroup(t, db, models.Org1, "fc32f928-ad37-477c-a88e-003d30fd7406", "Adults", "age >= 40")
+ testdata.InsertContactGroup(db, testdata.Org1, "fc32f928-ad37-477c-a88e-003d30fd7406", "Adults", "age >= 40")
// give our org a country by setting country on a channel
- db.MustExec(`UPDATE channels_channel SET country = 'US' WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET country = 'US' WHERE id = $1`, testdata.TwilioChannel.ID)
testJSON, err := ioutil.ReadFile("testdata/imports.json")
require.NoError(t, err)
@@ -66,13 +63,13 @@ func TestContactImports(t *testing.T) {
defer uuids.SetGenerator(uuids.DefaultGenerator)
for i, tc := range tcs {
- importID := testdata.InsertContactImport(t, db, models.Org1)
- batchID := testdata.InsertContactImportBatch(t, db, importID, tc.Specs)
+ importID := testdata.InsertContactImport(db, testdata.Org1)
+ batchID := testdata.InsertContactImportBatch(db, importID, tc.Specs)
batch, err := models.LoadContactImportBatch(ctx, db, batchID)
require.NoError(t, err)
- err = batch.Import(ctx, db, models.Org1)
+ err = batch.Import(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
results := &struct {
@@ -127,8 +124,8 @@ func TestContactImports(t *testing.T) {
test.AssertEqualJSON(t, tc.Errors, actual.Errors, "errors mismatch in '%s'", tc.Description)
- actualJSON, _ := jsonx.Marshal(actual.Contacts)
- expectedJSON, _ := jsonx.Marshal(tc.Contacts)
+ actualJSON := jsonx.MustMarshal(actual.Contacts)
+ expectedJSON := jsonx.MustMarshal(tc.Contacts)
test.AssertEqualJSON(t, expectedJSON, actualJSON, "imported contacts mismatch in '%s'", tc.Description)
} else {
tcs[i] = actual
@@ -145,11 +142,10 @@ func TestContactImports(t *testing.T) {
}
func TestContactImportBatch(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- importID := testdata.InsertContactImport(t, db, models.Org1)
- batchID := testdata.InsertContactImportBatch(t, db, importID, []byte(`[
+ importID := testdata.InsertContactImport(db, testdata.Org1)
+ batchID := testdata.InsertContactImportBatch(db, importID, []byte(`[
{"name": "Norbert", "language": "eng", "urns": ["tel:+16055740001"]},
{"name": "Leah", "urns": ["tel:+16055740002"]}
]`))
@@ -163,10 +159,10 @@ func TestContactImportBatch(t *testing.T) {
assert.Equal(t, 0, batch.RecordStart)
assert.Equal(t, 2, batch.RecordEnd)
- err = batch.Import(ctx, db, models.Org1)
+ err = batch.Import(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contactimportbatch WHERE status = 'C' AND finished_on IS NOT NULL`, []interface{}{}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contactimportbatch WHERE status = 'C' AND finished_on IS NOT NULL`).Returns(1)
}
func TestContactSpecUnmarshal(t *testing.T) {
diff --git a/core/models/labels_test.go b/core/models/labels_test.go
index 915cebb29..d581393ee 100644
--- a/core/models/labels_test.go
+++ b/core/models/labels_test.go
@@ -1,30 +1,36 @@
-package models
+package models_test
import (
"testing"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestLabels(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshLabels)
+ require.NoError(t, err)
- labels, err := loadLabels(ctx, db, 1)
- assert.NoError(t, err)
+ labels, err := oa.Labels()
+ require.NoError(t, err)
tcs := []struct {
- ID LabelID
+ ID models.LabelID
Name string
}{
- {ReportingLabelID, "Reporting"},
- {TestingLabelID, "Testing"},
+ {testdata.ReportingLabel.ID, "Reporting"},
+ {testdata.TestingLabel.ID, "Testing"},
}
assert.Equal(t, 3, len(labels))
for i, tc := range tcs {
- label := labels[i].(*Label)
+ label := labels[i].(*models.Label)
assert.Equal(t, tc.ID, label.ID())
assert.Equal(t, tc.Name, label.Name())
}
diff --git a/core/models/locations_test.go b/core/models/locations_test.go
index ab29379d3..aad5ccb24 100644
--- a/core/models/locations_test.go
+++ b/core/models/locations_test.go
@@ -1,25 +1,30 @@
-package models
+package models_test
import (
"testing"
"github.com/nyaruka/goflow/envs"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestLocations(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
db.MustExec(`INSERT INTO locations_boundaryalias(is_active, created_on, modified_on, name, boundary_id, created_by_id, modified_by_id, org_id)
VALUES(TRUE, NOW(), NOW(), 'Soko', 8148, 1, 1, 1);`)
db.MustExec(`INSERT INTO locations_boundaryalias(is_active, created_on, modified_on, name, boundary_id, created_by_id, modified_by_id, org_id)
VALUES(TRUE, NOW(), NOW(), 'Sokoz', 8148, 1, 1, 2);`)
- root, err := loadLocations(ctx, db, 1)
- assert.NoError(t, err)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshLocations)
+ require.NoError(t, err)
+
+ root, err := oa.Locations()
+ require.NoError(t, err)
locations := root[0].FindByName("Nigeria", 0, nil)
diff --git a/core/models/models_test.go b/core/models/models_test.go
deleted file mode 100644
index 79867b0f6..000000000
--- a/core/models/models_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package models
-
-import (
- "os"
- "testing"
-
- "github.com/nyaruka/mailroom/testsuite"
-)
-
-// Custom entry point so we can reset our database
-func TestMain(m *testing.M) {
- testsuite.Reset()
- os.Exit(m.Run())
-}
diff --git a/core/models/msgs.go b/core/models/msgs.go
index d5eea2cd5..2fd05286e 100644
--- a/core/models/msgs.go
+++ b/core/models/msgs.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/gsm7"
"github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/goflow/assets"
@@ -53,10 +54,10 @@ const (
type MsgType string
const (
- TypeInbox = MsgType("I")
- TypeFlow = MsgType("F")
- TypeIVR = MsgType("V")
- TypeUSSD = MsgType("U")
+ MsgTypeInbox = MsgType("I")
+ MsgTypeFlow = MsgType("F")
+ MsgTypeIVR = MsgType("V")
+ MsgTypeUSSD = MsgType("U")
)
type MsgStatus string
@@ -73,6 +74,7 @@ const (
MsgStatusQueued = MsgStatus("Q")
MsgStatusWired = MsgStatus("W")
MsgStatusSent = MsgStatus("S")
+ MsgStatusDelivered = MsgStatus("D")
MsgStatusHandled = MsgStatus("H")
MsgStatusErrored = MsgStatus("E")
MsgStatusFailed = MsgStatus("F")
@@ -99,7 +101,7 @@ type Msg struct {
HighPriority bool `db:"high_priority" json:"high_priority"`
CreatedOn time.Time `db:"created_on" json:"created_on"`
ModifiedOn time.Time `db:"modified_on" json:"modified_on"`
- SentOn time.Time `db:"sent_on" json:"sent_on"`
+ SentOn *time.Time `db:"sent_on" json:"sent_on"`
QueuedOn time.Time `db:"queued_on" json:"queued_on"`
Direction MsgDirection `db:"direction" json:"direction"`
Status MsgStatus `db:"status" json:"status"`
@@ -107,7 +109,7 @@ type Msg struct {
MsgType MsgType `db:"msg_type"`
MsgCount int `db:"msg_count" json:"tps_cost"`
ErrorCount int `db:"error_count" json:"error_count"`
- NextAttempt time.Time `db:"next_attempt" json:"next_attempt"`
+ NextAttempt *time.Time `db:"next_attempt" json:"next_attempt"`
ExternalID null.String `db:"external_id" json:"external_id"`
Attachments pq.StringArray `db:"attachments" json:"attachments"`
Metadata null.Map `db:"metadata" json:"metadata,omitempty"`
@@ -118,6 +120,7 @@ type Msg struct {
ContactURNID *URNID `db:"contact_urn_id" json:"contact_urn_id"`
ResponseToID MsgID `db:"response_to_id" json:"response_to_id"`
ResponseToExternalID null.String ` json:"response_to_external_id"`
+ IsResend bool ` json:"is_resend,omitempty"`
URN urns.URN ` json:"urn"`
URNAuth null.String ` json:"urn_auth,omitempty"`
OrgID OrgID `db:"org_id" json:"org_id"`
@@ -144,14 +147,14 @@ func (m *Msg) Text() string { return m.m.Text }
func (m *Msg) HighPriority() bool { return m.m.HighPriority }
func (m *Msg) CreatedOn() time.Time { return m.m.CreatedOn }
func (m *Msg) ModifiedOn() time.Time { return m.m.ModifiedOn }
-func (m *Msg) SentOn() time.Time { return m.m.SentOn }
+func (m *Msg) SentOn() *time.Time { return m.m.SentOn }
func (m *Msg) QueuedOn() time.Time { return m.m.QueuedOn }
func (m *Msg) Direction() MsgDirection { return m.m.Direction }
func (m *Msg) Status() MsgStatus { return m.m.Status }
func (m *Msg) Visibility() MsgVisibility { return m.m.Visibility }
func (m *Msg) MsgType() MsgType { return m.m.MsgType }
func (m *Msg) ErrorCount() int { return m.m.ErrorCount }
-func (m *Msg) NextAttempt() time.Time { return m.m.NextAttempt }
+func (m *Msg) NextAttempt() *time.Time { return m.m.NextAttempt }
func (m *Msg) ExternalID() null.String { return m.m.ExternalID }
func (m *Msg) Metadata() map[string]interface{} { return m.m.Metadata.Map() }
func (m *Msg) MsgCount() int { return m.m.MsgCount }
@@ -164,6 +167,7 @@ func (m *Msg) OrgID() OrgID { return m.m.OrgID }
func (m *Msg) TopupID() TopupID { return m.m.TopupID }
func (m *Msg) ContactID() ContactID { return m.m.ContactID }
func (m *Msg) ContactURNID() *URNID { return m.m.ContactURNID }
+func (m *Msg) IsResend() bool { return m.m.IsResend }
func (m *Msg) SetTopup(topupID TopupID) { m.m.TopupID = topupID }
func (m *Msg) SetChannelID(channelID ChannelID) { m.m.ChannelID = channelID }
@@ -223,7 +227,7 @@ func NewIncomingIVR(orgID OrgID, conn *ChannelConnection, in *flows.MsgIn, creat
m.Direction = DirectionIn
m.Status = MsgStatusHandled
m.Visibility = VisibilityVisible
- m.MsgType = TypeIVR
+ m.MsgType = MsgTypeIVR
m.ContactID = conn.ContactID()
urnID := conn.ContactURNID()
@@ -240,14 +244,14 @@ func NewIncomingIVR(orgID OrgID, conn *ChannelConnection, in *flows.MsgIn, creat
// add any attachments
for _, a := range in.Attachments() {
- m.Attachments = append(m.Attachments, string(NormalizeAttachment(a)))
+ m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a)))
}
return msg
}
// NewOutgoingIVR creates a new IVR message for the passed in text with the optional attachment
-func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, createdOn time.Time) (*Msg, error) {
+func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, createdOn time.Time) *Msg {
msg := &Msg{}
m := &msg.m
@@ -258,7 +262,7 @@ func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, cre
m.Direction = DirectionOut
m.Status = MsgStatusWired
m.Visibility = VisibilityVisible
- m.MsgType = TypeIVR
+ m.MsgType = MsgTypeIVR
m.ContactID = conn.ContactID()
urnID := conn.ContactURNID()
@@ -272,14 +276,15 @@ func NewOutgoingIVR(orgID OrgID, conn *ChannelConnection, out *flows.MsgOut, cre
m.OrgID = orgID
m.TopupID = NilTopupID
m.CreatedOn = createdOn
+ m.SentOn = &createdOn
msg.SetChannelID(conn.ChannelID())
// if we have attachments, add them
for _, a := range out.Attachments() {
- m.Attachments = append(m.Attachments, string(NormalizeAttachment(a)))
+ m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a)))
}
- return msg, nil
+ return msg
}
// NewOutgoingMsg creates an outgoing message for the passed in flow message.
@@ -299,7 +304,7 @@ func NewOutgoingMsg(org *Org, channel *Channel, contactID ContactID, out *flows.
m.Direction = DirectionOut
m.Status = status
m.Visibility = VisibilityVisible
- m.MsgType = TypeFlow
+ m.MsgType = MsgTypeFlow
m.ContactID = contactID
m.OrgID = org.ID()
m.TopupID = NilTopupID
@@ -321,7 +326,7 @@ func NewOutgoingMsg(org *Org, channel *Channel, contactID ContactID, out *flows.
// if we have attachments, add them
if len(out.Attachments()) > 0 {
for _, a := range out.Attachments() {
- m.Attachments = append(m.Attachments, string(NormalizeAttachment(a)))
+ m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a)))
}
}
@@ -361,7 +366,7 @@ func NewIncomingMsg(orgID OrgID, channel *Channel, contactID ContactID, in *flow
m.Direction = DirectionIn
m.Status = MsgStatusHandled
m.Visibility = VisibilityVisible
- m.MsgType = TypeFlow
+ m.MsgType = MsgTypeFlow
m.ContactID = contactID
m.OrgID = orgID
@@ -376,15 +381,69 @@ func NewIncomingMsg(orgID OrgID, channel *Channel, contactID ContactID, in *flow
// add any attachments
for _, a := range in.Attachments() {
- m.Attachments = append(m.Attachments, string(NormalizeAttachment(a)))
+ m.Attachments = append(m.Attachments, string(NormalizeAttachment(config.Mailroom, a)))
}
return msg
}
+var loadMessagesSQL = `
+SELECT
+ id,
+ broadcast_id,
+ uuid,
+ text,
+ created_on,
+ direction,
+ status,
+ visibility,
+ msg_count,
+ error_count,
+ next_attempt,
+ external_id,
+ attachments,
+ metadata,
+ channel_id,
+ connection_id,
+ contact_id,
+ contact_urn_id,
+ response_to_id,
+ org_id,
+ topup_id
+FROM
+ msgs_msg
+WHERE
+ org_id = $1 AND
+ direction = $2 AND
+ id = ANY($3)
+ORDER BY
+ id ASC`
+
+// LoadMessages loads the given messages for the passed in org
+func LoadMessages(ctx context.Context, db Queryer, orgID OrgID, direction MsgDirection, msgIDs []MsgID) ([]*Msg, error) {
+ rows, err := db.QueryxContext(ctx, loadMessagesSQL, orgID, direction, pq.Array(msgIDs))
+ if err != nil {
+ return nil, errors.Wrapf(err, "error querying msgs for org: %d", orgID)
+ }
+ defer rows.Close()
+
+ msgs := make([]*Msg, 0)
+ for rows.Next() {
+ msg := &Msg{}
+ err = rows.StructScan(&msg.m)
+ if err != nil {
+ return nil, errors.Wrap(err, "error scanning msg row")
+ }
+
+ msgs = append(msgs, msg)
+ }
+
+ return msgs, nil
+}
+
// NormalizeAttachment will turn any relative URL in the passed in attachment and normalize it to
// include the full host for attachment domains
-func NormalizeAttachment(attachment utils.Attachment) utils.Attachment {
+func NormalizeAttachment(cfg *config.Config, attachment utils.Attachment) utils.Attachment {
// don't try to modify geo type attachments which are just coordinates
if attachment.ContentType() == "geo" {
return attachment
@@ -393,9 +452,9 @@ func NormalizeAttachment(attachment utils.Attachment) utils.Attachment {
url := attachment.URL()
if !strings.HasPrefix(url, "http") {
if strings.HasPrefix(url, "/") {
- url = fmt.Sprintf("https://%s%s", config.Mailroom.AttachmentDomain, url)
+ url = fmt.Sprintf("https://%s%s", cfg.AttachmentDomain, url)
} else {
- url = fmt.Sprintf("https://%s/%s", config.Mailroom.AttachmentDomain, url)
+ url = fmt.Sprintf("https://%s/%s", cfg.AttachmentDomain, url)
}
}
return utils.Attachment(fmt.Sprintf("%s:%s", attachment.ContentType(), url))
@@ -424,10 +483,10 @@ func InsertMessages(ctx context.Context, tx Queryer, msgs []*Msg) error {
const insertMsgSQL = `
INSERT INTO
-msgs_msg(uuid, text, high_priority, created_on, modified_on, queued_on, direction, status, attachments, metadata,
+msgs_msg(uuid, text, high_priority, created_on, modified_on, queued_on, sent_on, direction, status, attachments, metadata,
visibility, msg_type, msg_count, error_count, next_attempt, channel_id, connection_id, response_to_id,
contact_id, contact_urn_id, org_id, topup_id, broadcast_id)
- VALUES(:uuid, :text, :high_priority, :created_on, now(), now(), :direction, :status, :attachments, :metadata,
+ VALUES(:uuid, :text, :high_priority, :created_on, now(), now(), :sent_on, :direction, :status, :attachments, :metadata,
:visibility, :msg_type, :msg_count, :error_count, :next_attempt, :channel_id, :connection_id, :response_to_id,
:contact_id, :contact_urn_id, :org_id, :topup_id, :broadcast_id)
RETURNING
@@ -517,17 +576,19 @@ type Broadcast struct {
GroupIDs []GroupID `json:"group_ids,omitempty"`
OrgID OrgID `json:"org_id" db:"org_id"`
ParentID BroadcastID `json:"parent_id,omitempty" db:"parent_id"`
+ TicketID TicketID `json:"ticket_id,omitempty" db:"ticket_id"`
}
}
-func (b *Broadcast) BroadcastID() BroadcastID { return b.b.BroadcastID }
+func (b *Broadcast) ID() BroadcastID { return b.b.BroadcastID }
+func (b *Broadcast) OrgID() OrgID { return b.b.OrgID }
func (b *Broadcast) ContactIDs() []ContactID { return b.b.ContactIDs }
func (b *Broadcast) GroupIDs() []GroupID { return b.b.GroupIDs }
func (b *Broadcast) URNs() []urns.URN { return b.b.URNs }
-func (b *Broadcast) OrgID() OrgID { return b.b.OrgID }
func (b *Broadcast) BaseLanguage() envs.Language { return b.b.BaseLanguage }
func (b *Broadcast) Translations() map[envs.Language]*BroadcastTranslation { return b.b.Translations }
func (b *Broadcast) TemplateState() TemplateState { return b.b.TemplateState }
+func (b *Broadcast) TicketID() TicketID { return b.b.TicketID }
func (b *Broadcast) MarshalJSON() ([]byte, error) { return json.Marshal(b.b) }
func (b *Broadcast) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &b.b) }
@@ -535,7 +596,7 @@ func (b *Broadcast) UnmarshalJSON(data []byte) error { return json.Unmarshal(dat
// NewBroadcast creates a new broadcast with the passed in parameters
func NewBroadcast(
orgID OrgID, id BroadcastID, translations map[envs.Language]*BroadcastTranslation,
- state TemplateState, baseLanguage envs.Language, urns []urns.URN, contactIDs []ContactID, groupIDs []GroupID) *Broadcast {
+ state TemplateState, baseLanguage envs.Language, urns []urns.URN, contactIDs []ContactID, groupIDs []GroupID, ticketID TicketID) *Broadcast {
bcast := &Broadcast{}
bcast.b.OrgID = orgID
@@ -546,6 +607,7 @@ func NewBroadcast(
bcast.b.URNs = urns
bcast.b.ContactIDs = contactIDs
bcast.b.GroupIDs = groupIDs
+ bcast.b.TicketID = ticketID
return bcast
}
@@ -561,9 +623,10 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (*
parent.b.URNs,
parent.b.ContactIDs,
parent.b.GroupIDs,
+ parent.b.TicketID,
)
// populate our parent id
- child.b.ParentID = parent.BroadcastID()
+ child.b.ParentID = parent.ID()
// populate text from our translations
child.b.Text.Map = make(map[string]sql.NullString)
@@ -577,14 +640,14 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (*
// insert our broadcast
err := BulkQuery(ctx, "inserting broadcast", db, insertBroadcastSQL, []interface{}{&child.b})
if err != nil {
- return nil, errors.Wrapf(err, "error inserting child broadcast for broadcast: %d", parent.BroadcastID())
+ return nil, errors.Wrapf(err, "error inserting child broadcast for broadcast: %d", parent.ID())
}
// build up all our contact associations
contacts := make([]interface{}, 0, len(child.b.ContactIDs))
for _, contactID := range child.b.ContactIDs {
contacts = append(contacts, &broadcastContact{
- BroadcastID: child.BroadcastID(),
+ BroadcastID: child.ID(),
ContactID: contactID,
})
}
@@ -599,7 +662,7 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (*
groups := make([]interface{}, 0, len(child.b.GroupIDs))
for _, groupID := range child.b.GroupIDs {
groups = append(groups, &broadcastGroup{
- BroadcastID: child.BroadcastID(),
+ BroadcastID: child.ID(),
GroupID: groupID,
})
}
@@ -618,7 +681,7 @@ func InsertChildBroadcast(ctx context.Context, db Queryer, parent *Broadcast) (*
return nil, errors.Errorf("attempt to insert new broadcast with URNs that do not have id: %s", urn)
}
urns = append(urns, &broadcastURN{
- BroadcastID: child.BroadcastID(),
+ BroadcastID: child.ID(),
URNID: urnID,
})
}
@@ -649,8 +712,8 @@ type broadcastGroup struct {
const insertBroadcastSQL = `
INSERT INTO
- msgs_broadcast( org_id, parent_id, is_active, created_on, modified_on, status, text, base_language, send_all)
- VALUES(:org_id, :parent_id, TRUE, NOW() , NOW(), 'Q', :text, :base_language, FALSE)
+ msgs_broadcast( org_id, parent_id, ticket_id, created_on, modified_on, status, text, base_language, send_all)
+ VALUES(:org_id, :parent_id, :ticket_id, NOW() , NOW(), 'Q', :text, :base_language, FALSE)
RETURNING
id
`
@@ -700,7 +763,7 @@ func NewBroadcastFromEvent(ctx context.Context, tx Queryer, org *OrgAssets, even
}
}
- return NewBroadcast(org.OrgID(), NilBroadcastID, translations, TemplateStateEvaluated, event.BaseLanguage, event.URNs, contactIDs, groupIDs), nil
+ return NewBroadcast(org.OrgID(), NilBroadcastID, translations, TemplateStateEvaluated, event.BaseLanguage, event.URNs, contactIDs, groupIDs, NilTicketID), nil
}
func (b *Broadcast) CreateBatch(contactIDs []ContactID) *BroadcastBatch {
@@ -710,6 +773,7 @@ func (b *Broadcast) CreateBatch(contactIDs []ContactID) *BroadcastBatch {
batch.b.Translations = b.b.Translations
batch.b.TemplateState = b.b.TemplateState
batch.b.OrgID = b.b.OrgID
+ batch.b.TicketID = b.b.TicketID
batch.b.ContactIDs = contactIDs
return batch
}
@@ -725,6 +789,7 @@ type BroadcastBatch struct {
ContactIDs []ContactID `json:"contact_ids,omitempty"`
IsLast bool `json:"is_last"`
OrgID OrgID `json:"org_id"`
+ TicketID TicketID `json:"ticket_id"`
}
}
@@ -733,6 +798,7 @@ func (b *BroadcastBatch) ContactIDs() []ContactID { return b.b.Conta
func (b *BroadcastBatch) URNs() map[ContactID]urns.URN { return b.b.URNs }
func (b *BroadcastBatch) SetURNs(urns map[ContactID]urns.URN) { b.b.URNs = urns }
func (b *BroadcastBatch) OrgID() OrgID { return b.b.OrgID }
+func (b *BroadcastBatch) TicketID() TicketID { return b.b.TicketID }
func (b *BroadcastBatch) Translations() map[envs.Language]*BroadcastTranslation {
return b.b.Translations
}
@@ -759,10 +825,8 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa
repeatedContacts[id] = true
}
}
- }
- // if we have URN we need to send to, add those contacts as well if not already repeated
- if broadcastURNs != nil {
+ // if we have URN we need to send to, add those contacts as well if not already repeated
for id := range broadcastURNs {
if !repeatedContacts[id] {
contactIDs = append(contactIDs, id)
@@ -946,9 +1010,91 @@ func CreateBroadcastMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa
return nil, errors.Wrapf(err, "error inserting broadcast messages")
}
+ // if the broadcast was a ticket reply, update the ticket
+ if bcast.TicketID() != NilTicketID {
+ err = updateTicketLastActivity(ctx, db, []TicketID{bcast.TicketID()}, dates.Now())
+ if err != nil {
+ return nil, errors.Wrapf(err, "error updating broadcast ticket")
+ }
+ }
+
return msgs, nil
}
+const updateMsgForResendingSQL = `
+ UPDATE
+ msgs_msg m
+ SET
+ channel_id = r.channel_id::int,
+ topup_id = r.topup_id::int,
+ status = 'P',
+ error_count = 0,
+ queued_on = r.queued_on::timestamp with time zone,
+ sent_on = NULL,
+ modified_on = NOW()
+ FROM (
+ VALUES(:id, :channel_id, :topup_id, :queued_on)
+ ) AS
+ r(id, channel_id, topup_id, queued_on)
+ WHERE
+ m.id = r.id::bigint
+`
+
+// ResendMessages prepares messages for resending by reselecting a channel and marking them as PENDING
+func ResendMessages(ctx context.Context, db Queryer, rp *redis.Pool, oa *OrgAssets, msgs []*Msg) error {
+ channels := oa.SessionAssets().Channels()
+ resends := make([]interface{}, len(msgs))
+
+ for i, msg := range msgs {
+ // reselect channel for this message's URN
+ urn, err := URNForID(ctx, db, oa, *msg.ContactURNID())
+ if err != nil {
+ return errors.Wrap(err, "error loading URN")
+ }
+ msg.m.URN = urn // needs to be set for queueing to courier
+
+ contactURN, err := flows.ParseRawURN(channels, urn, assets.IgnoreMissing)
+ if err != nil {
+ return errors.Wrap(err, "error parsing URN")
+ }
+
+ ch := channels.GetForURN(contactURN, assets.ChannelRoleSend)
+ if ch != nil {
+ channel := oa.ChannelByUUID(ch.UUID())
+ msg.m.ChannelID = channel.ID()
+ msg.m.ChannelUUID = channel.UUID()
+ msg.channel = channel
+ } else {
+ msg.m.ChannelID = NilChannelID
+ msg.m.ChannelUUID = assets.ChannelUUID("")
+ msg.channel = nil
+ }
+
+ // allocate a new topup for this message if org uses topups
+ msg.m.TopupID, err = AllocateTopups(ctx, db, rp, oa.Org(), 1)
+ if err != nil {
+ return errors.Wrapf(err, "error allocating topup for message resending")
+ }
+
+ // mark message as being a resend so it will be queued to courier as such
+ msg.m.Status = MsgStatusPending
+ msg.m.QueuedOn = dates.Now()
+ msg.m.SentOn = nil
+ msg.m.ErrorCount = 0
+ msg.m.IsResend = true
+
+ resends[i] = msg.m
+ }
+
+ // update the messages in the database
+ err := BulkQuery(ctx, "updating messages for resending", db, updateMsgForResendingSQL, resends)
+ if err != nil {
+ return errors.Wrapf(err, "error updating messages for resending")
+ }
+
+ return nil
+}
+
// MarkBroadcastSent marks the passed in broadcast as sent
func MarkBroadcastSent(ctx context.Context, db Queryer, id BroadcastID) error {
// noop if it is a nil id
diff --git a/core/models/msgs_test.go b/core/models/msgs_test.go
index 6f0478c51..af67c3e04 100644
--- a/core/models/msgs_test.go
+++ b/core/models/msgs_test.go
@@ -5,11 +5,12 @@ import (
"testing"
"time"
+ "github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/goflow/envs"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
- "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
@@ -19,8 +20,7 @@ import (
)
func TestOutgoingMsgs(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
tcs := []struct {
ChannelUUID assets.ChannelUUID
@@ -41,7 +41,7 @@ func TestOutgoingMsgs(t *testing.T) {
{
ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8",
Text: "missing urn id",
- ContactID: models.CathyID,
+ ContactID: testdata.Cathy.ID,
URN: urns.URN("tel:+250700000001"),
URNID: models.URNID(0),
ExpectedStatus: models.MsgStatusQueued,
@@ -52,9 +52,9 @@ func TestOutgoingMsgs(t *testing.T) {
{
ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8",
Text: "test outgoing",
- ContactID: models.CathyID,
- URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)),
- URNID: models.CathyURNID,
+ ContactID: testdata.Cathy.ID,
+ URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)),
+ URNID: testdata.Cathy.URNID,
QuickReplies: []string{"yes", "no"},
Topic: flows.MsgTopicPurchase,
ExpectedStatus: models.MsgStatusQueued,
@@ -67,9 +67,9 @@ func TestOutgoingMsgs(t *testing.T) {
{
ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8",
Text: "test outgoing",
- ContactID: models.CathyID,
- URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)),
- URNID: models.CathyURNID,
+ ContactID: testdata.Cathy.ID,
+ URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)),
+ URNID: testdata.Cathy.URNID,
Attachments: []utils.Attachment{utils.Attachment("image/jpeg:https://dl-foo.com/image.jpg")},
ExpectedStatus: models.MsgStatusQueued,
ExpectedMetadata: map[string]interface{}{},
@@ -78,9 +78,9 @@ func TestOutgoingMsgs(t *testing.T) {
{
ChannelUUID: "74729f45-7f29-4868-9dc4-90e491e3c7d8",
Text: "suspended org",
- ContactID: models.CathyID,
- URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID)),
- URNID: models.CathyURNID,
+ ContactID: testdata.Cathy.ID,
+ URN: urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID)),
+ URNID: testdata.Cathy.URNID,
SuspendedOrg: true,
ExpectedStatus: models.MsgStatusFailed,
ExpectedMetadata: map[string]interface{}{},
@@ -94,9 +94,9 @@ func TestOutgoingMsgs(t *testing.T) {
tx, err := db.BeginTxx(ctx, nil)
require.NoError(t, err)
- db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, tc.SuspendedOrg, models.Org1)
+ db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, tc.SuspendedOrg, testdata.Org1.ID)
- oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg)
require.NoError(t, err)
channel := oa.ChannelByUUID(tc.ChannelUUID)
@@ -137,9 +137,9 @@ func TestOutgoingMsgs(t *testing.T) {
}
func TestGetMessageIDFromUUID(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- msgIn := testdata.InsertIncomingMsg(t, db, models.Org1, models.CathyID, models.CathyURN, models.CathyURNID, "hi there")
+ ctx, _, db, _ := testsuite.Get()
+
+ msgIn := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "hi there", models.MsgStatusHandled)
msgID, err := models.GetMessageIDFromUUID(ctx, db, msgIn.UUID())
@@ -147,9 +147,74 @@ func TestGetMessageIDFromUUID(t *testing.T) {
assert.Equal(t, models.MsgID(msgIn.ID()), msgID)
}
+func TestLoadMessages(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ msgIn1 := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "in 1", models.MsgStatusHandled)
+ msgOut1 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 1", []utils.Attachment{"image/jpeg:hi.jpg"}, models.MsgStatusSent)
+ msgOut2 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 2", nil, models.MsgStatusSent)
+ msgOut3 := testdata.InsertOutgoingMsg(db, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "out 3", nil, models.MsgStatusSent)
+ testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "hi 3", nil, models.MsgStatusSent)
+
+ ids := []models.MsgID{models.MsgID(msgIn1.ID()), models.MsgID(msgOut1.ID()), models.MsgID(msgOut2.ID()), models.MsgID(msgOut3.ID())}
+
+ msgs, err := models.LoadMessages(ctx, db, testdata.Org1.ID, models.DirectionOut, ids)
+
+ // should only return the outgoing messages for this org
+ require.NoError(t, err)
+ assert.Equal(t, 2, len(msgs))
+ assert.Equal(t, "out 1", msgs[0].Text())
+ assert.Equal(t, []utils.Attachment{"image/jpeg:hi.jpg"}, msgs[0].Attachments())
+ assert.Equal(t, "out 2", msgs[1].Text())
+
+ msgs, err = models.LoadMessages(ctx, db, testdata.Org1.ID, models.DirectionIn, ids)
+
+ // should only return the incoming message for this org
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(msgs))
+ assert.Equal(t, "in 1", msgs[0].Text())
+}
+
+func TestResendMessages(t *testing.T) {
+ ctx, _, db, rp := testsuite.Get()
+
+ defer testsuite.Reset()
+
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
+ require.NoError(t, err)
+
+ msgOut1 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 1", nil, models.MsgStatusFailed)
+ msgOut2 := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Bob, "out 2", nil, models.MsgStatusFailed)
+ testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "out 3", nil, models.MsgStatusFailed)
+
+ // give Bob's URN an affinity for the Vonage channel
+ db.MustExec(`UPDATE contacts_contacturn SET channel_id = $1 WHERE id = $2`, testdata.VonageChannel.ID, testdata.Bob.URNID)
+
+ msgs, err := models.LoadMessages(ctx, db, testdata.Org1.ID, models.DirectionOut, []models.MsgID{models.MsgID(msgOut1.ID()), models.MsgID(msgOut2.ID())})
+ require.NoError(t, err)
+
+ now := dates.Now()
+
+ // resend both msgs
+ err = models.ResendMessages(ctx, db, rp, oa, msgs)
+ require.NoError(t, err)
+
+ // both messages should now have a channel, a topup and be marked for resending
+ assert.True(t, msgs[0].IsResend())
+ assert.Equal(t, testdata.TwilioChannel.ID, msgs[0].ChannelID())
+ assert.Equal(t, models.TopupID(1), msgs[0].TopupID())
+ assert.True(t, msgs[1].IsResend())
+ assert.Equal(t, testdata.VonageChannel.ID, msgs[1].ChannelID())
+ assert.Equal(t, models.TopupID(1), msgs[1].TopupID())
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P' AND queued_on > $1 AND sent_on IS NULL`, now).Returns(2)
+}
+
func TestNormalizeAttachment(t *testing.T) {
- config.Mailroom.AttachmentDomain = "foo.bar.com"
- defer func() { config.Mailroom.AttachmentDomain = "" }()
+ _, rt, _, _ := testsuite.Get()
+
+ rt.Config.AttachmentDomain = "foo.bar.com"
+ defer func() { rt.Config.AttachmentDomain = "" }()
tcs := []struct {
raw string
@@ -163,23 +228,23 @@ func TestNormalizeAttachment(t *testing.T) {
}
for _, tc := range tcs {
- assert.Equal(t, tc.normalized, string(models.NormalizeAttachment(utils.Attachment(tc.raw))))
+ assert.Equal(t, tc.normalized, string(models.NormalizeAttachment(rt.Config, utils.Attachment(tc.raw))))
}
}
func TestMarkMessages(t *testing.T) {
- ctx, db, _ := testsuite.Reset()
+ ctx, _, db, _ := testsuite.Reset()
defer testsuite.Reset()
- oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg)
require.NoError(t, err)
- channel := oa.ChannelByUUID(models.TwilioChannelUUID)
+ channel := oa.ChannelByUUID(testdata.TwilioChannel.UUID)
insertMsg := func(text string) *models.Msg {
- urn := urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", models.CathyURNID))
+ urn := urns.URN(fmt.Sprintf("tel:+250700000001?id=%d", testdata.Cathy.URNID))
flowMsg := flows.NewMsgOut(urn, channel.ChannelReference(), text, nil, nil, nil, flows.NilMsgTopic)
- msg, err := models.NewOutgoingMsg(oa.Org(), channel, models.CathyID, flowMsg, time.Now())
+ msg, err := models.NewOutgoingMsg(oa.Org(), channel, testdata.Cathy.ID, flowMsg, time.Now())
require.NoError(t, err)
err = models.InsertMessages(ctx, db, []*models.Msg{msg})
@@ -194,7 +259,7 @@ func TestMarkMessages(t *testing.T) {
models.MarkMessagesPending(ctx, db, []*models.Msg{msg1, msg2})
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`, nil, 2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`).Returns(2)
// try running on database with BIGINT message ids
db.MustExec(`ALTER TABLE "msgs_msg" ALTER COLUMN "id" TYPE bigint USING "id"::bigint;`)
@@ -208,5 +273,92 @@ func TestMarkMessages(t *testing.T) {
err = models.MarkMessagesPending(ctx, db, []*models.Msg{msg4})
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`, nil, 3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`).Returns(3)
+}
+
+func TestNonPersistentBroadcasts(t *testing.T) {
+ ctx, _, db, rp := testsuite.Reset()
+
+ defer func() {
+ db.MustExec(`DELETE FROM msgs_msg`)
+ db.MustExec(`DELETE FROM tickets_ticket`)
+ }()
+
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Bob, testdata.Mailgun, "Problem", "", "", nil)
+ modelTicket := ticket.Load(db)
+
+ translations := map[envs.Language]*models.BroadcastTranslation{envs.Language("eng"): {Text: "Hi there"}}
+
+ // create a broadcast which doesn't actually exist in the DB
+ bcast := models.NewBroadcast(
+ testdata.Org1.ID,
+ models.NilBroadcastID,
+ translations,
+ models.TemplateStateUnevaluated,
+ envs.Language("eng"),
+ []urns.URN{"tel:+593979012345"},
+ []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID, testdata.Cathy.ID},
+ []models.GroupID{testdata.DoctorsGroup.ID},
+ ticket.ID,
+ )
+
+ assert.Equal(t, models.NilBroadcastID, bcast.ID())
+ assert.Equal(t, testdata.Org1.ID, bcast.OrgID())
+ assert.Equal(t, envs.Language("eng"), bcast.BaseLanguage())
+ assert.Equal(t, translations, bcast.Translations())
+ assert.Equal(t, models.TemplateStateUnevaluated, bcast.TemplateState())
+ assert.Equal(t, ticket.ID, bcast.TicketID())
+ assert.Equal(t, []urns.URN{"tel:+593979012345"}, bcast.URNs())
+ assert.Equal(t, []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID, testdata.Cathy.ID}, bcast.ContactIDs())
+ assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, bcast.GroupIDs())
+
+ batch := bcast.CreateBatch([]models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID})
+
+ assert.Equal(t, models.NilBroadcastID, batch.BroadcastID())
+ assert.Equal(t, testdata.Org1.ID, batch.OrgID())
+ assert.Equal(t, envs.Language("eng"), batch.BaseLanguage())
+ assert.Equal(t, translations, batch.Translations())
+ assert.Equal(t, models.TemplateStateUnevaluated, batch.TemplateState())
+ assert.Equal(t, ticket.ID, batch.TicketID())
+ assert.Equal(t, []models.ContactID{testdata.Alexandria.ID, testdata.Bob.ID}, batch.ContactIDs())
+
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
+ require.NoError(t, err)
+
+ msgs, err := models.CreateBroadcastMessages(ctx, db, rp, oa, batch)
+ require.NoError(t, err)
+
+ assert.Equal(t, 2, len(msgs))
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE direction = 'O' AND broadcast_id IS NULL AND text = 'Hi there'`).Returns(2)
+
+ // test ticket was updated
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND last_activity_on > $2`, ticket.ID, modelTicket.LastActivityOn()).Returns(1)
+}
+
+func TestNewOutgoingIVR(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
+ require.NoError(t, err)
+
+ vonage := oa.ChannelByUUID(testdata.VonageChannel.UUID)
+ conn, err := models.InsertIVRConnection(ctx, db, testdata.Org1.ID, testdata.VonageChannel.ID, models.NilStartID, testdata.Cathy.ID, testdata.Cathy.URNID, models.ConnectionDirectionOut, models.ConnectionStatusInProgress, "")
+ require.NoError(t, err)
+
+ createdOn := time.Date(2021, 7, 26, 12, 6, 30, 0, time.UTC)
+
+ flowMsg := flows.NewMsgOut(testdata.Cathy.URN, vonage.ChannelReference(), "Hello", []utils.Attachment{"audio/mp3:http://example.com/hi.mp3"}, nil, nil, flows.NilMsgTopic)
+ dbMsg := models.NewOutgoingIVR(testdata.Org1.ID, conn, flowMsg, createdOn)
+
+ assert.Equal(t, flowMsg.UUID(), dbMsg.UUID())
+ assert.Equal(t, "Hello", dbMsg.Text())
+ assert.Equal(t, []utils.Attachment{"audio/mp3:http://example.com/hi.mp3"}, dbMsg.Attachments())
+ assert.Equal(t, createdOn, dbMsg.CreatedOn())
+ assert.Equal(t, &createdOn, dbMsg.SentOn())
+
+ err = models.InsertMessages(ctx, db, []*models.Msg{dbMsg})
+ require.NoError(t, err)
+
+ testsuite.AssertQuery(t, db, `SELECT text, created_on, sent_on FROM msgs_msg WHERE uuid = $1`, dbMsg.UUID()).Columns(map[string]interface{}{"text": "Hello", "created_on": createdOn, "sent_on": createdOn})
}
diff --git a/core/models/orgs.go b/core/models/orgs.go
index 8537c7d0f..29f4389ca 100644
--- a/core/models/orgs.go
+++ b/core/models/orgs.go
@@ -55,19 +55,22 @@ func init() {
// OrgID is our type for orgs ids
type OrgID int
-// UserID is our type for user ids used by modified_by, which can be null
-type UserID null.Int
+// SessionStorageMode is our type for how we persist our sessions
+type SessionStorageMode string
const (
// NilOrgID is the id 0 considered as nil org id
NilOrgID = OrgID(0)
- // NilUserID si the id 0 considered as nil user id
- NilUserID = UserID(0)
-
configSMTPServer = "smtp_server"
configDTOneKey = "dtone_key"
configDTOneSecret = "dtone_secret"
+
+ configSessionStorageMode = "session_storage_mode"
+
+ DBSessions = SessionStorageMode("db")
+ S3Sessions = SessionStorageMode("s3")
+ S3WriteSessions = SessionStorageMode("s3_write")
)
// Org is mailroom's type for RapidPro orgs. It also implements the envs.Environment interface for GoFlow
@@ -90,6 +93,10 @@ func (o *Org) Suspended() bool { return o.o.Suspended }
// UsesTopups returns whether the org uses topups
func (o *Org) UsesTopups() bool { return o.o.UsesTopups }
+func (o *Org) SessionStorageMode() SessionStorageMode {
+ return SessionStorageMode(o.ConfigValue(configSessionStorageMode, string(DBSessions)))
+}
+
// DateFormat returns the date format for this org
func (o *Org) DateFormat() envs.DateFormat { return o.env.DateFormat() }
@@ -175,7 +182,7 @@ func (o *Org) AirtimeService(httpClient *http.Client, httpRetries *httpx.RetryCo
}
// StoreAttachment saves an attachment to storage
-func (o *Org) StoreAttachment(s storage.Storage, filename string, contentType string, content io.ReadCloser) (utils.Attachment, error) {
+func (o *Org) StoreAttachment(ctx context.Context, s storage.Storage, filename string, contentType string, content io.ReadCloser) (utils.Attachment, error) {
prefix := config.Mailroom.S3MediaPrefix
// read the content
@@ -192,7 +199,7 @@ func (o *Org) StoreAttachment(s storage.Storage, filename string, contentType st
path := o.attachmentPath(prefix, filename)
- url, err := s.Put(path, contentType, contentBytes)
+ url, err := s.Put(ctx, path, contentType, contentBytes)
if err != nil {
return "", errors.Wrapf(err, "unable to store attachment content")
}
@@ -229,12 +236,12 @@ func orgFromSession(session flows.Session) *Org {
return session.Assets().Source().(*OrgAssets).Org()
}
-// loadOrg loads the org for the passed in id, returning any error encountered
-func loadOrg(ctx context.Context, db sqlx.Queryer, orgID OrgID) (*Org, error) {
+// LoadOrg loads the org for the passed in id, returning any error encountered
+func LoadOrg(ctx context.Context, cfg *config.Config, db sqlx.Queryer, orgID OrgID) (*Org, error) {
start := time.Now()
org := &Org{}
- rows, err := db.Queryx(selectOrgByID, orgID, config.Mailroom.MaxValueLength)
+ rows, err := db.Queryx(selectOrgByID, orgID, cfg.MaxValueLength)
if err != nil {
return nil, errors.Wrapf(err, "error loading org: %d", orgID)
}
@@ -264,8 +271,7 @@ SELECT ROW_TO_JSON(o) FROM (SELECT
timezone,
(SELECT CASE is_anon WHEN TRUE THEN 'urns' WHEN FALSE THEN 'none' END) AS redaction_policy,
$2::int AS max_value_length,
- (SELECT iso_code FROM orgs_language WHERE id = o.primary_language_id) AS default_language,
- (SELECT ARRAY_AGG(iso_code ORDER BY iso_code ASC) FROM orgs_language WHERE org_id = o.id) AS allowed_languages,
+ flow_languages AS allowed_languages,
COALESCE(
(
SELECT
diff --git a/core/models/orgs_test.go b/core/models/orgs_test.go
index 72427a066..08e1e9ec7 100644
--- a/core/models/orgs_test.go
+++ b/core/models/orgs_test.go
@@ -1,39 +1,40 @@
-package models
+package models_test
import (
+ "context"
"os"
"testing"
"time"
"github.com/nyaruka/goflow/envs"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOrgs(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, rt, db, _ := testsuite.Get()
+
+ tz, _ := time.LoadLocation("America/Los_Angeles")
tx, err := db.BeginTxx(ctx, nil)
assert.NoError(t, err)
defer tx.Rollback()
- tx.MustExec("UPDATE channels_channel SET country = 'FR' WHERE id = $1;", TwitterChannelID)
- tx.MustExec("UPDATE channels_channel SET country = 'US' WHERE id IN ($1,$2);", TwilioChannelID, VonageChannelID)
- tx.MustExec(`INSERT INTO orgs_language(is_active, created_on, modified_on, name, iso_code, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, NOW(), NOW(), 'French', 'fra', 1, 1, 2);`)
- tx.MustExec(`INSERT INTO orgs_language(is_active, created_on, modified_on, name, iso_code, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, NOW(), NOW(), 'English', 'eng', 1, 1, 2);`)
+ tx.MustExec("UPDATE channels_channel SET country = 'FR' WHERE id = $1;", testdata.TwitterChannel.ID)
+ tx.MustExec("UPDATE channels_channel SET country = 'US' WHERE id IN ($1,$2);", testdata.TwilioChannel.ID, testdata.VonageChannel.ID)
- tx.MustExec("UPDATE orgs_org SET primary_language_id = 2 WHERE id = 2;")
+ tx.MustExec(`UPDATE orgs_org SET flow_languages = '{"fra", "eng"}' WHERE id = $1`, testdata.Org1.ID)
+ tx.MustExec(`UPDATE orgs_org SET flow_languages = '{}' WHERE id = $1`, testdata.Org2.ID)
- org, err := loadOrg(ctx, tx, 1)
+ org, err := models.LoadOrg(ctx, rt.Config, tx, testdata.Org1.ID)
assert.NoError(t, err)
- assert.Equal(t, OrgID(1), org.ID())
+ assert.Equal(t, models.OrgID(1), org.ID())
assert.False(t, org.Suspended())
assert.True(t, org.UsesTopups())
assert.Equal(t, envs.DateFormatDayMonthYear, org.DateFormat())
@@ -41,41 +42,39 @@ func TestOrgs(t *testing.T) {
assert.Equal(t, envs.RedactionPolicyNone, org.RedactionPolicy())
assert.Equal(t, 640, org.MaxValueLength())
assert.Equal(t, string(envs.Country("US")), string(org.DefaultCountry()))
- tz, _ := time.LoadLocation("America/Los_Angeles")
assert.Equal(t, tz, org.Timezone())
- assert.Equal(t, 0, len(org.AllowedLanguages()))
- assert.Equal(t, envs.Language(""), org.DefaultLanguage())
- assert.Equal(t, "", org.DefaultLocale().ToISO639_2())
+ assert.Equal(t, []envs.Language{"fra", "eng"}, org.AllowedLanguages())
+ assert.Equal(t, envs.Language("fra"), org.DefaultLanguage())
+ assert.Equal(t, "fr-US", org.DefaultLocale().ToBCP47())
- org, err = loadOrg(ctx, tx, 2)
+ org, err = models.LoadOrg(ctx, rt.Config, tx, testdata.Org2.ID)
assert.NoError(t, err)
- assert.Equal(t, []envs.Language{"eng", "fra"}, org.AllowedLanguages())
- assert.Equal(t, envs.Language("eng"), org.DefaultLanguage())
- assert.Equal(t, "en", org.DefaultLocale().ToISO639_2())
+ assert.Equal(t, []envs.Language{}, org.AllowedLanguages())
+ assert.Equal(t, envs.NilLanguage, org.DefaultLanguage())
+ assert.Equal(t, "", org.DefaultLocale().ToBCP47())
- _, err = loadOrg(ctx, tx, 99)
+ _, err = models.LoadOrg(ctx, rt.Config, tx, 99)
assert.Error(t, err)
}
func TestStoreAttachment(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, rt, db, _ := testsuite.Get()
- store := testsuite.Storage()
+ store := testsuite.MediaStorage()
defer testsuite.ResetStorage()
image, err := os.Open("testdata/test.jpg")
require.NoError(t, err)
- org, err := loadOrg(ctx, db, Org1)
+ org, err := models.LoadOrg(ctx, rt.Config, db, testdata.Org1.ID)
assert.NoError(t, err)
- attachment, err := org.StoreAttachment(store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image)
+ attachment, err := org.StoreAttachment(context.Background(), store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image)
require.NoError(t, err)
- assert.Equal(t, utils.Attachment("image/jpeg:_test_storage/media/1/6683/83ba/668383ba-387c-49bc-b164-1213ac0ea7aa.jpg"), attachment)
+ assert.Equal(t, utils.Attachment("image/jpeg:_test_media_storage/media/1/6683/83ba/668383ba-387c-49bc-b164-1213ac0ea7aa.jpg"), attachment)
// err trying to read from same reader again
- _, err = org.StoreAttachment(store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image)
+ _, err = org.StoreAttachment(context.Background(), store, "668383ba-387c-49bc-b164-1213ac0ea7aa.jpg", "image/jpeg", image)
assert.EqualError(t, err, "unable to read attachment content: read testdata/test.jpg: file already closed")
}
diff --git a/core/models/resthooks_test.go b/core/models/resthooks_test.go
index 05809763c..435ed4647 100644
--- a/core/models/resthooks_test.go
+++ b/core/models/resthooks_test.go
@@ -1,44 +1,45 @@
-package models
+package models_test
import (
"testing"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestResthooks(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- tx, err := db.BeginTxx(ctx, nil)
- assert.NoError(t, err)
- defer tx.Rollback()
-
- tx.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id)
+ db.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id)
VALUES(TRUE, NOW(), NOW(), 'registration', 1, 1, 1);`)
- tx.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id)
+ db.MustExec(`INSERT INTO api_resthook(is_active, created_on, modified_on, slug, created_by_id, modified_by_id, org_id)
VALUES(TRUE, NOW(), NOW(), 'block', 1, 1, 1);`)
- tx.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id)
+ db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id)
VALUES(TRUE, NOW(), NOW(), 'https://foo.bar', 1, 1, 2);`)
- tx.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id)
+ db.MustExec(`INSERT INTO api_resthooksubscriber(is_active, created_on, modified_on, target_url, created_by_id, modified_by_id, resthook_id)
VALUES(TRUE, NOW(), NOW(), 'https://bar.foo', 1, 1, 2);`)
- resthooks, err := loadResthooks(ctx, tx, 1)
- assert.NoError(t, err)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshResthooks)
+ require.NoError(t, err)
+
+ resthooks, err := oa.Resthooks()
+ require.NoError(t, err)
tcs := []struct {
- ID ResthookID
+ ID models.ResthookID
Slug string
Subscribers []string
}{
- {ResthookID(2), "block", []string{"https://bar.foo", "https://foo.bar"}},
- {ResthookID(1), "registration", nil},
+ {models.ResthookID(2), "block", []string{"https://bar.foo", "https://foo.bar"}},
+ {models.ResthookID(1), "registration", nil},
}
assert.Equal(t, 2, len(resthooks))
for i, tc := range tcs {
- resthook := resthooks[i].(*Resthook)
+ resthook := resthooks[i].(*models.Resthook)
assert.Equal(t, tc.ID, resthook.ID())
assert.Equal(t, tc.Slug, resthook.Slug())
assert.Equal(t, tc.Subscribers, resthook.Subscribers())
diff --git a/core/models/runs.go b/core/models/runs.go
index 4da57c980..f4b327387 100644
--- a/core/models/runs.go
+++ b/core/models/runs.go
@@ -6,13 +6,18 @@ import (
"database/sql"
"encoding/json"
"fmt"
+ "net/url"
+ "path"
"time"
+ "github.com/aws/aws-sdk-go/service/s3"
+ "github.com/nyaruka/gocommon/storage"
"github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/envs"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/events"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/null"
@@ -101,7 +106,8 @@ type Session struct {
SessionType FlowType `db:"session_type"`
Status SessionStatus `db:"status"`
Responded bool `db:"responded"`
- Output string `db:"output"`
+ Output null.String `db:"output"`
+ OutputURL null.String `db:"output_url"`
ContactID ContactID `db:"contact_id"`
OrgID OrgID `db:"org_id"`
CreatedOn time.Time `db:"created_on"`
@@ -141,7 +147,8 @@ func (s *Session) UUID() flows.SessionUUID { return flows.SessionUUID
func (s *Session) SessionType() FlowType { return s.s.SessionType }
func (s *Session) Status() SessionStatus { return s.s.Status }
func (s *Session) Responded() bool { return s.s.Responded }
-func (s *Session) Output() string { return s.s.Output }
+func (s *Session) Output() string { return string(s.s.Output) }
+func (s *Session) OutputURL() string { return string(s.s.OutputURL) }
func (s *Session) ContactID() ContactID { return s.s.ContactID }
func (s *Session) OrgID() OrgID { return s.s.OrgID }
func (s *Session) CreatedOn() time.Time { return s.s.CreatedOn }
@@ -155,6 +162,53 @@ func (s *Session) IncomingMsgID() MsgID { return s.incomingMsgID }
func (s *Session) IncomingMsgExternalID() null.String { return s.incomingExternalID }
func (s *Session) Scene() *Scene { return s.scene }
+// WriteSessionsToStorage writes the outputs of the passed in sessions to our storage (S3), updating the
+// output_url for each on success. Failure of any will cause all to fail.
+func WriteSessionOutputsToStorage(ctx context.Context, st storage.Storage, sessions []*Session) error {
+ start := time.Now()
+
+ uploads := make([]*storage.Upload, len(sessions))
+ for i, s := range sessions {
+ uploads[i] = &storage.Upload{
+ Path: s.StoragePath(config.Mailroom),
+ Body: []byte(s.Output()),
+ ContentType: "application/json",
+ ACL: s3.ObjectCannedACLPrivate,
+ }
+ }
+
+ err := st.BatchPut(ctx, uploads)
+ if err != nil {
+ return errors.Wrapf(err, "error writing sessions to storage")
+ }
+
+ for i, s := range sessions {
+ s.s.OutputURL = null.String(uploads[i].URL)
+ }
+
+ logrus.WithField("elapsed", time.Since(start)).WithField("count", len(sessions)).Debug("wrote sessions to s3")
+
+ return nil
+}
+
+const storageTSFormat = "20060102T150405.999Z"
+
+// StoragePath returns the path for the session
+func (s *Session) StoragePath(cfg *config.Config) string {
+ ts := s.CreatedOn().UTC().Format(storageTSFormat)
+
+ // example output: /orgs/1/c/20a5/20a5534c-b2ad-4f18-973a-f1aa3b4e6c74/session_20060102T150405.123Z_8a7fc501-177b-4567-a0aa-81c48e6de1c5_51df83ac21d3cf136d8341f0b11cb1a7.json"
+ return path.Join(
+ cfg.S3SessionPrefix,
+ "orgs",
+ fmt.Sprintf("%d", s.OrgID()),
+ "c",
+ string(s.ContactUUID()[:4]),
+ string(s.ContactUUID()),
+ fmt.Sprintf("%s_session_%s_%s.json", ts, s.UUID(), s.OutputMD5()),
+ )
+}
+
// ContactUUID returns the UUID of our contact
func (s *Session) ContactUUID() flows.ContactUUID {
return s.contact.UUID()
@@ -320,7 +374,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi
s.Status = sessionStatus
s.SessionType = sessionType
s.Responded = false
- s.Output = string(output)
+ s.Output = null.String(output)
s.ContactID = ContactID(fs.Contact().ID())
s.OrgID = org.OrgID()
s.CreatedOn = fs.Runs()[0].CreatedOn()
@@ -343,7 +397,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi
// if this run is waiting, save it as the current flow
if r.Status() == flows.RunStatusWaiting {
- flowID, err := flowIDForUUID(ctx, tx, org, r.FlowReference().UUID)
+ flowID, err := FlowIDForUUID(ctx, tx, org, r.FlowReference().UUID)
if err != nil {
return nil, errors.Wrapf(err, "error loading current flow for UUID: %s", r.FlowReference().UUID)
}
@@ -358,7 +412,7 @@ func NewSession(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, fs flows.Sessi
}
// ActiveSessionForContact returns the active session for the passed in contact, if any
-func ActiveSessionForContact(ctx context.Context, db *sqlx.DB, org *OrgAssets, sessionType FlowType, contact *flows.Contact) (*Session, error) {
+func ActiveSessionForContact(ctx context.Context, db *sqlx.DB, st storage.Storage, org *OrgAssets, sessionType FlowType, contact *flows.Contact) (*Session, error) {
rows, err := db.QueryxContext(ctx, selectLastSessionSQL, sessionType, contact.ID())
if err != nil {
return nil, errors.Wrapf(err, "error selecting active session")
@@ -380,6 +434,31 @@ func ActiveSessionForContact(ctx context.Context, db *sqlx.DB, org *OrgAssets, s
return nil, errors.Wrapf(err, "error scanning session")
}
+ // load our output if necessary
+ if org.Org().SessionStorageMode() == S3Sessions && session.OutputURL() != "" {
+ start := time.Now()
+
+ // strip just the path out of our output URL
+ u, err := url.Parse(session.OutputURL())
+ if err != nil {
+ return nil, errors.Wrapf(err, "error parsing output URL: %s", session.OutputURL())
+ }
+
+ // get our session from storage
+ _, output, err := st.Get(ctx, u.Path)
+ if err != nil {
+ logrus.WithField("output_url", session.OutputURL()).WithField("org_id", org.OrgID()).WithField("session_uuid", session.UUID()).WithError(err).Error("error reading in session output from storage")
+
+ // we'll throw an error up only if we don't have a DB backdown
+ if session.Output() == "" {
+ return nil, errors.Wrapf(err, "error reading session from storage: %s", session.OutputURL())
+ }
+ } else {
+ session.s.Output = null.String(output)
+ logrus.WithField("elapsed", time.Since(start)).WithField("output_url", session.OutputURL()).Debug("loaded session from storage")
+ }
+ }
+
return session, nil
}
@@ -391,6 +470,7 @@ SELECT
status,
responded,
output,
+ output_url,
contact_id,
org_id,
created_on,
@@ -412,21 +492,21 @@ LIMIT 1
const insertCompleteSessionSQL = `
INSERT INTO
- flows_flowsession( uuid, session_type, status, responded, output, contact_id, org_id, created_on, ended_on, wait_started_on, connection_id)
- VALUES(:uuid,:session_type,:status,:responded,:output,:contact_id,:org_id, NOW(), NOW(), NULL, :connection_id)
+ flows_flowsession( uuid, session_type, status, responded, output, output_url, contact_id, org_id, created_on, ended_on, wait_started_on, connection_id)
+ VALUES(:uuid,:session_type,:status,:responded,:output,:output_url,:contact_id,:org_id, NOW(), NOW(), NULL, :connection_id)
RETURNING id
`
const insertIncompleteSessionSQL = `
INSERT INTO
- flows_flowsession( uuid, session_type, status, responded, output, contact_id, org_id, created_on, current_flow_id, timeout_on, wait_started_on, connection_id)
- VALUES(:uuid,:session_type,:status,:responded,:output,:contact_id,:org_id, NOW(), :current_flow_id,:timeout_on,:wait_started_on,:connection_id)
+ flows_flowsession( uuid, session_type, status, responded, output, output_url, contact_id, org_id, created_on, current_flow_id, timeout_on, wait_started_on, connection_id)
+ VALUES(:uuid,:session_type,:status,:responded,:output,:output_url,:contact_id,:org_id, NOW(), :current_flow_id,:timeout_on,:wait_started_on,:connection_id)
RETURNING id
`
// FlowSession creates a flow session for the passed in session object. It also populates the runs we know about
func (s *Session) FlowSession(sa flows.SessionAssets, env envs.Environment) (flows.Session, error) {
- session, err := goflow.Engine().ReadSession(sa, json.RawMessage(s.s.Output), assets.IgnoreMissing)
+ session, err := goflow.Engine(config.Mailroom).ReadSession(sa, json.RawMessage(s.s.Output), assets.IgnoreMissing)
if err != nil {
return nil, errors.Wrapf(err, "unable to unmarshal session")
}
@@ -460,7 +540,7 @@ func (s *Session) calculateTimeout(fs flows.Session, sprint flows.Sprint) {
}
// WriteUpdatedSession updates the session based on the state passed in from our engine session, this also takes care of applying any event hooks
-func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *OrgAssets, fs flows.Session, sprint flows.Sprint, hook SessionCommitHook) error {
+func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, st storage.Storage, org *OrgAssets, fs flows.Session, sprint flows.Sprint, hook SessionCommitHook) error {
// make sure we have our seen runs
if s.seenRuns == nil {
return errors.Errorf("missing seen runs, cannot update session")
@@ -470,7 +550,7 @@ func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redi
if err != nil {
return errors.Wrapf(err, "error marshalling flow session")
}
- s.s.Output = string(output)
+ s.s.Output = null.String(output)
// map our status over
status, found := sessionStatusMap[fs.Status()]
@@ -501,7 +581,7 @@ func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redi
for _, r := range fs.Runs() {
// if this run is waiting, save it as the current flow
if r.Status() == flows.RunStatusWaiting {
- flowID, err := flowIDForUUID(ctx, tx, org, r.FlowReference().UUID)
+ flowID, err := FlowIDForUUID(ctx, tx, org, r.FlowReference().UUID)
if err != nil {
return errors.Wrapf(err, "error loading flow: %s", r.FlowReference().UUID)
}
@@ -528,6 +608,15 @@ func (s *Session) WriteUpdatedSession(ctx context.Context, tx *sqlx.Tx, rp *redi
}
}
+ // if writing to S3, do so
+ sessionMode := org.Org().SessionStorageMode()
+ if sessionMode == S3Sessions || sessionMode == S3WriteSessions {
+ err := WriteSessionOutputsToStorage(ctx, st, []*Session{s})
+ if err != nil {
+ logrus.WithError(err).Error("error writing session to s3")
+ }
+ }
+
// write our new session state to the db
_, err = tx.NamedExecContext(ctx, updateSessionSQL, s.s)
if err != nil {
@@ -603,6 +692,7 @@ UPDATE
flows_flowsession
SET
output = :output,
+ output_url = :output_url,
status = :status,
ended_on = CASE WHEN :status = 'W' THEN NULL ELSE NOW() END,
responded = :responded,
@@ -638,7 +728,7 @@ WHERE
// WriteSessions writes the passed in session to our database, writes any runs that need to be created
// as well as appying any events created in the session
-func WriteSessions(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *OrgAssets, ss []flows.Session, sprints []flows.Sprint, hook SessionCommitHook) ([]*Session, error) {
+func WriteSessions(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, st storage.Storage, org *OrgAssets, ss []flows.Session, sprints []flows.Sprint, hook SessionCommitHook) ([]*Session, error) {
if len(ss) == 0 {
return nil, nil
}
@@ -683,6 +773,16 @@ func WriteSessions(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, org *OrgAss
}
}
+ // if writing our sessions to S3, do so
+ sessionMode := org.Org().SessionStorageMode()
+ if sessionMode == S3Sessions || sessionMode == S3WriteSessions {
+ err := WriteSessionOutputsToStorage(ctx, st, sessions)
+ if err != nil {
+ // for now, continue on for errors, we are still reading from the DB
+ logrus.WithError(err).Error("error writing sessions to s3")
+ }
+ }
+
// insert our complete sessions first
err := BulkQuery(ctx, "insert completed sessions", tx, insertCompleteSessionSQL, completeSessionsI)
if err != nil {
@@ -769,7 +869,7 @@ func newRun(ctx context.Context, tx *sqlx.Tx, org *OrgAssets, session *Session,
return nil, err
}
- flowID, err := flowIDForUUID(ctx, tx, org, fr.FlowReference().UUID)
+ flowID, err := FlowIDForUUID(ctx, tx, org, fr.FlowReference().UUID)
if err != nil {
return nil, errors.Wrapf(err, "unable to load flow with uuid: %s", fr.FlowReference().UUID)
}
diff --git a/core/models/schedules.go b/core/models/schedules.go
index 705d1a834..b3dfe918a 100644
--- a/core/models/schedules.go
+++ b/core/models/schedules.go
@@ -67,10 +67,24 @@ type Schedule struct {
}
}
-func (s *Schedule) ID() ScheduleID { return s.s.ID }
-func (s *Schedule) OrgID() OrgID { return s.s.OrgID }
-func (s *Schedule) Broadcast() *Broadcast { return s.s.Broadcast }
-func (s *Schedule) FlowStart() *FlowStart { return s.s.FlowStart }
+func NewSchedule(period RepeatPeriod, hourOfDay, minuteOfHour, dayOfMonth *int, daysOfWeek string) *Schedule {
+ sched := &Schedule{}
+ s := &sched.s
+ s.RepeatPeriod = period
+ s.HourOfDay = hourOfDay
+ s.MinuteOfHour = minuteOfHour
+ s.DayOfMonth = dayOfMonth
+ s.DaysOfWeek = null.String(daysOfWeek)
+ return sched
+}
+
+func (s *Schedule) ID() ScheduleID { return s.s.ID }
+func (s *Schedule) OrgID() OrgID { return s.s.OrgID }
+func (s *Schedule) Broadcast() *Broadcast { return s.s.Broadcast }
+func (s *Schedule) FlowStart() *FlowStart { return s.s.FlowStart }
+func (s *Schedule) RepeatPeriod() RepeatPeriod { return s.s.RepeatPeriod }
+func (s *Schedule) NextFire() *time.Time { return s.s.NextFire }
+func (s *Schedule) LastFire() *time.Time { return s.s.LastFire }
func (s *Schedule) Timezone() (*time.Location, error) {
return time.LoadLocation(s.s.Timezone)
}
@@ -253,7 +267,15 @@ SELECT ROW_TO_JSON(s) FROM (SELECT
triggers_trigger_groups tg
WHERE
tg.trigger_id = t.id
- ) tg) as group_ids
+ ) tg) as group_ids,
+ (SELECT ARRAY_AGG(tg.contactgroup_id) FROM (
+ SELECT
+ tg.contactgroup_id
+ FROM
+ triggers_trigger_exclude_groups tg
+ WHERE
+ tg.trigger_id = t.id
+ ) tg) as exclude_group_ids
FROM
triggers_trigger t JOIN
flows_flow f on t.flow_id = f.id
diff --git a/core/models/schedules_test.go b/core/models/schedules_test.go
index 52cce3eb1..e01ecb0aa 100644
--- a/core/models/schedules_test.go
+++ b/core/models/schedules_test.go
@@ -1,4 +1,4 @@
-package models
+package models_test
import (
"testing"
@@ -6,106 +6,97 @@ import (
"github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/goflow/envs"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
- "github.com/nyaruka/null"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestGetExpired(t *testing.T) {
- ctx := testsuite.CTX()
+ ctx, _, db, _ := testsuite.Get()
// add a schedule and tie a broadcast to it
- db := testsuite.DB()
- var s1 ScheduleID
+ var s1 models.ScheduleID
err := db.Get(
&s1,
`INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id)
VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '1 DAY', 1, 1, $1) RETURNING id`,
- Org1,
- )
- assert.NoError(t, err)
- var b1 BroadcastID
- err = db.Get(
- &b1,
- `INSERT INTO msgs_broadcast(status, text, base_language, is_active, created_on, modified_on, send_all, created_by_id, modified_by_id, org_id, schedule_id)
- VALUES('P', hstore(ARRAY['eng','Test message', 'fra', 'Un Message']), 'eng', TRUE, NOW(), NOW(), TRUE, 1, 1, $1, $2) RETURNING id`,
- Org1, s1,
+ testdata.Org1.ID,
)
assert.NoError(t, err)
- // add a few contacts to the broadcast
- db.MustExec(`INSERT INTO msgs_broadcast_contacts(broadcast_id, contact_id) VALUES($1, $2),($1, $3)`, b1, CathyID, GeorgeID)
-
- // and a group
- db.MustExec(`INSERT INTO msgs_broadcast_groups(broadcast_id, contactgroup_id) VALUES($1, $2)`, b1, DoctorsGroupID)
+ b1 := testdata.InsertBroadcast(db, testdata.Org1, "eng", map[envs.Language]string{"eng": "Test message", "fra": "Un Message"}, s1,
+ []*testdata.Contact{testdata.Cathy, testdata.George}, []*testdata.Group{testdata.DoctorsGroup},
+ )
- // and a URN
- db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, CathyURNID)
+ // add a URN
+ db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, testdata.Cathy.URNID)
// add another and tie a trigger to it
- var s2 ScheduleID
+ var s2 models.ScheduleID
err = db.Get(
&s2,
`INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id)
VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '2 DAY', 1, 1, $1) RETURNING id`,
- Org1,
+ testdata.Org1.ID,
)
assert.NoError(t, err)
- var t1 TriggerID
+ var t1 models.TriggerID
err = db.Get(
&t1,
`INSERT INTO triggers_trigger(is_active, created_on, modified_on, is_archived, trigger_type, created_by_id, modified_by_id, org_id, flow_id, schedule_id)
VALUES(TRUE, NOW(), NOW(), FALSE, 'S', 1, 1, $1, $2, $3) RETURNING id`,
- Org1, FavoritesFlowID, s2,
+ testdata.Org1.ID, testdata.Favorites.ID, s2,
)
assert.NoError(t, err)
// add a few contacts to the trigger
- db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, CathyID, GeorgeID)
+ db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, testdata.Cathy.ID, testdata.George.ID)
// and a group
- db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, DoctorsGroupID)
+ db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, testdata.DoctorsGroup.ID)
- var s3 ScheduleID
+ var s3 models.ScheduleID
err = db.Get(
&s3,
`INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id)
VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '3 DAY', 1, 1, $1) RETURNING id`,
- Org1,
+ testdata.Org1.ID,
)
assert.NoError(t, err)
// get expired schedules
- schedules, err := GetUnfiredSchedules(ctx, db)
+ schedules, err := models.GetUnfiredSchedules(ctx, db)
assert.NoError(t, err)
assert.Equal(t, 3, len(schedules))
assert.Equal(t, s3, schedules[0].ID())
assert.Nil(t, schedules[0].Broadcast())
- assert.Equal(t, RepeatPeriodNever, schedules[0].s.RepeatPeriod)
- assert.NotNil(t, schedules[0].s.NextFire)
- assert.Nil(t, schedules[0].s.LastFire)
+ assert.Equal(t, models.RepeatPeriodNever, schedules[0].RepeatPeriod())
+ assert.NotNil(t, schedules[0].NextFire())
+ assert.Nil(t, schedules[0].LastFire())
assert.Equal(t, s2, schedules[1].ID())
assert.Nil(t, schedules[1].Broadcast())
start := schedules[1].FlowStart()
assert.NotNil(t, start)
- assert.Equal(t, FlowTypeMessaging, start.FlowType())
- assert.Equal(t, FavoritesFlowID, start.FlowID())
- assert.Equal(t, Org1, start.OrgID())
- assert.Equal(t, []ContactID{CathyID, GeorgeID}, start.ContactIDs())
- assert.Equal(t, []GroupID{DoctorsGroupID}, start.GroupIDs())
+ assert.Equal(t, models.FlowTypeMessaging, start.FlowType())
+ assert.Equal(t, testdata.Favorites.ID, start.FlowID())
+ assert.Equal(t, testdata.Org1.ID, start.OrgID())
+ assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, start.ContactIDs())
+ assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, start.GroupIDs())
assert.Equal(t, s1, schedules[2].ID())
bcast := schedules[2].Broadcast()
assert.NotNil(t, bcast)
assert.Equal(t, envs.Language("eng"), bcast.BaseLanguage())
- assert.Equal(t, TemplateStateUnevaluated, bcast.TemplateState())
+ assert.Equal(t, models.TemplateStateUnevaluated, bcast.TemplateState())
assert.Equal(t, "Test message", bcast.Translations()["eng"].Text)
assert.Equal(t, "Un Message", bcast.Translations()["fra"].Text)
- assert.Equal(t, Org1, bcast.OrgID())
- assert.Equal(t, []ContactID{CathyID, GeorgeID}, bcast.ContactIDs())
- assert.Equal(t, []GroupID{DoctorsGroupID}, bcast.GroupIDs())
+ assert.Equal(t, testdata.Org1.ID, bcast.OrgID())
+ assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, bcast.ContactIDs())
+ assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, bcast.GroupIDs())
assert.Equal(t, []urns.URN{urns.URN("tel:+16055741111?id=10000")}, bcast.URNs())
}
@@ -126,11 +117,11 @@ func TestNextFire(t *testing.T) {
Label string
Now time.Time
Location *time.Location
- Period RepeatPeriod
+ Period models.RepeatPeriod
HourOfDay *int
MinuteOfHour *int
DayOfMonth *int
- DaysOfWeek null.String
+ DaysOfWeek string
Next []*time.Time
Error string
}{
@@ -138,14 +129,14 @@ func TestNextFire(t *testing.T) {
Label: "no hour of day set",
Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
Error: "schedule 0 has no repeat_hour_of_day set",
},
{
Label: "no minute of hour set",
Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
HourOfDay: ip(12),
Error: "schedule 0 has no repeat_minute_of_hour set",
},
@@ -162,7 +153,7 @@ func TestNextFire(t *testing.T) {
Label: "no repeat",
Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodNever,
+ Period: models.RepeatPeriodNever,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
Next: nil,
@@ -171,7 +162,7 @@ func TestNextFire(t *testing.T) {
Label: "daily repeat on same day",
Now: time.Date(2019, 8, 20, 10, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
Next: []*time.Time{dp(2019, 8, 20, 12, 35, la)},
@@ -180,7 +171,7 @@ func TestNextFire(t *testing.T) {
Label: "daily repeat on same hour minute",
Now: time.Date(2019, 8, 20, 12, 35, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
Next: []*time.Time{dp(2019, 8, 21, 12, 35, la)},
@@ -189,7 +180,7 @@ func TestNextFire(t *testing.T) {
Label: "daily repeat for next day",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
Next: []*time.Time{dp(2019, 8, 21, 12, 35, la)},
@@ -198,7 +189,7 @@ func TestNextFire(t *testing.T) {
Label: "daily repeat for next day across DST start",
Now: time.Date(2019, 3, 9, 12, 30, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
HourOfDay: ip(12),
MinuteOfHour: ip(30),
Next: []*time.Time{
@@ -210,7 +201,7 @@ func TestNextFire(t *testing.T) {
Label: "daily repeat for next day across DST end",
Now: time.Date(2019, 11, 2, 12, 30, 0, 0, la),
Location: la,
- Period: RepeatPeriodDaily,
+ Period: models.RepeatPeriodDaily,
HourOfDay: ip(12),
MinuteOfHour: ip(30),
Next: []*time.Time{
@@ -222,7 +213,7 @@ func TestNextFire(t *testing.T) {
Label: "weekly repeat missing days of week",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodWeekly,
+ Period: models.RepeatPeriodWeekly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
Error: "schedule 0 repeats weekly but has no repeat_days_of_week",
@@ -231,20 +222,20 @@ func TestNextFire(t *testing.T) {
Label: "weekly with invalid days of week",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodWeekly,
+ Period: models.RepeatPeriodWeekly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
- DaysOfWeek: null.String("Z"),
+ DaysOfWeek: "Z",
Error: "schedule 0 has unknown day of week: Z",
},
{
Label: "weekly repeat to day later in week",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodWeekly,
+ Period: models.RepeatPeriodWeekly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
- DaysOfWeek: null.String("RU"),
+ DaysOfWeek: "RU",
Next: []*time.Time{
dp(2019, 8, 22, 12, 35, la),
dp(2019, 8, 25, 12, 35, la),
@@ -255,10 +246,10 @@ func TestNextFire(t *testing.T) {
Label: "weekly repeat to day later in week using fire date",
Now: time.Date(2019, 8, 26, 12, 35, 0, 0, la),
Location: la,
- Period: RepeatPeriodWeekly,
+ Period: models.RepeatPeriodWeekly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
- DaysOfWeek: null.String("MTWRFSU"),
+ DaysOfWeek: "MTWRFSU",
Next: []*time.Time{
dp(2019, 8, 27, 12, 35, la),
dp(2019, 8, 28, 12, 35, la),
@@ -272,27 +263,27 @@ func TestNextFire(t *testing.T) {
Label: "weekly repeat for next day across DST",
Now: time.Date(2019, 3, 9, 12, 30, 0, 0, la),
Location: la,
- Period: RepeatPeriodWeekly,
+ Period: models.RepeatPeriodWeekly,
HourOfDay: ip(12),
MinuteOfHour: ip(30),
- DaysOfWeek: null.String("MTWRFSU"),
+ DaysOfWeek: "MTWRFSU",
Next: []*time.Time{dp(2019, 3, 10, 12, 30, la)},
},
{
Label: "weekly repeat to day in next week",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodWeekly,
+ Period: models.RepeatPeriodWeekly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
- DaysOfWeek: null.String("M"),
+ DaysOfWeek: "M",
Next: []*time.Time{dp(2019, 8, 26, 12, 35, la)},
},
{
Label: "monthly repeat with no day of month set",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
Error: "schedule 0 repeats monthly but has no repeat_day_of_month",
@@ -301,7 +292,7 @@ func TestNextFire(t *testing.T) {
Label: "monthly repeat to day in same month",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
DayOfMonth: ip(31),
@@ -316,7 +307,7 @@ func TestNextFire(t *testing.T) {
Label: "monthly repeat to day in same month from fire date",
Now: time.Date(2019, 8, 20, 12, 35, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
DayOfMonth: ip(20),
@@ -326,7 +317,7 @@ func TestNextFire(t *testing.T) {
Label: "monthly repeat to day in next month",
Now: time.Date(2019, 8, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
DayOfMonth: ip(5),
@@ -336,7 +327,7 @@ func TestNextFire(t *testing.T) {
Label: "monthly repeat to day that exceeds month",
Now: time.Date(2019, 9, 20, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
DayOfMonth: ip(31),
@@ -346,7 +337,7 @@ func TestNextFire(t *testing.T) {
Label: "monthly repeat to day in next month that exceeds month",
Now: time.Date(2019, 8, 31, 13, 57, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(35),
DayOfMonth: ip(31),
@@ -356,7 +347,7 @@ func TestNextFire(t *testing.T) {
Label: "monthy repeat for next month across DST",
Now: time.Date(2019, 2, 10, 12, 30, 0, 0, la),
Location: la,
- Period: RepeatPeriodMonthly,
+ Period: models.RepeatPeriodMonthly,
HourOfDay: ip(12),
MinuteOfHour: ip(30),
DayOfMonth: ip(10),
@@ -367,14 +358,7 @@ func TestNextFire(t *testing.T) {
tests:
for _, tc := range tcs {
// create a fake schedule
- sched := &Schedule{}
- s := &sched.s
- s.RepeatPeriod = tc.Period
- s.HourOfDay = tc.HourOfDay
- s.MinuteOfHour = tc.MinuteOfHour
- s.DayOfMonth = tc.DayOfMonth
- s.DaysOfWeek = tc.DaysOfWeek
-
+ sched := models.NewSchedule(tc.Period, tc.HourOfDay, tc.MinuteOfHour, tc.DayOfMonth, tc.DaysOfWeek)
now := tc.Now
for _, n := range tc.Next {
diff --git a/core/models/search_test.go b/core/models/search_test.go
index e6b0a9777..c92932aa8 100644
--- a/core/models/search_test.go
+++ b/core/models/search_test.go
@@ -8,6 +8,7 @@ import (
"github.com/nyaruka/goflow/test"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/olivere/elastic/v7"
"github.com/stretchr/testify/assert"
@@ -15,9 +16,7 @@ import (
)
func TestContactIDsForQueryPage(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Reset()
es := testsuite.NewMockElasticServer()
defer es.Close()
@@ -44,7 +43,7 @@ func TestContactIDsForQueryPage(t *testing.T) {
ExpectedError string
}{
{
- Group: models.AllContactsGroupUUID,
+ Group: testdata.AllContactsGroup.UUID,
Query: "george",
ExpectedESRequest: `{
"_source": false,
@@ -113,13 +112,13 @@ func TestContactIDsForQueryPage(t *testing.T) {
}
]
}
- }`, models.GeorgeID),
- ExpectedContacts: []models.ContactID{models.GeorgeID},
+ }`, testdata.George.ID),
+ ExpectedContacts: []models.ContactID{testdata.George.ID},
ExpectedTotal: 1,
},
{
- Group: models.BlockedContactsGroupUUID,
- ExcludeIDs: []models.ContactID{models.BobID, models.CathyID},
+ Group: testdata.BlockedContactsGroup.UUID,
+ ExcludeIDs: []models.ContactID{testdata.Bob.ID, testdata.Cathy.ID},
Query: "age > 32",
Sort: "-age",
ExpectedESRequest: `{
@@ -225,8 +224,8 @@ func TestContactIDsForQueryPage(t *testing.T) {
}
]
}
- }`, models.GeorgeID),
- ExpectedContacts: []models.ContactID{models.GeorgeID},
+ }`, testdata.George.ID),
+ ExpectedContacts: []models.ContactID{testdata.George.ID},
ExpectedTotal: 1,
},
{
@@ -253,9 +252,7 @@ func TestContactIDsForQueryPage(t *testing.T) {
}
func TestContactIDsForQuery(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Reset()
es := testsuite.NewMockElasticServer()
defer es.Close()
@@ -337,8 +334,8 @@ func TestContactIDsForQuery(t *testing.T) {
}
]
}
- }`, models.GeorgeID),
- ExpectedContacts: []models.ContactID{models.GeorgeID},
+ }`, testdata.George.ID),
+ ExpectedContacts: []models.ContactID{testdata.George.ID},
}, {
Query: "nobody",
ExpectedESRequest: `{
diff --git a/core/models/starts.go b/core/models/starts.go
index cbd465944..035f9be15 100644
--- a/core/models/starts.go
+++ b/core/models/starts.go
@@ -103,13 +103,13 @@ func MarkStartFailed(ctx context.Context, db Queryer, startID StartID) error {
// FlowStartBatch represents a single flow batch that needs to be started
type FlowStartBatch struct {
b struct {
- StartID StartID `json:"start_id"`
- StartType StartType `json:"start_type"`
- OrgID OrgID `json:"org_id"`
- CreatedBy string `json:"created_by"`
- FlowID FlowID `json:"flow_id"`
- FlowType FlowType `json:"flow_type"`
- ContactIDs []ContactID `json:"contact_ids"`
+ StartID StartID `json:"start_id"`
+ StartType StartType `json:"start_type"`
+ OrgID OrgID `json:"org_id"`
+ CreatedByID UserID `json:"created_by_id"`
+ FlowID FlowID `json:"flow_id"`
+ FlowType FlowType `json:"flow_type"`
+ ContactIDs []ContactID `json:"contact_ids"`
ParentSummary null.JSON `json:"parent_summary,omitempty"`
SessionHistory null.JSON `json:"session_history,omitempty"`
@@ -120,13 +120,15 @@ type FlowStartBatch struct {
IsLast bool `json:"is_last,omitempty"`
TotalContacts int `json:"total_contacts"`
+
+ CreatedBy string `json:"created_by"` // deprecated
}
}
func (b *FlowStartBatch) StartID() StartID { return b.b.StartID }
func (b *FlowStartBatch) StartType() StartType { return b.b.StartType }
func (b *FlowStartBatch) OrgID() OrgID { return b.b.OrgID }
-func (b *FlowStartBatch) CreatedBy() string { return b.b.CreatedBy }
+func (b *FlowStartBatch) CreatedByID() UserID { return b.b.CreatedByID }
func (b *FlowStartBatch) FlowID() FlowID { return b.b.FlowID }
func (b *FlowStartBatch) ContactIDs() []ContactID { return b.b.ContactIDs }
func (b *FlowStartBatch) RestartParticipants() RestartParticipants { return b.b.RestartParticipants }
@@ -144,19 +146,20 @@ func (b *FlowStartBatch) UnmarshalJSON(data []byte) error { return json.Unmarsha
// FlowStart represents the top level flow start in our system
type FlowStart struct {
s struct {
- ID StartID `json:"start_id" db:"id"`
- UUID uuids.UUID ` db:"uuid"`
- StartType StartType `json:"start_type" db:"start_type"`
- OrgID OrgID `json:"org_id" db:"org_id"`
- FlowID FlowID `json:"flow_id" db:"flow_id"`
- FlowType FlowType `json:"flow_type"`
-
- GroupIDs []GroupID `json:"group_ids,omitempty"`
- ContactIDs []ContactID `json:"contact_ids,omitempty"`
- URNs []urns.URN `json:"urns,omitempty"`
- Query null.String `json:"query,omitempty" db:"query"`
-
- CreateContact bool `json:"create_contact"`
+ ID StartID `json:"start_id" db:"id"`
+ UUID uuids.UUID ` db:"uuid"`
+ StartType StartType `json:"start_type" db:"start_type"`
+ OrgID OrgID `json:"org_id" db:"org_id"`
+ CreatedByID UserID `json:"created_by_id" db:"created_by_id"`
+ FlowID FlowID `json:"flow_id" db:"flow_id"`
+ FlowType FlowType `json:"flow_type"`
+
+ URNs []urns.URN `json:"urns,omitempty"`
+ ContactIDs []ContactID `json:"contact_ids,omitempty"`
+ GroupIDs []GroupID `json:"group_ids,omitempty"`
+ ExcludeGroupIDs []GroupID `json:"exclude_group_ids,omitempty"` // used when loading scheduled triggers as flow starts
+ Query null.String `json:"query,omitempty" db:"query"`
+ CreateContact bool `json:"create_contact"`
RestartParticipants RestartParticipants `json:"restart_participants" db:"restart_participants"`
IncludeActive IncludeActive `json:"include_active" db:"include_active"`
@@ -165,20 +168,26 @@ type FlowStart struct {
ParentSummary null.JSON `json:"parent_summary,omitempty" db:"parent_summary"`
SessionHistory null.JSON `json:"session_history,omitempty" db:"session_history"`
- CreatedBy string `json:"created_by"`
+ CreatedBy string `json:"created_by"` // TODO deprecated
}
}
-func (s *FlowStart) ID() StartID { return s.s.ID }
-func (s *FlowStart) OrgID() OrgID { return s.s.OrgID }
-func (s *FlowStart) FlowID() FlowID { return s.s.FlowID }
-func (s *FlowStart) FlowType() FlowType { return s.s.FlowType }
+func (s *FlowStart) ID() StartID { return s.s.ID }
+func (s *FlowStart) OrgID() OrgID { return s.s.OrgID }
+func (s *FlowStart) CreatedByID() UserID { return s.s.CreatedByID }
+func (s *FlowStart) FlowID() FlowID { return s.s.FlowID }
+func (s *FlowStart) FlowType() FlowType { return s.s.FlowType }
func (s *FlowStart) GroupIDs() []GroupID { return s.s.GroupIDs }
func (s *FlowStart) WithGroupIDs(groupIDs []GroupID) *FlowStart {
s.s.GroupIDs = groupIDs
return s
}
+func (s *FlowStart) ExcludeGroupIDs() []GroupID { return s.s.ExcludeGroupIDs }
+func (s *FlowStart) WithExcludeGroupIDs(groupIDs []GroupID) *FlowStart {
+ s.s.ExcludeGroupIDs = groupIDs
+ return s
+}
func (s *FlowStart) ContactIDs() []ContactID { return s.s.ContactIDs }
func (s *FlowStart) WithContactIDs(contactIDs []ContactID) *FlowStart {
@@ -354,6 +363,7 @@ func (s *FlowStart) CreateBatch(contactIDs []ContactID, last bool, totalContacts
b.b.IsLast = last
b.b.TotalContacts = totalContacts
b.b.CreatedBy = s.s.CreatedBy
+ b.b.CreatedByID = s.s.CreatedByID
return b
}
diff --git a/core/models/starts_test.go b/core/models/starts_test.go
index 802ffb414..b6260bfca 100644
--- a/core/models/starts_test.go
+++ b/core/models/starts_test.go
@@ -5,7 +5,10 @@ import (
"fmt"
"testing"
+ "github.com/nyaruka/gocommon/jsonx"
+ "github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/flows"
+ "github.com/nyaruka/goflow/test"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
@@ -15,20 +18,20 @@ import (
)
func TestStarts(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- startID := testdata.InsertFlowStart(t, db, models.Org1, models.SingleMessageFlowID, []models.ContactID{models.CathyID, models.BobID})
+ startID := testdata.InsertFlowStart(db, testdata.Org1, testdata.SingleMessage, []*testdata.Contact{testdata.Cathy, testdata.Bob})
startJSON := []byte(fmt.Sprintf(`{
"start_id": %d,
"start_type": "M",
"org_id": %d,
- "created_by": "rowan@nyaruka.com",
+ "created_by_id": %d,
"flow_id": %d,
"flow_type": "M",
"contact_ids": [%d, %d],
- "group_ids": [6789],
+ "group_ids": [%d],
+ "exclude_group_ids": [%d],
"urns": ["tel:+12025550199"],
"query": null,
"restart_participants": true,
@@ -36,38 +39,42 @@ func TestStarts(t *testing.T) {
"parent_summary": {"uuid": "b65b1a22-db6d-4f5a-9b3d-7302368a82e6"},
"session_history": {"parent_uuid": "532a3899-492f-4ffe-aed7-e75ad524efab", "ancestors": 3, "ancestors_since_input": 1},
"extra": {"foo": "bar"}
- }`, startID, models.Org1, models.SingleMessageFlowID, models.CathyID, models.BobID))
+ }`, startID, testdata.Org1.ID, testdata.Admin.ID, testdata.SingleMessage.ID, testdata.Cathy.ID, testdata.Bob.ID, testdata.DoctorsGroup.ID, testdata.TestersGroup.ID))
start := &models.FlowStart{}
err := json.Unmarshal(startJSON, start)
require.NoError(t, err)
assert.Equal(t, startID, start.ID())
- assert.Equal(t, models.Org1, start.OrgID())
- assert.Equal(t, models.SingleMessageFlowID, start.FlowID())
+ assert.Equal(t, testdata.Org1.ID, start.OrgID())
+ assert.Equal(t, testdata.Admin.ID, start.CreatedByID())
+ assert.Equal(t, testdata.SingleMessage.ID, start.FlowID())
assert.Equal(t, models.FlowTypeMessaging, start.FlowType())
assert.Equal(t, "", start.Query())
assert.Equal(t, models.DoRestartParticipants, start.RestartParticipants())
assert.Equal(t, models.DoIncludeActive, start.IncludeActive())
+ assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, start.ContactIDs())
+ assert.Equal(t, []models.GroupID{testdata.DoctorsGroup.ID}, start.GroupIDs())
+ assert.Equal(t, []models.GroupID{testdata.TestersGroup.ID}, start.ExcludeGroupIDs())
assert.Equal(t, json.RawMessage(`{"uuid": "b65b1a22-db6d-4f5a-9b3d-7302368a82e6"}`), start.ParentSummary())
assert.Equal(t, json.RawMessage(`{"parent_uuid": "532a3899-492f-4ffe-aed7-e75ad524efab", "ancestors": 3, "ancestors_since_input": 1}`), start.SessionHistory())
assert.Equal(t, json.RawMessage(`{"foo": "bar"}`), start.Extra())
- err = models.MarkStartStarted(ctx, db, startID, 2, []models.ContactID{models.GeorgeID})
+ err = models.MarkStartStarted(ctx, db, startID, 2, []models.ContactID{testdata.George.ID})
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'S' AND contact_count = 2`, []interface{}{startID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart_contacts WHERE flowstart_id = $1`, []interface{}{startID}, 3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'S' AND contact_count = 2`, startID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart_contacts WHERE flowstart_id = $1`, startID).Returns(3)
- batch := start.CreateBatch([]models.ContactID{models.CathyID, models.BobID}, false, 3)
+ batch := start.CreateBatch([]models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, false, 3)
assert.Equal(t, startID, batch.StartID())
assert.Equal(t, models.StartTypeManual, batch.StartType())
- assert.Equal(t, models.SingleMessageFlowID, batch.FlowID())
- assert.Equal(t, []models.ContactID{models.CathyID, models.BobID}, batch.ContactIDs())
+ assert.Equal(t, testdata.SingleMessage.ID, batch.FlowID())
+ assert.Equal(t, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}, batch.ContactIDs())
assert.Equal(t, models.DoRestartParticipants, batch.RestartParticipants())
assert.Equal(t, models.DoIncludeActive, batch.IncludeActive())
- assert.Equal(t, "rowan@nyaruka.com", batch.CreatedBy())
+ assert.Equal(t, testdata.Admin.ID, batch.CreatedByID())
assert.False(t, batch.IsLast())
assert.Equal(t, 3, batch.TotalContacts())
@@ -79,11 +86,44 @@ func TestStarts(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, flows.SessionUUID("532a3899-492f-4ffe-aed7-e75ad524efab"), history.ParentUUID)
- history, err = models.ReadSessionHistory([]byte(`{`))
+ _, err = models.ReadSessionHistory([]byte(`{`))
assert.EqualError(t, err, "unexpected end of JSON input")
err = models.MarkStartComplete(ctx, db, startID)
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'C'`, []interface{}{startID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart WHERE id = $1 AND status = 'C'`, startID).Returns(1)
+}
+
+func TestStartsBuilding(t *testing.T) {
+ uuids.SetGenerator(uuids.NewSeededGenerator(12345))
+ defer uuids.SetGenerator(uuids.DefaultGenerator)
+
+ start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeManual, models.FlowTypeMessaging, testdata.Favorites.ID, models.DoRestartParticipants, models.DoIncludeActive).
+ WithGroupIDs([]models.GroupID{testdata.DoctorsGroup.ID}).
+ WithExcludeGroupIDs([]models.GroupID{testdata.TestersGroup.ID}).
+ WithContactIDs([]models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}).
+ WithQuery(`language != ""`).
+ WithCreateContact(true)
+
+ marshalled, err := jsonx.Marshal(start)
+ require.NoError(t, err)
+
+ test.AssertEqualJSON(t, []byte(fmt.Sprintf(`{
+ "UUID": "1ae96956-4b34-433e-8d1a-f05fe6923d6d",
+ "contact_ids": [%d, %d],
+ "create_contact": true,
+ "created_by": "",
+ "created_by_id": null,
+ "exclude_group_ids": [%d],
+ "flow_id": %d,
+ "flow_type": "M",
+ "group_ids": [%d],
+ "include_active": true,
+ "org_id": 1,
+ "query": "language != \"\"",
+ "restart_participants": true,
+ "start_id": null,
+ "start_type": "M"
+ }`, testdata.Cathy.ID, testdata.Bob.ID, testdata.TestersGroup.ID, testdata.Favorites.ID, testdata.DoctorsGroup.ID)), marshalled)
}
diff --git a/core/models/templates.go b/core/models/templates.go
index 580e08374..137bd7c6e 100644
--- a/core/models/templates.go
+++ b/core/models/templates.go
@@ -44,6 +44,7 @@ type TemplateTranslation struct {
Channel assets.ChannelReference `json:"channel" validate:"required"`
Language envs.Language `json:"language" validate:"required"`
Country null.String `json:"country"`
+ Namespace string `json:"namespace"`
Content string `json:"content" validate:"required"`
VariableCount int `json:"variable_count"`
}
@@ -59,6 +60,7 @@ func (t *TemplateTranslation) Channel() assets.ChannelReference { return t.t.Cha
func (t *TemplateTranslation) Language() envs.Language { return t.t.Language }
func (t *TemplateTranslation) Country() envs.Country { return envs.Country(t.t.Country) }
func (t *TemplateTranslation) Content() string { return t.t.Content }
+func (t *TemplateTranslation) Namespace() string { return t.t.Namespace }
func (t *TemplateTranslation) VariableCount() int { return t.t.VariableCount }
// loads the templates for the passed in org
@@ -96,6 +98,7 @@ SELECT ROW_TO_JSON(r) FROM (SELECT
tr.language as language,
tr.country as country,
tr.content as content,
+ tr.namespace as namespace,
tr.variable_count as variable_count,
JSON_BUILD_OBJECT('uuid', c.uuid, 'name', c.name) as channel
FROM
diff --git a/core/models/templates_test.go b/core/models/templates_test.go
index 02c5f6b20..189953821 100644
--- a/core/models/templates_test.go
+++ b/core/models/templates_test.go
@@ -1,20 +1,25 @@
-package models
+package models_test
import (
"testing"
"github.com/nyaruka/goflow/envs"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestTemplates(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- templates, err := loadTemplates(ctx, db, 1)
- assert.NoError(t, err)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTemplates)
+ require.NoError(t, err)
+
+ templates, err := oa.Templates()
+ require.NoError(t, err)
assert.Equal(t, 2, len(templates))
assert.Equal(t, "goodbye", templates[0].Name())
@@ -24,13 +29,15 @@ func TestTemplates(t *testing.T) {
tt := templates[0].Translations()[0]
assert.Equal(t, envs.Language("fra"), tt.Language())
assert.Equal(t, envs.NilCountry, tt.Country())
- assert.Equal(t, TwitterChannelUUID, tt.Channel().UUID)
+ assert.Equal(t, "", tt.Namespace())
+ assert.Equal(t, testdata.TwitterChannel.UUID, tt.Channel().UUID)
assert.Equal(t, "Salut!", tt.Content())
assert.Equal(t, 1, len(templates[1].Translations()))
tt = templates[1].Translations()[0]
assert.Equal(t, envs.Language("eng"), tt.Language())
assert.Equal(t, envs.Country("US"), tt.Country())
- assert.Equal(t, TwitterChannelUUID, tt.Channel().UUID)
+ assert.Equal(t, "2d40b45c_25cd_4965_9019_f05d0124c5fa", tt.Namespace())
+ assert.Equal(t, testdata.TwitterChannel.UUID, tt.Channel().UUID)
assert.Equal(t, "Hi {{1}}, are you still experiencing problems with {{2}}?", tt.Content())
}
diff --git a/core/models/test_constants.go b/core/models/test_constants.go
deleted file mode 100644
index 06d3105ee..000000000
--- a/core/models/test_constants.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package models
-
-import (
- "github.com/nyaruka/gocommon/urns"
- "github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/goflow/assets"
- "github.com/nyaruka/goflow/flows"
-)
-
-// Constants used in tests, these are tied to the DB created by the
-// RapidPro `mailroom_db` management command. These need to live in the master /models
-// dir because we are using typed values and otherwire we'd have a circular dependency.
-//
-// Solution there would be to create a new package for ID types which would let us put
-// these in testsuite or the like.
-//
-// Note that integer ids MAY be fragile depending on how clumsy people are adding things
-// to the mailroom_db command (hint, add things to the end). If this turns into an issue
-// we could start deriving these instead from the UUIDs.
-
-var Org1 = OrgID(1)
-var Org1UUID = uuids.UUID("bf0514a5-9407-44c9-b0f9-3f36f9c18414")
-
-var TwilioChannelID = ChannelID(10000)
-var TwilioChannelUUID = assets.ChannelUUID("74729f45-7f29-4868-9dc4-90e491e3c7d8")
-
-var VonageChannelID = ChannelID(10001)
-var VonageChannelUUID = assets.ChannelUUID("19012bfd-3ce3-4cae-9bb9-76cf92c73d49")
-
-var TwitterChannelID = ChannelID(10002)
-var TwitterChannelUUID = assets.ChannelUUID("0f661e8b-ea9d-4bd3-9953-d368340acf91")
-
-var CathyID = ContactID(10000)
-var CathyUUID = flows.ContactUUID("6393abc0-283d-4c9b-a1b3-641a035c34bf")
-var CathyURN = urns.URN("tel:+16055741111")
-var CathyURNID = URNID(10000)
-
-var BobID = ContactID(10001)
-var BobUUID = flows.ContactUUID("b699a406-7e44-49be-9f01-1a82893e8a10")
-var BobURN = urns.URN("tel:+16055742222")
-var BobURNID = URNID(10001)
-var GeorgeID = ContactID(10002)
-var GeorgeUUID = flows.ContactUUID("8d024bcd-f473-4719-a00a-bd0bb1190135")
-var GeorgeURN = urns.URN("tel:+16055743333")
-var GeorgeURNID = URNID(10002)
-
-var AlexandriaID = ContactID(10003)
-var AlexandriaUUID = flows.ContactUUID("9709c157-4606-4d41-9df3-9e9c9b4ae2d4")
-var AlexandriaURN = urns.URN("tel:+16055744444")
-var AlexandriaURNID = URNID(10003)
-
-var FavoritesFlowID = FlowID(10000)
-var FavoritesFlowUUID = assets.FlowUUID("9de3663f-c5c5-4c92-9f45-ecbc09abcc85")
-
-var PickNumberFlowID = FlowID(10001)
-var PickNumberFlowUUID = assets.FlowUUID("5890fe3a-f204-4661-b74d-025be4ee019c")
-
-var SingleMessageFlowID = FlowID(10004)
-var SingleMessageFlowUUID = assets.FlowUUID("a7c11d68-f008-496f-b56d-2d5cf4cf16a5")
-
-var IVRFlowID = FlowID(10003)
-var IVRFlowUUID = assets.FlowUUID("2f81d0ea-4d75-4843-9371-3f7465311cce")
-
-var SurveyorFlowID = FlowID(10005)
-var SurveyorFlowUUID = assets.FlowUUID("ed8cf8d4-a42c-4ce1-a7e3-44a2918e3cec")
-
-var IncomingExtraFlowID = FlowID(10006)
-var IncomingExtraFlowUUID = assets.FlowUUID("376d3de6-7f0e-408c-80d6-b1919738bc80")
-
-var ParentTimeoutID = FlowID(10007)
-var ParentTimeoutUUID = assets.FlowUUID("81c0f323-7e06-4e0c-a960-19c20f17117c")
-
-var CampaignFlowID = FlowID(10009)
-var CampaignFlowUUID = assets.FlowUUID("3a92a964-3a8d-420b-9206-2cd9d884ac30")
-
-var DoctorRemindersCampaignUUID = CampaignUUID("72aa12c5-cc11-4bc7-9406-044047845c70")
-var DoctorRemindersCampaignID = CampaignID(10000)
-
-var RemindersEvent1ID = CampaignEventID(10000)
-var RemindersEvent2ID = CampaignEventID(10001)
-
-var DoctorsGroupID = GroupID(10000)
-var DoctorsGroupUUID = assets.GroupUUID("c153e265-f7c9-4539-9dbc-9b358714b638")
-
-var AllContactsGroupID = GroupID(1)
-var AllContactsGroupUUID = assets.GroupUUID("d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008")
-
-var BlockedContactsGroupID = GroupID(2)
-var BlockedContactsGroupUUID = assets.GroupUUID("9295ebab-5c2d-4eb1-86f9-7c15ed2f3219")
-
-var TestersGroupID = GroupID(10001)
-var TestersGroupUUID = assets.GroupUUID("5e9d8fab-5e7e-4f51-b533-261af5dea70d")
-
-var CreatedOnFieldID = FieldID(3)
-var LastSeenOnFieldID = FieldID(5)
-
-var AgeFieldUUID = assets.FieldUUID("903f51da-2717-47c7-a0d3-f2f32877013d")
-var GenderFieldUUID = assets.FieldUUID("3a5891e4-756e-4dc9-8e12-b7a766168824")
-
-var JoinedFieldID = FieldID(8)
-var JoinedFieldUUID = assets.FieldUUID("d83aae24-4bbf-49d0-ab85-6bfd201eac6d")
-
-var ReportingLabelID = LabelID(10000)
-var ReportingLabelUUID = assets.LabelUUID("ebc4dedc-91c4-4ed4-9dd6-daa05ea82698")
-
-var TestingLabelID = LabelID(10001)
-var TestingLabelUUID = assets.LabelUUID("a6338cdc-7938-4437-8b05-2d5d785e3a08")
-
-// classifiers
-
-var LuisID = ClassifierID(1)
-var LuisUUID = assets.ClassifierUUID("097e026c-ae79-4740-af67-656dbedf0263")
-
-var WitID = ClassifierID(2)
-var WitUUID = assets.ClassifierUUID("ff2a817c-040a-4eb2-8404-7d92e8b79dd0")
-
-var BothubID = ClassifierID(3)
-var BothubUUID = assets.ClassifierUUID("859b436d-3005-4e43-9ad5-3de5f26ede4c")
-
-// ticketers
-
-var MailgunID = TicketerID(1)
-var MailgunUUID = assets.TicketerUUID("f9c9447f-a291-4f3c-8c79-c089bbd4e713")
-
-var ZendeskID = TicketerID(2)
-var ZendeskUUID = assets.TicketerUUID("4ee6d4f3-f92b-439b-9718-8da90c05490b")
-
-var RocketChatID = TicketerID(3)
-var RocketChatUUID = assets.TicketerUUID("6c50665f-b4ff-4e37-9625-bc464fe6a999")
-
-var InternalID = TicketerID(4)
-var InternalUUID = assets.TicketerUUID("8bd48029-6ca1-46a8-aa14-68f7213b82b3")
-
-// constants for org 2, just a few here
-
-var Org2 = OrgID(2)
-var Org2UUID = uuids.UUID("3ae7cdeb-fd96-46e5-abc4-a4622f349921")
-
-var Org2ChannelID = ChannelID(20000)
-var Org2ChannelUUID = assets.ChannelUUID("a89bc872-3763-4b95-91d9-31d4e56c6651")
-
-var Org2FredID = ContactID(20000)
-var Org2FredUUID = flows.ContactUUID("26d20b72-f7d8-44dc-87f2-aae046dbff95")
-var Org2FredURN = urns.URN("tel:+250700000005")
-var Org2FredURNID = URNID(20000)
-
-var Org2FavoritesFlowID = FlowID(20000)
-var Org2FavoritesFlowUUID = assets.FlowUUID("f161bd16-3c60-40bd-8c92-228ce815b9cd")
-
-var Org2SingleMessageFlowID = FlowID(20001)
-var Org2SingleMessageFlowUUID = assets.FlowUUID("5277916d-6011-41ac-a4a4-f6ac6a4f1dd9")
diff --git a/core/models/ticket_events.go b/core/models/ticket_events.go
new file mode 100644
index 000000000..a18331d51
--- /dev/null
+++ b/core/models/ticket_events.go
@@ -0,0 +1,106 @@
+package models
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/nyaruka/gocommon/dates"
+ "github.com/nyaruka/null"
+)
+
+type TicketEventID int
+type TicketEventType string
+
+const (
+ TicketEventTypeOpened TicketEventType = "O"
+ TicketEventTypeAssigned TicketEventType = "A"
+ TicketEventTypeNote TicketEventType = "N"
+ TicketEventTypeClosed TicketEventType = "C"
+ TicketEventTypeReopened TicketEventType = "R"
+)
+
+type TicketEvent struct {
+ e struct {
+ ID TicketEventID `json:"id" db:"id"`
+ OrgID OrgID `json:"org_id" db:"org_id"`
+ ContactID ContactID `json:"contact_id" db:"contact_id"`
+ TicketID TicketID `json:"ticket_id" db:"ticket_id"`
+ EventType TicketEventType `json:"event_type" db:"event_type"`
+ Note null.String `json:"note,omitempty" db:"note"`
+ AssigneeID UserID `json:"assignee_id,omitempty" db:"assignee_id"`
+ CreatedByID UserID `json:"created_by_id,omitempty" db:"created_by_id"`
+ CreatedOn time.Time `json:"created_on" db:"created_on"`
+ }
+}
+
+func NewTicketOpenedEvent(t *Ticket, userID UserID, assigneeID UserID) *TicketEvent {
+ return newTicketEvent(t, userID, TicketEventTypeOpened, "", assigneeID)
+}
+
+func NewTicketAssignedEvent(t *Ticket, userID UserID, assigneeID UserID, note string) *TicketEvent {
+ return newTicketEvent(t, userID, TicketEventTypeAssigned, note, assigneeID)
+}
+
+func NewTicketNoteEvent(t *Ticket, userID UserID, note string) *TicketEvent {
+ return newTicketEvent(t, userID, TicketEventTypeNote, note, NilUserID)
+}
+
+func NewTicketClosedEvent(t *Ticket, userID UserID) *TicketEvent {
+ return newTicketEvent(t, userID, TicketEventTypeClosed, "", NilUserID)
+}
+
+func NewTicketReopenedEvent(t *Ticket, userID UserID) *TicketEvent {
+ return newTicketEvent(t, userID, TicketEventTypeReopened, "", NilUserID)
+}
+
+func newTicketEvent(t *Ticket, userID UserID, eventType TicketEventType, note string, assigneeID UserID) *TicketEvent {
+ event := &TicketEvent{}
+ e := &event.e
+ e.OrgID = t.OrgID()
+ e.ContactID = t.ContactID()
+ e.TicketID = t.ID()
+ e.EventType = eventType
+ e.Note = null.String(note)
+ e.AssigneeID = assigneeID
+ e.CreatedOn = dates.Now()
+ e.CreatedByID = userID
+ return event
+}
+
+func (e *TicketEvent) ID() TicketEventID { return e.e.ID }
+func (e *TicketEvent) OrgID() OrgID { return e.e.OrgID }
+func (e *TicketEvent) ContactID() ContactID { return e.e.ContactID }
+func (e *TicketEvent) TicketID() TicketID { return e.e.TicketID }
+func (e *TicketEvent) EventType() TicketEventType { return e.e.EventType }
+func (e *TicketEvent) Note() null.String { return e.e.Note }
+func (e *TicketEvent) AssigneeID() UserID { return e.e.AssigneeID }
+func (e *TicketEvent) CreatedByID() UserID { return e.e.CreatedByID }
+
+// MarshalJSON is our custom marshaller so that our inner struct get output
+func (e *TicketEvent) MarshalJSON() ([]byte, error) {
+ return json.Marshal(e.e)
+}
+
+// UnmarshalJSON is our custom marshaller so that our inner struct get output
+func (e *TicketEvent) UnmarshalJSON(b []byte) error {
+ return json.Unmarshal(b, &e.e)
+}
+
+const insertTicketEventsSQL = `
+INSERT INTO
+ tickets_ticketevent(org_id, contact_id, ticket_id, event_type, note, assignee_id, created_on, created_by_id)
+ VALUES(:org_id, :contact_id, :ticket_id, :event_type, :note, :assignee_id, :created_on, :created_by_id)
+RETURNING
+ id
+`
+
+func InsertTicketEvents(ctx context.Context, db Queryer, evts []*TicketEvent) error {
+ // convert to interface arrray
+ is := make([]interface{}, len(evts))
+ for i := range evts {
+ is[i] = &evts[i].e
+ }
+
+ return BulkQuery(ctx, "inserting ticket events", db, insertTicketEventsSQL, is)
+}
diff --git a/core/models/ticket_events_test.go b/core/models/ticket_events_test.go
new file mode 100644
index 000000000..c9de363cd
--- /dev/null
+++ b/core/models/ticket_events_test.go
@@ -0,0 +1,58 @@
+package models_test
+
+import (
+ "testing"
+
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+ "github.com/nyaruka/null"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTicketEvents(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer func() {
+ db.MustExec(`DELETE FROM tickets_ticketevent`)
+ db.MustExec(`DELETE FROM tickets_ticket`)
+ }()
+
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", nil)
+ modelTicket := ticket.Load(db)
+
+ e1 := models.NewTicketOpenedEvent(modelTicket, testdata.Admin.ID, testdata.Agent.ID)
+ assert.Equal(t, testdata.Org1.ID, e1.OrgID())
+ assert.Equal(t, testdata.Cathy.ID, e1.ContactID())
+ assert.Equal(t, ticket.ID, e1.TicketID())
+ assert.Equal(t, models.TicketEventTypeOpened, e1.EventType())
+ assert.Equal(t, null.NullString, e1.Note())
+ assert.Equal(t, testdata.Admin.ID, e1.CreatedByID())
+
+ e2 := models.NewTicketAssignedEvent(modelTicket, testdata.Admin.ID, testdata.Agent.ID, "please handle")
+ assert.Equal(t, models.TicketEventTypeAssigned, e2.EventType())
+ assert.Equal(t, testdata.Agent.ID, e2.AssigneeID())
+ assert.Equal(t, null.String("please handle"), e2.Note())
+ assert.Equal(t, testdata.Admin.ID, e2.CreatedByID())
+
+ e3 := models.NewTicketNoteEvent(modelTicket, testdata.Agent.ID, "please handle")
+ assert.Equal(t, models.TicketEventTypeNote, e3.EventType())
+ assert.Equal(t, null.String("please handle"), e3.Note())
+ assert.Equal(t, testdata.Agent.ID, e3.CreatedByID())
+
+ e4 := models.NewTicketClosedEvent(modelTicket, testdata.Agent.ID)
+ assert.Equal(t, models.TicketEventTypeClosed, e4.EventType())
+ assert.Equal(t, testdata.Agent.ID, e4.CreatedByID())
+
+ e5 := models.NewTicketReopenedEvent(modelTicket, testdata.Editor.ID)
+ assert.Equal(t, models.TicketEventTypeReopened, e5.EventType())
+ assert.Equal(t, testdata.Editor.ID, e5.CreatedByID())
+
+ err := models.InsertTicketEvents(ctx, db, []*models.TicketEvent{e1, e2, e3, e4, e5})
+ require.NoError(t, err)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent`).Returns(5)
+ testsuite.AssertQuery(t, db, `SELECT assignee_id, note FROM tickets_ticketevent WHERE id = $1`, e2.ID()).
+ Columns(map[string]interface{}{"assignee_id": int64(testdata.Agent.ID), "note": "please handle"})
+}
diff --git a/core/models/tickets.go b/core/models/tickets.go
index 1e90014ec..4f1f0267f 100644
--- a/core/models/tickets.go
+++ b/core/models/tickets.go
@@ -12,6 +12,7 @@ import (
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/utils/dbutil"
"github.com/nyaruka/null"
@@ -22,7 +23,31 @@ import (
"github.com/sirupsen/logrus"
)
-type TicketID int
+type TicketID null.Int
+
+// NilTicketID is our constant for a nil ticket id
+const NilTicketID = TicketID(0)
+
+// MarshalJSON marshals into JSON. 0 values will become null
+func (i TicketID) MarshalJSON() ([]byte, error) {
+ return null.Int(i).MarshalJSON()
+}
+
+// UnmarshalJSON unmarshals from JSON. null values become 0
+func (i *TicketID) UnmarshalJSON(b []byte) error {
+ return null.UnmarshalInt(b, (*null.Int)(i))
+}
+
+// Value returns the db value, null is returned for 0
+func (i TicketID) Value() (driver.Value, error) {
+ return null.Int(i).Value()
+}
+
+// Scan scans from the db value. null values become 0
+func (i *TicketID) Scan(value interface{}) error {
+ return null.ScanInt(value, (*null.Int)(i))
+}
+
type TicketerID null.Int
type TicketStatus string
@@ -35,31 +60,33 @@ const (
func init() {
goflow.RegisterTicketServiceFactory(
func(session flows.Session, ticketer *flows.Ticketer) (flows.TicketService, error) {
- return ticketer.Asset().(*Ticketer).AsService(ticketer)
+ return ticketer.Asset().(*Ticketer).AsService(config.Mailroom, ticketer)
},
)
}
type Ticket struct {
t struct {
- ID TicketID `db:"id"`
- UUID flows.TicketUUID `db:"uuid"`
- OrgID OrgID `db:"org_id"`
- ContactID ContactID `db:"contact_id"`
- TicketerID TicketerID `db:"ticketer_id"`
- ExternalID null.String `db:"external_id"`
- Status TicketStatus `db:"status"`
- Subject string `db:"subject"`
- Body string `db:"body"`
- Config null.Map `db:"config"`
- OpenedOn time.Time `db:"opened_on"`
- ModifiedOn time.Time `db:"modified_on"`
- ClosedOn *time.Time `db:"closed_on"`
+ ID TicketID `db:"id"`
+ UUID flows.TicketUUID `db:"uuid"`
+ OrgID OrgID `db:"org_id"`
+ ContactID ContactID `db:"contact_id"`
+ TicketerID TicketerID `db:"ticketer_id"`
+ ExternalID null.String `db:"external_id"`
+ Status TicketStatus `db:"status"`
+ Subject string `db:"subject"`
+ Body string `db:"body"`
+ AssigneeID UserID `db:"assignee_id"`
+ Config null.Map `db:"config"`
+ OpenedOn time.Time `db:"opened_on"`
+ ModifiedOn time.Time `db:"modified_on"`
+ ClosedOn *time.Time `db:"closed_on"`
+ LastActivityOn time.Time `db:"last_activity_on"`
}
}
// NewTicket creates a new open ticket
-func NewTicket(uuid flows.TicketUUID, orgID OrgID, contactID ContactID, ticketerID TicketerID, externalID, subject, body string, config map[string]interface{}) *Ticket {
+func NewTicket(uuid flows.TicketUUID, orgID OrgID, contactID ContactID, ticketerID TicketerID, externalID, subject, body string, assigneeID UserID, config map[string]interface{}) *Ticket {
t := &Ticket{}
t.t.UUID = uuid
t.t.OrgID = orgID
@@ -69,23 +96,50 @@ func NewTicket(uuid flows.TicketUUID, orgID OrgID, contactID ContactID, ticketer
t.t.Status = TicketStatusOpen
t.t.Subject = subject
t.t.Body = body
+ t.t.AssigneeID = assigneeID
t.t.Config = null.NewMap(config)
return t
}
-func (t *Ticket) ID() TicketID { return t.t.ID }
-func (t *Ticket) UUID() flows.TicketUUID { return t.t.UUID }
-func (t *Ticket) OrgID() OrgID { return t.t.OrgID }
-func (t *Ticket) ContactID() ContactID { return t.t.ContactID }
-func (t *Ticket) TicketerID() TicketerID { return t.t.TicketerID }
-func (t *Ticket) ExternalID() null.String { return t.t.ExternalID }
-func (t *Ticket) Status() TicketStatus { return t.t.Status }
-func (t *Ticket) Subject() string { return t.t.Subject }
-func (t *Ticket) Body() string { return t.t.Body }
+func (t *Ticket) ID() TicketID { return t.t.ID }
+func (t *Ticket) UUID() flows.TicketUUID { return t.t.UUID }
+func (t *Ticket) OrgID() OrgID { return t.t.OrgID }
+func (t *Ticket) ContactID() ContactID { return t.t.ContactID }
+func (t *Ticket) TicketerID() TicketerID { return t.t.TicketerID }
+func (t *Ticket) ExternalID() null.String { return t.t.ExternalID }
+func (t *Ticket) Status() TicketStatus { return t.t.Status }
+func (t *Ticket) Subject() string { return t.t.Subject }
+func (t *Ticket) Body() string { return t.t.Body }
+func (t *Ticket) AssigneeID() UserID { return t.t.AssigneeID }
+func (t *Ticket) LastActivityOn() time.Time { return t.t.LastActivityOn }
func (t *Ticket) Config(key string) string {
return t.t.Config.GetString(key, "")
}
+func (t *Ticket) FlowTicket(oa *OrgAssets) (*flows.Ticket, error) {
+ modelTicketer := oa.TicketerByID(t.TicketerID())
+ if modelTicketer == nil {
+ return nil, errors.New("unable to load ticketer with id %d")
+ }
+
+ var flowUser *flows.User
+ if t.AssigneeID() != NilUserID {
+ user := oa.UserByID(t.AssigneeID())
+ if user != nil {
+ flowUser = oa.SessionAssets().Users().Get(user.Email())
+ }
+ }
+
+ return flows.NewTicket(
+ t.UUID(),
+ oa.SessionAssets().Ticketers().Get(modelTicketer.UUID()),
+ t.Subject(),
+ t.Body(),
+ string(t.ExternalID()),
+ flowUser,
+ ), nil
+}
+
// ForwardIncoming forwards an incoming message from a contact to this ticket
func (t *Ticket) ForwardIncoming(ctx context.Context, db Queryer, org *OrgAssets, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment) error {
ticketer := org.TicketerByID(t.t.TicketerID)
@@ -93,7 +147,7 @@ func (t *Ticket) ForwardIncoming(ctx context.Context, db Queryer, org *OrgAssets
return errors.Errorf("can't find ticketer with id %d", t.t.TicketerID)
}
- service, err := ticketer.AsService(flows.NewTicketer(ticketer))
+ service, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer))
if err != nil {
return err
}
@@ -101,7 +155,9 @@ func (t *Ticket) ForwardIncoming(ctx context.Context, db Queryer, org *OrgAssets
logger := &HTTPLogger{}
err = service.Forward(t, msgUUID, text, attachments, logger.Ticketer(ticketer))
- return logger.Insert(ctx, db)
+ logger.Insert(ctx, db)
+
+ return err
}
const selectOpenTicketsSQL = `
@@ -115,10 +171,12 @@ SELECT
t.status AS status,
t.subject AS subject,
t.body AS body,
+ t.assignee_id AS assignee_id,
t.config AS config,
t.opened_on AS opened_on,
t.modified_on AS modified_on,
- t.closed_on AS closed_on
+ t.closed_on AS closed_on,
+ t.last_activity_on AS last_activity_on
FROM
tickets_ticket t
WHERE
@@ -142,21 +200,21 @@ SELECT
t.status AS status,
t.subject AS subject,
t.body AS body,
+ t.assignee_id AS assignee_id,
t.config AS config,
t.opened_on AS opened_on,
t.modified_on AS modified_on,
- t.closed_on AS closed_on
+ t.closed_on AS closed_on,
+ t.last_activity_on AS last_activity_on
FROM
tickets_ticket t
WHERE
- t.org_id = $1 AND
- t.id = ANY($2) AND
- t.status = $3
+ t.id = ANY($1)
`
// LoadTickets loads all of the tickets with the given ids
-func LoadTickets(ctx context.Context, db Queryer, orgID OrgID, ids []TicketID, status TicketStatus) ([]*Ticket, error) {
- return loadTickets(ctx, db, selectTicketsByIDSQL, orgID, pq.Array(ids), status)
+func LoadTickets(ctx context.Context, db Queryer, ids []TicketID) ([]*Ticket, error) {
+ return loadTickets(ctx, db, selectTicketsByIDSQL, pq.Array(ids))
}
func loadTickets(ctx context.Context, db Queryer, query string, params ...interface{}) ([]*Ticket, error) {
@@ -181,50 +239,54 @@ func loadTickets(ctx context.Context, db Queryer, query string, params ...interf
const selectTicketByUUIDSQL = `
SELECT
- id,
- uuid,
- org_id,
- contact_id,
- ticketer_id,
- external_id,
- status,
- subject,
- body,
- config,
- opened_on,
- modified_on,
- closed_on
+ t.id AS id,
+ t.uuid AS uuid,
+ t.org_id AS org_id,
+ t.contact_id AS contact_id,
+ t.ticketer_id AS ticketer_id,
+ t.external_id AS external_id,
+ t.status AS status,
+ t.subject AS subject,
+ t.body AS body,
+ t.assignee_id AS assignee_id,
+ t.config AS config,
+ t.opened_on AS opened_on,
+ t.modified_on AS modified_on,
+ t.closed_on AS closed_on,
+ t.last_activity_on AS last_activity_on
FROM
- tickets_ticket
+ tickets_ticket t
WHERE
- uuid = $1
+ t.uuid = $1
`
// LookupTicketByUUID looks up the ticket with the passed in UUID
-func LookupTicketByUUID(ctx context.Context, db Queryer, uuid flows.TicketUUID) (*Ticket, error) {
+func LookupTicketByUUID(ctx context.Context, db *sqlx.DB, uuid flows.TicketUUID) (*Ticket, error) {
return lookupTicket(ctx, db, selectTicketByUUIDSQL, uuid)
}
const selectTicketByExternalIDSQL = `
SELECT
- id,
- uuid,
- org_id,
- contact_id,
- ticketer_id,
- external_id,
- status,
- subject,
- body,
- config,
- opened_on,
- modified_on,
- closed_on
+ t.id AS id,
+ t.uuid AS uuid,
+ t.org_id AS org_id,
+ t.contact_id AS contact_id,
+ t.ticketer_id AS ticketer_id,
+ t.external_id AS external_id,
+ t.status AS status,
+ t.subject AS subject,
+ t.body AS body,
+ t.assignee_id AS assignee_id,
+ t.config AS config,
+ t.opened_on AS opened_on,
+ t.modified_on AS modified_on,
+ t.closed_on AS closed_on,
+ t.last_activity_on AS last_activity_on
FROM
- tickets_ticket
+ tickets_ticket t
WHERE
- ticketer_id = $1 AND
- external_id = $2
+ t.ticketer_id = $1 AND
+ t.external_id = $2
`
// LookupTicketByExternalID looks up the ticket with the passed in ticketer and external ID
@@ -254,8 +316,8 @@ func lookupTicket(ctx context.Context, db Queryer, query string, params ...inter
const insertTicketSQL = `
INSERT INTO
- tickets_ticket(uuid, org_id, contact_id, ticketer_id, external_id, status, subject, body, config, opened_on, modified_on)
- VALUES( :uuid, :org_id, :contact_id, :ticketer_id, :external_id, :status, :subject, :body, :config, NOW(), NOW() )
+ tickets_ticket(uuid, org_id, contact_id, ticketer_id, external_id, status, subject, body, assignee_id, config, opened_on, modified_on, last_activity_on)
+ VALUES( :uuid, :org_id, :contact_id, :ticketer_id, :external_id, :status, :subject, :body, :assignee_id, :config, NOW(), NOW() , NOW())
RETURNING
id
`
@@ -274,45 +336,106 @@ func InsertTickets(ctx context.Context, tx Queryer, tickets []*Ticket) error {
return BulkQuery(ctx, "inserted tickets", tx, insertTicketSQL, ts)
}
-const updateTicketExternalIDSQL = `
-UPDATE
- tickets_ticket
-SET
- external_id = $2
-WHERE
- id = $1
-`
-
// UpdateTicketExternalID updates the external ID of the given ticket
func UpdateTicketExternalID(ctx context.Context, db Queryer, ticket *Ticket, externalID string) error {
t := &ticket.t
t.ExternalID = null.String(externalID)
- return Exec(ctx, "update ticket external ID", db, updateTicketExternalIDSQL, t.ID, t.ExternalID)
+ return Exec(ctx, "update ticket external ID", db, `UPDATE tickets_ticket SET external_id = $2 WHERE id = $1`, t.ID, t.ExternalID)
+}
+
+// UpdateTicketConfig updates the passed in ticket's config with any passed in values
+func UpdateTicketConfig(ctx context.Context, db Queryer, ticket *Ticket, config map[string]string) error {
+ t := &ticket.t
+ for key, value := range config {
+ t.Config.Map()[key] = value
+ }
+
+ return Exec(ctx, "update ticket config", db, `UPDATE tickets_ticket SET config = $2 WHERE id = $1`, t.ID, t.Config)
+}
+
+// UpdateTicketLastActivity updates the last_activity_on of the given tickets to be now
+func UpdateTicketLastActivity(ctx context.Context, db Queryer, tickets []*Ticket) error {
+ now := dates.Now()
+ ids := make([]TicketID, len(tickets))
+ for i, t := range tickets {
+ t.t.LastActivityOn = now
+ ids[i] = t.ID()
+ }
+ return updateTicketLastActivity(ctx, db, ids, now)
+}
+
+func updateTicketLastActivity(ctx context.Context, db Queryer, ids []TicketID, now time.Time) error {
+ return Exec(ctx, "update ticket last activity", db, `UPDATE tickets_ticket SET last_activity_on = $2 WHERE id = ANY($1)`, pq.Array(ids), now)
}
-const updateTicketAndKeepOpenSQL = `
+const assignTicketSQL = `
UPDATE
tickets_ticket
SET
- status = $2,
- config = $3,
- modified_on = $4,
- closed_on = NULL
+ assignee_id = $2,
+ modified_on = $3,
+ last_activity_on = $3
WHERE
- id = $1
+ id = ANY($1)
`
-// UpdateAndKeepOpenTicket updates the passed in ticket to ensure it's open and updates the config with any passed in values
-func UpdateAndKeepOpenTicket(ctx context.Context, db Queryer, ticket *Ticket, config map[string]string) error {
+// AssignTickets assigns the passed in tickets
+func AssignTickets(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID, tickets []*Ticket, assigneeID UserID, note string) (map[*Ticket]*TicketEvent, error) {
+ ids := make([]TicketID, 0, len(tickets))
+ events := make([]*TicketEvent, 0, len(tickets))
+ eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets))
now := dates.Now()
- t := &ticket.t
- t.Status = TicketStatusOpen
- t.ModifiedOn = now
- for key, value := range config {
- t.Config.Map()[key] = value
+
+ for _, ticket := range tickets {
+ if ticket.AssigneeID() != assigneeID {
+ ids = append(ids, ticket.ID())
+ t := &ticket.t
+ t.AssigneeID = assigneeID
+ t.ModifiedOn = now
+ t.LastActivityOn = now
+
+ e := NewTicketAssignedEvent(ticket, userID, assigneeID, note)
+ events = append(events, e)
+ eventsByTicket[ticket] = e
+ }
+ }
+
+ // mark the tickets as assigned in the db
+ err := Exec(ctx, "assign tickets", db, assignTicketSQL, pq.Array(ids), assigneeID, now)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error updating tickets")
}
- return Exec(ctx, "update ticket", db, updateTicketAndKeepOpenSQL, t.ID, t.Status, t.Config, t.ModifiedOn)
+ err = InsertTicketEvents(ctx, db, events)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error inserting ticket events")
+ }
+
+ return eventsByTicket, nil
+}
+
+// NoteTickets adds a note to the passed in tickets
+func NoteTickets(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID, tickets []*Ticket, note string) (map[*Ticket]*TicketEvent, error) {
+ events := make([]*TicketEvent, 0, len(tickets))
+ eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets))
+
+ for _, ticket := range tickets {
+ e := NewTicketNoteEvent(ticket, userID, note)
+ events = append(events, e)
+ eventsByTicket[ticket] = e
+ }
+
+ err := UpdateTicketLastActivity(ctx, db, tickets)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error updating ticket activity")
+ }
+
+ err = InsertTicketEvents(ctx, db, events)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error inserting ticket events")
+ }
+
+ return eventsByTicket, nil
}
const closeTicketSQL = `
@@ -321,43 +444,65 @@ UPDATE
SET
status = 'C',
modified_on = $2,
- closed_on = $2
+ closed_on = $2,
+ last_activity_on = $2
WHERE
id = ANY($1)
`
// CloseTickets closes the passed in tickets
-func CloseTickets(ctx context.Context, db Queryer, org *OrgAssets, tickets []*Ticket, externally bool, logger *HTTPLogger) error {
+func CloseTickets(ctx context.Context, db Queryer, oa *OrgAssets, userID UserID, tickets []*Ticket, externally bool, logger *HTTPLogger) (map[*Ticket]*TicketEvent, error) {
byTicketer := make(map[TicketerID][]*Ticket)
- ids := make([]TicketID, len(tickets))
+ ids := make([]TicketID, 0, len(tickets))
+ events := make([]*TicketEvent, 0, len(tickets))
+ eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets))
now := dates.Now()
- for i, ticket := range tickets {
- byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket)
- ids[i] = ticket.ID()
- t := &ticket.t
- t.Status = TicketStatusClosed
- t.ModifiedOn = now
- t.ClosedOn = &now
+
+ for _, ticket := range tickets {
+ if ticket.Status() != TicketStatusClosed {
+ byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket)
+ ids = append(ids, ticket.ID())
+ t := &ticket.t
+ t.Status = TicketStatusClosed
+ t.ModifiedOn = now
+ t.ClosedOn = &now
+ t.LastActivityOn = now
+
+ e := NewTicketClosedEvent(ticket, userID)
+ events = append(events, e)
+ eventsByTicket[ticket] = e
+ }
}
if externally {
for ticketerID, ticketerTickets := range byTicketer {
- ticketer := org.TicketerByID(ticketerID)
+ ticketer := oa.TicketerByID(ticketerID)
if ticketer != nil {
- service, err := ticketer.AsService(flows.NewTicketer(ticketer))
+ service, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer))
if err != nil {
- return err
+ return nil, err
}
err = service.Close(ticketerTickets, logger.Ticketer(ticketer))
if err != nil {
- return err
+ return nil, err
}
}
}
}
- return Exec(ctx, "close tickets", db, closeTicketSQL, pq.Array(ids), now)
+ // mark the tickets as closed in the db
+ err := Exec(ctx, "close tickets", db, closeTicketSQL, pq.Array(ids), now)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error updating tickets")
+ }
+
+ err = InsertTicketEvents(ctx, db, events)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error inserting ticket events")
+ }
+
+ return eventsByTicket, nil
}
const reopenTicketSQL = `
@@ -366,43 +511,65 @@ UPDATE
SET
status = 'O',
modified_on = $2,
- closed_on = NULL
+ closed_on = NULL,
+ last_activity_on = $2
WHERE
id = ANY($1)
`
// ReopenTickets reopens the passed in tickets
-func ReopenTickets(ctx context.Context, db Queryer, org *OrgAssets, tickets []*Ticket, externally bool, logger *HTTPLogger) error {
+func ReopenTickets(ctx context.Context, db Queryer, org *OrgAssets, userID UserID, tickets []*Ticket, externally bool, logger *HTTPLogger) (map[*Ticket]*TicketEvent, error) {
byTicketer := make(map[TicketerID][]*Ticket)
- ids := make([]TicketID, len(tickets))
+ ids := make([]TicketID, 0, len(tickets))
+ events := make([]*TicketEvent, 0, len(tickets))
+ eventsByTicket := make(map[*Ticket]*TicketEvent, len(tickets))
now := dates.Now()
- for i, ticket := range tickets {
- byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket)
- ids[i] = ticket.ID()
- t := &ticket.t
- t.Status = TicketStatusOpen
- t.ModifiedOn = now
- t.ClosedOn = nil
+
+ for _, ticket := range tickets {
+ if ticket.Status() != TicketStatusOpen {
+ byTicketer[ticket.TicketerID()] = append(byTicketer[ticket.TicketerID()], ticket)
+ ids = append(ids, ticket.ID())
+ t := &ticket.t
+ t.Status = TicketStatusOpen
+ t.ModifiedOn = now
+ t.ClosedOn = nil
+ t.LastActivityOn = now
+
+ e := NewTicketReopenedEvent(ticket, userID)
+ events = append(events, e)
+ eventsByTicket[ticket] = e
+ }
}
if externally {
for ticketerID, ticketerTickets := range byTicketer {
ticketer := org.TicketerByID(ticketerID)
if ticketer != nil {
- service, err := ticketer.AsService(flows.NewTicketer(ticketer))
+ service, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer))
if err != nil {
- return err
+ return nil, err
}
err = service.Reopen(ticketerTickets, logger.Ticketer(ticketer))
if err != nil {
- return err
+ return nil, err
}
}
}
}
- return Exec(ctx, "reopen tickets", db, reopenTicketSQL, pq.Array(ids), now)
+ // mark the tickets as opened in the db
+ err := Exec(ctx, "reopen tickets", db, reopenTicketSQL, pq.Array(ids), now)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error updating tickets")
+ }
+
+ err = InsertTicketEvents(ctx, db, events)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error inserting ticket events")
+ }
+
+ return eventsByTicket, nil
}
// Ticketer is our type for a ticketer asset
@@ -435,27 +602,23 @@ func (t *Ticketer) Type() string { return t.t.Type }
// Config returns the named config value
func (t *Ticketer) Config(key string) string { return t.t.Config[key] }
+// Reference returns an asset reference to this ticketer
+func (t *Ticketer) Reference() *assets.TicketerReference {
+ return assets.NewTicketerReference(t.t.UUID, t.t.Name)
+}
+
// AsService builds the corresponding engine service for the passed in Ticketer
-func (t *Ticketer) AsService(ticketer *flows.Ticketer) (TicketService, error) {
- httpClient, httpRetries, _ := goflow.HTTP()
+func (t *Ticketer) AsService(cfg *config.Config, ticketer *flows.Ticketer) (TicketService, error) {
+ httpClient, httpRetries, _ := goflow.HTTP(cfg)
initFunc := ticketServices[t.Type()]
if initFunc != nil {
- return initFunc(httpClient, httpRetries, ticketer, t.t.Config)
+ return initFunc(cfg, httpClient, httpRetries, ticketer, t.t.Config)
}
return nil, errors.Errorf("unrecognized ticket service type '%s'", t.Type())
}
-const updateTicketerConfigSQL = `
-UPDATE
- tickets_ticketer
-SET
- config = $2
-WHERE
- id = $1
-`
-
// UpdateConfig updates the configuration of this ticketer with the given values
func (t *Ticketer) UpdateConfig(ctx context.Context, db Queryer, add map[string]string, remove map[string]bool) error {
for key, value := range add {
@@ -471,7 +634,7 @@ func (t *Ticketer) UpdateConfig(ctx context.Context, db Queryer, add map[string]
dbMap[key] = value
}
- return Exec(ctx, "update ticketer config", db, updateTicketerConfigSQL, t.t.ID, null.NewMap(dbMap))
+ return Exec(ctx, "update ticketer config", db, `UPDATE tickets_ticketer SET config = $2 WHERE id = $1`, t.t.ID, null.NewMap(dbMap))
}
// TicketService extends the engine's ticket service and adds support for forwarding new incoming messages
@@ -484,7 +647,7 @@ type TicketService interface {
}
// TicketServiceFunc is a func which creates a ticket service
-type TicketServiceFunc func(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (TicketService, error)
+type TicketServiceFunc func(*config.Config, *http.Client, *httpx.RetryConfig, *flows.Ticketer, map[string]string) (TicketService, error)
var ticketServices = map[string]TicketServiceFunc{}
diff --git a/core/models/tickets_test.go b/core/models/tickets_test.go
index f18228788..d8da4b2ae 100644
--- a/core/models/tickets_test.go
+++ b/core/models/tickets_test.go
@@ -2,13 +2,17 @@ package models_test
import (
"testing"
+ "time"
+ "github.com/jmoiron/sqlx"
+ "github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom/core/models"
_ "github.com/nyaruka/mailroom/services/tickets/mailgun"
_ "github.com/nyaruka/mailroom/services/tickets/zendesk"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/null"
"github.com/stretchr/testify/assert"
@@ -16,37 +20,35 @@ import (
)
func TestTicketers(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Reset()
// can load directly by UUID
- ticketer, err := models.LookupTicketerByUUID(ctx, db, models.ZendeskUUID)
+ ticketer, err := models.LookupTicketerByUUID(ctx, db, testdata.Zendesk.UUID)
assert.NoError(t, err)
- assert.Equal(t, models.ZendeskID, ticketer.ID())
- assert.Equal(t, models.ZendeskUUID, ticketer.UUID())
+ assert.Equal(t, testdata.Zendesk.ID, ticketer.ID())
+ assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID())
assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name())
assert.Equal(t, "1234-abcd", ticketer.Config("push_id"))
assert.Equal(t, "523562", ticketer.Config("push_token"))
// org through org assets
- org1, err := models.GetOrgAssets(ctx, db, models.Org1)
+ org1, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
- ticketer = org1.TicketerByID(models.ZendeskID)
- assert.Equal(t, models.ZendeskUUID, ticketer.UUID())
+ ticketer = org1.TicketerByID(testdata.Zendesk.ID)
+ assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID())
assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name())
assert.Equal(t, "1234-abcd", ticketer.Config("push_id"))
- ticketer = org1.TicketerByUUID(models.ZendeskUUID)
- assert.Equal(t, models.ZendeskUUID, ticketer.UUID())
+ ticketer = org1.TicketerByUUID(testdata.Zendesk.UUID)
+ assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID())
assert.Equal(t, "Zendesk (Nyaruka)", ticketer.Name())
assert.Equal(t, "1234-abcd", ticketer.Config("push_id"))
ticketer.UpdateConfig(ctx, db, map[string]string{"new-key": "foo"}, map[string]bool{"push_id": true})
- models.FlushCache()
- org1, _ = models.GetOrgAssets(ctx, db, models.Org1)
- ticketer = org1.TicketerByID(models.ZendeskID)
+ org1, _ = models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers)
+ ticketer = org1.TicketerByID(testdata.Zendesk.ID)
assert.Equal(t, "foo", ticketer.Config("new-key")) // new config value added
assert.Equal(t, "", ticketer.Config("push_id")) // existing config value removed
@@ -54,67 +56,63 @@ func TestTicketers(t *testing.T) {
}
func TestTickets(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- defer httpx.SetRequestor(httpx.DefaultRequestor)
+ defer deleteTickets(db)
- httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
- "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": {
- httpx.NewMockResponse(200, nil, `{
- "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>",
- "message": "Queued. Thank you."
- }`),
- },
- }))
+ deleteTickets(db)
ticket1 := models.NewTicket(
"2ef57efc-d85f-4291-b330-e4afe68af5fe",
- models.Org1,
- models.CathyID,
- models.MailgunID,
+ testdata.Org1.ID,
+ testdata.Cathy.ID,
+ testdata.Mailgun.ID,
"EX12345",
"New Ticket",
"Where are my cookies?",
+ testdata.Admin.ID,
map[string]interface{}{
"contact-display": "Cathy",
},
)
ticket2 := models.NewTicket(
"64f81be1-00ff-48ef-9e51-97d6f924c1a4",
- models.Org1,
- models.BobID,
- models.ZendeskID,
+ testdata.Org1.ID,
+ testdata.Bob.ID,
+ testdata.Zendesk.ID,
"EX7869",
"New Zen Ticket",
"Where are my trousers?",
+ models.NilUserID,
nil,
)
ticket3 := models.NewTicket(
"28ef8ddc-b221-42f3-aeae-ee406fc9d716",
- models.Org2,
- models.AlexandriaID,
- models.ZendeskID,
+ testdata.Org2.ID,
+ testdata.Alexandria.ID,
+ testdata.Zendesk.ID,
"EX6677",
"Other Org Ticket",
"Where are my pants?",
+ testdata.Org2Admin.ID,
nil,
)
assert.Equal(t, flows.TicketUUID("2ef57efc-d85f-4291-b330-e4afe68af5fe"), ticket1.UUID())
- assert.Equal(t, models.Org1, ticket1.OrgID())
- assert.Equal(t, models.CathyID, ticket1.ContactID())
- assert.Equal(t, models.MailgunID, ticket1.TicketerID())
+ assert.Equal(t, testdata.Org1.ID, ticket1.OrgID())
+ assert.Equal(t, testdata.Cathy.ID, ticket1.ContactID())
+ assert.Equal(t, testdata.Mailgun.ID, ticket1.TicketerID())
assert.Equal(t, null.String("EX12345"), ticket1.ExternalID())
assert.Equal(t, "New Ticket", ticket1.Subject())
assert.Equal(t, "Cathy", ticket1.Config("contact-display"))
+ assert.Equal(t, testdata.Admin.ID, ticket1.AssigneeID())
assert.Equal(t, "", ticket1.Config("xyz"))
err := models.InsertTickets(ctx, db, []*models.Ticket{ticket1, ticket2, ticket3})
assert.NoError(t, err)
// check all tickets were created
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE status = 'O' AND closed_on IS NULL`, nil, 3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE status = 'O' AND closed_on IS NULL`).Returns(3)
// can lookup a ticket by UUID
tk1, err := models.LookupTicketByUUID(ctx, db, "2ef57efc-d85f-4291-b330-e4afe68af5fe")
@@ -122,37 +120,223 @@ func TestTickets(t *testing.T) {
assert.Equal(t, "New Ticket", tk1.Subject())
// can lookup a ticket by external ID and ticketer
- tk2, err := models.LookupTicketByExternalID(ctx, db, models.ZendeskID, "EX7869")
+ tk2, err := models.LookupTicketByExternalID(ctx, db, testdata.Zendesk.ID, "EX7869")
assert.NoError(t, err)
assert.Equal(t, "New Zen Ticket", tk2.Subject())
// can lookup open tickets by contact
- org1, _ := models.GetOrgAssets(ctx, db, models.Org1)
- cathy, err := models.LoadContact(ctx, db, org1, models.CathyID)
+ org1, _ := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
+ cathy, err := models.LoadContact(ctx, db, org1, testdata.Cathy.ID)
require.NoError(t, err)
tks, err := models.LoadOpenTicketsForContact(ctx, db, cathy)
assert.NoError(t, err)
assert.Equal(t, 1, len(tks))
assert.Equal(t, "New Ticket", tks[0].Subject())
+}
- err = models.UpdateAndKeepOpenTicket(ctx, db, ticket1, map[string]string{"last-message-id": "2352"})
- assert.NoError(t, err)
+func TestUpdateTicketConfig(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer deleteTickets(db)
+
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket := ticket.Load(db)
+
+ // empty configs are null
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE config IS NULL AND id = $1`, ticket.ID).Returns(1)
+
+ models.UpdateTicketConfig(ctx, db, modelTicket, map[string]string{"foo": "2352", "bar": "abc"})
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE config='{"foo": "2352", "bar": "abc"}'::jsonb AND id = $1`, ticket.ID).Returns(1)
+
+ // updates are additive
+ models.UpdateTicketConfig(ctx, db, modelTicket, map[string]string{"foo": "6547", "zed": "xyz"})
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE config='{"foo": "6547", "bar": "abc", "zed": "xyz"}'::jsonb AND id = $1`, ticket.ID).Returns(1)
+}
+
+func TestUpdateTicketLastActivity(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer deleteTickets(db)
+
+ now := time.Date(2021, 6, 22, 15, 59, 30, 123456789, time.UTC)
+
+ defer dates.SetNowSource(dates.DefaultNowSource)
+ dates.SetNowSource(dates.NewFixedNowSource(now))
+
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket := ticket.Load(db)
+
+ models.UpdateTicketLastActivity(ctx, db, []*models.Ticket{modelTicket})
+
+ assert.Equal(t, now, modelTicket.LastActivityOn())
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND last_activity_on = $2`, ticket.ID, modelTicket.LastActivityOn()).Returns(1)
+
+}
+
+func TestAssignTickets(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer deleteTickets(db)
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers)
+ require.NoError(t, err)
+
+ ticket1 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket1 := ticket1.Load(db)
+
+ ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil)
+ modelTicket2 := ticket2.Load(db)
+
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Ignore", "", "", nil)
+
+ evts, err := models.AssignTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, testdata.Agent.ID, "please handle these")
+ require.NoError(t, err)
+ assert.Equal(t, 2, len(evts))
+ assert.Equal(t, models.TicketEventTypeAssigned, evts[modelTicket1].EventType())
+ assert.Equal(t, models.TicketEventTypeAssigned, evts[modelTicket2].EventType())
+
+ // check tickets are now assigned
+ testsuite.AssertQuery(t, db, `SELECT assignee_id FROM tickets_ticket WHERE id = $1`, ticket1.ID).Columns(map[string]interface{}{"assignee_id": int64(testdata.Agent.ID)})
+ testsuite.AssertQuery(t, db, `SELECT assignee_id FROM tickets_ticket WHERE id = $1`, ticket2.ID).Columns(map[string]interface{}{"assignee_id": int64(testdata.Agent.ID)})
+
+ // and there are new assigned events
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'A' AND note = 'please handle these'`).Returns(2)
+}
+
+func TestNoteTickets(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer deleteTickets(db)
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers)
+ require.NoError(t, err)
+
+ ticket1 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket1 := ticket1.Load(db)
+
+ ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil)
+ modelTicket2 := ticket2.Load(db)
+
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Ignore", "", "", nil)
+
+ evts, err := models.NoteTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, "spam")
+ require.NoError(t, err)
+ assert.Equal(t, 2, len(evts))
+ assert.Equal(t, models.TicketEventTypeNote, evts[modelTicket1].EventType())
+ assert.Equal(t, models.TicketEventTypeNote, evts[modelTicket2].EventType())
+
+ // check there are new note events
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'N' AND note = 'spam'`).Returns(2)
+}
+
+func TestCloseTickets(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer httpx.SetRequestor(httpx.DefaultRequestor)
+ defer deleteTickets(db)
+
+ httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
+ "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": {
+ httpx.NewMockResponse(200, nil, `{
+ "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>",
+ "message": "Queued. Thank you."
+ }`),
+ },
+ }))
- // check ticket remains open and config was updated
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE org_id = $1 AND status = 'O' AND config='{"contact-display": "Cathy", "last-message-id": "2352"}'::jsonb AND closed_on IS NULL`, []interface{}{models.Org1}, 1)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers)
+ require.NoError(t, err)
+
+ ticket1 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket1 := ticket1.Load(db)
+
+ ticket2 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil)
+ modelTicket2 := ticket2.Load(db)
logger := &models.HTTPLogger{}
+ evts, err := models.CloseTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, true, logger)
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(evts))
+ assert.Equal(t, models.TicketEventTypeClosed, evts[modelTicket1].EventType())
- err = models.CloseTickets(ctx, db, org1, []*models.Ticket{ticket1}, true, logger)
- assert.NoError(t, err)
+ // check ticket #1 is now closed
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND status = 'C' AND closed_on IS NOT NULL`, ticket1.ID).Returns(1)
- // check ticket is now closed
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE org_id = $1 AND status = 'C' AND closed_on IS NOT NULL`, []interface{}{models.Org1}, 1)
+ // and there's closed event for it
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE org_id = $1 AND ticket_id = $2 AND event_type = 'C'`,
+ []interface{}{testdata.Org1.ID, ticket1.ID}, 1)
- err = models.UpdateAndKeepOpenTicket(ctx, db, ticket1, map[string]string{"last-message-id": "6754"})
- assert.NoError(t, err)
+ // and the logger has an http log it can insert for that ticketer
+ require.NoError(t, logger.Insert(ctx, db))
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM request_logs_httplog WHERE ticketer_id = $1`, testdata.Mailgun.ID).Returns(1)
+
+ // but no events for ticket #2 which waas already closed
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'C'`, ticket2.ID).Returns(0)
+
+ // can close tickets without a user
+ ticket3 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket3 := ticket3.Load(db)
+
+ evts, err = models.CloseTickets(ctx, db, oa, models.NilUserID, []*models.Ticket{modelTicket3}, false, logger)
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(evts))
+ assert.Equal(t, models.TicketEventTypeClosed, evts[modelTicket3].EventType())
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'C' AND created_by_id IS NULL`, ticket3.ID).Returns(1)
+}
+
+func TestReopenTickets(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ defer httpx.SetRequestor(httpx.DefaultRequestor)
+ defer deleteTickets(db)
+
+ httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
+ "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": {
+ httpx.NewMockResponse(200, nil, `{
+ "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>",
+ "message": "Queued. Thank you."
+ }`),
+ },
+ }))
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTicketers)
+ require.NoError(t, err)
+
+ ticket1 := testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where my shoes", "123", nil)
+ modelTicket1 := ticket1.Load(db)
+
+ ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old Problem", "Where my pants", "234", nil)
+ modelTicket2 := ticket2.Load(db)
+
+ logger := &models.HTTPLogger{}
+ evts, err := models.ReopenTickets(ctx, db, oa, testdata.Admin.ID, []*models.Ticket{modelTicket1, modelTicket2}, true, logger)
+ require.NoError(t, err)
+ assert.Equal(t, 1, len(evts))
+ assert.Equal(t, models.TicketEventTypeReopened, evts[modelTicket1].EventType())
+
+ // check ticket #1 is now closed
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND status = 'O' AND closed_on IS NULL`, ticket1.ID).Returns(1)
+
+ // and there's reopened event for it
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE org_id = $1 AND ticket_id = $2 AND event_type = 'R'`, testdata.Org1.ID, ticket1.ID).Returns(1)
+
+ // and the logger has an http log it can insert for that ticketer
+ require.NoError(t, logger.Insert(ctx, db))
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM request_logs_httplog WHERE ticketer_id = $1`, testdata.Mailgun.ID).Returns(1)
+
+ // but no events for ticket #2 which waas already open
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticketevent WHERE ticket_id = $1 AND event_type = 'R'`, ticket2.ID).Returns(0)
+}
- // check ticket is open again
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM tickets_ticket WHERE org_id = $1 AND status = 'O' AND closed_on IS NULL`, []interface{}{models.Org1}, 2)
+func deleteTickets(db *sqlx.DB) {
+ db.MustExec(`DELETE FROM request_logs_httplog`)
+ db.MustExec(`DELETE FROM tickets_ticketevent`)
+ db.MustExec(`DELETE FROM tickets_ticket`)
}
diff --git a/core/models/topups.go b/core/models/topups.go
index 04725698f..46f6773a1 100644
--- a/core/models/topups.go
+++ b/core/models/topups.go
@@ -48,7 +48,7 @@ func AllocateTopups(ctx context.Context, db Queryer, rp *redis.Pool, org *Org, a
}
// no active topup found, lets calculate it
- topup, err := calculateActiveTopup(ctx, db, org.ID())
+ topup, err := CalculateActiveTopup(ctx, db, org.ID())
if err != nil {
return NilTopupID, err
}
@@ -99,8 +99,8 @@ end
return {activeTopup, remaining}
`)
-// calculateActiveTopup loads the active topup for the passed in org
-func calculateActiveTopup(ctx context.Context, db Queryer, orgID OrgID) (*Topup, error) {
+// CalculateActiveTopup loads the active topup for the passed in org
+func CalculateActiveTopup(ctx context.Context, db Queryer, orgID OrgID) (*Topup, error) {
topup := &Topup{}
rows, err := db.QueryxContext(ctx, selectActiveTopup, orgID)
if err != nil {
diff --git a/core/models/topups_test.go b/core/models/topups_test.go
index 54e8faaf5..c126149e0 100644
--- a/core/models/topups_test.go
+++ b/core/models/topups_test.go
@@ -1,17 +1,18 @@
-package models
+package models_test
import (
"testing"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestTopups(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
+ ctx, rt, db, rp := testsuite.Get()
tx, err := db.BeginTxx(ctx, nil)
assert.NoError(t, err)
@@ -21,19 +22,19 @@ func TestTopups(t *testing.T) {
VALUES(TRUE, 1000000, 1),(TRUE, 99000, 2),(TRUE, 998, 2)`)
tcs := []struct {
- OrgID OrgID
- TopupID TopupID
+ OrgID models.OrgID
+ TopupID models.TopupID
Remaining int
}{
- {Org1, NilTopupID, 0},
- {Org2, TopupID(2), 2},
+ {testdata.Org1.ID, models.NilTopupID, 0},
+ {testdata.Org2.ID, models.TopupID(2), 2},
}
for _, tc := range tcs {
- topup, err := calculateActiveTopup(ctx, tx, tc.OrgID)
+ topup, err := models.CalculateActiveTopup(ctx, tx, tc.OrgID)
assert.NoError(t, err)
- if tc.TopupID == NilTopupID {
+ if tc.TopupID == models.NilTopupID {
assert.Nil(t, topup)
} else {
assert.NotNil(t, topup)
@@ -43,29 +44,31 @@ func TestTopups(t *testing.T) {
}
tc2s := []struct {
- OrgID OrgID
- TopupID TopupID
+ OrgID models.OrgID
+ TopupID models.TopupID
}{
- {Org1, NilTopupID},
- {Org2, TopupID(2)},
- {Org2, TopupID(2)},
- {Org2, NilTopupID},
+ {testdata.Org1.ID, models.NilTopupID},
+ {testdata.Org2.ID, models.TopupID(2)},
+ {testdata.Org2.ID, models.TopupID(2)},
+ {testdata.Org2.ID, models.NilTopupID},
}
for _, tc := range tc2s {
- org, err := loadOrg(ctx, tx, tc.OrgID)
+ org, err := models.LoadOrg(ctx, rt.Config, tx, tc.OrgID)
assert.NoError(t, err)
- topup, err := AllocateTopups(ctx, tx, rp, org, 1)
+ topup, err := models.AllocateTopups(ctx, tx, rp, org, 1)
assert.NoError(t, err)
assert.Equal(t, tc.TopupID, topup)
tx.MustExec(`INSERT INTO orgs_topupcredits(is_squashed, used, topup_id) VALUES(TRUE, 1, $1)`, tc.OrgID)
}
// topups can be disabled for orgs
- tx.MustExec(`UPDATE orgs_org SET uses_topups = FALSE WHERE id = $1`, Org1)
- org, err := loadOrg(ctx, tx, Org1)
- topup, err := AllocateTopups(ctx, tx, rp, org, 1)
+ tx.MustExec(`UPDATE orgs_org SET uses_topups = FALSE WHERE id = $1`, testdata.Org1.ID)
+ org, err := models.LoadOrg(ctx, rt.Config, tx, testdata.Org1.ID)
+ require.NoError(t, err)
+
+ topup, err := models.AllocateTopups(ctx, tx, rp, org, 1)
assert.NoError(t, err)
- assert.Equal(t, NilTopupID, topup)
+ assert.Equal(t, models.NilTopupID, topup)
}
diff --git a/core/models/triggers.go b/core/models/triggers.go
index 15d3b8509..094c267ba 100644
--- a/core/models/triggers.go
+++ b/core/models/triggers.go
@@ -2,6 +2,7 @@ package models
import (
"context"
+ "sort"
"strings"
"time"
@@ -31,14 +32,15 @@ const (
MissedCallTriggerType = TriggerType("M")
NewConversationTriggerType = TriggerType("N")
ReferralTriggerType = TriggerType("R")
- CallTriggerType = TriggerType("V")
+ IncomingCallTriggerType = TriggerType("V")
ScheduleTriggerType = TriggerType("S")
+ TicketClosedTriggerType = TriggerType("T")
)
// match type constants
const (
- MatchFirst = "F"
- MatchOnly = "O"
+ MatchFirst MatchType = "F"
+ MatchOnly MatchType = "O"
)
// NilTriggerID is the nil value for trigger IDs
@@ -47,29 +49,31 @@ const NilTriggerID = TriggerID(0)
// Trigger represents a trigger in an organization
type Trigger struct {
t struct {
- ID TriggerID `json:"id"`
- FlowID FlowID `json:"flow_id"`
- TriggerType TriggerType `json:"trigger_type"`
- Keyword string `json:"keyword"`
- MatchType MatchType `json:"match_type"`
- ChannelID ChannelID `json:"channel_id"`
- ReferrerID string `json:"referrer_id"`
- GroupIDs []GroupID `json:"group_ids"`
- ContactIDs []ContactID `json:"contact_ids,omitempty"`
+ ID TriggerID `json:"id"`
+ FlowID FlowID `json:"flow_id"`
+ TriggerType TriggerType `json:"trigger_type"`
+ Keyword string `json:"keyword"`
+ MatchType MatchType `json:"match_type"`
+ ChannelID ChannelID `json:"channel_id"`
+ ReferrerID string `json:"referrer_id"`
+ IncludeGroupIDs []GroupID `json:"include_group_ids"`
+ ExcludeGroupIDs []GroupID `json:"exclude_group_ids"`
+ ContactIDs []ContactID `json:"contact_ids,omitempty"`
}
}
// ID returns the id of this trigger
func (t *Trigger) ID() TriggerID { return t.t.ID }
-func (t *Trigger) FlowID() FlowID { return t.t.FlowID }
-func (t *Trigger) TriggerType() TriggerType { return t.t.TriggerType }
-func (t *Trigger) Keyword() string { return t.t.Keyword }
-func (t *Trigger) MatchType() MatchType { return t.t.MatchType }
-func (t *Trigger) ChannelID() ChannelID { return t.t.ChannelID }
-func (t *Trigger) ReferrerID() string { return t.t.ReferrerID }
-func (t *Trigger) GroupIDs() []GroupID { return t.t.GroupIDs }
-func (t *Trigger) ContactIDs() []ContactID { return t.t.ContactIDs }
+func (t *Trigger) FlowID() FlowID { return t.t.FlowID }
+func (t *Trigger) TriggerType() TriggerType { return t.t.TriggerType }
+func (t *Trigger) Keyword() string { return t.t.Keyword }
+func (t *Trigger) MatchType() MatchType { return t.t.MatchType }
+func (t *Trigger) ChannelID() ChannelID { return t.t.ChannelID }
+func (t *Trigger) ReferrerID() string { return t.t.ReferrerID }
+func (t *Trigger) IncludeGroupIDs() []GroupID { return t.t.IncludeGroupIDs }
+func (t *Trigger) ExcludeGroupIDs() []GroupID { return t.t.ExcludeGroupIDs }
+func (t *Trigger) ContactIDs() []ContactID { return t.t.ContactIDs }
func (t *Trigger) KeywordMatchType() triggers.KeywordMatchType {
if t.t.MatchType == MatchFirst {
return triggers.KeywordMatchTypeFirstWord
@@ -105,6 +109,7 @@ func loadTriggers(ctx context.Context, db Queryer, orgID OrgID) ([]*Trigger, err
if err != nil {
return nil, errors.Wrap(err, "error scanning label row")
}
+
triggers = append(triggers, trigger)
}
@@ -113,176 +118,182 @@ func loadTriggers(ctx context.Context, db Queryer, orgID OrgID) ([]*Trigger, err
return triggers, nil
}
-// FindMatchingNewConversationTrigger returns the matching trigger for the passed in trigger type
-func FindMatchingNewConversationTrigger(org *OrgAssets, channel *Channel) *Trigger {
- var match *Trigger
- for _, t := range org.Triggers() {
- if t.TriggerType() == NewConversationTriggerType {
- // exact match? return right away
- if t.ChannelID() == channel.ID() {
- return t
- }
+// FindMatchingMsgTrigger finds the best match trigger for an incoming message from the given contact
+func FindMatchingMsgTrigger(oa *OrgAssets, contact *flows.Contact, text string) *Trigger {
+ // determine our message keyword
+ words := utils.TokenizeString(text)
+ keyword := ""
+ only := false
+ if len(words) > 0 {
+ // our keyword is our first word
+ keyword = strings.ToLower(words[0])
+ only = len(words) == 1
+ }
- // trigger has no channel filter, record this as match
- if t.ChannelID() == NilChannelID && match == nil {
- match = t
- }
- }
+ candidates := findTriggerCandidates(oa, KeywordTriggerType, func(t *Trigger) bool {
+ return t.Keyword() == keyword && (t.MatchType() == MatchFirst || (t.MatchType() == MatchOnly && only))
+ })
+
+ // if we have a matching keyword trigger return that, otherwise we move on to catchall triggers..
+ byKeyword := findBestTriggerMatch(candidates, nil, contact)
+ if byKeyword != nil {
+ return byKeyword
}
- return match
+ candidates = findTriggerCandidates(oa, CatchallTriggerType, nil)
+
+ return findBestTriggerMatch(candidates, nil, contact)
}
-// FindMatchingMissedCallTrigger finds any trigger set up for incoming calls (these would be IVR flows)
-func FindMatchingMissedCallTrigger(org *OrgAssets) *Trigger {
- for _, t := range org.Triggers() {
- if t.TriggerType() == MissedCallTriggerType {
- return t
- }
- }
+// FindMatchingIncomingCallTrigger finds the best match trigger for incoming calls
+func FindMatchingIncomingCallTrigger(oa *OrgAssets, contact *flows.Contact) *Trigger {
+ candidates := findTriggerCandidates(oa, IncomingCallTriggerType, nil)
- return nil
+ return findBestTriggerMatch(candidates, nil, contact)
}
-// FindMatchingMOCallTrigger finds any trigger set up for incoming calls (these would be IVR flows)
-// Contact is needed as this trigger can be filtered by contact group
-func FindMatchingMOCallTrigger(org *OrgAssets, contact *Contact) *Trigger {
- // build a set of the groups this contact is in
- groupIDs := make(map[GroupID]bool, 10)
- for _, g := range contact.Groups() {
- groupIDs[g.ID()] = true
- }
+// FindMatchingMissedCallTrigger finds the best match trigger for missed incoming calls
+func FindMatchingMissedCallTrigger(oa *OrgAssets) *Trigger {
+ candidates := findTriggerCandidates(oa, MissedCallTriggerType, nil)
- var match *Trigger
- for _, t := range org.Triggers() {
- if t.TriggerType() == CallTriggerType {
- // this trigger has no groups, it's a match!
- if len(t.GroupIDs()) == 0 {
- if match == nil {
- match = t
- }
- continue
- }
+ return findBestTriggerMatch(candidates, nil, nil)
+}
- // test whether we are part of this trigger's group
- for _, g := range t.GroupIDs() {
- if groupIDs[g] {
- // group keyword matches always take precedence, can return right away
- return t
- }
- }
- }
+// FindMatchingNewConversationTrigger finds the best match trigger for new conversation channel events
+func FindMatchingNewConversationTrigger(oa *OrgAssets, channel *Channel) *Trigger {
+ candidates := findTriggerCandidates(oa, NewConversationTriggerType, nil)
+
+ return findBestTriggerMatch(candidates, channel, nil)
+}
+
+// FindMatchingReferralTrigger finds the best match trigger for referral click channel events
+func FindMatchingReferralTrigger(oa *OrgAssets, channel *Channel, referrerID string) *Trigger {
+ // first try to find matching referrer ID
+ candidates := findTriggerCandidates(oa, ReferralTriggerType, func(t *Trigger) bool {
+ return strings.EqualFold(t.ReferrerID(), referrerID)
+ })
+
+ match := findBestTriggerMatch(candidates, channel, nil)
+ if match != nil {
+ return match
}
- return match
+ // if that didn't work look for an empty referrer ID
+ candidates = findTriggerCandidates(oa, ReferralTriggerType, func(t *Trigger) bool {
+ return t.ReferrerID() == ""
+ })
+
+ return findBestTriggerMatch(candidates, channel, nil)
}
-// FindMatchingReferralTrigger returns the matching trigger for the passed in trigger type
-// Matches are based on referrer_id first (if present), then channel, then any referrer trigger
-func FindMatchingReferralTrigger(org *OrgAssets, channel *Channel, referrerID string) *Trigger {
- var match *Trigger
- for _, t := range org.Triggers() {
- if t.TriggerType() == ReferralTriggerType {
- // matches referrer id? that takes top precedence, return right away
- if strings.ToLower(referrerID) != "" && strings.ToLower(referrerID) == strings.ToLower(t.ReferrerID()) && (t.ChannelID() == NilChannelID || t.ChannelID() == channel.ID()) {
- return t
- }
+// FindMatchingTicketClosedTrigger finds the best match trigger for ticket closed events
+func FindMatchingTicketClosedTrigger(oa *OrgAssets, contact *flows.Contact) *Trigger {
+ candidates := findTriggerCandidates(oa, TicketClosedTriggerType, nil)
- // if this trigger has no referrer id, maybe we match by channel
- if t.ReferrerID() == "" {
- // matches channel? that is a good match
- if t.ChannelID() == channel.ID() {
- match = t
- } else if match == nil && t.ChannelID() == NilChannelID {
- // otherwise if we haven't been set yet, pick that
- match = t
- }
- }
+ return findBestTriggerMatch(candidates, nil, contact)
+}
+
+// finds trigger candidates based on type and optional filter
+func findTriggerCandidates(oa *OrgAssets, type_ TriggerType, filter func(*Trigger) bool) []*Trigger {
+ candidates := make([]*Trigger, 0, 10)
+
+ for _, t := range oa.Triggers() {
+ if t.TriggerType() == type_ && (filter == nil || filter(t)) {
+ candidates = append(candidates, t)
}
}
- return match
+ return candidates
}
-// FindMatchingMsgTrigger returns the matching trigger (if any) for the passed in text and channel id
-// TODO: with a different structure this could probably be a lot faster.. IE, we could have a map
-// of list of triggers by keyword that is built when we load the triggers, then just evaluate against that.
-func FindMatchingMsgTrigger(org *OrgAssets, contact *flows.Contact, text string) *Trigger {
- // build a set of the groups this contact is in
- groupIDs := make(map[GroupID]bool, 10)
- for _, g := range contact.Groups().All() {
- groupIDs[g.Asset().(*Group).ID()] = true
+type triggerMatch struct {
+ trigger *Trigger
+ score int
+}
+
+// matching triggers are given a score based on how they matched, and this score is used to select the most
+// specific match:
+//
+// channel (4) + include (2) + exclude (1) = 7
+// channel (4) + include (2) = 6
+// channel (4) + exclude (1) = 5
+// channel (4) = 4
+// include (2) + exclude (1) = 3
+// include (2) = 2
+// exclude (1) = 1
+//
+const triggerScoreByChannel = 4
+const triggerScoreByInclusion = 2
+const triggerScoreByExclusion = 1
+
+func findBestTriggerMatch(candidates []*Trigger, channel *Channel, contact *flows.Contact) *Trigger {
+ matches := make([]*triggerMatch, 0, len(candidates))
+
+ var groupIDs map[GroupID]bool
+
+ if contact != nil {
+ // build a set of the groups this contact is in
+ groupIDs = make(map[GroupID]bool, 10)
+ for _, g := range contact.Groups().All() {
+ groupIDs[g.Asset().(*Group).ID()] = true
+ }
}
- // determine our message keyword
- words := utils.TokenizeString(text)
- keyword := ""
- only := false
- if len(words) > 0 {
- // our keyword is our first word
- keyword = strings.ToLower(words[0])
- only = len(words) == 1
+ for _, t := range candidates {
+ matched, score := triggerMatchQualifiers(t, channel, groupIDs)
+ if matched {
+ matches = append(matches, &triggerMatch{t, score})
+ }
}
- var match, catchAll, groupCatchAll *Trigger
- for _, t := range org.Triggers() {
- if t.TriggerType() == KeywordTriggerType {
- // does this match based on the rules of the trigger?
- matched := (t.Keyword() == keyword && (t.MatchType() == MatchFirst || (t.MatchType() == MatchOnly && only)))
+ if len(matches) == 0 {
+ return nil
+ }
- // no match? move on
- if !matched {
- continue
- }
+ // sort the matches to get them in descending order of score
+ sort.SliceStable(matches, func(i, j int) bool { return matches[i].score > matches[j].score })
- // this trigger has no groups, it's a match!
- if len(t.GroupIDs()) == 0 {
- if match == nil {
- match = t
- }
- continue
- }
+ return matches[0].trigger
+}
- // test whether we are part of this trigger's group
- for _, g := range t.GroupIDs() {
- if groupIDs[g] {
- // group keyword matches always take precedence, can return right away
- return t
- }
- }
- } else if t.TriggerType() == CatchallTriggerType {
- // if this catch all is on no groups, save it as our catch all
- if len(t.GroupIDs()) == 0 {
- if catchAll == nil {
- catchAll = t
- }
- continue
- }
+// matches against the qualifiers (inclusion groups, exclusion groups, channel) on this trigger and returns a score
+func triggerMatchQualifiers(t *Trigger, channel *Channel, contactGroups map[GroupID]bool) (bool, int) {
+ score := 0
- // otherwise see if this catchall matches our group
- if groupCatchAll == nil {
- for _, g := range t.GroupIDs() {
- if groupIDs[g] {
- groupCatchAll = t
- break
- }
- }
- }
+ if channel != nil && t.ChannelID() != NilChannelID {
+ if t.ChannelID() == channel.ID() {
+ score += triggerScoreByChannel
+ } else {
+ return false, 0
}
}
- // have a normal match? return that
- if match != nil {
- return match
+ if len(t.IncludeGroupIDs()) > 0 {
+ inGroup := false
+ // if contact is in any of the groups to include that's a match by inclusion
+ for _, g := range t.IncludeGroupIDs() {
+ if contactGroups[g] {
+ inGroup = true
+ score += triggerScoreByInclusion
+ break
+ }
+ }
+ if !inGroup {
+ return false, 0
+ }
}
- // otherwise return our group catch all if we found one
- if groupCatchAll != nil {
- return groupCatchAll
+ if len(t.ExcludeGroupIDs()) > 0 {
+ // if contact is in none of the groups to exclude that's a match by exclusion
+ for _, g := range t.ExcludeGroupIDs() {
+ if contactGroups[g] {
+ return false, 0
+ }
+ }
+ score += triggerScoreByExclusion
}
- // or our global catchall
- return catchAll
+ return true, score
}
const selectTriggersSQL = `
@@ -294,10 +305,12 @@ SELECT ROW_TO_JSON(r) FROM (SELECT
t.match_type as match_type,
t.channel_id as channel_id,
COALESCE(t.referrer_id, '') as referrer_id,
- ARRAY_REMOVE(ARRAY_AGG(g.contactgroup_id), NULL) as group_ids
+ ARRAY_REMOVE(ARRAY_AGG(DISTINCT ig.contactgroup_id), NULL) as include_group_ids,
+ ARRAY_REMOVE(ARRAY_AGG(DISTINCT eg.contactgroup_id), NULL) as exclude_group_ids
FROM
triggers_trigger t
- LEFT OUTER JOIN triggers_trigger_groups g ON t.id = g.trigger_id
+ LEFT OUTER JOIN triggers_trigger_groups ig ON t.id = ig.trigger_id
+ LEFT OUTER JOIN triggers_trigger_exclude_groups eg ON t.id = eg.trigger_id
WHERE
t.org_id = $1 AND
t.is_active = TRUE AND
@@ -335,7 +348,8 @@ SET
WHERE
id = ANY($1) AND
NOT EXISTS (SELECT * FROM triggers_trigger_contacts WHERE trigger_id = triggers_trigger.id) AND
- NOT EXISTS (SELECT * FROM triggers_trigger_groups WHERE trigger_id = triggers_trigger.id)
+ NOT EXISTS (SELECT * FROM triggers_trigger_groups WHERE trigger_id = triggers_trigger.id) AND
+ NOT EXISTS (SELECT * FROM triggers_trigger_exclude_groups WHERE trigger_id = triggers_trigger.id)
`
// ArchiveContactTriggers removes the given contacts from any triggers and archives any triggers
diff --git a/core/models/triggers_test.go b/core/models/triggers_test.go
index 0128db280..d94772937 100644
--- a/core/models/triggers_test.go
+++ b/core/models/triggers_test.go
@@ -1,151 +1,296 @@
-package models
+package models_test
import (
- "fmt"
"testing"
+ "github.com/nyaruka/gocommon/uuids"
+ "github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
- "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func insertTrigger(t *testing.T, db *sqlx.DB, active bool, flowID FlowID, triggerType TriggerType, keyword string, matchType MatchType, groupIDs []GroupID, contactIDs []ContactID, referrerID string, channelID ChannelID) TriggerID {
- var triggerID TriggerID
- err := db.Get(&triggerID,
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, referrer_id, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id)
- VALUES($1, now(), now(), $2, $6, false, $3, $4, $5, 1, 1, 1, $7) RETURNING id`, active, keyword, flowID, triggerType, matchType, referrerID, channelID)
+func TestLoadTriggers(t *testing.T) {
+ ctx, _, db, _ := testsuite.Reset()
- assert.NoError(t, err)
+ db.MustExec(`DELETE FROM triggers_trigger`)
+ farmersGroup := testdata.InsertContactGroup(db, testdata.Org1, assets.GroupUUID(uuids.New()), "Farmers", "")
- // insert any group associations
- for _, g := range groupIDs {
- db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, triggerID, g)
+ // create trigger for other org to ensure it isn't loaded
+ testdata.InsertCatchallTrigger(db, testdata.Org2, testdata.Org2Favorites, nil, nil)
+
+ tcs := []struct {
+ id models.TriggerID
+ type_ models.TriggerType
+ flowID models.FlowID
+ keyword string
+ keywordMatchType models.MatchType
+ referrerID string
+ includeGroups []models.GroupID
+ excludeGroups []models.GroupID
+ includeContacts []models.ContactID
+ channelID models.ChannelID
+ }{
+ {
+ id: testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "join", models.MatchFirst, nil, nil),
+ type_: models.KeywordTriggerType,
+ flowID: testdata.Favorites.ID,
+ keyword: "join",
+ keywordMatchType: models.MatchFirst,
+ },
+ {
+ id: testdata.InsertKeywordTrigger(
+ db, testdata.Org1, testdata.PickANumber, "start", models.MatchOnly,
+ []*testdata.Group{testdata.DoctorsGroup, testdata.TestersGroup},
+ []*testdata.Group{farmersGroup},
+ ),
+ type_: models.KeywordTriggerType,
+ flowID: testdata.PickANumber.ID,
+ keyword: "start",
+ keywordMatchType: models.MatchOnly,
+ includeGroups: []models.GroupID{testdata.DoctorsGroup.ID, testdata.TestersGroup.ID},
+ excludeGroups: []models.GroupID{farmersGroup.ID},
+ },
+ {
+ id: testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup, testdata.TestersGroup}, []*testdata.Group{farmersGroup}),
+ type_: models.IncomingCallTriggerType,
+ flowID: testdata.Favorites.ID,
+ includeGroups: []models.GroupID{testdata.DoctorsGroup.ID, testdata.TestersGroup.ID},
+ excludeGroups: []models.GroupID{farmersGroup.ID},
+ },
+ {
+ id: testdata.InsertMissedCallTrigger(db, testdata.Org1, testdata.Favorites),
+ type_: models.MissedCallTriggerType,
+ flowID: testdata.Favorites.ID,
+ },
+ {
+ id: testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, testdata.TwilioChannel),
+ type_: models.NewConversationTriggerType,
+ flowID: testdata.Favorites.ID,
+ channelID: testdata.TwilioChannel.ID,
+ },
+ {
+ id: testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "", nil),
+ type_: models.ReferralTriggerType,
+ flowID: testdata.Favorites.ID,
+ },
+ {
+ id: testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "3256437635", testdata.TwilioChannel),
+ type_: models.ReferralTriggerType,
+ flowID: testdata.Favorites.ID,
+ referrerID: "3256437635",
+ channelID: testdata.TwilioChannel.ID,
+ },
+ {
+ id: testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.Favorites, nil, nil),
+ type_: models.CatchallTriggerType,
+ flowID: testdata.Favorites.ID,
+ },
}
- // insert any contact associations
- for _, c := range contactIDs {
- db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2)`, triggerID, c)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
+
+ require.Equal(t, len(tcs), len(oa.Triggers()))
+
+ for i, tc := range tcs {
+ actual := oa.Triggers()[i]
+
+ assert.Equal(t, tc.id, actual.ID(), "id mismatch in trigger #%d", i)
+ assert.Equal(t, tc.type_, actual.TriggerType(), "type mismatch in trigger #%d", i)
+ assert.Equal(t, tc.flowID, actual.FlowID(), "flow id mismatch in trigger #%d", i)
+ assert.Equal(t, tc.keyword, actual.Keyword(), "keyword mismatch in trigger #%d", i)
+ assert.Equal(t, tc.keywordMatchType, actual.MatchType(), "match type mismatch in trigger #%d", i)
+ assert.Equal(t, tc.referrerID, actual.ReferrerID(), "referrer id mismatch in trigger #%d", i)
+ assert.ElementsMatch(t, tc.includeGroups, actual.IncludeGroupIDs(), "include groups mismatch in trigger #%d", i)
+ assert.ElementsMatch(t, tc.excludeGroups, actual.ExcludeGroupIDs(), "exclude groups mismatch in trigger #%d", i)
+ assert.ElementsMatch(t, tc.includeContacts, actual.ContactIDs(), "include contacts mismatch in trigger #%d", i)
+ assert.Equal(t, tc.channelID, actual.ChannelID(), "channel id mismatch in trigger #%d", i)
+ }
+}
+
+func TestFindMatchingMsgTrigger(t *testing.T) {
+ ctx, _, db, _ := testsuite.Reset()
+
+ db.MustExec(`DELETE FROM triggers_trigger`)
+
+ joinID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "join", models.MatchFirst, nil, nil)
+ resistID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.SingleMessage, "resist", models.MatchOnly, nil, nil)
+ doctorsID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.SingleMessage, "resist", models.MatchOnly, []*testdata.Group{testdata.DoctorsGroup}, nil)
+ doctorsAndNotTestersID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.SingleMessage, "resist", models.MatchOnly, []*testdata.Group{testdata.DoctorsGroup}, []*testdata.Group{testdata.TestersGroup})
+ doctorsCatchallID := testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.SingleMessage, []*testdata.Group{testdata.DoctorsGroup}, nil)
+ othersAllID := testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.SingleMessage, nil, nil)
+
+ // trigger for other org
+ testdata.InsertCatchallTrigger(db, testdata.Org2, testdata.Org2Favorites, nil, nil)
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
+
+ testdata.DoctorsGroup.Add(db, testdata.Bob)
+ testdata.TestersGroup.Add(db, testdata.Bob)
+
+ _, cathy := testdata.Cathy.Load(db, oa)
+ _, george := testdata.George.Load(db, oa)
+ _, bob := testdata.Bob.Load(db, oa)
+
+ tcs := []struct {
+ text string
+ contact *flows.Contact
+ expectedTriggerID models.TriggerID
+ }{
+ {"join", cathy, joinID},
+ {"JOIN", cathy, joinID},
+ {"join this", cathy, joinID},
+ {"resist", george, resistID},
+ {"resist", bob, doctorsID},
+ {"resist", cathy, doctorsAndNotTestersID},
+ {"resist this", cathy, doctorsCatchallID},
+ {"other", cathy, doctorsCatchallID},
+ {"other", george, othersAllID},
+ {"", george, othersAllID},
}
- return triggerID
+ for _, tc := range tcs {
+ trigger := models.FindMatchingMsgTrigger(oa, tc.contact, tc.text)
+
+ assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch for %s sending '%s'", tc.contact.Name(), tc.text)
+ }
}
-func TestChannelTriggers(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
+func TestFindMatchingIncomingCallTrigger(t *testing.T) {
+ ctx, _, db, _ := testsuite.Reset()
- fooID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "foo", TwitterChannelID)
- barID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "bar", NilChannelID)
- bazID := insertTrigger(t, db, true, FavoritesFlowID, ReferralTriggerType, "", MatchFirst, nil, nil, "", TwitterChannelID)
+ doctorsAndNotTestersTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup}, []*testdata.Group{testdata.TestersGroup})
+ doctorsTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup}, nil)
+ notTestersTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, nil, []*testdata.Group{testdata.TestersGroup})
+ everyoneTriggerID := testdata.InsertIncomingCallTrigger(db, testdata.Org1, testdata.Favorites, nil, nil)
- FlushCache()
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
+
+ testdata.DoctorsGroup.Add(db, testdata.Bob)
+ testdata.TestersGroup.Add(db, testdata.Bob, testdata.Alexandria)
- org, err := GetOrgAssets(ctx, db, Org1)
- assert.NoError(t, err)
+ _, cathy := testdata.Cathy.Load(db, oa)
+ _, bob := testdata.Bob.Load(db, oa)
+ _, george := testdata.George.Load(db, oa)
+ _, alexa := testdata.Alexandria.Load(db, oa)
tcs := []struct {
- ReferrerID string
- Channel ChannelID
- TriggerID TriggerID
+ contact *flows.Contact
+ expectedTriggerID models.TriggerID
}{
- {"", TwilioChannelID, NilTriggerID},
- {"foo", TwilioChannelID, NilTriggerID},
- {"foo", TwitterChannelID, fooID},
- {"FOO", TwitterChannelID, fooID},
- {"bar", TwilioChannelID, barID},
- {"bar", TwitterChannelID, barID},
- {"zap", TwilioChannelID, NilTriggerID},
- {"zap", TwitterChannelID, bazID},
+ {cathy, doctorsAndNotTestersTriggerID}, // they're in doctors and not in testers
+ {bob, doctorsTriggerID}, // they're in doctors and testers
+ {george, notTestersTriggerID}, // they're not in doctors and not in testers
+ {alexa, everyoneTriggerID}, // they're not in doctors but are in testers
}
- for i, tc := range tcs {
- channel := org.ChannelByID(tc.Channel)
-
- trigger := FindMatchingReferralTrigger(org, channel, tc.ReferrerID)
- if trigger == nil {
- assert.Equal(t, tc.TriggerID, NilTriggerID, "%d: did not get back expected trigger", i)
- } else {
- assert.Equal(t, tc.TriggerID, trigger.ID(), "%d: did not get back expected trigger", i)
- }
+ for _, tc := range tcs {
+ trigger := models.FindMatchingIncomingCallTrigger(oa, tc.contact)
+
+ assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch for %s", tc.contact.Name())
}
}
-func TestTriggers(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
+func TestFindMatchingMissedCallTrigger(t *testing.T) {
+ ctx, _, db, _ := testsuite.Reset()
- joinID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, nil, "", NilChannelID)
- resistID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, nil, nil, "", NilChannelID)
- farmersID := insertTrigger(t, db, true, SingleMessageFlowID, KeywordTriggerType, "resist", MatchOnly, []GroupID{DoctorsGroupID}, nil, "", NilChannelID)
- farmersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, []GroupID{DoctorsGroupID}, nil, "", NilChannelID)
- othersAllID := insertTrigger(t, db, true, SingleMessageFlowID, CatchallTriggerType, "", MatchOnly, nil, nil, "", NilChannelID)
+ testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.SingleMessage, nil, nil)
- FlushCache()
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
- org, err := GetOrgAssets(ctx, db, Org1)
- assert.NoError(t, err)
+ // no missed call trigger yet
+ trigger := models.FindMatchingMissedCallTrigger(oa)
+ assert.Nil(t, trigger)
- contactIDs := []ContactID{CathyID, GeorgeID}
- contacts, err := LoadContacts(ctx, db, org, contactIDs)
- assert.NoError(t, err)
+ triggerID := testdata.InsertMissedCallTrigger(db, testdata.Org1, testdata.Favorites)
- cathy, err := contacts[0].FlowContact(org)
- assert.NoError(t, err)
+ oa, err = models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
+
+ trigger = models.FindMatchingMissedCallTrigger(oa)
+ assertTrigger(t, triggerID, trigger)
+}
- george, err := contacts[1].FlowContact(org)
- assert.NoError(t, err)
+func TestFindMatchingNewConversationTrigger(t *testing.T) {
+ ctx, _, db, _ := testsuite.Reset()
+
+ twilioTriggerID := testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, testdata.TwilioChannel)
+ noChTriggerID := testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, nil)
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
tcs := []struct {
- Text string
- Contact *flows.Contact
- TriggerID TriggerID
+ channelID models.ChannelID
+ expectedTriggerID models.TriggerID
}{
- {"join", cathy, joinID},
- {"JOIN", cathy, joinID},
- {"join this", cathy, joinID},
- {"resist", george, resistID},
- {"resist", cathy, farmersID},
- {"resist this", cathy, farmersAllID},
- {"other", cathy, farmersAllID},
- {"other", george, othersAllID},
- {"", george, othersAllID},
+ {testdata.TwilioChannel.ID, twilioTriggerID},
+ {testdata.VonageChannel.ID, noChTriggerID},
}
- for _, tc := range tcs {
- testID := fmt.Sprintf("'%s' sent by %s", tc.Text, tc.Contact.Name())
+ for i, tc := range tcs {
+ channel := oa.ChannelByID(tc.channelID)
+ trigger := models.FindMatchingNewConversationTrigger(oa, channel)
- actualTriggerID := NilTriggerID
- actualTrigger := FindMatchingMsgTrigger(org, tc.Contact, tc.Text)
- if actualTrigger != nil {
- actualTriggerID = actualTrigger.ID()
- }
+ assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch in test case #%d", i)
+ }
+}
+
+func TestFindMatchingReferralTrigger(t *testing.T) {
+ ctx, _, db, _ := testsuite.Reset()
+
+ fooID := testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "foo", testdata.TwitterChannel)
+ barID := testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "bar", nil)
+ bazID := testdata.InsertReferralTrigger(db, testdata.Org1, testdata.Favorites, "", testdata.TwitterChannel)
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshTriggers)
+ require.NoError(t, err)
+
+ tcs := []struct {
+ referrerID string
+ channelID models.ChannelID
+ expectedTriggerID models.TriggerID
+ }{
+ {"", testdata.TwilioChannel.ID, models.NilTriggerID},
+ {"foo", testdata.TwilioChannel.ID, models.NilTriggerID},
+ {"foo", testdata.TwitterChannel.ID, fooID},
+ {"FOO", testdata.TwitterChannel.ID, fooID},
+ {"bar", testdata.TwilioChannel.ID, barID},
+ {"bar", testdata.TwitterChannel.ID, barID},
+ {"zap", testdata.TwilioChannel.ID, models.NilTriggerID},
+ {"zap", testdata.TwitterChannel.ID, bazID},
+ }
+
+ for i, tc := range tcs {
+ channel := oa.ChannelByID(tc.channelID)
+ trigger := models.FindMatchingReferralTrigger(oa, channel, tc.referrerID)
- assert.Equal(t, tc.TriggerID, actualTriggerID, "did not get back expected trigger for %s", testID)
+ assertTrigger(t, tc.expectedTriggerID, trigger, "trigger mismatch in test case #%d", i)
}
}
func TestArchiveContactTriggers(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
-
- everybodyID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, nil, "", NilChannelID)
- cathyOnly1ID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{CathyID}, "", NilChannelID)
- cathyOnly2ID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "this", MatchOnly, nil, []ContactID{CathyID}, "", NilChannelID)
- cathyAndGeorgeID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{CathyID, GeorgeID}, "", NilChannelID)
- cathyAndGroupID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, []GroupID{DoctorsGroupID}, []ContactID{CathyID}, "", NilChannelID)
- georgeOnlyID := insertTrigger(t, db, true, FavoritesFlowID, KeywordTriggerType, "join", MatchFirst, nil, []ContactID{GeorgeID}, "", NilChannelID)
-
- err := ArchiveContactTriggers(ctx, db, []ContactID{CathyID, BobID})
+ ctx, _, db, _ := testsuite.Reset()
+
+ everybodyID := testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "join", models.MatchFirst, nil, nil)
+ cathyOnly1ID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.Cathy})
+ cathyOnly2ID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.Cathy})
+ cathyAndGeorgeID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.Cathy, testdata.George})
+ cathyAndGroupID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, []*testdata.Group{testdata.DoctorsGroup}, nil, []*testdata.Contact{testdata.Cathy})
+ georgeOnlyID := testdata.InsertScheduledTrigger(db, testdata.Org1, testdata.Favorites, nil, nil, []*testdata.Contact{testdata.George})
+
+ err := models.ArchiveContactTriggers(ctx, db, []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID})
require.NoError(t, err)
- assertTriggerArchived := func(id TriggerID, archived bool) {
+ assertTriggerArchived := func(id models.TriggerID, archived bool) {
var isArchived bool
db.Get(&isArchived, `SELECT is_archived FROM triggers_trigger WHERE id = $1`, id)
assert.Equal(t, archived, isArchived, `is_archived mismatch for trigger %d`, id)
@@ -158,3 +303,11 @@ func TestArchiveContactTriggers(t *testing.T) {
assertTriggerArchived(cathyAndGroupID, false)
assertTriggerArchived(georgeOnlyID, false)
}
+
+func assertTrigger(t *testing.T, expected models.TriggerID, actual *models.Trigger, msgAndArgs ...interface{}) {
+ if actual == nil {
+ assert.Equal(t, expected, models.NilTriggerID, msgAndArgs...)
+ } else {
+ assert.Equal(t, expected, actual.ID(), msgAndArgs...)
+ }
+}
diff --git a/core/models/users.go b/core/models/users.go
new file mode 100644
index 000000000..a3af281b5
--- /dev/null
+++ b/core/models/users.go
@@ -0,0 +1,139 @@
+package models
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "strings"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/mailroom/utils/dbutil"
+ "github.com/nyaruka/null"
+ "github.com/pkg/errors"
+ "github.com/sirupsen/logrus"
+)
+
+const (
+ // NilUserID is the id 0 considered as nil user id
+ NilUserID = UserID(0)
+)
+
+// UserID is our type for user ids, which can be null
+type UserID null.Int
+
+// MarshalJSON marshals into JSON. 0 values will become null
+func (i UserID) MarshalJSON() ([]byte, error) {
+ return null.Int(i).MarshalJSON()
+}
+
+// UnmarshalJSON unmarshals from JSON. null values become 0
+func (i *UserID) UnmarshalJSON(b []byte) error {
+ return null.UnmarshalInt(b, (*null.Int)(i))
+}
+
+// Value returns the db value, null is returned for 0
+func (i UserID) Value() (driver.Value, error) {
+ return null.Int(i).Value()
+}
+
+// Scan scans from the db value. null values become 0
+func (i *UserID) Scan(value interface{}) error {
+ return null.ScanInt(value, (*null.Int)(i))
+}
+
+type UserRole string
+
+const (
+ UserRoleAdministrator UserRole = "A"
+ UserRoleEditor UserRole = "E"
+ UserRoleViewer UserRole = "V"
+ UserRoleAgent UserRole = "T"
+ UserRoleSurveyor UserRole = "S"
+)
+
+// User is our type for a user asset
+type User struct {
+ u struct {
+ ID UserID `json:"id"`
+ Email string `json:"email"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Role UserRole `json:"role"`
+ }
+}
+
+// ID returns the ID
+func (u *User) ID() UserID { return u.u.ID }
+
+// Email returns the email address
+func (u *User) Email() string { return u.u.Email }
+
+// Role returns the user's role in the current org
+func (u *User) Role() UserRole { return u.u.Role }
+
+// Name returns the name
+func (u *User) Name() string {
+ names := make([]string, 0, 2)
+ if u.u.FirstName != "" {
+ names = append(names, u.u.FirstName)
+ }
+ if u.u.LastName != "" {
+ names = append(names, u.u.LastName)
+ }
+ return strings.Join(names, " ")
+}
+
+var _ assets.User = (*User)(nil)
+
+const selectOrgUsersSQL = `
+SELECT ROW_TO_JSON(r) FROM (SELECT
+ u.id AS "id",
+ u.email AS "email",
+ u.first_name as "first_name",
+ u.last_name as "last_name",
+ o.role AS "role"
+FROM
+ auth_user u
+INNER JOIN (
+ SELECT user_id, 'A' AS "role" FROM orgs_org_administrators WHERE org_id = $1
+ UNION
+ SELECT user_id, 'E' AS "role" FROM orgs_org_editors WHERE org_id = $1
+ UNION
+ SELECT user_id, 'V' AS "role" FROM orgs_org_viewers WHERE org_id = $1
+ UNION
+ SELECT user_id, 'T' AS "role" FROM orgs_org_agents WHERE org_id = $1
+ UNION
+ SELECT user_id, 'S' AS "role" FROM orgs_org_surveyors WHERE org_id = $1
+) o ON o.user_id = u.id
+WHERE
+ u.is_active = TRUE
+ORDER BY
+ u.email ASC
+) r;`
+
+// loadUsers loads all the users for the passed in org
+func loadUsers(ctx context.Context, db sqlx.Queryer, orgID OrgID) ([]assets.User, error) {
+ start := time.Now()
+
+ rows, err := db.Queryx(selectOrgUsersSQL, orgID)
+ if err != nil && err != sql.ErrNoRows {
+ return nil, errors.Wrapf(err, "error querying users for org: %d", orgID)
+ }
+ defer rows.Close()
+
+ users := make([]assets.User, 0, 10)
+ for rows.Next() {
+ user := &User{}
+ err := dbutil.ReadJSONRow(rows, &user.u)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error unmarshalling user")
+ }
+ users = append(users, user)
+ }
+
+ logrus.WithField("elapsed", time.Since(start)).WithField("org_id", orgID).WithField("count", len(users)).Debug("loaded users")
+
+ return users, nil
+}
diff --git a/core/models/users_test.go b/core/models/users_test.go
new file mode 100644
index 000000000..e6fec5886
--- /dev/null
+++ b/core/models/users_test.go
@@ -0,0 +1,50 @@
+package models_test
+
+import (
+ "testing"
+
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoadUsers(t *testing.T) {
+ ctx, _, db, _ := testsuite.Get()
+
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshUsers)
+ require.NoError(t, err)
+
+ users, err := oa.Users()
+ require.NoError(t, err)
+
+ expectedUsers := []struct {
+ id models.UserID
+ email string
+ name string
+ role models.UserRole
+ }{
+ {testdata.Admin.ID, testdata.Admin.Email, "Andy Admin", models.UserRoleAdministrator},
+ {testdata.Agent.ID, testdata.Agent.Email, "Ann D'Agent", models.UserRoleAgent},
+ {testdata.Editor.ID, testdata.Editor.Email, "Ed McEditor", models.UserRoleEditor},
+ {testdata.Surveyor.ID, testdata.Surveyor.Email, "Steve Surveys", models.UserRoleSurveyor},
+ {testdata.Viewer.ID, testdata.Viewer.Email, "Veronica Views", models.UserRoleViewer},
+ }
+
+ require.Equal(t, len(expectedUsers), len(users))
+
+ for i, expected := range expectedUsers {
+ assetUser := users[i]
+ assert.Equal(t, expected.email, assetUser.Email())
+ assert.Equal(t, expected.name, assetUser.Name())
+
+ modelUser := assetUser.(*models.User)
+ assert.Equal(t, expected.id, modelUser.ID())
+ assert.Equal(t, expected.email, modelUser.Email())
+ assert.Equal(t, expected.role, modelUser.Role())
+
+ assert.Equal(t, modelUser, oa.UserByID(expected.id))
+ assert.Equal(t, modelUser, oa.UserByEmail(expected.email))
+ }
+}
diff --git a/core/models/utils_test.go b/core/models/utils_test.go
index 2f6011831..1d5d54fae 100644
--- a/core/models/utils_test.go
+++ b/core/models/utils_test.go
@@ -10,8 +10,8 @@ import (
)
func TestBulkQueryBatches(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
defer testsuite.Reset()
db.MustExec(`CREATE TABLE foo (id serial NOT NULL PRIMARY KEY, name TEXT, age INT)`)
@@ -35,8 +35,8 @@ func TestBulkQueryBatches(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 1, foo1.ID)
assert.Equal(t, 2, foo2.ID)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'A' AND age = 30`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'B' AND age = 31`, nil, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'A' AND age = 30`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'B' AND age = 31`).Returns(1)
// test when multiple batches are required
foo3 := &foo{Name: "C", Age: 32}
@@ -51,10 +51,10 @@ func TestBulkQueryBatches(t *testing.T) {
assert.Equal(t, 5, foo5.ID)
assert.Equal(t, 6, foo6.ID)
assert.Equal(t, 7, foo7.ID)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'C' AND age = 32`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'D' AND age = 33`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'E' AND age = 34`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'F' AND age = 35`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'G' AND age = 36`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo `, nil, 7)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'C' AND age = 32`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'D' AND age = 33`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'E' AND age = 34`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'F' AND age = 35`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'G' AND age = 36`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo `).Returns(7)
}
diff --git a/core/models/webhook_event_test.go b/core/models/webhook_event_test.go
index 1fff60882..562686134 100644
--- a/core/models/webhook_event_test.go
+++ b/core/models/webhook_event_test.go
@@ -1,37 +1,37 @@
-package models
+package models_test
import (
"testing"
"time"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestWebhookEvents(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
// create a resthook to insert against
- var resthookID ResthookID
+ var resthookID models.ResthookID
db.Get(&resthookID, `INSERT INTO api_resthook(is_active, slug, org_id, created_on, modified_on, created_by_id, modified_by_id) VALUES(TRUE, 'foo', 1, NOW(), NOW(), 1, 1) RETURNING id;`)
tcs := []struct {
- OrgID OrgID
- ResthookID ResthookID
+ OrgID models.OrgID
+ ResthookID models.ResthookID
Data string
}{
- {Org1, resthookID, `{"foo":"bar"}`},
+ {testdata.Org1.ID, resthookID, `{"foo":"bar"}`},
}
for _, tc := range tcs {
- e := NewWebhookEvent(tc.OrgID, tc.ResthookID, tc.Data, time.Now())
- err := InsertWebhookEvents(ctx, db, []*WebhookEvent{e})
+ e := models.NewWebhookEvent(tc.OrgID, tc.ResthookID, tc.Data, time.Now())
+ err := models.InsertWebhookEvents(ctx, db, []*models.WebhookEvent{e})
assert.NoError(t, err)
assert.NotZero(t, e.ID())
- testsuite.AssertQueryCount(t, db, `
- SELECT count(*) FROM api_webhookevent WHERE org_id = $1 AND resthook_id = $2 AND data = $3
- `, []interface{}{tc.OrgID, tc.ResthookID, tc.Data}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM api_webhookevent WHERE org_id = $1 AND resthook_id = $2 AND data = $3`, tc.OrgID, tc.ResthookID, tc.Data).Returns(1)
}
}
diff --git a/core/models/webhook_results_test.go b/core/models/webhook_results_test.go
index 4c9523ddb..250cba0c9 100644
--- a/core/models/webhook_results_test.go
+++ b/core/models/webhook_results_test.go
@@ -1,20 +1,22 @@
-package models
+package models_test
import (
"testing"
"time"
+ "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestWebhookResults(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
tcs := []struct {
- OrgID OrgID
- ContactID ContactID
+ OrgID models.OrgID
+ ContactID models.ContactID
URL string
Request string
StatusCode int
@@ -22,19 +24,18 @@ func TestWebhookResults(t *testing.T) {
Duration time.Duration
RequestTime int
}{
- {Org1, CathyID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1501, 1501},
- {Org1, BobID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1502, 1502},
+ {testdata.Org1.ID, testdata.Cathy.ID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1501, 1501},
+ {testdata.Org1.ID, testdata.Bob.ID, "http://foo.bar", "GET http://foo.bar", 200, "hello world", time.Millisecond * 1502, 1502},
}
for _, tc := range tcs {
- r := NewWebhookResult(tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.Duration, time.Now())
- err := InsertWebhookResults(ctx, db, []*WebhookResult{r})
+ r := models.NewWebhookResult(tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.Duration, time.Now())
+ err := models.InsertWebhookResults(ctx, db, []*models.WebhookResult{r})
assert.NoError(t, err)
assert.NotZero(t, r.ID())
- testsuite.AssertQueryCount(t, db, `
- SELECT count(*) FROM api_webhookresult WHERE org_id = $1 AND contact_id = $2 AND url = $3 AND request = $4 AND
- status_code = $5 AND response = $6 AND request_time = $7
- `, []interface{}{tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.RequestTime}, 1)
+ testsuite.AssertQuery(t, db,
+ `SELECT count(*) FROM api_webhookresult WHERE org_id = $1 AND contact_id = $2 AND url = $3 AND request = $4 AND status_code = $5 AND response = $6 AND request_time = $7`,
+ tc.OrgID, tc.ContactID, tc.URL, tc.Request, tc.StatusCode, tc.Response, tc.RequestTime).Returns(1)
}
}
diff --git a/core/msgio/android.go b/core/msgio/android.go
index 67c80db81..b623b2c74 100644
--- a/core/msgio/android.go
+++ b/core/msgio/android.go
@@ -45,11 +45,11 @@ func SyncAndroidChannels(fc *fcm.Client, channels []*models.Channel) {
}
// CreateFCMClient creates an FCM client based on the configured FCM API key
-func CreateFCMClient() *fcm.Client {
- if config.Mailroom.FCMKey == "" {
+func CreateFCMClient(cfg *config.Config) *fcm.Client {
+ if cfg.FCMKey == "" {
return nil
}
- client, err := fcm.NewClient(config.Mailroom.FCMKey)
+ client, err := fcm.NewClient(cfg.FCMKey)
if err != nil {
panic(errors.Wrap(err, "unable to create FCM client"))
}
diff --git a/core/msgio/android_test.go b/core/msgio/android_test.go
index a442763b9..d851dad88 100644
--- a/core/msgio/android_test.go
+++ b/core/msgio/android_test.go
@@ -8,7 +8,6 @@ import (
"github.com/nyaruka/gocommon/jsonx"
"github.com/nyaruka/goflow/utils"
- "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/msgio"
"github.com/nyaruka/mailroom/testsuite"
@@ -64,8 +63,7 @@ func newMockFCMEndpoint(tokens ...string) *MockFCMEndpoint {
}
func TestSyncAndroidChannels(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
mockFCM := newMockFCMEndpoint("FCMID3")
defer mockFCM.Stop()
@@ -73,16 +71,16 @@ func TestSyncAndroidChannels(t *testing.T) {
fc := mockFCM.Client("FCMKEY123")
// create some Android channels
- channel1ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": ""}) // no FCM ID
- channel2ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"}) // invalid FCM ID
- channel3ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"}) // valid FCM ID
+ testChannel1 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": ""}) // no FCM ID
+ testChannel2 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"}) // invalid FCM ID
+ testChannel3 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"}) // valid FCM ID
- oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshChannels)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshChannels)
require.NoError(t, err)
- channel1 := oa.ChannelByID(channel1ID)
- channel2 := oa.ChannelByID(channel2ID)
- channel3 := oa.ChannelByID(channel3ID)
+ channel1 := oa.ChannelByID(testChannel1.ID)
+ channel2 := oa.ChannelByID(testChannel2.ID)
+ channel3 := oa.ChannelByID(testChannel3.ID)
msgio.SyncAndroidChannels(fc, []*models.Channel{channel1, channel2, channel3})
@@ -97,11 +95,13 @@ func TestSyncAndroidChannels(t *testing.T) {
}
func TestCreateFCMClient(t *testing.T) {
- config.Mailroom.FCMKey = "1234"
+ _, rt, _, _ := testsuite.Get()
- assert.NotNil(t, msgio.CreateFCMClient())
+ rt.Config.FCMKey = "1234"
- config.Mailroom.FCMKey = ""
+ assert.NotNil(t, msgio.CreateFCMClient(rt.Config))
- assert.Nil(t, msgio.CreateFCMClient())
+ rt.Config.FCMKey = ""
+
+ assert.Nil(t, msgio.CreateFCMClient(rt.Config))
}
diff --git a/core/msgio/courier_test.go b/core/msgio/courier_test.go
index 65b236976..f9609fdfa 100644
--- a/core/msgio/courier_test.go
+++ b/core/msgio/courier_test.go
@@ -1,10 +1,8 @@
package msgio_test
import (
- "encoding/json"
"testing"
- "github.com/gomodule/redigo/redis"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/msgio"
"github.com/nyaruka/mailroom/testsuite"
@@ -15,68 +13,62 @@ import (
)
func TestQueueCourierMessages(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rc := testsuite.RC()
- testsuite.Reset()
- models.FlushCache()
-
+ ctx, _, db, rp := testsuite.Reset()
+ rc := rp.Get()
defer rc.Close()
// create an Andoid channel
- androidChannelID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID"})
+ androidChannel := testdata.InsertChannel(db, testdata.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID"})
- oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg|models.RefreshChannels)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg|models.RefreshChannels)
require.NoError(t, err)
tests := []struct {
Description string
Msgs []msgSpec
- QueueSizes map[string]int
+ QueueSizes map[string][]int
}{
{
Description: "2 queueable messages",
Msgs: []msgSpec{
{
- ChannelID: models.TwilioChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: testdata.TwilioChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
{
- ChannelID: models.TwilioChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: testdata.TwilioChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
},
- QueueSizes: map[string]int{
- "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 2,
+ QueueSizes: map[string][]int{
+ "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": {2},
},
},
{
Description: "1 queueable message and 1 failed",
Msgs: []msgSpec{
{
- ChannelID: models.TwilioChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: testdata.TwilioChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
Failed: true,
},
{
- ChannelID: models.TwilioChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: testdata.TwilioChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
},
- QueueSizes: map[string]int{
- "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 1,
+ QueueSizes: map[string][]int{
+ "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": {1},
},
},
{
Description: "0 messages",
Msgs: []msgSpec{},
- QueueSizes: map[string]int{
- "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 0,
- },
+ QueueSizes: map[string][]int{},
},
}
@@ -91,41 +83,18 @@ func TestQueueCourierMessages(t *testing.T) {
rc.Do("FLUSHDB")
msgio.QueueCourierMessages(rc, contactID, msgs)
- assertCourierQueueSizes(t, rc, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description)
+ testsuite.AssertCourierQueues(t, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description)
}
// check that trying to queue a courier message will panic
assert.Panics(t, func() {
ms := msgSpec{
- ChannelID: androidChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: androidChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
}
- msgio.QueueCourierMessages(rc, models.CathyID, []*models.Msg{ms.createMsg(t, db, oa)})
+ msgio.QueueCourierMessages(rc, testdata.Cathy.ID, []*models.Msg{ms.createMsg(t, db, oa)})
})
testsuite.Reset()
}
-
-func assertCourierQueueSizes(t *testing.T, rc redis.Conn, sizes map[string]int, msgAndArgs ...interface{}) {
- for queueKey, size := range sizes {
- if size == 0 {
- result, err := rc.Do("ZCARD", queueKey)
- require.NoError(t, err)
- assert.Equal(t, size, int(result.(int64)))
- } else {
- result, err := rc.Do("ZPOPMAX", queueKey)
- require.NoError(t, err)
-
- results := result.([]interface{})
- assert.Equal(t, 2, len(results)) // result is (item, score)
-
- batchJSON := results[0].([]byte)
- var batch []map[string]interface{}
- err = json.Unmarshal(batchJSON, &batch)
- require.NoError(t, err)
-
- assert.Equal(t, size, len(batch), msgAndArgs...)
- }
- }
-}
diff --git a/core/msgio/send.go b/core/msgio/send.go
index 850886890..e05026a3f 100644
--- a/core/msgio/send.go
+++ b/core/msgio/send.go
@@ -4,6 +4,7 @@ import (
"context"
"github.com/edganiukov/fcm"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/apex/log"
@@ -53,9 +54,7 @@ func SendMessages(ctx context.Context, db models.Queryer, rp *redis.Pool, fc *fc
// in the case of errors we do want to change the messages back to pending however so they
// get queued later. (for the common case messages are only inserted and queued, without a status update)
- for _, msg := range contactMsgs {
- pending = append(pending, msg)
- }
+ pending = append(pending, contactMsgs...)
}
}
}
@@ -63,7 +62,7 @@ func SendMessages(ctx context.Context, db models.Queryer, rp *redis.Pool, fc *fc
// if we have any android messages, trigger syncs for the unique channels
if len(androidChannels) > 0 {
if fc == nil {
- fc = CreateFCMClient()
+ fc = CreateFCMClient(config.Mailroom)
}
SyncAndroidChannels(fc, androidChannels)
}
diff --git a/core/msgio/send_test.go b/core/msgio/send_test.go
index ec60a6d2c..4f8475b92 100644
--- a/core/msgio/send_test.go
+++ b/core/msgio/send_test.go
@@ -1,6 +1,7 @@
package msgio_test
import (
+ "context"
"fmt"
"testing"
"time"
@@ -29,9 +30,9 @@ func (m *msgSpec) createMsg(t *testing.T, db *sqlx.DB, oa *models.OrgAssets) *mo
// Only way to create a failed outgoing message is to suspend the org and reload the org.
// However the channels have to be fetched from the same org assets thus why this uses its
// own org assets instance.
- ctx := testsuite.CTX()
- db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, m.Failed, models.Org1)
- oaOrg, _ := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshOrg)
+ ctx := context.Background()
+ db.MustExec(`UPDATE orgs_org SET is_suspended = $1 WHERE id = $2`, m.Failed, testdata.Org1.ID)
+ oaOrg, _ := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg)
var channel *models.Channel
var channelRef *assets.ChannelReference
@@ -53,9 +54,7 @@ func (m *msgSpec) createMsg(t *testing.T, db *sqlx.DB, oa *models.OrgAssets) *mo
}
func TestSendMessages(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
+ ctx, _, db, rp := testsuite.Get()
rc := rp.Get()
defer rc.Close()
@@ -65,17 +64,17 @@ func TestSendMessages(t *testing.T) {
fc := mockFCM.Client("FCMKEY123")
// create some Andoid channels
- androidChannel1ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID1"})
- androidChannel2ID := testdata.InsertChannel(t, db, models.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"})
- testdata.InsertChannel(t, db, models.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"})
+ androidChannel1 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 1", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID1"})
+ androidChannel2 := testdata.InsertChannel(db, testdata.Org1, "A", "Android 2", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID2"})
+ testdata.InsertChannel(db, testdata.Org1, "A", "Android 3", []string{"tel"}, "SR", map[string]interface{}{"FCM_ID": "FCMID3"})
- oa, err := models.GetOrgAssetsWithRefresh(ctx, db, models.Org1, models.RefreshChannels)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshChannels)
require.NoError(t, err)
tests := []struct {
Description string
Msgs []msgSpec
- QueueSizes map[string]int
+ QueueSizes map[string][]int
FCMTokensSynced []string
PendingMsgs int
}{
@@ -83,23 +82,23 @@ func TestSendMessages(t *testing.T) {
Description: "2 messages for Courier, and 1 Android",
Msgs: []msgSpec{
{
- ChannelID: models.TwilioChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: testdata.TwilioChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
{
- ChannelID: androidChannel1ID,
- ContactID: models.BobID,
- URNID: models.BobURNID,
+ ChannelID: androidChannel1.ID,
+ ContactID: testdata.Bob.ID,
+ URNID: testdata.Bob.URNID,
},
{
- ChannelID: models.TwilioChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: testdata.TwilioChannel.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
},
- QueueSizes: map[string]int{
- "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": 2,
+ QueueSizes: map[string][]int{
+ "msgs:74729f45-7f29-4868-9dc4-90e491e3c7d8|10/0": {2},
},
FCMTokensSynced: []string{"FCMID1"},
PendingMsgs: 0,
@@ -108,22 +107,22 @@ func TestSendMessages(t *testing.T) {
Description: "each Android channel synced once",
Msgs: []msgSpec{
{
- ChannelID: androidChannel1ID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: androidChannel1.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
{
- ChannelID: androidChannel2ID,
- ContactID: models.BobID,
- URNID: models.BobURNID,
+ ChannelID: androidChannel2.ID,
+ ContactID: testdata.Bob.ID,
+ URNID: testdata.Bob.URNID,
},
{
- ChannelID: androidChannel1ID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ChannelID: androidChannel1.ID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
},
- QueueSizes: map[string]int{},
+ QueueSizes: map[string][]int{},
FCMTokensSynced: []string{"FCMID1", "FCMID2"},
PendingMsgs: 0,
},
@@ -132,11 +131,11 @@ func TestSendMessages(t *testing.T) {
Msgs: []msgSpec{
{
ChannelID: models.NilChannelID,
- ContactID: models.CathyID,
- URNID: models.CathyURNID,
+ ContactID: testdata.Cathy.ID,
+ URNID: testdata.Cathy.URNID,
},
},
- QueueSizes: map[string]int{},
+ QueueSizes: map[string][]int{},
FCMTokensSynced: []string{},
PendingMsgs: 1,
},
@@ -153,7 +152,7 @@ func TestSendMessages(t *testing.T) {
msgio.SendMessages(ctx, db, rp, fc, msgs)
- assertCourierQueueSizes(t, rc, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description)
+ testsuite.AssertCourierQueues(t, tc.QueueSizes, "courier queue sizes mismatch in '%s'", tc.Description)
// check the FCM tokens that were synced
actualTokens := make([]string, len(mockFCM.Messages))
@@ -163,6 +162,6 @@ func TestSendMessages(t *testing.T) {
assert.Equal(t, tc.FCMTokensSynced, actualTokens, "FCM tokens mismatch in '%s'", tc.Description)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`, nil, tc.PendingMsgs, `pending messages mismatch in '%s'`, tc.Description)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE status = 'P'`).Returns(tc.PendingMsgs, `pending messages mismatch in '%s'`, tc.Description)
}
}
diff --git a/core/runner/runner.go b/core/runner/runner.go
index c5fc9e3c2..af5d99e56 100644
--- a/core/runner/runner.go
+++ b/core/runner/runner.go
@@ -14,6 +14,7 @@ import (
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/locker"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@@ -62,7 +63,7 @@ type StartOptions struct {
type TriggerBuilder func(contact *flows.Contact) flows.Trigger
// ResumeFlow resumes the passed in session using the passed in session
-func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, session *models.Session, resume flows.Resume, hook models.SessionCommitHook) (*models.Session, error) {
+func ResumeFlow(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, session *models.Session, resume flows.Resume, hook models.SessionCommitHook) (*models.Session, error) {
start := time.Now()
sa := oa.SessionAssets()
@@ -72,7 +73,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org
// if this flow just isn't available anymore, log this error
if err == models.ErrNotFound {
logrus.WithField("contact_uuid", session.Contact().UUID()).WithField("session_id", session.ID()).WithField("flow_id", session.CurrentFlowID()).Error("unable to find flow in resume")
- return nil, models.ExitSessions(ctx, db, []models.SessionID{session.ID()}, models.ExitFailed, time.Now())
+ return nil, models.ExitSessions(ctx, rt.DB, []models.SessionID{session.ID()}, models.ExitFailed, time.Now())
}
return nil, errors.Wrapf(err, "error loading session flow: %d", session.CurrentFlowID())
}
@@ -97,13 +98,13 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org
txCTX, cancel := context.WithTimeout(ctx, commitTimeout)
defer cancel()
- tx, err := db.BeginTxx(txCTX, nil)
+ tx, err := rt.DB.BeginTxx(txCTX, nil)
if err != nil {
return nil, errors.Wrapf(err, "error starting transaction")
}
// write our updated session and runs
- err = session.WriteUpdatedSession(txCTX, tx, rp, oa, fs, sprint, hook)
+ err = session.WriteUpdatedSession(txCTX, tx, rt.RP, rt.SessionStorage, oa, fs, sprint, hook)
if err != nil {
tx.Rollback()
return nil, errors.Wrapf(err, "error updating session for resume")
@@ -120,12 +121,12 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org
txCTX, cancel = context.WithTimeout(ctx, postCommitTimeout)
defer cancel()
- tx, err = db.BeginTxx(txCTX, nil)
+ tx, err = rt.DB.BeginTxx(txCTX, nil)
if err != nil {
return nil, errors.Wrapf(err, "error starting transaction for post commit hooks")
}
- err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, oa, []*models.Scene{session.Scene()})
+ err = models.ApplyEventPostCommitHooks(txCTX, tx, rt.RP, oa, []*models.Scene{session.Scene()})
if err == nil {
err = tx.Commit()
}
@@ -141,7 +142,7 @@ func ResumeFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.Org
// StartFlowBatch starts the flow for the passed in org, contacts and flow
func StartFlowBatch(
- ctx context.Context, db *sqlx.DB, rp *redis.Pool,
+ ctx context.Context, rt *runtime.Runtime,
batch *models.FlowStartBatch) ([]*models.Session, error) {
start := time.Now()
@@ -149,7 +150,7 @@ func StartFlowBatch(
// if this is our last start, no matter what try to set the start as complete as a last step
if batch.IsLast() {
defer func() {
- err := models.MarkStartComplete(ctx, db, batch.StartID())
+ err := models.MarkStartComplete(ctx, rt.DB, batch.StartID())
if err != nil {
logrus.WithError(err).WithField("start_id", batch.StartID).Error("error marking start as complete")
}
@@ -157,7 +158,7 @@ func StartFlowBatch(
}
// create our org assets
- oa, err := models.GetOrgAssets(ctx, db, batch.OrgID())
+ oa, err := models.GetOrgAssets(ctx, rt.DB, batch.OrgID())
if err != nil {
return nil, errors.Wrapf(err, "error creating assets for org: %d", batch.OrgID())
}
@@ -172,6 +173,15 @@ func StartFlowBatch(
return nil, errors.Wrapf(err, "error loading campaign flow: %d", batch.FlowID())
}
+ // get the user that created this flow start if there was one
+ var flowUser *flows.User
+ if batch.CreatedByID() != models.NilUserID {
+ user := oa.UserByID(batch.CreatedByID())
+ if user != nil {
+ flowUser = oa.SessionAssets().Users().Get(user.Email())
+ }
+ }
+
var params *types.XObject
if len(batch.Extra()) > 0 {
params, err = types.ReadXObject(batch.Extra())
@@ -208,7 +218,7 @@ func StartFlowBatch(
if batchStart {
tb = tb.AsBatch()
}
- return tb.WithUser(batch.CreatedBy()).WithOrigin(startTypeToOrigin[batch.StartType()]).Build()
+ return tb.WithUser(flowUser).WithOrigin(startTypeToOrigin[batch.StartType()]).Build()
}
// before committing our runs we want to set the start they are associated with
@@ -230,7 +240,7 @@ func StartFlowBatch(
options.TriggerBuilder = triggerBuilder
options.CommitHook = updateStartID
- sessions, err := StartFlow(ctx, db, rp, oa, flow, batch.ContactIDs(), options)
+ sessions, err := StartFlow(ctx, rt, oa, flow, batch.ContactIDs(), options)
if err != nil {
return nil, errors.Wrapf(err, "error starting flow batch")
}
@@ -244,7 +254,7 @@ func StartFlowBatch(
// FireCampaignEvents starts the flow for the passed in org, contact and flow
func FireCampaignEvents(
- ctx context.Context, db *sqlx.DB, rp *redis.Pool,
+ ctx context.Context, rt *runtime.Runtime,
orgID models.OrgID, fires []*models.EventFire, flowUUID assets.FlowUUID,
campaign *triggers.CampaignReference, eventUUID triggers.CampaignEventUUID) ([]models.ContactID, error) {
@@ -264,7 +274,7 @@ func FireCampaignEvents(
}
// create our org assets
- oa, err := models.GetOrgAssets(ctx, db, orgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, orgID)
if err != nil {
return nil, errors.Wrapf(err, "error creating assets for org: %d", orgID)
}
@@ -274,7 +284,7 @@ func FireCampaignEvents(
// no longer active? delete these event fires and return
if dbEvent == nil {
- err := models.DeleteEventFires(ctx, db, fires)
+ err := models.DeleteEventFires(ctx, rt.DB, fires)
if err != nil {
return nil, errors.Wrapf(err, "error deleting events for already fired events")
}
@@ -284,7 +294,7 @@ func FireCampaignEvents(
// try to load our flow
flow, err := oa.Flow(flowUUID)
if err == models.ErrNotFound {
- err := models.DeleteEventFires(ctx, db, fires)
+ err := models.DeleteEventFires(ctx, rt.DB, fires)
if err != nil {
return nil, errors.Wrapf(err, "error deleting events for archived or inactive flow")
}
@@ -317,7 +327,7 @@ func FireCampaignEvents(
// if this is an ivr flow, we need to create a task to perform the start there
if dbFlow.FlowType() == models.FlowTypeVoice {
// Trigger our IVR flow start
- err := TriggerIVRFlow(ctx, db, rp, oa.OrgID(), dbFlow.ID(), contactIDs, func(ctx context.Context, tx *sqlx.Tx) error {
+ err := TriggerIVRFlow(ctx, rt, oa.OrgID(), dbFlow.ID(), contactIDs, func(ctx context.Context, tx *sqlx.Tx) error {
return models.MarkEventsFired(ctx, tx, fires, time.Now(), models.FireResultFired)
})
if err != nil {
@@ -370,7 +380,7 @@ func FireCampaignEvents(
return nil
}
- sessions, err := StartFlow(ctx, db, rp, oa, dbFlow, contactIDs, options)
+ sessions, err := StartFlow(ctx, rt, oa, dbFlow, contactIDs, options)
if err != nil {
logrus.WithField("contact_ids", contactIDs).WithError(err).Errorf("error starting flow for campaign event: %s", eventUUID)
} else {
@@ -379,7 +389,7 @@ func FireCampaignEvents(
for _, e := range skippedContacts {
fires = append(fires, e)
}
- err = models.MarkEventsFired(ctx, db, fires, fired, models.FireResultSkipped)
+ err = models.MarkEventsFired(ctx, rt.DB, fires, fired, models.FireResultSkipped)
if err != nil {
logrus.WithField("fire_ids", fires).WithError(err).Errorf("error marking events as skipped: %s", eventUUID)
}
@@ -399,7 +409,7 @@ func FireCampaignEvents(
// StartFlow runs the passed in flow for the passed in contact
func StartFlow(
- ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets,
+ ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets,
flow *models.Flow, contactIDs []models.ContactID, options *StartOptions) ([]*models.Session, error) {
if len(contactIDs) == 0 {
@@ -412,7 +422,7 @@ func StartFlow(
// filter out anybody who has has a flow run in this flow if appropriate
if !options.RestartParticipants {
// find all participants that have been in this flow
- started, err := models.FindFlowStartedOverlap(ctx, db, flow.ID(), contactIDs)
+ started, err := models.FindFlowStartedOverlap(ctx, rt.DB, flow.ID(), contactIDs)
if err != nil {
return nil, errors.Wrapf(err, "error finding others started flow: %d", flow.ID())
}
@@ -424,7 +434,7 @@ func StartFlow(
// filter out our list of contacts to only include those that should be started
if !options.IncludeActive {
// find all participants active in any flow
- active, err := models.FindActiveSessionOverlap(ctx, db, flow.FlowType(), contactIDs)
+ active, err := models.FindActiveSessionOverlap(ctx, rt.DB, flow.FlowType(), contactIDs)
if err != nil {
return nil, errors.Wrapf(err, "error finding other active flow: %d", flow.ID())
}
@@ -464,7 +474,7 @@ func StartFlow(
// try up to a second to get a lock for a contact
for _, contactID := range remaining {
lockID := models.ContactLock(oa.OrgID(), contactID)
- lock, err := locker.GrabLock(rp, lockID, time.Minute*5, time.Second)
+ lock, err := locker.GrabLock(rt.RP, lockID, time.Minute*5, time.Second)
if err != nil {
return nil, errors.Wrapf(err, "error attempting to grab lock")
}
@@ -478,13 +488,13 @@ func StartFlow(
// defer unlocking if we exit due to error
defer func() {
if !released[lockID] {
- locker.ReleaseLock(rp, lockID, lock)
+ locker.ReleaseLock(rt.RP, lockID, lock)
}
}()
}
// load our locked contacts
- contacts, err := models.LoadContacts(ctx, db, oa, locked)
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, locked)
if err != nil {
return nil, errors.Wrapf(err, "error loading contacts to start")
}
@@ -500,20 +510,18 @@ func StartFlow(
triggers = append(triggers, trigger)
}
- ss, err := StartFlowForContacts(ctx, db, rp, oa, flow, triggers, options.CommitHook, options.Interrupt)
+ ss, err := StartFlowForContacts(ctx, rt, oa, flow, triggers, options.CommitHook, options.Interrupt)
if err != nil {
return nil, errors.Wrapf(err, "error starting flow for contacts")
}
// append all the sessions that were started
- for _, s := range ss {
- sessions = append(sessions, s)
- }
+ sessions = append(sessions, ss...)
// release all our locks
for i := range locked {
lockID := models.ContactLock(oa.OrgID(), locked[i])
- locker.ReleaseLock(rp, lockID, locks[i])
+ locker.ReleaseLock(rt.RP, lockID, locks[i])
released[lockID] = true
}
@@ -526,7 +534,7 @@ func StartFlow(
// StartFlowForContacts runs the passed in flow for the passed in contact
func StartFlowForContacts(
- ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets,
+ ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets,
flow *models.Flow, triggers []flows.Trigger, hook models.SessionCommitHook, interrupt bool) ([]*models.Session, error) {
sa := oa.SessionAssets()
@@ -547,7 +555,7 @@ func StartFlowForContacts(
log := log.WithField("contact_uuid", trigger.Contact().UUID())
start := time.Now()
- session, sprint, err := goflow.Engine().NewSession(sa, trigger)
+ session, sprint, err := goflow.Engine(rt.Config).NewSession(sa, trigger)
if err != nil {
log.WithError(err).Errorf("error starting flow")
continue
@@ -567,7 +575,7 @@ func StartFlowForContacts(
txCTX, cancel := context.WithTimeout(ctx, commitTimeout*time.Duration(len(sessions)))
defer cancel()
- tx, err := db.BeginTxx(txCTX, nil)
+ tx, err := rt.DB.BeginTxx(txCTX, nil)
if err != nil {
return nil, errors.Wrapf(err, "error starting transaction")
}
@@ -588,7 +596,7 @@ func StartFlowForContacts(
}
// write our session to the db
- dbSessions, err := models.WriteSessions(txCTX, tx, rp, oa, sessions, sprints, hook)
+ dbSessions, err := models.WriteSessions(txCTX, tx, rt.RP, rt.SessionStorage, oa, sessions, sprints, hook)
if err == nil {
// commit it at once
commitStart := time.Now()
@@ -613,7 +621,7 @@ func StartFlowForContacts(
txCTX, cancel := context.WithTimeout(ctx, commitTimeout)
defer cancel()
- tx, err := db.BeginTxx(txCTX, nil)
+ tx, err := rt.DB.BeginTxx(txCTX, nil)
if err != nil {
return nil, errors.Wrapf(err, "error starting transaction for retry")
}
@@ -628,7 +636,7 @@ func StartFlowForContacts(
}
}
- dbSession, err := models.WriteSessions(txCTX, tx, rp, oa, []flows.Session{session}, []flows.Sprint{sprint}, hook)
+ dbSession, err := models.WriteSessions(txCTX, tx, rt.RP, rt.SessionStorage, oa, []flows.Session{session}, []flows.Sprint{sprint}, hook)
if err != nil {
tx.Rollback()
log.WithField("contact_uuid", session.Contact().UUID()).WithError(err).Errorf("error writing session to db")
@@ -650,7 +658,7 @@ func StartFlowForContacts(
txCTX, cancel = context.WithTimeout(ctx, postCommitTimeout*time.Duration(len(sessions)))
defer cancel()
- tx, err = db.BeginTxx(txCTX, nil)
+ tx, err = rt.DB.BeginTxx(txCTX, nil)
if err != nil {
return nil, errors.Wrapf(err, "error starting transaction for post commit hooks")
}
@@ -660,7 +668,7 @@ func StartFlowForContacts(
scenes = append(scenes, s.Scene())
}
- err = models.ApplyEventPostCommitHooks(txCTX, tx, rp, oa, scenes)
+ err = models.ApplyEventPostCommitHooks(txCTX, tx, rt.RP, oa, scenes)
if err == nil {
err = tx.Commit()
}
@@ -675,14 +683,14 @@ func StartFlowForContacts(
txCTX, cancel = context.WithTimeout(ctx, postCommitTimeout)
defer cancel()
- tx, err := db.BeginTxx(txCTX, nil)
+ tx, err := rt.DB.BeginTxx(txCTX, nil)
if err != nil {
tx.Rollback()
log.WithError(err).Error("error starting transaction to retry post commits")
continue
}
- err = models.ApplyEventPostCommitHooks(ctx, tx, rp, oa, []*models.Scene{session.Scene()})
+ err = models.ApplyEventPostCommitHooks(ctx, tx, rt.RP, oa, []*models.Scene{session.Scene()})
if err != nil {
tx.Rollback()
log.WithError(err).Errorf("error applying post commit hook")
@@ -708,8 +716,8 @@ type DBHook func(ctx context.Context, tx *sqlx.Tx) error
// TriggerIVRFlow will create a new flow start with the passed in flow and set of contacts. This will cause us to
// request calls to start, which once we get the callback will trigger our actual flow to start.
-func TriggerIVRFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, orgID models.OrgID, flowID models.FlowID, contactIDs []models.ContactID, hook DBHook) error {
- tx, _ := db.BeginTxx(ctx, nil)
+func TriggerIVRFlow(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID, flowID models.FlowID, contactIDs []models.ContactID, hook DBHook) error {
+ tx, _ := rt.DB.BeginTxx(ctx, nil)
// create our start
start := models.NewFlowStart(orgID, models.StartTypeTrigger, models.FlowTypeVoice, flowID, models.DoRestartParticipants, models.DoIncludeActive).
@@ -742,7 +750,7 @@ func TriggerIVRFlow(ctx context.Context, db *sqlx.DB, rp *redis.Pool, orgID mode
task := start.CreateBatch(contactIDs, true, len(contactIDs))
// queue this to our ivr starter, it will take care of creating the connections then calling back in
- rc := rp.Get()
+ rc := rt.RP.Get()
defer rc.Close()
err = queue.AddTask(rc, queue.BatchQueue, queue.StartIVRFlowBatch, int(orgID), task, queue.HighPriority)
if err != nil {
diff --git a/core/runner/runner_test.go b/core/runner/runner_test.go
index eace94449..10df36d02 100644
--- a/core/runner/runner_test.go
+++ b/core/runner/runner_test.go
@@ -1,4 +1,4 @@
-package runner
+package runner_test
import (
"encoding/json"
@@ -11,104 +11,98 @@ import (
"github.com/nyaruka/goflow/flows/triggers"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/core/runner"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/lib/pq"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestCampaignStarts(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
+ ctx, rt, db, _ := testsuite.Reset()
- campaign := triggers.NewCampaignReference(triggers.CampaignUUID(models.DoctorRemindersCampaignUUID), "Doctor Reminders")
+ campaign := triggers.NewCampaignReference(triggers.CampaignUUID(testdata.RemindersCampaign.UUID), "Doctor Reminders")
// create our event fires
now := time.Now()
- db.MustExec(`INSERT INTO campaigns_eventfire(event_id, scheduled, contact_id) VALUES($1, $2, $3),($1, $2, $4),($1, $2, $5);`, models.RemindersEvent2ID, now, models.CathyID, models.BobID, models.AlexandriaID)
+ db.MustExec(`INSERT INTO campaigns_eventfire(event_id, scheduled, contact_id) VALUES($1, $2, $3),($1, $2, $4),($1, $2, $5);`, testdata.RemindersEvent2.ID, now, testdata.Cathy.ID, testdata.Bob.ID, testdata.Alexandria.ID)
// create an active session for Alexandria to test skipping
- db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'M', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), models.Org1, models.AlexandriaID, models.PickNumberFlowID)
+ db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'M', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), testdata.Org1.ID, testdata.Alexandria.ID, testdata.PickANumber.ID)
// create an active voice call for Cathy to make sure it doesn't get interrupted or cause skipping
- db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'V', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), models.Org1, models.CathyID, models.IVRFlowID)
+ db.MustExec(`INSERT INTO flows_flowsession(uuid, session_type, org_id, contact_id, status, responded, created_on, current_flow_id) VALUES($1, 'V', $2, $3, 'W', FALSE, NOW(), $4);`, uuids.New(), testdata.Org1.ID, testdata.Cathy.ID, testdata.IVRFlow.ID)
// set our event to skip
- db.MustExec(`UPDATE campaigns_campaignevent SET start_mode = 'S' WHERE id= $1`, models.RemindersEvent2ID)
+ db.MustExec(`UPDATE campaigns_campaignevent SET start_mode = 'S' WHERE id= $1`, testdata.RemindersEvent2.ID)
- contacts := []models.ContactID{models.CathyID, models.BobID}
+ contacts := []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}
fires := []*models.EventFire{
{
FireID: 1,
- EventID: models.RemindersEvent2ID,
- ContactID: models.CathyID,
+ EventID: testdata.RemindersEvent2.ID,
+ ContactID: testdata.Cathy.ID,
Scheduled: now,
},
{
FireID: 2,
- EventID: models.RemindersEvent2ID,
- ContactID: models.BobID,
+ EventID: testdata.RemindersEvent2.ID,
+ ContactID: testdata.Bob.ID,
Scheduled: now,
},
{
FireID: 3,
- EventID: models.RemindersEvent2ID,
- ContactID: models.AlexandriaID,
+ EventID: testdata.RemindersEvent2.ID,
+ ContactID: testdata.Alexandria.ID,
Scheduled: now,
},
}
- sessions, err := FireCampaignEvents(ctx, db, rp, models.Org1, fires, models.CampaignFlowUUID, campaign, "e68f4c70-9db1-44c8-8498-602d6857235e")
+ sessions, err := runner.FireCampaignEvents(ctx, rt, testdata.Org1.ID, fires, testdata.CampaignFlow.UUID, campaign, "e68f4c70-9db1-44c8-8498-602d6857235e")
assert.NoError(t, err)
assert.Equal(t, 2, len(sessions), "expected only two sessions to be created")
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM flows_flowsession WHERE contact_id = ANY($1)
- AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`,
- []interface{}{pq.Array(contacts)}, 2, "expected only two sessions to be created",
- )
+ testsuite.AssertQuery(t, db,
+ `SELECT count(*) FROM flows_flowsession WHERE contact_id = ANY($1) AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, pq.Array(contacts)).
+ Returns(2, "expected only two sessions to be created")
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM flows_flowrun WHERE contact_id = ANY($1) and flow_id = $2
AND is_active = FALSE AND responded = FALSE AND org_id = 1 AND parent_id IS NULL AND exit_type = 'C' AND status = 'C'
AND results IS NOT NULL AND path IS NOT NULL AND events IS NOT NULL
AND session_id IS NOT NULL`,
- []interface{}{pq.Array(contacts), models.CampaignFlowID}, 2, "expected only two runs to be created",
- )
+ pq.Array(contacts), testdata.CampaignFlow.ID).Returns(2, "expected only two runs to be created")
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM msgs_msg WHERE contact_id = ANY($1)
AND text like '% it is time to consult with your patients.' AND org_id = 1 AND status = 'Q'
AND queued_on IS NOT NULL AND direction = 'O' AND topup_id IS NOT NULL AND msg_type = 'F' AND channel_id = $2`,
- []interface{}{pq.Array(contacts), models.TwilioChannelID}, 2, "expected only two messages to be sent",
- )
+ pq.Array(contacts), testdata.TwilioChannel.ID).Returns(2, "expected only two messages to be sent")
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from campaigns_eventfire WHERE fired IS NULL`, nil, 0, "expected all events to be fired")
+ testsuite.AssertQuery(t, db, `SELECT count(*) from campaigns_eventfire WHERE fired IS NULL`).
+ Returns(0, "expected all events to be fired")
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1,$2) AND event_id = $3 AND fired_result = 'F'`, []interface{}{models.CathyID, models.BobID, models.RemindersEvent2ID}, 2, "expected bob and cathy to have their event sent to fired")
+ testsuite.AssertQuery(t, db,
+ `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1,$2) AND event_id = $3 AND fired_result = 'F'`, testdata.Cathy.ID, testdata.Bob.ID, testdata.RemindersEvent2.ID).
+ Returns(2, "expected bob and cathy to have their event sent to fired")
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1) AND event_id = $2 AND fired_result = 'S'`, []interface{}{models.AlexandriaID, models.RemindersEvent2ID}, 1, "expected alexandria to have her event set to skipped")
+ testsuite.AssertQuery(t, db,
+ `SELECT count(*) from campaigns_eventfire WHERE fired IS NOT NULL AND contact_id IN ($1) AND event_id = $2 AND fired_result = 'S'`, testdata.Alexandria.ID, testdata.RemindersEvent2.ID).
+ Returns(1, "expected alexandria to have her event set to skipped")
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from flows_flowsession WHERE status = 'W' AND contact_id = $1 AND session_type = 'V'`, []interface{}{models.CathyID}, 1)
+ testsuite.AssertQuery(t, db,
+ `SELECT count(*) from flows_flowsession WHERE status = 'W' AND contact_id = $1 AND session_type = 'V'`, testdata.Cathy.ID).Returns(1)
}
func TestBatchStart(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
+ ctx, rt, db, _ := testsuite.Reset()
// create a start object
- testdata.InsertFlowStart(t, db, models.Org1, models.SingleMessageFlowID, nil)
+ testdata.InsertFlowStart(db, testdata.Org1, testdata.SingleMessage, nil)
// and our batch object
- contactIDs := []models.ContactID{models.CathyID, models.BobID}
+ contactIDs := []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID}
tcs := []struct {
Flow models.FlowID
@@ -119,12 +113,12 @@ func TestBatchStart(t *testing.T) {
Count int
TotalCount int
}{
- {models.SingleMessageFlowID, true, true, nil, "Hey, how are you?", 2, 2},
- {models.SingleMessageFlowID, false, true, nil, "Hey, how are you?", 0, 2},
- {models.SingleMessageFlowID, false, false, nil, "Hey, how are you?", 0, 2},
- {models.SingleMessageFlowID, true, false, nil, "Hey, how are you?", 2, 4},
+ {testdata.SingleMessage.ID, true, true, nil, "Hey, how are you?", 2, 2},
+ {testdata.SingleMessage.ID, false, true, nil, "Hey, how are you?", 0, 2},
+ {testdata.SingleMessage.ID, false, false, nil, "Hey, how are you?", 0, 2},
+ {testdata.SingleMessage.ID, true, false, nil, "Hey, how are you?", 2, 4},
{
- Flow: models.IncomingExtraFlowID,
+ Flow: testdata.IncomingExtraFlow.ID,
Restart: true,
IncludeActive: false,
Extra: json.RawMessage([]byte(`{"name":"Fred", "age":33}`)),
@@ -142,75 +136,63 @@ func TestBatchStart(t *testing.T) {
WithExtra(tc.Extra)
batch := start.CreateBatch(contactIDs, true, len(contactIDs))
- sessions, err := StartFlowBatch(ctx, db, rp, batch)
- assert.NoError(t, err)
+ sessions, err := runner.StartFlowBatch(ctx, rt, batch)
+ require.NoError(t, err)
assert.Equal(t, tc.Count, len(sessions), "%d: unexpected number of sessions created", i)
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM flows_flowsession WHERE contact_id = ANY($1)
- AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL AND created_on > $2`,
- []interface{}{pq.Array(contactIDs), last}, tc.Count, "%d: unexpected number of sessions", i,
- )
+ AND status = 'C' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL AND created_on > $2`, pq.Array(contactIDs), last).
+ Returns(tc.Count, "%d: unexpected number of sessions", i)
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM flows_flowrun WHERE contact_id = ANY($1) and flow_id = $2
AND is_active = FALSE AND responded = FALSE AND org_id = 1 AND parent_id IS NULL AND exit_type = 'C' AND status = 'C'
AND results IS NOT NULL AND path IS NOT NULL AND events IS NOT NULL
- AND session_id IS NOT NULL`,
- []interface{}{pq.Array(contactIDs), tc.Flow}, tc.TotalCount, "%d: unexpected number of runs", i,
- )
+ AND session_id IS NOT NULL`, pq.Array(contactIDs), tc.Flow).
+ Returns(tc.TotalCount, "%d: unexpected number of runs", i)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = ANY($1)
- AND text = $2 AND org_id = 1 AND status = 'Q'
+ testsuite.AssertQuery(t, db,
+ `SELECT count(*) FROM msgs_msg WHERE contact_id = ANY($1) AND text = $2 AND org_id = 1 AND status = 'Q'
AND queued_on IS NOT NULL AND direction = 'O' AND topup_id IS NOT NULL AND msg_type = 'F' AND channel_id = $3`,
- []interface{}{pq.Array(contactIDs), tc.Msg, models.TwilioChannelID}, tc.TotalCount, "%d: unexpected number of messages", i,
- )
+ pq.Array(contactIDs), tc.Msg, testdata.TwilioChannel.ID).
+ Returns(tc.TotalCount, "%d: unexpected number of messages", i)
last = time.Now()
}
}
-func TestContactRuns(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
+func TestResume(t *testing.T) {
+ ctx, rt, db, _ := testsuite.Reset()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
- assert.NoError(t, err)
+ defer testsuite.ResetStorage()
- flow, err := oa.FlowByID(models.FavoritesFlowID)
- assert.NoError(t, err)
+ // write sessions to storage as well
+ db.MustExec(`UPDATE orgs_org set config = '{"session_storage_mode": "s3"}' WHERE id = 1`)
+ defer testsuite.ResetDB()
- // load our contact
- contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{models.CathyID})
- assert.NoError(t, err)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, db, testdata.Org1.ID, models.RefreshOrg)
+ require.NoError(t, err)
- contact, err := contacts[0].FlowContact(oa)
- assert.NoError(t, err)
+ flow, err := oa.FlowByID(testdata.Favorites.ID)
+ require.NoError(t, err)
+
+ _, contact := testdata.Cathy.Load(db, oa)
trigger := triggers.NewBuilder(oa.Env(), flow.FlowReference(), contact).Manual().Build()
- sessions, err := StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, nil, true)
+ sessions, err := runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{trigger}, nil, true)
assert.NoError(t, err)
assert.NotNil(t, sessions)
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND current_flow_id = $2
- AND status = 'W' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`,
- []interface{}{contact.ID(), flow.ID()}, 1,
- )
+ AND status = 'W' AND responded = FALSE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`, contact.ID(), flow.ID()).Returns(1)
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND flow_id = $2
- AND is_active = TRUE AND responded = FALSE AND org_id = 1`,
- []interface{}{contact.ID(), flow.ID()}, 1,
- )
+ AND is_active = TRUE AND responded = FALSE AND org_id = 1`, contact.ID(), flow.ID()).Returns(1)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like '%favorite color%'`,
- []interface{}{contact.ID()}, 1,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like '%favorite color%'`, contact.ID()).Returns(1)
tcs := []struct {
Message string
@@ -228,41 +210,36 @@ func TestContactRuns(t *testing.T) {
session := sessions[0]
for i, tc := range tcs {
// answer our first question
- msg := flows.NewMsgIn(flows.MsgUUID(uuids.New()), models.CathyURN, nil, tc.Message, nil)
+ msg := flows.NewMsgIn(flows.MsgUUID(uuids.New()), testdata.Cathy.URN, nil, tc.Message, nil)
msg.SetID(10)
resume := resumes.NewMsg(oa.Env(), contact, msg)
- session, err = ResumeFlow(ctx, db, rp, oa, session, resume, nil)
+ session, err = runner.ResumeFlow(ctx, rt, oa, session, resume, nil)
assert.NoError(t, err)
assert.NotNil(t, session)
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db,
`SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND current_flow_id = $2
- AND status = $3 AND responded = TRUE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL`,
- []interface{}{contact.ID(), flow.ID(), tc.SessionStatus}, 1, "%d: didn't find expected session", i,
- )
+ AND status = $3 AND responded = TRUE AND org_id = 1 AND connection_id IS NULL AND output IS NOT NULL AND output_url IS NOT NULL`, contact.ID(), flow.ID(), tc.SessionStatus).
+ Returns(1, "%d: didn't find expected session", i)
runIsActive := tc.RunStatus == models.RunStatusActive || tc.RunStatus == models.RunStatusWaiting
runQuery := `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND flow_id = $2
- AND status = $3 AND is_active = $4 AND responded = TRUE AND org_id = 1 AND current_node_uuid IS NOT NULL
- AND json_array_length(path::json) = $5 AND json_array_length(events::json) = $6
- AND session_id IS NOT NULL `
+ AND status = $3 AND is_active = $4 AND responded = TRUE AND org_id = 1 AND current_node_uuid IS NOT NULL
+ AND json_array_length(path::json) = $5 AND json_array_length(events::json) = $6
+ AND session_id IS NOT NULL`
if runIsActive {
- runQuery += `AND expires_on IS NOT NULL`
+ runQuery += ` AND expires_on IS NOT NULL`
} else {
- runQuery += `AND expires_on IS NULL`
+ runQuery += ` AND expires_on IS NULL`
}
- testsuite.AssertQueryCount(t, db,
- runQuery,
- []interface{}{contact.ID(), flow.ID(), tc.RunStatus, runIsActive, tc.PathLength, tc.EventLength}, 1, "%d: didn't find expected run", i,
- )
+ testsuite.AssertQuery(t, db, runQuery, contact.ID(), flow.ID(), tc.RunStatus, runIsActive, tc.PathLength, tc.EventLength).
+ Returns(1, "%d: didn't find expected run", i)
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like $2`,
- []interface{}{contact.ID(), tc.Substring}, 1, "%d: didn't find expected message", i,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text like $2`, contact.ID(), tc.Substring).
+ Returns(1, "%d: didn't find expected message", i)
}
}
diff --git a/core/tasks/base.go b/core/tasks/base.go
index 7d8061d5b..b6f8ca2dd 100644
--- a/core/tasks/base.go
+++ b/core/tasks/base.go
@@ -9,6 +9,7 @@ import (
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/pkg/errors"
)
@@ -19,7 +20,7 @@ var registeredTypes = map[string](func() Task){}
func RegisterType(name string, initFunc func() Task) {
registeredTypes[name] = initFunc
- mailroom.AddTaskFunction(name, func(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
+ mailroom.AddTaskFunction(name, func(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
// decode our task body
typedTask, err := ReadTask(task.Type, task.Task)
if err != nil {
@@ -29,7 +30,7 @@ func RegisterType(name string, initFunc func() Task) {
ctx, cancel := context.WithTimeout(ctx, typedTask.Timeout())
defer cancel()
- return typedTask.Perform(ctx, mr, models.OrgID(task.OrgID))
+ return typedTask.Perform(ctx, rt, models.OrgID(task.OrgID))
})
}
@@ -39,7 +40,7 @@ type Task interface {
Timeout() time.Duration
// Perform performs the task
- Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error
+ Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error
}
//------------------------------------------------------------------------------------------
diff --git a/core/tasks/base_test.go b/core/tasks/base_test.go
index 3761d3570..9f75b8e7d 100644
--- a/core/tasks/base_test.go
+++ b/core/tasks/base_test.go
@@ -5,7 +5,7 @@ import (
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks"
- "github.com/nyaruka/mailroom/core/tasks/groups"
+ "github.com/nyaruka/mailroom/core/tasks/contacts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -18,7 +18,7 @@ func TestReadTask(t *testing.T) {
}`))
require.NoError(t, err)
- typedTask := task.(*groups.PopulateDynamicGroupTask)
+ typedTask := task.(*contacts.PopulateDynamicGroupTask)
assert.Equal(t, models.GroupID(23), typedTask.GroupID)
assert.Equal(t, "gender = F", typedTask.Query)
}
diff --git a/core/tasks/broadcasts/worker.go b/core/tasks/broadcasts/worker.go
index a7c5364eb..ceb2f5420 100644
--- a/core/tasks/broadcasts/worker.go
+++ b/core/tasks/broadcasts/worker.go
@@ -12,6 +12,7 @@ import (
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/msgio"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
@@ -26,7 +27,7 @@ func init() {
}
// handleSendBroadcast creates all the batches of contacts that need to be sent to
-func handleSendBroadcast(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
+func handleSendBroadcast(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute*60)
defer cancel()
@@ -40,7 +41,7 @@ func handleSendBroadcast(ctx context.Context, mr *mailroom.Mailroom, task *queue
return errors.Wrapf(err, "error unmarshalling broadcast: %s", string(task.Task))
}
- return CreateBroadcastBatches(ctx, mr.DB, mr.RP, broadcast)
+ return CreateBroadcastBatches(ctx, rt.DB, rt.RP, broadcast)
}
// CreateBroadcastBatches takes our master broadcast and creates batches of broadcast sends for all the unique contacts
@@ -130,7 +131,7 @@ func CreateBroadcastBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, bc
}
// handleSendBroadcastBatch sends our messages
-func handleSendBroadcastBatch(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
+func handleSendBroadcastBatch(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute*60)
defer cancel()
@@ -145,7 +146,7 @@ func handleSendBroadcastBatch(ctx context.Context, mr *mailroom.Mailroom, task *
}
// try to send the batch
- return SendBroadcastBatch(ctx, mr.DB, mr.RP, broadcast)
+ return SendBroadcastBatch(ctx, rt.DB, rt.RP, broadcast)
}
// SendBroadcastBatch sends the passed in broadcast batch
diff --git a/core/tasks/broadcasts/worker_test.go b/core/tasks/broadcasts/worker_test.go
index e54356393..475a75c76 100644
--- a/core/tasks/broadcasts/worker_test.go
+++ b/core/tasks/broadcasts/worker_test.go
@@ -20,14 +20,11 @@ import (
)
func TestBroadcastEvents(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
- db := testsuite.DB()
- rc := testsuite.RC()
+ ctx, _, db, rp := testsuite.Reset()
+ rc := rp.Get()
defer rc.Close()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
eng := envs.Language("eng")
@@ -39,20 +36,20 @@ func TestBroadcastEvents(t *testing.T) {
},
}
- doctors := assets.NewGroupReference(models.DoctorsGroupUUID, "Doctors")
+ doctors := assets.NewGroupReference(testdata.DoctorsGroup.UUID, "Doctors")
doctorsOnly := []*assets.GroupReference{doctors}
- cathy := flows.NewContactReference(models.CathyUUID, "Cathy")
+ cathy := flows.NewContactReference(testdata.Cathy.UUID, "Cathy")
cathyOnly := []*flows.ContactReference{cathy}
// add an extra URN fo cathy
- testdata.InsertContactURN(t, db, models.Org1, models.CathyID, urns.URN("tel:+12065551212"), 1001)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.Cathy, urns.URN("tel:+12065551212"), 1001)
// change george's URN to an invalid twitter URN so it can't be sent
db.MustExec(
- `UPDATE contacts_contacturn SET identity = 'twitter:invalid-urn', scheme = 'twitter', path='invalid-urn' WHERE id = $1`, models.GeorgeURNID,
+ `UPDATE contacts_contacturn SET identity = 'twitter:invalid-urn', scheme = 'twitter', path='invalid-urn' WHERE id = $1`, testdata.George.URNID,
)
- george := flows.NewContactReference(models.GeorgeUUID, "George")
+ george := flows.NewContactReference(testdata.George.UUID, "George")
georgeOnly := []*flows.ContactReference{george}
tcs := []struct {
@@ -112,9 +109,8 @@ func TestBroadcastEvents(t *testing.T) {
assert.Equal(t, tc.BatchCount, count, "%d: unexpected batch count", i)
// assert our count of total msgs created
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`,
- []interface{}{lastNow, tc.MsgText}, tc.MsgCount, "%d: unexpected msg count", i)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`, lastNow, tc.MsgText).
+ Returns(tc.MsgCount, "%d: unexpected msg count", i)
lastNow = time.Now()
time.Sleep(10 * time.Millisecond)
@@ -122,23 +118,19 @@ func TestBroadcastEvents(t *testing.T) {
}
func TestBroadcastTask(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
- db := testsuite.DB()
- rc := testsuite.RC()
+ ctx, _, db, rp := testsuite.Reset()
+ rc := rp.Get()
defer rc.Close()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
assert.NoError(t, err)
eng := envs.Language("eng")
// insert a broadcast so we can check it is being set to sent
- var legacyID models.BroadcastID
- err = db.Get(&legacyID,
- `INSERT INTO msgs_broadcast(status, text, base_language, is_active, created_on, modified_on, send_all, created_by_id, modified_by_id, org_id)
- VALUES('P', '"base"=>"hi @(PROPER(contact.name)) legacy"'::hstore, 'base', TRUE, NOW(), NOW(), FALSE, 1, 1, 1) RETURNING id`)
- assert.NoError(t, err)
+ legacyID := testdata.InsertBroadcast(db, testdata.Org1, "base", map[envs.Language]string{"base": "hi @(PROPER(contact.name)) legacy"}, models.NilScheduleID, nil, nil)
+
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "", "", nil)
+ modelTicket := ticket.Load(db)
evaluated := map[envs.Language]*models.BroadcastTranslation{
eng: {
@@ -164,11 +156,11 @@ func TestBroadcastTask(t *testing.T) {
},
}
- doctorsOnly := []models.GroupID{models.DoctorsGroupID}
- cathyOnly := []models.ContactID{models.CathyID}
+ doctorsOnly := []models.GroupID{testdata.DoctorsGroup.ID}
+ cathyOnly := []models.ContactID{testdata.Cathy.ID}
// add an extra URN fo cathy
- testdata.InsertContactURN(t, db, models.Org1, models.CathyID, urns.URN("tel:+12065551212"), 1001)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.Cathy, urns.URN("tel:+12065551212"), 1001)
tcs := []struct {
BroadcastID models.BroadcastID
@@ -178,14 +170,54 @@ func TestBroadcastTask(t *testing.T) {
GroupIDs []models.GroupID
ContactIDs []models.ContactID
URNs []urns.URN
+ TicketID models.TicketID
Queue string
BatchCount int
MsgCount int
MsgText string
}{
- {models.NilBroadcastID, evaluated, models.TemplateStateEvaluated, eng, doctorsOnly, cathyOnly, nil, queue.BatchQueue, 2, 121, "hello world"},
- {legacyID, legacy, models.TemplateStateLegacy, eng, nil, cathyOnly, nil, queue.HandlerQueue, 1, 1, "hi Cathy legacy URN: +12065551212 Gender: F"},
- {models.NilBroadcastID, template, models.TemplateStateUnevaluated, eng, nil, cathyOnly, nil, queue.HandlerQueue, 1, 1, "hi Cathy from Nyaruka goflow URN: tel:+12065551212 Gender: F"},
+ {
+ models.NilBroadcastID,
+ evaluated,
+ models.TemplateStateEvaluated,
+ eng,
+ doctorsOnly,
+ cathyOnly,
+ nil,
+ ticket.ID,
+ queue.BatchQueue,
+ 2,
+ 121,
+ "hello world",
+ },
+ {
+ legacyID,
+ legacy,
+ models.TemplateStateLegacy,
+ eng,
+ nil,
+ cathyOnly,
+ nil,
+ models.NilTicketID,
+ queue.HandlerQueue,
+ 1,
+ 1,
+ "hi Cathy legacy URN: +12065551212 Gender: F",
+ },
+ {
+ models.NilBroadcastID,
+ template,
+ models.TemplateStateUnevaluated,
+ eng,
+ nil,
+ cathyOnly,
+ nil,
+ models.NilTicketID,
+ queue.HandlerQueue,
+ 1,
+ 1,
+ "hi Cathy from Nyaruka goflow URN: tel:+12065551212 Gender: F",
+ },
}
lastNow := time.Now()
@@ -193,7 +225,7 @@ func TestBroadcastTask(t *testing.T) {
for i, tc := range tcs {
// handle our start task
- bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs)
+ bcast := models.NewBroadcast(oa.OrgID(), tc.BroadcastID, tc.Translations, tc.TemplateState, tc.BaseLanguage, tc.URNs, tc.ContactIDs, tc.GroupIDs, tc.TicketID)
err = CreateBroadcastBatches(ctx, db, rp, bcast)
assert.NoError(t, err)
@@ -221,15 +253,19 @@ func TestBroadcastTask(t *testing.T) {
assert.Equal(t, tc.BatchCount, count, "%d: unexpected batch count", i)
// assert our count of total msgs created
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`,
- []interface{}{lastNow, tc.MsgText}, tc.MsgCount, "%d: unexpected msg count", i)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE org_id = 1 AND created_on > $1 AND topup_id IS NOT NULL AND text = $2`, lastNow, tc.MsgText).
+ Returns(tc.MsgCount, "%d: unexpected msg count", i)
// make sure our broadcast is marked as sent
if tc.BroadcastID != models.NilBroadcastID {
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_broadcast WHERE id = $1 AND status = 'S'`,
- []interface{}{tc.BroadcastID}, 1, "%d: broadcast not marked as sent", i)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_broadcast WHERE id = $1 AND status = 'S'`, tc.BroadcastID).
+ Returns(1, "%d: broadcast not marked as sent", i)
+ }
+
+ // if we had a ticket, make sure its last_activity_on was updated
+ if tc.TicketID != models.NilTicketID {
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE id = $1 AND last_activity_on > $2`, tc.TicketID, modelTicket.LastActivityOn()).
+ Returns(1, "%d: ticket last_activity_on not updated", i)
}
lastNow = time.Now()
diff --git a/core/tasks/campaigns/cron.go b/core/tasks/campaigns/cron.go
index 13f8e4b4e..3d9b5d7bf 100644
--- a/core/tasks/campaigns/cron.go
+++ b/core/tasks/campaigns/cron.go
@@ -3,6 +3,7 @@ package campaigns
import (
"context"
"fmt"
+ "sync"
"time"
"github.com/nyaruka/goflow/assets"
@@ -10,6 +11,7 @@ import (
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/nyaruka/mailroom/utils/marker"
@@ -30,12 +32,12 @@ func init() {
}
// StartCampaignCron starts our cron job of firing expired campaign events
-func StartCampaignCron(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, campaignsLock, time.Second*60,
+func StartCampaignCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, campaignsLock, time.Second*60,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return fireCampaignEvents(ctx, mr.DB, mr.RP, lockName, lockValue)
+ return fireCampaignEvents(ctx, rt.DB, rt.RP, lockName, lockValue)
},
)
diff --git a/core/tasks/campaigns/cron_test.go b/core/tasks/campaigns/cron_test.go
index a16f90856..ab25e5b88 100644
--- a/core/tasks/campaigns/cron_test.go
+++ b/core/tasks/campaigns/cron_test.go
@@ -4,31 +4,26 @@ import (
"testing"
"time"
- "github.com/nyaruka/mailroom"
- "github.com/nyaruka/mailroom/config"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/core/tasks"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCampaigns(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
- rc := testsuite.RC()
- defer rc.Close()
+ ctx, rt, db, rp := testsuite.Reset()
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil}
+ rc := rp.Get()
+ defer rc.Close()
// let's create a campaign event fire for one of our contacts (for now this is totally hacked, they aren't in the group and
// their relative to date isn't relative, but this still tests execution)
- db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, models.CathyID, models.GeorgeID, models.RemindersEvent1ID)
+ rt.DB.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, testdata.Cathy.ID, testdata.George.ID, testdata.RemindersEvent1.ID)
time.Sleep(10 * time.Millisecond)
// schedule our campaign to be started
@@ -44,32 +39,27 @@ func TestCampaigns(t *testing.T) {
require.NoError(t, err)
// work on that task
- err = typedTask.Perform(ctx, mr, models.OrgID(task.OrgID))
+ err = typedTask.Perform(ctx, rt, models.OrgID(task.OrgID))
assert.NoError(t, err)
// should now have a flow run for that contact and flow
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, []interface{}{models.CathyID, models.FavoritesFlowID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, []interface{}{models.GeorgeID, models.FavoritesFlowID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, testdata.Cathy.ID, testdata.Favorites.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowrun WHERE contact_id = $1 AND flow_id = $2;`, testdata.George.ID, testdata.Favorites.ID).Returns(1)
}
func TestIVRCampaigns(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
- rc := testsuite.RC()
+ ctx, rt, db, rp := testsuite.Reset()
+ rc := rp.Get()
defer rc.Close()
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil}
-
// let's create a campaign event fire for one of our contacts (for now this is totally hacked, they aren't in the group and
// their relative to date isn't relative, but this still tests execution)
- db.MustExec(`UPDATE campaigns_campaignevent SET flow_id = $1 WHERE id = $2`, models.IVRFlowID, models.RemindersEvent1ID)
- db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, models.CathyID, models.GeorgeID, models.RemindersEvent1ID)
+ rt.DB.MustExec(`UPDATE campaigns_campaignevent SET flow_id = $1 WHERE id = $2`, testdata.IVRFlow.ID, testdata.RemindersEvent1.ID)
+ rt.DB.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, testdata.Cathy.ID, testdata.George.ID, testdata.RemindersEvent1.ID)
time.Sleep(10 * time.Millisecond)
// schedule our campaign to be started
- err := fireCampaignEvents(ctx, db, rp, campaignsLock, "lock")
+ err := fireCampaignEvents(ctx, rt.DB, rt.RP, campaignsLock, "lock")
assert.NoError(t, err)
// then actually work on the event
@@ -81,16 +71,16 @@ func TestIVRCampaigns(t *testing.T) {
require.NoError(t, err)
// work on that task
- err = typedTask.Perform(ctx, mr, models.OrgID(task.OrgID))
+ err = typedTask.Perform(ctx, rt, models.OrgID(task.OrgID))
assert.NoError(t, err)
// should now have a flow start created
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P';`, []interface{}{models.IVRFlowID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, []interface{}{models.CathyID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, []interface{}{models.GeorgeID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P';`, testdata.IVRFlow.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, testdata.Cathy.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) from flows_flowstart_contacts WHERE contact_id = $1 AND flowstart_id = 1;`, testdata.George.ID).Returns(1)
// event should be marked as fired
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) from campaigns_eventfire WHERE event_id = $1 AND fired IS NOT NULL;`, []interface{}{models.RemindersEvent1ID}, 2)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) from campaigns_eventfire WHERE event_id = $1 AND fired IS NOT NULL;`, testdata.RemindersEvent1.ID).Returns(2)
// pop our next task, should be the start
task, err = queue.PopNextTask(rc, queue.BatchQueue)
diff --git a/core/tasks/campaigns/fire_campaign_event.go b/core/tasks/campaigns/fire_campaign_event.go
index e5536c0ec..a034cf85c 100644
--- a/core/tasks/campaigns/fire_campaign_event.go
+++ b/core/tasks/campaigns/fire_campaign_event.go
@@ -7,10 +7,10 @@ import (
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows/triggers"
- "github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/runner"
"github.com/nyaruka/mailroom/core/tasks"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/marker"
"github.com/pkg/errors"
@@ -47,9 +47,9 @@ func (t *FireCampaignEventTask) Timeout() time.Duration {
// - creates the trigger for that event
// - runs the flow that is to be started through our engine
// - saves the flow run and session resulting from our run
-func (t *FireCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error {
- db := mr.DB
- rp := mr.RP
+func (t *FireCampaignEventTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error {
+ db := rt.DB
+ rp := rt.RP
log := logrus.WithField("comp", "campaign_worker").WithField("event_id", t.EventID)
// grab all the fires for this event
@@ -82,7 +82,7 @@ func (t *FireCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailro
campaign := triggers.NewCampaignReference(triggers.CampaignUUID(t.CampaignUUID), t.CampaignName)
- started, err := runner.FireCampaignEvents(ctx, db, rp, orgID, fires, t.FlowUUID, campaign, triggers.CampaignEventUUID(t.EventUUID))
+ started, err := runner.FireCampaignEvents(ctx, rt, orgID, fires, t.FlowUUID, campaign, triggers.CampaignEventUUID(t.EventUUID))
// remove all the contacts that were started
for _, contactID := range started {
@@ -93,7 +93,10 @@ func (t *FireCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailro
if len(contactMap) > 0 {
rc := rp.Get()
for _, failed := range contactMap {
- marker.RemoveTask(rc, campaignsLock, fmt.Sprintf("%d", failed.FireID))
+ rerr := marker.RemoveTask(rc, campaignsLock, fmt.Sprintf("%d", failed.FireID))
+ if rerr != nil {
+ log.WithError(rerr).WithField("fire_id", failed.FireID).Error("error unmarking campaign fire")
+ }
}
rc.Close()
}
diff --git a/core/tasks/campaigns/schedule_campaign_event.go b/core/tasks/campaigns/schedule_campaign_event.go
index 24fe5490d..c420f542d 100644
--- a/core/tasks/campaigns/schedule_campaign_event.go
+++ b/core/tasks/campaigns/schedule_campaign_event.go
@@ -5,9 +5,9 @@ import (
"fmt"
"time"
- "github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/locker"
"github.com/pkg/errors"
@@ -33,9 +33,9 @@ func (t *ScheduleCampaignEventTask) Timeout() time.Duration {
}
// Perform creates the actual event fires to schedule the given campaign event
-func (t *ScheduleCampaignEventTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error {
- db := mr.DB
- rp := mr.RP
+func (t *ScheduleCampaignEventTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error {
+ db := rt.DB
+ rp := rt.RP
lockKey := fmt.Sprintf(scheduleLockKey, t.CampaignEventID)
lock, err := locker.GrabLock(rp, lockKey, time.Hour, time.Minute*5)
diff --git a/core/tasks/campaigns/schedule_campaign_event_test.go b/core/tasks/campaigns/schedule_campaign_event_test.go
index 495f35b55..59a198029 100644
--- a/core/tasks/campaigns/schedule_campaign_event_test.go
+++ b/core/tasks/campaigns/schedule_campaign_event_test.go
@@ -5,35 +5,27 @@ import (
"time"
"github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/mailroom"
- "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks/campaigns"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScheduleCampaignEvent(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil}
-
- models.FlushCache()
+ ctx, rt, db, _ := testsuite.Reset()
// add bob, george and alexandria to doctors group which campaign is based on
- db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contact_id, contactgroup_id) VALUES($1, $2)`, models.BobID, models.DoctorsGroupID)
- db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contact_id, contactgroup_id) VALUES($1, $2)`, models.GeorgeID, models.DoctorsGroupID)
- db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contact_id, contactgroup_id) VALUES($1, $2)`, models.AlexandriaID, models.DoctorsGroupID)
+ testdata.DoctorsGroup.Add(db, testdata.Bob, testdata.George, testdata.Alexandria)
// give bob and george values for joined in the future
- db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-01-01T00:00:00Z"}}' WHERE id = $1`, models.BobID)
- db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-08-18T11:31:30Z"}}' WHERE id = $1`, models.GeorgeID)
+ db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-01-01T00:00:00Z"}}' WHERE id = $1`, testdata.Bob.ID)
+ db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2030-08-18T11:31:30Z"}}' WHERE id = $1`, testdata.George.ID)
// give alexandria a value in the past
- db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2015-01-01T00:00:00Z"}}' WHERE id = $1`, models.AlexandriaID)
+ db.MustExec(`UPDATE contacts_contact SET fields = '{"d83aae24-4bbf-49d0-ab85-6bfd201eac6d": {"datetime": "2015-01-01T00:00:00Z"}}' WHERE id = $1`, testdata.Alexandria.ID)
db.MustExec(`DELETE FROM campaigns_eventfire`)
@@ -42,62 +34,62 @@ func TestScheduleCampaignEvent(t *testing.T) {
// 2. +10 Minutes send message
// schedule first event...
- task := &campaigns.ScheduleCampaignEventTask{CampaignEventID: models.RemindersEvent1ID}
- err := task.Perform(ctx, mr, models.Org1)
+ task := &campaigns.ScheduleCampaignEventTask{CampaignEventID: testdata.RemindersEvent1.ID}
+ err := task.Perform(ctx, rt, testdata.Org1.ID)
require.NoError(t, err)
// cathy has no value for joined and alexandia has a value too far in past, but bob and george will have values...
- assertContactFires(t, models.RemindersEvent1ID, map[models.ContactID]time.Time{
- models.BobID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC), // 12:00 in PST
- models.GeorgeID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC), // 12:00 in PST with DST
+ assertContactFires(t, testdata.RemindersEvent1.ID, map[models.ContactID]time.Time{
+ testdata.Bob.ID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC), // 12:00 in PST
+ testdata.George.ID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC), // 12:00 in PST with DST
})
// schedule second event...
- task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: models.RemindersEvent2ID}
- err = task.Perform(ctx, mr, models.Org1)
+ task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: testdata.RemindersEvent2.ID}
+ err = task.Perform(ctx, rt, testdata.Org1.ID)
require.NoError(t, err)
- assertContactFires(t, models.RemindersEvent2ID, map[models.ContactID]time.Time{
- models.BobID: time.Date(2030, 1, 1, 0, 10, 0, 0, time.UTC),
- models.GeorgeID: time.Date(2030, 8, 18, 11, 42, 0, 0, time.UTC),
+ assertContactFires(t, testdata.RemindersEvent2.ID, map[models.ContactID]time.Time{
+ testdata.Bob.ID: time.Date(2030, 1, 1, 0, 10, 0, 0, time.UTC),
+ testdata.George.ID: time.Date(2030, 8, 18, 11, 42, 0, 0, time.UTC),
})
// fires for first event unaffected
- assertContactFires(t, models.RemindersEvent1ID, map[models.ContactID]time.Time{
- models.BobID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC),
- models.GeorgeID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC),
+ assertContactFires(t, testdata.RemindersEvent1.ID, map[models.ContactID]time.Time{
+ testdata.Bob.ID: time.Date(2030, 1, 5, 20, 0, 0, 0, time.UTC),
+ testdata.George.ID: time.Date(2030, 8, 23, 19, 0, 0, 0, time.UTC),
})
// remove alexandria from campaign group
- db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.AlexandriaID)
+ db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.Alexandria.ID)
// bump created_on for cathy and alexandria
- db.MustExec(`UPDATE contacts_contact SET created_on = '2035-01-01T00:00:00Z' WHERE id = $1 OR id = $2`, models.CathyID, models.AlexandriaID)
+ db.MustExec(`UPDATE contacts_contact SET created_on = '2035-01-01T00:00:00Z' WHERE id = $1 OR id = $2`, testdata.Cathy.ID, testdata.Alexandria.ID)
// create new campaign event based on created_on + 5 minutes
- event3 := insertCampaignEvent(t, models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.CreatedOnFieldID, 5, "M")
+ event3 := insertCampaignEvent(t, testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.CreatedOnField.ID, 5, "M")
task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: event3}
- err = task.Perform(ctx, mr, models.Org1)
+ err = task.Perform(ctx, rt, testdata.Org1.ID)
require.NoError(t, err)
// only cathy is in the group and new enough to have a fire
assertContactFires(t, event3, map[models.ContactID]time.Time{
- models.CathyID: time.Date(2035, 1, 1, 0, 5, 0, 0, time.UTC),
+ testdata.Cathy.ID: time.Date(2035, 1, 1, 0, 5, 0, 0, time.UTC),
})
// create new campaign event based on last_seen_on + 1 day
- event4 := insertCampaignEvent(t, models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.LastSeenOnFieldID, 1, "D")
+ event4 := insertCampaignEvent(t, testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.LastSeenOnField.ID, 1, "D")
// bump last_seen_on for bob
- db.MustExec(`UPDATE contacts_contact SET last_seen_on = '2040-01-01T00:00:00Z' WHERE id = $1`, models.BobID)
+ db.MustExec(`UPDATE contacts_contact SET last_seen_on = '2040-01-01T00:00:00Z' WHERE id = $1`, testdata.Bob.ID)
task = &campaigns.ScheduleCampaignEventTask{CampaignEventID: event4}
- err = task.Perform(ctx, mr, models.Org1)
+ err = task.Perform(ctx, rt, testdata.Org1.ID)
require.NoError(t, err)
assertContactFires(t, event4, map[models.ContactID]time.Time{
- models.BobID: time.Date(2040, 1, 2, 0, 0, 0, 0, time.UTC),
+ testdata.Bob.ID: time.Date(2040, 1, 2, 0, 0, 0, 0, time.UTC),
})
}
diff --git a/core/tasks/contacts/import_contact_batch.go b/core/tasks/contacts/import_contact_batch.go
index a3a321664..16c114a8b 100644
--- a/core/tasks/contacts/import_contact_batch.go
+++ b/core/tasks/contacts/import_contact_batch.go
@@ -4,9 +4,9 @@ import (
"context"
"time"
- "github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/pkg/errors"
)
@@ -28,13 +28,13 @@ func (t *ImportContactBatchTask) Timeout() time.Duration {
}
// Perform figures out the membership for a query based group then repopulates it
-func (t *ImportContactBatchTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error {
- batch, err := models.LoadContactImportBatch(ctx, mr.DB, t.ContactImportBatchID)
+func (t *ImportContactBatchTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error {
+ batch, err := models.LoadContactImportBatch(ctx, rt.DB, t.ContactImportBatchID)
if err != nil {
return errors.Wrapf(err, "unable to load contact import batch with id %d", t.ContactImportBatchID)
}
- if err := batch.Import(ctx, mr.DB, orgID); err != nil {
+ if err := batch.Import(ctx, rt.DB, orgID); err != nil {
return errors.Wrapf(err, "unable to import contact import batch %d", t.ContactImportBatchID)
}
diff --git a/core/tasks/contacts/import_contact_batch_test.go b/core/tasks/contacts/import_contact_batch_test.go
index 0823a84c2..77571371d 100644
--- a/core/tasks/contacts/import_contact_batch_test.go
+++ b/core/tasks/contacts/import_contact_batch_test.go
@@ -3,10 +3,7 @@ package contacts_test
import (
"testing"
- "github.com/nyaruka/mailroom"
- "github.com/nyaruka/mailroom/config"
_ "github.com/nyaruka/mailroom/core/handlers"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks/contacts"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
@@ -15,22 +12,19 @@ import (
)
func TestImportContactBatch(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, rt, db, _ := testsuite.Get()
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil}
-
- importID := testdata.InsertContactImport(t, db, models.Org1)
- batchID := testdata.InsertContactImportBatch(t, db, importID, []byte(`[
+ importID := testdata.InsertContactImport(db, testdata.Org1)
+ batchID := testdata.InsertContactImportBatch(db, importID, []byte(`[
{"name": "Norbert", "language": "eng", "urns": ["tel:+16055740001"]},
{"name": "Leah", "urns": ["tel:+16055740002"]}
]`))
task := &contacts.ImportContactBatchTask{ContactImportBatchID: batchID}
- err := task.Perform(ctx, mr, models.Org1)
+ err := task.Perform(ctx, rt, testdata.Org1.ID)
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Norbert' AND language = 'eng'`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Leah' AND language IS NULL`, nil, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Norbert' AND language = 'eng'`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE name = 'Leah' AND language IS NULL`).Returns(1)
}
diff --git a/core/tasks/groups/populate_dynamic_group.go b/core/tasks/contacts/populate_dynamic_group.go
similarity index 82%
rename from core/tasks/groups/populate_dynamic_group.go
rename to core/tasks/contacts/populate_dynamic_group.go
index eb5cdb36a..50eb9a4f3 100644
--- a/core/tasks/groups/populate_dynamic_group.go
+++ b/core/tasks/contacts/populate_dynamic_group.go
@@ -1,13 +1,13 @@
-package groups
+package contacts
import (
"context"
"fmt"
"time"
- "github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/locker"
"github.com/pkg/errors"
@@ -35,13 +35,13 @@ func (t *PopulateDynamicGroupTask) Timeout() time.Duration {
}
// Perform figures out the membership for a query based group then repopulates it
-func (t *PopulateDynamicGroupTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error {
+func (t *PopulateDynamicGroupTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error {
lockKey := fmt.Sprintf(populateLockKey, t.GroupID)
- lock, err := locker.GrabLock(mr.RP, lockKey, time.Hour, time.Minute*5)
+ lock, err := locker.GrabLock(rt.RP, lockKey, time.Hour, time.Minute*5)
if err != nil {
return errors.Wrapf(err, "error grabbing lock to repopulate dynamic group: %d", t.GroupID)
}
- defer locker.ReleaseLock(mr.RP, lockKey, lock)
+ defer locker.ReleaseLock(rt.RP, lockKey, lock)
start := time.Now()
log := logrus.WithFields(logrus.Fields{
@@ -52,12 +52,12 @@ func (t *PopulateDynamicGroupTask) Perform(ctx context.Context, mr *mailroom.Mai
log.Info("starting population of dynamic group")
- oa, err := models.GetOrgAssets(ctx, mr.DB, orgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, orgID)
if err != nil {
return errors.Wrapf(err, "unable to load org when populating group: %d", t.GroupID)
}
- count, err := models.PopulateDynamicGroup(ctx, mr.DB, mr.ElasticClient, oa, t.GroupID, t.Query)
+ count, err := models.PopulateDynamicGroup(ctx, rt.DB, rt.ES, oa, t.GroupID, t.Query)
if err != nil {
return errors.Wrapf(err, "error populating dynamic group: %d", t.GroupID)
}
diff --git a/core/tasks/groups/populate_dynamic_group_test.go b/core/tasks/contacts/populate_dynamic_group_test.go
similarity index 52%
rename from core/tasks/groups/populate_dynamic_group_test.go
rename to core/tasks/contacts/populate_dynamic_group_test.go
index a397abbb9..686ede1ee 100644
--- a/core/tasks/groups/populate_dynamic_group_test.go
+++ b/core/tasks/contacts/populate_dynamic_group_test.go
@@ -1,13 +1,11 @@
-package groups_test
+package contacts_test
import (
"fmt"
"testing"
- "github.com/nyaruka/mailroom"
- "github.com/nyaruka/mailroom/config"
- "github.com/nyaruka/mailroom/core/models"
- "github.com/nyaruka/mailroom/core/tasks/groups"
+ "github.com/nyaruka/gocommon/dates"
+ "github.com/nyaruka/mailroom/core/tasks/contacts"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
@@ -16,21 +14,17 @@ import (
)
func TestPopulateTask(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, rt, db, _ := testsuite.Reset()
mes := testsuite.NewMockElasticServer()
defer mes.Close()
-
es, err := elastic.NewClient(
elastic.SetURL(mes.URL()),
elastic.SetHealthcheck(false),
elastic.SetSniff(false),
)
require.NoError(t, err)
-
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: es}
+ rt.ES = es
mes.NextResponse = fmt.Sprintf(`{
"_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAbgc0WS1hqbHlfb01SM2lLTWJRMnVOSVZDdw==",
@@ -56,16 +50,19 @@ func TestPopulateTask(t *testing.T) {
}
]
}
- }`, models.CathyID)
+ }`, testdata.Cathy.ID)
- groupID := testdata.InsertContactGroup(t, db, models.Org1, "e52fee05-2f95-4445-aef6-2fe7dac2fd56", "Women", "gender = F")
+ group := testdata.InsertContactGroup(db, testdata.Org1, "e52fee05-2f95-4445-aef6-2fe7dac2fd56", "Women", "gender = F")
+ start := dates.Now()
- task := &groups.PopulateDynamicGroupTask{
- GroupID: groupID,
+ task := &contacts.PopulateDynamicGroupTask{
+ GroupID: group.ID,
Query: "gender = F",
}
- err = task.Perform(ctx, mr, models.Org1)
+ err = task.Perform(ctx, rt, testdata.Org1.ID)
require.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contactgroup_id = $1`, []interface{}{groupID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contactgroup_contacts WHERE contactgroup_id = $1`, group.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT contact_id FROM contacts_contactgroup_contacts WHERE contactgroup_id = $1`, group.ID).Returns(int64(testdata.Cathy.ID))
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND modified_on > $2`, testdata.Cathy.ID, start).Returns(1)
}
diff --git a/core/tasks/expirations/cron.go b/core/tasks/expirations/cron.go
index 86b070f21..bebc215f1 100644
--- a/core/tasks/expirations/cron.go
+++ b/core/tasks/expirations/cron.go
@@ -3,12 +3,14 @@ package expirations
import (
"context"
"fmt"
+ "sync"
"time"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks/handler"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/nyaruka/mailroom/utils/marker"
@@ -29,12 +31,12 @@ func init() {
}
// StartExpirationCron starts our cron job of expiring runs every minute
-func StartExpirationCron(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, expirationLock, time.Second*60,
+func StartExpirationCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, expirationLock, time.Second*60,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return expireRuns(ctx, mr.DB, mr.RP, lockName, lockValue)
+ return expireRuns(ctx, rt.DB, rt.RP, lockName, lockValue)
},
)
return nil
@@ -106,7 +108,7 @@ func expireRuns(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName strin
// ok, queue this task
task := handler.NewExpirationTask(expiration.OrgID, expiration.ContactID, *expiration.SessionID, expiration.RunID, expiration.ExpiresOn)
- err = handler.AddHandleTask(rc, expiration.ContactID, task)
+ err = handler.QueueHandleTask(rc, expiration.ContactID, task)
if err != nil {
return errors.Wrapf(err, "error adding new expiration task")
}
diff --git a/core/tasks/expirations/cron_test.go b/core/tasks/expirations/cron_test.go
index eab4e8162..91708cea5 100644
--- a/core/tasks/expirations/cron_test.go
+++ b/core/tasks/expirations/cron_test.go
@@ -6,8 +6,6 @@ import (
"testing"
"time"
- "github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/goflow/flows"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
@@ -25,67 +23,63 @@ func TestMain(m *testing.M) {
}
func TestExpirations(t *testing.T) {
- ctx := testsuite.CTX()
- rp := testsuite.RP()
- rc := testsuite.RC()
+ ctx, _, db, rp := testsuite.Get()
+ rc := rp.Get()
defer rc.Close()
err := marker.ClearTasks(rc, expirationLock)
assert.NoError(t, err)
- // need to create a session that has an expired timeout
- db := testsuite.DB()
-
// create a few sessions
- s1 := testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.CathyID, models.SessionStatusWaiting, nil)
- s2 := testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.GeorgeID, models.SessionStatusWaiting, nil)
- s3 := testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.BobID, models.SessionStatusWaiting, nil)
+ s1 := testdata.InsertFlowSession(db, testdata.Org1, testdata.Cathy, models.SessionStatusWaiting, nil)
+ s2 := testdata.InsertFlowSession(db, testdata.Org1, testdata.George, models.SessionStatusWaiting, nil)
+ s3 := testdata.InsertFlowSession(db, testdata.Org1, testdata.Bob, models.SessionStatusWaiting, nil)
// simple run, no parent
r1ExpiresOn := time.Now()
- testdata.InsertFlowRun(t, db, "f240ab19-ed5d-4b51-b934-f2fbb9f8e5ad", models.Org1, s1, models.CathyID, models.FavoritesFlowID, models.RunStatusWaiting, "", &r1ExpiresOn)
+ testdata.InsertFlowRun(db, testdata.Org1, s1, testdata.Cathy, testdata.Favorites, models.RunStatusWaiting, "", &r1ExpiresOn)
// parent run
r2ExpiresOn := time.Now().Add(time.Hour * 24)
- testdata.InsertFlowRun(t, db, "c4126b59-7a61-4ed5-a2da-c7857580355b", models.Org1, s2, models.GeorgeID, models.FavoritesFlowID, models.RunStatusWaiting, "", &r2ExpiresOn)
+ testdata.InsertFlowRun(db, testdata.Org1, s2, testdata.George, testdata.Favorites, models.RunStatusWaiting, "", &r2ExpiresOn)
// child run
r3ExpiresOn := time.Now()
- testdata.InsertFlowRun(t, db, "a87b7079-5a3c-4e5f-8a6a-62084807c522", models.Org1, s2, models.GeorgeID, models.FavoritesFlowID, models.RunStatusWaiting, "c4126b59-7a61-4ed5-a2da-c7857580355b", &r3ExpiresOn)
+ testdata.InsertFlowRun(db, testdata.Org1, s2, testdata.George, testdata.Favorites, models.RunStatusWaiting, "c4126b59-7a61-4ed5-a2da-c7857580355b", &r3ExpiresOn)
// run with no session
r4ExpiresOn := time.Now()
- testdata.InsertFlowRun(t, db, "d64fac33-933f-44b4-a6e4-53283d07a609", models.Org1, models.SessionID(0), models.CathyID, models.FavoritesFlowID, models.RunStatusWaiting, "", &r4ExpiresOn)
+ testdata.InsertFlowRun(db, testdata.Org1, models.SessionID(0), testdata.Cathy, testdata.Favorites, models.RunStatusWaiting, "", &r4ExpiresOn)
// run with no expires_on
- testdata.InsertFlowRun(t, db, "4391fdc4-25ca-4e66-8e05-0cd3a6cbb6a2", models.Org1, s3, models.BobID, models.FavoritesFlowID, models.RunStatusWaiting, "", nil)
+ testdata.InsertFlowRun(db, testdata.Org1, s3, testdata.Bob, testdata.Favorites, models.RunStatusWaiting, "", nil)
time.Sleep(10 * time.Millisecond)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.CathyID}, 2)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.CathyID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Cathy.ID).Returns(2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Cathy.ID).Returns(0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.GeorgeID}, 2)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.GeorgeID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.George.ID).Returns(2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.George.ID).Returns(0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.BobID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.BobID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Bob.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Bob.ID).Returns(0)
// expire our runs
err = expireRuns(ctx, db, rp, expirationLock, "foo")
assert.NoError(t, err)
// shouldn't have any active runs or sessions
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.CathyID}, 0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.CathyID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Cathy.ID).Returns(0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Cathy.ID).Returns(1)
// should still have two active runs for George as it needs to continue
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.GeorgeID}, 2)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.GeorgeID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.George.ID).Returns(2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.George.ID).Returns(0)
// runs without expires_on won't be expired
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, []interface{}{models.BobID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, []interface{}{models.BobID}, 0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE is_active = TRUE AND contact_id = $1;`, testdata.Bob.ID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE status = 'X' AND contact_id = $1;`, testdata.Bob.ID).Returns(0)
// should have created one task
task, err := queue.PopNextTask(rc, queue.HandlerQueue)
@@ -98,7 +92,7 @@ func TestExpirations(t *testing.T) {
assert.NoError(t, err)
// assert its the right contact
- assert.Equal(t, models.GeorgeID, eventTask.ContactID)
+ assert.Equal(t, testdata.George.ID, eventTask.ContactID)
// no other tasks
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
diff --git a/core/tasks/handler/cron.go b/core/tasks/handler/cron.go
index 615769153..4ef26d8f8 100644
--- a/core/tasks/handler/cron.go
+++ b/core/tasks/handler/cron.go
@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
+ "sync"
"time"
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/nyaruka/mailroom/utils/marker"
@@ -29,19 +31,19 @@ func init() {
}
// StartRetryCron starts our cron job of retrying pending incoming messages
-func StartRetryCron(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, retryLock, time.Minute*5,
+func StartRetryCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, retryLock, time.Minute*5,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return retryPendingMsgs(ctx, mr.DB, mr.RP, lockName, lockValue)
+ return RetryPendingMsgs(ctx, rt.DB, rt.RP, lockName, lockValue)
},
)
return nil
}
-// retryPendingMsgs looks for any pending msgs older than five minutes and queues them to be handled again
-func retryPendingMsgs(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName string, lockValue string) error {
+// RetryPendingMsgs looks for any pending msgs older than five minutes and queues them to be handled again
+func RetryPendingMsgs(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName string, lockValue string) error {
if !config.Mailroom.RetryPendingMessages {
return nil
}
@@ -104,7 +106,7 @@ func retryPendingMsgs(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName
}
// queue this event up for handling
- err = AddHandleTask(rc, contactID, task)
+ err = QueueHandleTask(rc, contactID, task)
if err != nil {
return errors.Wrapf(err, "error queuing retry for task")
}
diff --git a/core/tasks/handler/cron_test.go b/core/tasks/handler/cron_test.go
index 50b712cfa..65cb5bdc3 100644
--- a/core/tasks/handler/cron_test.go
+++ b/core/tasks/handler/cron_test.go
@@ -1,4 +1,4 @@
-package handler
+package handler_test
import (
"testing"
@@ -9,21 +9,20 @@ import (
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/core/tasks/handler"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestRetryMsgs(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- rp := testsuite.RP()
- ctx := testsuite.CTX()
-
+ ctx, rt, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
// noop does nothing
- err := retryPendingMsgs(ctx, db, rp, "test", "test")
+ err := handler.RetryPendingMsgs(ctx, db, rp, "test", "test")
assert.NoError(t, err)
testMsgs := []struct {
@@ -40,20 +39,20 @@ func TestRetryMsgs(t *testing.T) {
db.MustExec(
`INSERT INTO msgs_msg(uuid, org_id, channel_id, contact_id, contact_urn_id, text, direction, status, created_on, visibility, msg_count, error_count, next_attempt)
VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, 'V', 1, 0, NOW())`,
- uuids.New(), models.Org1, models.TwilioChannelID, models.CathyID, models.CathyURNID, msg.Text, models.DirectionIn, msg.Status, msg.CreatedOn)
+ uuids.New(), testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Cathy.ID, testdata.Cathy.URNID, msg.Text, models.DirectionIn, msg.Status, msg.CreatedOn)
}
- err = retryPendingMsgs(ctx, db, rp, "test", "test")
+ err = handler.RetryPendingMsgs(ctx, db, rp, "test", "test")
assert.NoError(t, err)
// should have one message requeued
task, _ := queue.PopNextTask(rc, queue.HandlerQueue)
assert.NotNil(t, task)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err)
// message should be handled now
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_msg WHERE text = 'pending' AND status = 'H'`, []interface{}{}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_msg WHERE text = 'pending' AND status = 'H'`).Returns(1)
// only one message was queued
task, _ = queue.PopNextTask(rc, queue.HandlerQueue)
diff --git a/core/tasks/handler/handler_test.go b/core/tasks/handler/handler_test.go
index 35757163a..bd8b1c75b 100644
--- a/core/tasks/handler/handler_test.go
+++ b/core/tasks/handler/handler_test.go
@@ -1,4 +1,4 @@
-package handler
+package handler_test
import (
"encoding/json"
@@ -12,100 +12,102 @@ import (
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/core/tasks/handler"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
- "github.com/gomodule/redigo/redis"
- "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestMsgEvents(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- rp := testsuite.RP()
- ctx := testsuite.CTX()
-
+ ctx, rt, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), 'start', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`, models.FavoritesFlowID)
+ testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "start", models.MatchOnly, nil, nil)
+ testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.IVRFlow, "ivr", models.MatchOnly, nil, nil)
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), 'start', false, $1, 'K', 'O', 1, 1, 2) RETURNING id`, models.Org2FavoritesFlowID)
+ testdata.InsertKeywordTrigger(db, testdata.Org2, testdata.Org2Favorites, "start", models.MatchOnly, nil, nil)
+ testdata.InsertCatchallTrigger(db, testdata.Org2, testdata.Org2SingleMessage, nil, nil)
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), '', false, $1, 'C', 'O', 1, 1, 2) RETURNING id`, models.Org2SingleMessageFlowID)
+ // give Cathy and Bob some tickets...
+ openTickets := map[*testdata.Contact][]*testdata.Ticket{
+ testdata.Cathy: {
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Hi there", "Ok", "", nil),
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Hi again", "Ok", "", nil),
+ },
+ }
+ closedTickets := map[*testdata.Contact][]*testdata.Ticket{
+ testdata.Cathy: {
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Old", "", "", nil),
+ },
+ testdata.Bob: {
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Bob, testdata.Mailgun, "Hi there", "Ok", "", nil),
+ },
+ }
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), 'ivr', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`, models.IVRFlowID)
+ db.MustExec(`UPDATE tickets_ticket SET last_activity_on = '2021-01-01T00:00:00Z'`)
// clear all of Alexandria's URNs
- db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.AlexandriaID)
+ db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Alexandria.ID)
models.FlushCache()
+ // insert a dummy message into the database that will get the updates from handling each message event which pretends to be it
+ dbMsg := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "", models.MsgStatusPending)
+
tcs := []struct {
- Hook func()
- ContactID models.ContactID
- URN urns.URN
- URNID models.URNID
- Message string
- Response string
- ChannelID models.ChannelID
- OrgID models.OrgID
+ Hook func()
+ Org *testdata.Org
+ Channel *testdata.Channel
+ Contact *testdata.Contact
+ Text string
+ ExpectedReply string
+ ExpectedType models.MsgType
}{
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "noop", "", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "start other", "", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "start", "What is your favorite color?", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "purple", "I don't know that color. Try again.", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "blue", "Good choice, I like Blue too! What is your favorite beer?", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "MUTZIG", "Mmmmm... delicious Mutzig. If only they made blue Mutzig! Lastly, what is your name?", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "Cathy", "Thanks Cathy, we are all done!", models.TwitterChannelID, models.Org1},
- {nil, models.CathyID, models.CathyURN, models.CathyURNID, "noop", "", models.TwitterChannelID, models.Org1},
-
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "other", "Hey, how are you?", models.Org2ChannelID, models.Org2},
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start", "What is your favorite color?", models.Org2ChannelID, models.Org2},
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "green", "Good choice, I like Green too! What is your favorite beer?", models.Org2ChannelID, models.Org2},
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "primus", "Mmmmm... delicious Primus. If only they made green Primus! Lastly, what is your name?", models.Org2ChannelID, models.Org2},
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "george", "Thanks george, we are all done!", models.Org2ChannelID, models.Org2},
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "blargh", "Hey, how are you?", models.Org2ChannelID, models.Org2},
-
- {nil, models.BobID, models.BobURN, models.BobURNID, "ivr", "", models.TwitterChannelID, models.Org1},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "noop", "", models.MsgTypeInbox},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "start other", "", models.MsgTypeInbox},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "start", "What is your favorite color?", models.MsgTypeFlow},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "purple", "I don't know that color. Try again.", models.MsgTypeFlow},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "blue", "Good choice, I like Blue too! What is your favorite beer?", models.MsgTypeFlow},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "MUTZIG", "Mmmmm... delicious Mutzig. If only they made blue Mutzig! Lastly, what is your name?", models.MsgTypeFlow},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "Cathy", "Thanks Cathy, we are all done!", models.MsgTypeFlow},
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Cathy, "noop", "", models.MsgTypeInbox},
+
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "other", "Hey, how are you?", models.MsgTypeFlow},
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start", "What is your favorite color?", models.MsgTypeFlow},
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "green", "Good choice, I like Green too! What is your favorite beer?", models.MsgTypeFlow},
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "primus", "Mmmmm... delicious Primus. If only they made green Primus! Lastly, what is your name?", models.MsgTypeFlow},
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "george", "Thanks george, we are all done!", models.MsgTypeFlow},
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "blargh", "Hey, how are you?", models.MsgTypeFlow},
+
+ {nil, testdata.Org1, testdata.TwitterChannel, testdata.Bob, "ivr", "", models.MsgTypeFlow},
// no URN on contact but handle event, session gets started but no message created
- {nil, models.AlexandriaID, models.AlexandriaURN, models.AlexandriaURNID, "start", "", models.TwilioChannelID, models.Org1},
+ {nil, testdata.Org1, testdata.TwilioChannel, testdata.Alexandria, "start", "", models.MsgTypeFlow},
// start Fred back in our favorite flow, then make it inactive, will be handled by catch-all
- {nil, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start", "What is your favorite color?", models.Org2ChannelID, models.Org2},
+ {nil, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start", "What is your favorite color?", models.MsgTypeFlow},
{func() {
- db.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, models.Org2FavoritesFlowID)
- }, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "red", "Hey, how are you?", models.Org2ChannelID, models.Org2},
+ db.MustExec(`UPDATE flows_flow SET is_active = FALSE WHERE id = $1`, testdata.Org2Favorites.ID)
+ }, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "red", "Hey, how are you?", models.MsgTypeFlow},
// start Fred back in our favorites flow to test retries
{func() {
- db.MustExec(`UPDATE flows_flow SET is_active = TRUE WHERE id = $1`, models.Org2FavoritesFlowID)
- }, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start", "What is your favorite color?", models.Org2ChannelID, models.Org2},
+ db.MustExec(`UPDATE flows_flow SET is_active = TRUE WHERE id = $1`, testdata.Org2Favorites.ID)
+ }, testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start", "What is your favorite color?", models.MsgTypeFlow},
}
- makeMsgTask := func(orgID models.OrgID, channelID models.ChannelID, contactID models.ContactID, urn urns.URN, urnID models.URNID, text string) *queue.Task {
- event := &MsgEvent{
- ContactID: contactID,
- OrgID: orgID,
- ChannelID: channelID,
- MsgID: flows.MsgID(1),
- MsgUUID: flows.MsgUUID(uuids.New()),
- URN: urn,
- URNID: urnID,
+ makeMsgTask := func(org *testdata.Org, channel *testdata.Channel, contact *testdata.Contact, text string) *queue.Task {
+ event := &handler.MsgEvent{
+ ContactID: contact.ID,
+ OrgID: org.ID,
+ ChannelID: channel.ID,
+ MsgID: dbMsg.ID(),
+ MsgUUID: dbMsg.UUID(),
+ URN: contact.URN,
+ URNID: contact.URNID,
Text: text,
}
@@ -113,8 +115,8 @@ func TestMsgEvents(t *testing.T) {
assert.NoError(t, err)
task := &queue.Task{
- Type: MsgEventType,
- OrgID: int(orgID),
+ Type: handler.MsgEventType,
+ OrgID: int(org.ID),
Task: eventJSON,
}
@@ -126,26 +128,44 @@ func TestMsgEvents(t *testing.T) {
for i, tc := range tcs {
models.FlushCache()
+ // reset our dummy db message into an unhandled state
+ db.MustExec(`UPDATE msgs_msg SET status = 'P', msg_type = NULL WHERE id = $1`, dbMsg.ID())
+
// run our hook if we have one
if tc.Hook != nil {
tc.Hook()
}
- task := makeMsgTask(tc.OrgID, tc.ChannelID, tc.ContactID, tc.URN, tc.URNID, tc.Message)
+ task := makeMsgTask(tc.Org, tc.Channel, tc.Contact, tc.Text)
- err := AddHandleTask(rc, tc.ContactID, task)
+ err := handler.QueueHandleTask(rc, tc.Contact.ID, task)
assert.NoError(t, err, "%d: error adding task", i)
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NoError(t, err, "%d: error popping next task", i)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err, "%d: error when handling event", i)
- // if we are meant to have a response
- var text string
- db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.ContactID, last)
- assert.Equal(t, text, tc.Response, "%d: response: '%s' does not contain '%s'", i, text, tc.Response)
+ // check that message is marked as handled with expected type
+ testsuite.AssertQuery(t, db, `SELECT msg_type, status FROM msgs_msg WHERE id = $1`, dbMsg.ID()).
+ Columns(map[string]interface{}{"msg_type": string(tc.ExpectedType), "status": "H"}, "%d: msg state mismatch", i)
+
+ // if we are meant to have a reply, check it
+ if tc.ExpectedReply != "" {
+ testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.Contact.ID, last).
+ Returns(tc.ExpectedReply, "%d: response mismatch", i)
+ }
+
+ // check any open tickets for this contact where updated
+ numOpenTickets := len(openTickets[tc.Contact])
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE contact_id = $1 AND status = 'O' AND last_activity_on > $2`, tc.Contact.ID, last).
+ Returns(numOpenTickets, "%d: updated open ticket mismatch", i)
+
+ // check any closed tickets are unchanged
+ numClosedTickets := len(closedTickets[tc.Contact])
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM tickets_ticket WHERE contact_id = $1 AND status = 'C' AND last_activity_on = '2021-01-01T00:00:00Z'`, tc.Contact.ID).
+ Returns(numClosedTickets, "%d: unchanged closed ticket mismatch", i)
last = time.Now()
}
@@ -156,27 +176,25 @@ func TestMsgEvents(t *testing.T) {
assert.NotNil(t, task)
assert.Equal(t, queue.StartIVRFlowBatch, task.Type)
- // should have 7 queued priority messages
- count, err := redis.Int(rc.Do("zcard", fmt.Sprintf("msgs:%s|10/1", models.Org2ChannelUUID)))
- assert.NoError(t, err)
- assert.Equal(t, 9, count)
+ // check messages queued to courier
+ testsuite.AssertCourierQueues(t, map[string][]int{
+ fmt.Sprintf("msgs:%s|10/1", testdata.TwitterChannel.UUID): {1, 1, 1, 1, 1},
+ fmt.Sprintf("msgs:%s|10/1", testdata.Org2Channel.UUID): {1, 1, 1, 1, 1, 1, 1, 1, 1},
+ })
// Fred's sessions should not have a timeout because courier will set them
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from flows_flowsession where contact_id = $1 and timeout_on IS NULL AND wait_started_on IS NOT NULL`,
- []interface{}{models.Org2FredID}, 2,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowsession where contact_id = $1 and timeout_on IS NULL AND wait_started_on IS NOT NULL`, testdata.Org2Contact.ID).Returns(2)
// force an error by marking our run for fred as complete (our session is still active so this will blow up)
- db.MustExec(`UPDATE flows_flowrun SET is_active = FALSE WHERE contact_id = $1`, models.Org2FredID)
- task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "red")
- AddHandleTask(rc, models.Org2FredID, task)
+ db.MustExec(`UPDATE flows_flowrun SET is_active = FALSE WHERE contact_id = $1`, testdata.Org2Contact.ID)
+ task = makeMsgTask(testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "red")
+ handler.QueueHandleTask(rc, testdata.Org2Contact.ID, task)
// should get requeued three times automatically
for i := 0; i < 3; i++ {
task, _ = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NotNil(t, task)
- err := handleContactEvent(ctx, db, rp, task)
+ err := handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err)
}
@@ -186,67 +204,45 @@ func TestMsgEvents(t *testing.T) {
assert.Nil(t, task)
// mark Fred's flow as inactive
- db.MustExec(`UPDATE flows_flow SET is_active = FALSE where id = $1`, models.Org2FavoritesFlowID)
+ db.MustExec(`UPDATE flows_flow SET is_active = FALSE where id = $1`, testdata.Org2Favorites.ID)
models.FlushCache()
// try to resume now
- task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "red")
- AddHandleTask(rc, models.Org2FredID, task)
+ task = makeMsgTask(testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "red")
+ handler.QueueHandleTask(rc, testdata.Org2Contact.ID, task)
task, _ = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NotNil(t, task)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err)
// should get our catch all trigger
- text := ""
- db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' ORDER BY id DESC LIMIT 1`, models.Org2FredID)
- assert.Equal(t, "Hey, how are you?", text)
+ testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' ORDER BY id DESC LIMIT 1`, testdata.Org2Contact.ID).Returns("Hey, how are you?")
previous := time.Now()
// and should have failed previous session
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) from flows_flowsession where contact_id = $1 and status = 'F' and current_flow_id = $2`,
- []interface{}{models.Org2FredID, models.Org2FavoritesFlowID}, 2,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowsession where contact_id = $1 and status = 'F' and current_flow_id = $2`, testdata.Org2Contact.ID, testdata.Org2Favorites.ID).Returns(2)
// trigger should also not start a new session
- task = makeMsgTask(models.Org2, models.Org2ChannelID, models.Org2FredID, models.Org2FredURN, models.Org2FredURNID, "start")
- AddHandleTask(rc, models.Org2FredID, task)
+ task = makeMsgTask(testdata.Org2, testdata.Org2Channel, testdata.Org2Contact, "start")
+ handler.QueueHandleTask(rc, testdata.Org2Contact.ID, task)
task, _ = queue.PopNextTask(rc, queue.HandlerQueue)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err)
- db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND created_on > $2 ORDER BY id DESC LIMIT 1`, models.Org2FredID, previous)
- assert.Equal(t, "Hey, how are you?", text)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND created_on > $2`, testdata.Org2Contact.ID, previous).Returns(0)
}
func TestChannelEvents(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- rp := testsuite.RP()
- ctx := testsuite.CTX()
-
+ ctx, rt, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
- logrus.Info("starting channel test")
-
- // trigger on our twitter channel for new conversations and favorites flow
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id)
- VALUES(TRUE, now(), now(), NULL, false, $1, 'N', NULL, 1, 1, 1, $2) RETURNING id`,
- models.FavoritesFlowID, models.TwitterChannelID)
-
- // trigger on our vonage channel for referral and number flow
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id)
- VALUES(TRUE, now(), now(), NULL, false, $1, 'R', NULL, 1, 1, 1, $2) RETURNING id`,
- models.PickNumberFlowID, models.VonageChannelID)
+ // add some channel event triggers
+ testdata.InsertNewConversationTrigger(db, testdata.Org1, testdata.Favorites, testdata.TwitterChannel)
+ testdata.InsertReferralTrigger(db, testdata.Org1, testdata.PickANumber, "", testdata.VonageChannel)
// add a URN for cathy so we can test twitter URNs
- testdata.InsertContactURN(t, db, models.Org1, models.BobID, urns.URN("twitterid:123456"), 10)
+ testdata.InsertContactURN(db, testdata.Org1, testdata.Bob, urns.URN("twitterid:123456"), 10)
tcs := []struct {
EventType models.ChannelEventType
@@ -258,11 +254,11 @@ func TestChannelEvents(t *testing.T) {
Response string
UpdateLastSeen bool
}{
- {NewConversationEventType, models.CathyID, models.CathyURNID, models.Org1, models.TwitterChannelID, nil, "What is your favorite color?", true},
- {NewConversationEventType, models.CathyID, models.CathyURNID, models.Org1, models.VonageChannelID, nil, "", true},
- {WelcomeMessageEventType, models.CathyID, models.CathyURNID, models.Org1, models.VonageChannelID, nil, "", false},
- {ReferralEventType, models.CathyID, models.CathyURNID, models.Org1, models.TwitterChannelID, nil, "", true},
- {ReferralEventType, models.CathyID, models.CathyURNID, models.Org1, models.VonageChannelID, nil, "Pick a number between 1-10.", true},
+ {handler.NewConversationEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "What is your favorite color?", true},
+ {handler.NewConversationEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", true},
+ {handler.WelcomeMessageEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "", false},
+ {handler.ReferralEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.TwitterChannel.ID, nil, "", true},
+ {handler.ReferralEventType, testdata.Cathy.ID, testdata.Cathy.URNID, testdata.Org1.ID, testdata.VonageChannel.ID, nil, "Pick a number between 1-10.", true},
}
models.FlushCache()
@@ -281,21 +277,19 @@ func TestChannelEvents(t *testing.T) {
Task: eventJSON,
}
- err = AddHandleTask(rc, tc.ContactID, task)
+ err = handler.QueueHandleTask(rc, tc.ContactID, task)
assert.NoError(t, err, "%d: error adding task", i)
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NoError(t, err, "%d: error popping next task", i)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err, "%d: error when handling event", i)
// if we are meant to have a response
if tc.Response != "" {
- var text string
- err = db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND contact_urn_id = $2 AND created_on > $3 ORDER BY id DESC LIMIT 1`, tc.ContactID, tc.URNID, start)
- assert.NoError(t, err)
- assert.Equal(t, tc.Response, text, "%d: response: '%s' is not '%s'", i, text, tc.Response)
+ testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND contact_urn_id = $2 AND created_on > $3 ORDER BY id DESC LIMIT 1`, tc.ContactID, tc.URNID, start).
+ Returns(tc.Response, "%d: response mismatch", i)
}
if tc.UpdateLastSeen {
@@ -307,108 +301,117 @@ func TestChannelEvents(t *testing.T) {
}
}
-func TestStopEvent(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- rp := testsuite.RP()
- ctx := testsuite.CTX()
+func TestTicketEvents(t *testing.T) {
+ ctx, rt, db, _ := testsuite.Reset()
+ rc := rt.RP.Get()
+ defer rc.Close()
+
+ // add a ticket closed trigger
+ testdata.InsertTicketClosedTrigger(rt.DB, testdata.Org1, testdata.Favorites)
+
+ ticket := testdata.InsertClosedTicket(rt.DB, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Problem", "Where are my shoes?", "", nil)
+ modelTicket := ticket.Load(db)
+
+ event := models.NewTicketClosedEvent(modelTicket, testdata.Admin.ID)
+
+ err := handler.QueueTicketEvent(rc, testdata.Cathy.ID, event)
+ require.NoError(t, err)
+
+ task, err := queue.PopNextTask(rc, queue.HandlerQueue)
+ require.NoError(t, err)
+
+ err = handler.HandleEvent(ctx, rt, task)
+ require.NoError(t, err)
+ testsuite.AssertQuery(t, rt.DB, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND direction = 'O' AND text = 'What is your favorite color?'`, testdata.Cathy.ID).Returns(1)
+}
+
+func TestStopEvent(t *testing.T) {
+ ctx, rt, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
// schedule an event for cathy and george
- db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, models.CathyID, models.GeorgeID, models.RemindersEvent1ID)
+ db.MustExec(`INSERT INTO campaigns_eventfire(scheduled, contact_id, event_id) VALUES (NOW(), $1, $3), (NOW(), $2, $3);`, testdata.Cathy.ID, testdata.George.ID, testdata.RemindersEvent1.ID)
// and george to doctors group, cathy is already part of it
- db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contactgroup_id, contact_id) VALUES($1, $2);`, models.DoctorsGroupID, models.GeorgeID)
+ db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contactgroup_id, contact_id) VALUES($1, $2);`, testdata.DoctorsGroup.ID, testdata.George.ID)
- event := &StopEvent{OrgID: models.Org1, ContactID: models.CathyID}
+ event := &handler.StopEvent{OrgID: testdata.Org1.ID, ContactID: testdata.Cathy.ID}
eventJSON, err := json.Marshal(event)
+ require.NoError(t, err)
task := &queue.Task{
- Type: StopEventType,
- OrgID: int(models.Org1),
+ Type: handler.StopEventType,
+ OrgID: int(testdata.Org1.ID),
Task: eventJSON,
}
- err = AddHandleTask(rc, models.CathyID, task)
+ err = handler.QueueHandleTask(rc, testdata.Cathy.ID, task)
assert.NoError(t, err, "error adding task")
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NoError(t, err, "error popping next task")
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err, "error when handling event")
// check that only george is in our group
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, []interface{}{models.DoctorsGroupID, models.CathyID}, 0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, []interface{}{models.DoctorsGroupID, models.GeorgeID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, testdata.DoctorsGroup.ID, testdata.Cathy.ID).Returns(0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from contacts_contactgroup_contacts WHERE contactgroup_id = $1 AND contact_id = $2`, testdata.DoctorsGroup.ID, testdata.George.ID).Returns(1)
// that cathy is stopped
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, []interface{}{models.CathyID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM contacts_contact WHERE id = $1 AND status = 'S'`, testdata.Cathy.ID).Returns(1)
// and has no upcoming events
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, []interface{}{models.CathyID}, 0)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, []interface{}{models.GeorgeID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, testdata.Cathy.ID).Returns(0)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM campaigns_eventfire WHERE contact_id = $1`, testdata.George.ID).Returns(1)
}
func TestTimedEvents(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- rp := testsuite.RP()
- ctx := testsuite.CTX()
-
+ ctx, rt, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
// start to start our favorites flow
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), 'start', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`,
- models.FavoritesFlowID,
- )
-
- models.FlushCache()
+ testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.Favorites, "start", models.MatchOnly, nil, nil)
tcs := []struct {
EventType string
- ContactID models.ContactID
- URN urns.URN
- URNID models.URNID
+ Contact *testdata.Contact
Message string
Response string
ChannelID models.ChannelID
OrgID models.OrgID
}{
- // start the flow
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "start", "What is your favorite color?", models.TwitterChannelID, models.Org1},
+ // 0: start the flow
+ {handler.MsgEventType, testdata.Cathy, "start", "What is your favorite color?", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // this expiration does nothing because the times don't match
- {ExpirationEventType, models.CathyID, models.CathyURN, models.CathyURNID, "bad", "", models.TwitterChannelID, models.Org1},
+ // 1: this expiration does nothing because the times don't match
+ {handler.ExpirationEventType, testdata.Cathy, "bad", "", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // this checks that the flow wasn't expired
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "red", "Good choice, I like Red too! What is your favorite beer?", models.TwitterChannelID, models.Org1},
+ // 2: this checks that the flow wasn't expired
+ {handler.MsgEventType, testdata.Cathy, "red", "Good choice, I like Red too! What is your favorite beer?", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // this expiration will actually take
- {ExpirationEventType, models.CathyID, models.CathyURN, models.CathyURNID, "good", "", models.TwitterChannelID, models.Org1},
+ // 3: this expiration will actually take
+ {handler.ExpirationEventType, testdata.Cathy, "good", "", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // we won't get a response as we will be out of the flow
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "mutzig", "", models.TwitterChannelID, models.Org1},
+ // 4: we won't get a response as we will be out of the flow
+ {handler.MsgEventType, testdata.Cathy, "mutzig", "", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // start the parent expiration flow
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "parent", "Child", models.TwitterChannelID, models.Org1},
+ // 5: start the parent expiration flow
+ {handler.MsgEventType, testdata.Cathy, "parent", "Child", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // respond, should bring us out
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "hi", "Completed", models.TwitterChannelID, models.Org1},
+ // 6: respond, should bring us out
+ {handler.MsgEventType, testdata.Cathy, "hi", "Completed", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // expiring our child should be a no op
- {ExpirationEventType, models.CathyID, models.CathyURN, models.CathyURNID, "child", "", models.TwitterChannelID, models.Org1},
+ // 7: expiring our child should be a no op
+ {handler.ExpirationEventType, testdata.Cathy, "child", "", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // respond one last time, should be done
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "done", "Ended", models.TwitterChannelID, models.Org1},
+ // 8: respond one last time, should be done
+ {handler.MsgEventType, testdata.Cathy, "done", "Ended", testdata.TwitterChannel.ID, testdata.Org1.ID},
- // start our favorite flow again
- {MsgEventType, models.CathyID, models.CathyURN, models.CathyURNID, "start", "What is your favorite color?", models.TwitterChannelID, models.Org1},
+ // 9: start our favorite flow again
+ {handler.MsgEventType, testdata.Cathy, "start", "What is your favorite color?", testdata.TwitterChannel.ID, testdata.Org1.ID},
}
last := time.Now()
@@ -420,15 +423,15 @@ func TestTimedEvents(t *testing.T) {
time.Sleep(50 * time.Millisecond)
var task *queue.Task
- if tc.EventType == MsgEventType {
- event := &MsgEvent{
- ContactID: tc.ContactID,
+ if tc.EventType == handler.MsgEventType {
+ event := &handler.MsgEvent{
+ ContactID: tc.Contact.ID,
OrgID: tc.OrgID,
ChannelID: tc.ChannelID,
MsgID: flows.MsgID(1),
MsgUUID: flows.MsgUUID(uuids.New()),
- URN: tc.URN,
- URNID: tc.URNID,
+ URN: tc.Contact.URN,
+ URNID: tc.Contact.URNID,
Text: tc.Message,
}
@@ -440,7 +443,7 @@ func TestTimedEvents(t *testing.T) {
OrgID: int(tc.OrgID),
Task: eventJSON,
}
- } else if tc.EventType == ExpirationEventType {
+ } else if tc.EventType == handler.ExpirationEventType {
var expiration time.Time
if tc.Message == "bad" {
expiration = time.Now()
@@ -454,43 +457,43 @@ func TestTimedEvents(t *testing.T) {
expiration = time.Now().Add(time.Hour * 24)
}
- task = newTimedTask(
- ExpirationEventType,
+ task = handler.NewExpirationTask(
tc.OrgID,
- tc.ContactID,
+ tc.Contact.ID,
sessionID,
runID,
expiration,
)
}
- err := AddHandleTask(rc, tc.ContactID, task)
+ err := handler.QueueHandleTask(rc, tc.Contact.ID, task)
assert.NoError(t, err, "%d: error adding task", i)
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NoError(t, err, "%d: error popping next task", i)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err, "%d: error when handling event", i)
- var text string
- db.Get(&text, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.ContactID, last)
- assert.Equal(t, text, tc.Response, "%d: response: '%s' does not match '%s'", i, text, tc.Response)
+ if tc.Response != "" {
+ testsuite.AssertQuery(t, db, `SELECT text FROM msgs_msg WHERE contact_id = $1 AND created_on > $2 ORDER BY id DESC LIMIT 1`, tc.Contact.ID, last).
+ Returns(tc.Response, "%d: response: mismatch", i)
+ }
- err = db.Get(&sessionID, `SELECT id FROM flows_flowsession WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.ContactID)
+ err = db.Get(&sessionID, `SELECT id FROM flows_flowsession WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.Contact.ID)
assert.NoError(t, err)
- err = db.Get(&runID, `SELECT id FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.ContactID)
+ err = db.Get(&runID, `SELECT id FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.Contact.ID)
assert.NoError(t, err)
- err = db.Get(&runExpiration, `SELECT expires_on FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.ContactID)
+ err = db.Get(&runExpiration, `SELECT expires_on FROM flows_flowrun WHERE contact_id = $1 ORDER BY created_on DESC LIMIT 1`, tc.Contact.ID)
assert.NoError(t, err)
last = time.Now()
}
// test the case of a run and session no longer being the most recent but somehow still active, expiration should still work
- r, err := db.QueryContext(ctx, `SELECT id, session_id from flows_flowrun WHERE contact_id = $1 and is_active = FALSE order by created_on asc limit 1`, models.CathyID)
+ r, err := db.QueryContext(ctx, `SELECT id, session_id from flows_flowrun WHERE contact_id = $1 and is_active = FALSE order by created_on asc limit 1`, testdata.Cathy.ID)
assert.NoError(t, err)
defer r.Close()
r.Next()
@@ -505,26 +508,25 @@ func TestTimedEvents(t *testing.T) {
db.MustExec(`ALTER TABLE flows_flowrun ENABLE TRIGGER temba_flowrun_path_change`)
// try to expire the run
- task := newTimedTask(
- ExpirationEventType,
- models.Org1,
- models.CathyID,
+ task := handler.NewExpirationTask(
+ testdata.Org1.ID,
+ testdata.Cathy.ID,
sessionID,
runID,
expiration,
)
- err = AddHandleTask(rc, models.CathyID, task)
+ err = handler.QueueHandleTask(rc, testdata.Cathy.ID, task)
assert.NoError(t, err)
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
assert.NoError(t, err)
- err = handleContactEvent(ctx, db, rp, task)
+ err = handler.HandleEvent(ctx, rt, task)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowrun WHERE is_active = FALSE AND status = 'F' AND id = $1`, []interface{}{runID}, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowsession WHERE status = 'F' AND id = $1`, []interface{}{sessionID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowrun WHERE is_active = FALSE AND status = 'F' AND id = $1`, runID).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowsession WHERE status = 'F' AND id = $1`, sessionID).Returns(1)
testsuite.ResetDB()
}
diff --git a/core/tasks/handler/queue.go b/core/tasks/handler/queue.go
new file mode 100644
index 000000000..6520a4317
--- /dev/null
+++ b/core/tasks/handler/queue.go
@@ -0,0 +1,75 @@
+package handler
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/gomodule/redigo/redis"
+ "github.com/nyaruka/gocommon/dates"
+ "github.com/nyaruka/gocommon/jsonx"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/core/queue"
+ "github.com/pkg/errors"
+)
+
+// QueueHandleTask queues a single task for the given contact
+func QueueHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task) error {
+ return queueHandleTask(rc, contactID, task, false)
+}
+
+// queueHandleTask queues a single task for the passed in contact. `front` specifies whether the task
+// should be inserted in front of all other tasks for that contact
+func queueHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task, front bool) error {
+ // marshal our task
+ taskJSON, err := json.Marshal(task)
+ if err != nil {
+ return errors.Wrapf(err, "error marshalling contact task")
+ }
+
+ // first push the event on our contact queue
+ contactQ := fmt.Sprintf("c:%d:%d", task.OrgID, contactID)
+ if front {
+ _, err = redis.Int64(rc.Do("lpush", contactQ, string(taskJSON)))
+
+ } else {
+ _, err = redis.Int64(rc.Do("rpush", contactQ, string(taskJSON)))
+ }
+ if err != nil {
+ return errors.Wrapf(err, "error adding contact event")
+ }
+
+ return queueContactTask(rc, models.OrgID(task.OrgID), contactID)
+}
+
+// pushes a single contact task on our queue. Note this does not push the actual content of the task
+// only that a task exists for the contact, addHandleTask should be used if the task has already been pushed
+// off the contact specific queue.
+func queueContactTask(rc redis.Conn, orgID models.OrgID, contactID models.ContactID) error {
+ // create our contact event
+ contactTask := &HandleEventTask{ContactID: contactID}
+
+ // then add a handle task for that contact on our global handler queue
+ err := queue.AddTask(rc, queue.HandlerQueue, queue.HandleContactEvent, int(orgID), contactTask, queue.DefaultPriority)
+ if err != nil {
+ return errors.Wrapf(err, "error adding handle event task")
+ }
+ return nil
+}
+
+// QueueTicketEvent queues a ticket event to be handled
+func QueueTicketEvent(rc redis.Conn, contactID models.ContactID, evt *models.TicketEvent) error {
+ eventJSON := jsonx.MustMarshal(evt)
+ var task *queue.Task
+
+ switch evt.EventType() {
+ case models.TicketEventTypeClosed:
+ task = &queue.Task{
+ Type: TicketClosedEventType,
+ OrgID: int(evt.OrgID()),
+ Task: eventJSON,
+ QueuedOn: dates.Now(),
+ }
+ }
+
+ return queueHandleTask(rc, contactID, task, false)
+}
diff --git a/core/tasks/handler/worker.go b/core/tasks/handler/worker.go
index b4feb920c..c5924a7ce 100644
--- a/core/tasks/handler/worker.go
+++ b/core/tasks/handler/worker.go
@@ -20,6 +20,8 @@ import (
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/core/runner"
+ "github.com/nyaruka/mailroom/runtime"
+ "github.com/nyaruka/mailroom/utils/dbutil"
"github.com/nyaruka/mailroom/utils/locker"
"github.com/nyaruka/null"
"github.com/pkg/errors"
@@ -35,63 +37,20 @@ const (
MsgEventType = "msg_event"
ExpirationEventType = "expiration_event"
TimeoutEventType = "timeout_event"
+ TicketClosedEventType = "ticket_closed"
)
func init() {
- mailroom.AddTaskFunction(queue.HandleContactEvent, handleEvent)
+ mailroom.AddTaskFunction(queue.HandleContactEvent, HandleEvent)
}
-// AddHandleTask adds a single task for the passed in contact.
-func AddHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task) error {
- return addHandleTask(rc, contactID, task, false)
+func HandleEvent(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
+ return handleContactEvent(ctx, rt, task)
}
-// addContactTask pushes a single contact task on our queue. Note this does not push the actual content of the task
-// only that a task exists for the contact, addHandleTask should be used if the task has already been pushed
-// off the contact specific queue.
-func addContactTask(rc redis.Conn, orgID models.OrgID, contactID models.ContactID) error {
- // create our contact event
- contactTask := &HandleEventTask{ContactID: contactID}
-
- // then add a handle task for that contact on our global handler queue
- err := queue.AddTask(rc, queue.HandlerQueue, queue.HandleContactEvent, int(orgID), contactTask, queue.DefaultPriority)
- if err != nil {
- return errors.Wrapf(err, "error adding handle event task")
- }
- return nil
-}
-
-// addHandleTask adds a single task for the passed in contact. `front` specifies whether the task
-// should be inserted in front of all other tasks for that contact
-func addHandleTask(rc redis.Conn, contactID models.ContactID, task *queue.Task, front bool) error {
- // marshal our task
- taskJSON, err := json.Marshal(task)
- if err != nil {
- return errors.Wrapf(err, "error marshalling contact task")
- }
-
- // first push the event on our contact queue
- contactQ := fmt.Sprintf("c:%d:%d", task.OrgID, contactID)
- if front {
- _, err = redis.Int64(rc.Do("lpush", contactQ, string(taskJSON)))
-
- } else {
- _, err = redis.Int64(rc.Do("rpush", contactQ, string(taskJSON)))
- }
- if err != nil {
- return errors.Wrapf(err, "error adding contact event")
- }
-
- return addContactTask(rc, models.OrgID(task.OrgID), contactID)
-}
-
-func handleEvent(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
- return handleContactEvent(ctx, mr.DB, mr.RP, task)
-}
-
-// handleContactEvent is called when an event comes in for a contact. to make sure we don't get into
-// a situation of being off by one, this task ingests and handles all the events for a contact, one by one
-func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *queue.Task) error {
+// Called when an event comes in for a contact. To make sure we don't get into a situation of being off by one,
+// this task ingests and handles all the events for a contact, one by one.
+func handleContactEvent(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute*5)
defer cancel()
@@ -103,16 +62,16 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
// acquire the lock for this contact
lockID := models.ContactLock(models.OrgID(task.OrgID), eventTask.ContactID)
- lock, err := locker.GrabLock(rp, lockID, time.Minute*5, time.Second*10)
+ lock, err := locker.GrabLock(rt.RP, lockID, time.Minute*5, time.Second*10)
if err != nil {
return errors.Wrapf(err, "error acquiring lock for contact %d", eventTask.ContactID)
}
// we didn't get the lock within our timeout, skip and requeue for later
if lock == "" {
- rc := rp.Get()
+ rc := rt.RP.Get()
defer rc.Close()
- err = addContactTask(rc, models.OrgID(task.OrgID), eventTask.ContactID)
+ err = queueContactTask(rc, models.OrgID(task.OrgID), eventTask.ContactID)
if err != nil {
return errors.Wrapf(err, "error re-adding contact task after failing to get lock")
}
@@ -122,13 +81,13 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
}).Info("failed to get lock for contact, requeued and skipping")
return nil
}
- defer locker.ReleaseLock(rp, lockID, lock)
+ defer locker.ReleaseLock(rt.RP, lockID, lock)
// read all the events for this contact, one by one
contactQ := fmt.Sprintf("c:%d:%d", task.OrgID, eventTask.ContactID)
for {
// pop the next event off this contacts queue
- rc := rp.Get()
+ rc := rt.RP.Get()
event, err := redis.String(rc.Do("lpop", contactQ))
rc.Close()
@@ -160,7 +119,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
if err != nil {
return errors.Wrapf(err, "error unmarshalling stop event: %s", event)
}
- err = handleStopEvent(ctx, db, rp, evt)
+ err = handleStopEvent(ctx, rt.DB, rt.RP, evt)
case NewConversationEventType, ReferralEventType, MOMissEventType, WelcomeMessageEventType:
evt := &models.ChannelEvent{}
@@ -168,7 +127,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
if err != nil {
return errors.Wrapf(err, "error unmarshalling channel event: %s", event)
}
- _, err = HandleChannelEvent(ctx, db, rp, models.ChannelEventType(contactEvent.Type), evt, nil)
+ _, err = HandleChannelEvent(ctx, rt, models.ChannelEventType(contactEvent.Type), evt, nil)
case MsgEventType:
msg := &MsgEvent{}
@@ -176,7 +135,15 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
if err != nil {
return errors.Wrapf(err, "error unmarshalling msg event: %s", event)
}
- err = handleMsgEvent(ctx, db, rp, msg)
+ err = handleMsgEvent(ctx, rt, msg)
+
+ case TicketClosedEventType:
+ evt := &models.TicketEvent{}
+ err = json.Unmarshal(contactEvent.Task, evt)
+ if err != nil {
+ return errors.Wrapf(err, "error unmarshalling ticket event: %s", event)
+ }
+ err = handleTicketEvent(ctx, rt, evt)
case TimeoutEventType, ExpirationEventType:
evt := &TimedEvent{}
@@ -184,7 +151,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
if err != nil {
return errors.Wrapf(err, "error unmarshalling timeout event: %s", event)
}
- err = handleTimedEvent(ctx, db, rp, contactEvent.Type, evt)
+ err = handleTimedEvent(ctx, rt, contactEvent.Type, evt)
default:
return errors.Errorf("unknown contact event type: %s", contactEvent.Type)
@@ -204,10 +171,14 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
"event": event,
})
+ if qerr := dbutil.AsQueryError(err); qerr != nil {
+ log.WithFields(qerr.Fields())
+ }
+
contactEvent.ErrorCount++
if contactEvent.ErrorCount < 3 {
- rc := rp.Get()
- retryErr := addHandleTask(rc, eventTask.ContactID, contactEvent, true)
+ rc := rt.RP.Get()
+ retryErr := queueHandleTask(rc, eventTask.ContactID, contactEvent, true)
if retryErr != nil {
logrus.WithError(retryErr).Error("error requeuing errored contact event")
}
@@ -223,7 +194,7 @@ func handleContactEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, task *
}
// handleTimedEvent is called for timeout events
-func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventType string, event *TimedEvent) error {
+func handleTimedEvent(ctx context.Context, rt *runtime.Runtime, eventType string, event *TimedEvent) error {
start := time.Now()
log := logrus.WithFields(logrus.Fields{
"event_type": eventType,
@@ -231,13 +202,13 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp
"run_id": event.RunID,
"session_id": event.SessionID,
})
- oa, err := models.GetOrgAssets(ctx, db, event.OrgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID)
if err != nil {
return errors.Wrapf(err, "error loading org")
}
// load our contact
- contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID})
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{event.ContactID})
if err != nil {
return errors.Wrapf(err, "error loading contact")
}
@@ -256,7 +227,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp
}
// get the active session for this contact
- session, err := models.ActiveSessionForContact(ctx, db, oa, models.FlowTypeMessaging, contact)
+ session, err := models.ActiveSessionForContact(ctx, rt.DB, rt.SessionStorage, oa, models.FlowTypeMessaging, contact)
if err != nil {
return errors.Wrapf(err, "error loading active session for contact")
}
@@ -264,7 +235,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp
// if we didn't find a session or it is another session then this flow got interrupted and this is a race, fail it
if session == nil || session.ID() != event.SessionID {
log.Error("expiring run with mismatched session, session for run no longer active, failing runs and session")
- err = models.ExitSessions(ctx, db, []models.SessionID{event.SessionID}, models.ExitFailed, time.Now())
+ err = models.ExitSessions(ctx, rt.DB, []models.SessionID{event.SessionID}, models.ExitFailed, time.Now())
if err != nil {
return errors.Wrapf(err, "error failing expired runs for session that is no longer active")
}
@@ -277,7 +248,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp
case ExpirationEventType:
// check that our expiration is still the same
- expiration, err := models.RunExpiration(ctx, db, event.RunID)
+ expiration, err := models.RunExpiration(ctx, rt.DB, event.RunID)
if err != nil {
return errors.Wrapf(err, "unable to load expiration for run")
}
@@ -313,7 +284,7 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp
return errors.Errorf("unknown event type: %s", eventType)
}
- _, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, nil)
+ _, err = runner.ResumeFlow(ctx, rt, oa, session, resume, nil)
if err != nil {
return errors.Wrapf(err, "error resuming flow for timeout")
}
@@ -323,8 +294,8 @@ func handleTimedEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventTyp
}
// HandleChannelEvent is called for channel events
-func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventType models.ChannelEventType, event *models.ChannelEvent, conn *models.ChannelConnection) (*models.Session, error) {
- oa, err := models.GetOrgAssets(ctx, db, event.OrgID())
+func HandleChannelEvent(ctx context.Context, rt *runtime.Runtime, eventType models.ChannelEventType, event *models.ChannelEvent, conn *models.ChannelConnection) (*models.Session, error) {
+ oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID())
if err != nil {
return nil, errors.Wrapf(err, "error loading org")
}
@@ -337,7 +308,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT
}
// load our contact
- contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID()})
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{event.ContactID()})
if err != nil {
return nil, errors.Wrapf(err, "error loading contact")
}
@@ -350,12 +321,24 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT
modelContact := contacts[0]
if models.ContactSeenEvents[eventType] {
- err = modelContact.UpdateLastSeenOn(ctx, db, event.OccurredOn())
+ err = modelContact.UpdateLastSeenOn(ctx, rt.DB, event.OccurredOn())
if err != nil {
return nil, errors.Wrap(err, "error updating contact last_seen_on")
}
}
+ // make sure this URN is our highest priority (this is usually a noop)
+ err = modelContact.UpdatePreferredURN(ctx, rt.DB, oa, event.URNID(), channel)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error changing primary URN")
+ }
+
+ // build our flow contact
+ contact, err := modelContact.FlowContact(oa)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error creating flow contact")
+ }
+
// do we have associated trigger?
var trigger *models.Trigger
@@ -371,7 +354,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT
trigger = models.FindMatchingMissedCallTrigger(oa)
case models.MOCallEventType:
- trigger = models.FindMatchingMOCallTrigger(oa, modelContact)
+ trigger = models.FindMatchingIncomingCallTrigger(oa, contact)
case models.WelcomeMessageEventType:
trigger = nil
@@ -380,20 +363,8 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT
return nil, errors.Errorf("unknown channel event type: %s", eventType)
}
- // make sure this URN is our highest priority (this is usually a noop)
- err = modelContact.UpdatePreferredURN(ctx, db, oa, event.URNID(), channel)
- if err != nil {
- return nil, errors.Wrapf(err, "error changing primary URN")
- }
-
- // build our flow contact
- contact, err := modelContact.FlowContact(oa)
- if err != nil {
- return nil, errors.Wrapf(err, "error creating flow contact")
- }
-
if event.IsNewContact() {
- err = models.CalculateDynamicGroups(ctx, db, oa, contact)
+ err = models.CalculateDynamicGroups(ctx, rt.DB, oa, contact)
if err != nil {
return nil, errors.Wrapf(err, "unable to initialize new contact")
}
@@ -417,7 +388,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT
// if this is an IVR flow, we need to trigger that start (which happens in a different queue)
if flow.FlowType() == models.FlowTypeVoice && conn == nil {
- err = runner.TriggerIVRFlow(ctx, db, rp, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil)
+ err = runner.TriggerIVRFlow(ctx, rt, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil)
if err != nil {
return nil, errors.Wrapf(err, "error while triggering ivr flow")
}
@@ -470,7 +441,7 @@ func HandleChannelEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, eventT
}
}
- sessions, err := runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{flowTrigger}, hook, true)
+ sessions, err := runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{flowTrigger}, hook, true)
if err != nil {
return nil, errors.Wrapf(err, "error starting flow for contact")
}
@@ -507,27 +478,27 @@ func handleStopEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *St
}
// handleMsgEvent is called when a new message arrives from a contact
-func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *MsgEvent) error {
- oa, err := models.GetOrgAssets(ctx, db, event.OrgID)
+func handleMsgEvent(ctx context.Context, rt *runtime.Runtime, event *MsgEvent) error {
+ oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID)
if err != nil {
return errors.Wrapf(err, "error loading org")
}
// allocate a topup for this message if org uses topups
- topupID, err := models.AllocateTopups(ctx, db, rp, oa.Org(), 1)
+ topupID, err := models.AllocateTopups(ctx, rt.DB, rt.RP, oa.Org(), 1)
if err != nil {
return errors.Wrapf(err, "error allocating topup for incoming message")
}
// load our contact
- contacts, err := models.LoadContacts(ctx, db, oa, []models.ContactID{event.ContactID})
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{event.ContactID})
if err != nil {
return errors.Wrapf(err, "error loading contact")
}
// contact has been deleted, ignore this message but mark it as handled
if len(contacts) == 0 {
- err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topupID)
+ err := models.UpdateMessage(ctx, rt.DB, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.MsgTypeInbox, topupID)
if err != nil {
return errors.Wrapf(err, "error updating message for deleted contact")
}
@@ -541,7 +512,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// if we have URNs make sure the message URN is our highest priority (this is usually a noop)
if len(modelContact.URNs()) > 0 {
- err = modelContact.UpdatePreferredURN(ctx, db, oa, event.URNID, channel)
+ err = modelContact.UpdatePreferredURN(ctx, rt.DB, oa, event.URNID, channel)
if err != nil {
return errors.Wrapf(err, "error changing primary URN")
}
@@ -555,7 +526,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// if this channel is no longer active or this contact is blocked, ignore this message (mark it as handled)
if channel == nil || modelContact.Status() == models.ContactStatusBlocked {
- err := models.UpdateMessage(ctx, db, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.TypeInbox, topupID)
+ err := models.UpdateMessage(ctx, rt.DB, event.MsgID, models.MsgStatusHandled, models.VisibilityArchived, models.MsgTypeInbox, topupID)
if err != nil {
return errors.Wrapf(err, "error marking blocked or nil channel message as handled")
}
@@ -565,7 +536,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// stopped contact? they are unstopped if they send us an incoming message
newContact := event.NewContact
if modelContact.Status() == models.ContactStatusStopped {
- err := modelContact.Unstop(ctx, db)
+ err := modelContact.Unstop(ctx, rt.DB)
if err != nil {
return errors.Wrapf(err, "error unstopping contact")
}
@@ -575,26 +546,26 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// if this is a new contact, we need to calculate dynamic groups and campaigns
if newContact {
- err = models.CalculateDynamicGroups(ctx, db, oa, contact)
+ err = models.CalculateDynamicGroups(ctx, rt.DB, oa, contact)
if err != nil {
return errors.Wrapf(err, "unable to initialize new contact")
}
}
// look up any open tickets for this contact and forward this message to them
- tickets, err := models.LoadOpenTicketsForContact(ctx, db, modelContact)
+ tickets, err := models.LoadOpenTicketsForContact(ctx, rt.DB, modelContact)
if err != nil {
return errors.Wrapf(err, "unable to look up open tickets for contact")
}
for _, ticket := range tickets {
- ticket.ForwardIncoming(ctx, db, oa, event.MsgUUID, event.Text, event.Attachments)
+ ticket.ForwardIncoming(ctx, rt.DB, oa, event.MsgUUID, event.Text, event.Attachments)
}
// find any matching triggers
trigger := models.FindMatchingMsgTrigger(oa, contact, event.Text)
// get any active session for this contact
- session, err := models.ActiveSessionForContact(ctx, db, oa, models.FlowTypeMessaging, contact)
+ session, err := models.ActiveSessionForContact(ctx, rt.DB, rt.SessionStorage, oa, models.FlowTypeMessaging, contact)
if err != nil {
return errors.Wrapf(err, "error loading active session for contact")
}
@@ -606,7 +577,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// flow this session is in is gone, interrupt our session and reset it
if err == models.ErrNotFound {
- err = models.ExitSessions(ctx, db, []models.SessionID{session.ID()}, models.ExitFailed, time.Now())
+ err = models.ExitSessions(ctx, rt.DB, []models.SessionID{session.ID()}, models.ExitFailed, time.Now())
session = nil
}
@@ -619,19 +590,15 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
msgIn.SetExternalID(string(event.MsgExternalID))
msgIn.SetID(event.MsgID)
- // build our hook to mark our message as handled
- hook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error {
+ // build our hook to mark a flow message as handled
+ flowMsgHook := func(ctx context.Context, tx *sqlx.Tx, rp *redis.Pool, oa *models.OrgAssets, sessions []*models.Session) error {
// set our incoming message event on our session
if len(sessions) != 1 {
return errors.Errorf("handle hook called with more than one session")
}
sessions[0].SetIncomingMsg(event.MsgID, event.MsgExternalID)
- err = models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topupID)
- if err != nil {
- return errors.Wrapf(err, "error marking message as handled")
- }
- return nil
+ return markMsgHandled(ctx, tx, contact, msgIn, models.MsgTypeFlow, topupID, tickets)
}
// we found a trigger and their session is nil or doesn't ignore keywords
@@ -647,9 +614,10 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
if flow != nil {
// if this is an IVR flow, we need to trigger that start (which happens in a different queue)
if flow.FlowType() == models.FlowTypeVoice {
- err = runner.TriggerIVRFlow(ctx, db, rp, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, func(ctx context.Context, tx *sqlx.Tx) error {
- return models.UpdateMessage(ctx, tx, event.MsgID, models.MsgStatusHandled, models.VisibilityVisible, models.TypeFlow, topupID)
- })
+ ivrMsgHook := func(ctx context.Context, tx *sqlx.Tx) error {
+ return markMsgHandled(ctx, tx, contact, msgIn, models.MsgTypeFlow, topupID, tickets)
+ }
+ err = runner.TriggerIVRFlow(ctx, rt, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, ivrMsgHook)
if err != nil {
return errors.Wrapf(err, "error while triggering ivr flow")
}
@@ -658,7 +626,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// otherwise build the trigger and start the flow directly
trigger := triggers.NewBuilder(oa.Env(), flow.FlowReference(), contact).Msg(msgIn).WithMatch(trigger.Match()).Build()
- _, err = runner.StartFlowForContacts(ctx, db, rp, oa, flow, []flows.Trigger{trigger}, hook, true)
+ _, err = runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{trigger}, flowMsgHook, true)
if err != nil {
return errors.Wrapf(err, "error starting flow for contact")
}
@@ -669,7 +637,7 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
// if there is a session, resume it
if session != nil && flow != nil {
resume := resumes.NewMsg(oa.Env(), contact, msgIn)
- _, err = runner.ResumeFlow(ctx, db, rp, oa, session, resume, hook)
+ _, err = runner.ResumeFlow(ctx, rt, oa, session, resume, flowMsgHook)
if err != nil {
return errors.Wrapf(err, "error resuming flow for contact")
}
@@ -677,14 +645,113 @@ func handleMsgEvent(ctx context.Context, db *sqlx.DB, rp *redis.Pool, event *Msg
}
// this message didn't trigger and new sessions or resume any existing ones, so handle as inbox
- err = handleAsInbox(ctx, db, rp, oa, contact, msgIn, topupID)
+ err = handleAsInbox(ctx, rt.DB, rt.RP, oa, contact, msgIn, topupID, tickets)
if err != nil {
return errors.Wrapf(err, "error handling inbox message")
}
return nil
}
-func handleAsInbox(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, contact *flows.Contact, msg *flows.MsgIn, topupID models.TopupID) error {
+func handleTicketEvent(ctx context.Context, rt *runtime.Runtime, event *models.TicketEvent) error {
+ oa, err := models.GetOrgAssets(ctx, rt.DB, event.OrgID())
+ if err != nil {
+ return errors.Wrapf(err, "error loading org")
+ }
+
+ // load our ticket
+ tickets, err := models.LoadTickets(ctx, rt.DB, []models.TicketID{event.TicketID()})
+ if err != nil {
+ return errors.Wrapf(err, "error loading ticket")
+ }
+ // ticket has been deleted ignore this event
+ if len(tickets) == 0 {
+ return nil
+ }
+
+ modelTicket := tickets[0]
+
+ // load our contact
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{modelTicket.ContactID()})
+ if err != nil {
+ return errors.Wrapf(err, "error loading contact")
+ }
+
+ // contact has been deleted ignore this event
+ if len(contacts) == 0 {
+ return nil
+ }
+
+ modelContact := contacts[0]
+
+ // build our flow contact
+ contact, err := modelContact.FlowContact(oa)
+ if err != nil {
+ return errors.Wrapf(err, "error creating flow contact")
+ }
+
+ // do we have associated trigger?
+ var trigger *models.Trigger
+
+ switch event.EventType() {
+ case models.TicketEventTypeClosed:
+ trigger = models.FindMatchingTicketClosedTrigger(oa, contact)
+ default:
+ return errors.Errorf("unknown ticket event type: %s", event.EventType())
+ }
+
+ // no trigger, noop, move on
+ if trigger == nil {
+ logrus.WithField("ticket_id", event.TicketID).WithField("event_type", event.EventType()).Info("ignoring ticket event, no trigger found")
+ return nil
+ }
+
+ // load our flow
+ flow, err := oa.FlowByID(trigger.FlowID())
+ if err == models.ErrNotFound {
+ return nil
+ }
+ if err != nil {
+ return errors.Wrapf(err, "error loading flow for trigger")
+ }
+
+ // if this is an IVR flow, we need to trigger that start (which happens in a different queue)
+ if flow.FlowType() == models.FlowTypeVoice {
+ err = runner.TriggerIVRFlow(ctx, rt, oa.OrgID(), flow.ID(), []models.ContactID{modelContact.ID()}, nil)
+ if err != nil {
+ return errors.Wrapf(err, "error while triggering ivr flow")
+ }
+ return nil
+ }
+
+ // build our flow ticket
+ ticket, err := tickets[0].FlowTicket(oa)
+ if err != nil {
+ return errors.Wrapf(err, "error creating flow contact")
+ }
+
+ // build our flow trigger
+ var flowTrigger flows.Trigger
+
+ switch event.EventType() {
+ case models.TicketEventTypeClosed:
+ flowTrigger = triggers.NewBuilder(oa.Env(), flow.FlowReference(), contact).
+ Ticket(ticket, triggers.TicketEventTypeClosed).
+ Build()
+ default:
+ return errors.Errorf("unknown ticket event type: %s", event.EventType())
+ }
+
+ _, err = runner.StartFlowForContacts(ctx, rt, oa, flow, []flows.Trigger{flowTrigger}, nil, true)
+ if err != nil {
+ return errors.Wrapf(err, "error starting flow for contact")
+ }
+ return nil
+}
+
+// handles a message as an inbox message
+func handleAsInbox(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.OrgAssets, contact *flows.Contact, msg *flows.MsgIn, topupID models.TopupID, tickets []*models.Ticket) error {
+ // usually last_seen_on is updated by handling the msg_received event in the engine sprint, but since this is an inbox
+ // message we manually create that event and handle it
msgEvent := events.NewMsgReceived(msg)
contact.SetLastSeenOn(msgEvent.CreatedOn())
contactEvents := map[*flows.Contact][]flows.Event{contact: {msgEvent}}
@@ -694,11 +761,23 @@ func handleAsInbox(ctx context.Context, db *sqlx.DB, rp *redis.Pool, oa *models.
return errors.Wrap(err, "error handling inbox message events")
}
- err = models.UpdateMessage(ctx, db, msg.ID(), models.MsgStatusHandled, models.VisibilityVisible, models.TypeInbox, topupID)
+ return markMsgHandled(ctx, db, contact, msg, models.MsgTypeInbox, topupID, tickets)
+}
+
+// utility to mark as message as handled and update any open contact tickets
+func markMsgHandled(ctx context.Context, db models.Queryer, contact *flows.Contact, msg *flows.MsgIn, msgType models.MsgType, topupID models.TopupID, tickets []*models.Ticket) error {
+ err := models.UpdateMessage(ctx, db, msg.ID(), models.MsgStatusHandled, models.VisibilityVisible, msgType, topupID)
if err != nil {
return errors.Wrapf(err, "error marking message as handled")
}
+ if len(tickets) > 0 {
+ err = models.UpdateTicketLastActivity(ctx, db, tickets)
+ if err != nil {
+ return errors.Wrapf(err, "error updating last activity for open tickets")
+ }
+ }
+
return nil
}
@@ -735,7 +814,7 @@ type StopEvent struct {
OccurredOn time.Time `json:"occurred_on"`
}
-// NewTimeoutEvent creates a new event task for the passed in timeout event
+// creates a new event task for the passed in timeout event
func newTimedTask(eventType string, orgID models.OrgID, contactID models.ContactID, sessionID models.SessionID, runID models.FlowRunID, eventTime time.Time) *queue.Task {
event := &TimedEvent{
OrgID: orgID,
diff --git a/core/tasks/interrupts/interrupt_sessions.go b/core/tasks/interrupts/interrupt_sessions.go
index e1c049b75..dd25a4423 100644
--- a/core/tasks/interrupts/interrupt_sessions.go
+++ b/core/tasks/interrupts/interrupt_sessions.go
@@ -4,9 +4,9 @@ import (
"context"
"time"
- "github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/lib/pq"
"github.com/pkg/errors"
@@ -63,8 +63,8 @@ func (t *InterruptSessionsTask) Timeout() time.Duration {
return time.Hour
}
-func (t *InterruptSessionsTask) Perform(ctx context.Context, mr *mailroom.Mailroom, orgID models.OrgID) error {
- db := mr.DB
+func (t *InterruptSessionsTask) Perform(ctx context.Context, rt *runtime.Runtime, orgID models.OrgID) error {
+ db := rt.DB
sessionIDs := make(map[models.SessionID]bool)
for _, sid := range t.SessionIDs {
diff --git a/core/tasks/interrupts/interrupt_sessions_test.go b/core/tasks/interrupts/interrupt_sessions_test.go
index 6d271f3c5..c45733e72 100644
--- a/core/tasks/interrupts/interrupt_sessions_test.go
+++ b/core/tasks/interrupts/interrupt_sessions_test.go
@@ -4,21 +4,16 @@ import (
"testing"
"github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/mailroom"
- "github.com/nyaruka/mailroom/config"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
)
func TestInterrupts(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
-
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: testsuite.RP(), ElasticClient: nil}
+ ctx, rt, db, _ := testsuite.Reset()
insertConnection := func(orgID models.OrgID, channelID models.ChannelID, contactID models.ContactID, urnID models.URNID) models.ConnectionID {
var connectionID models.ConnectionID
@@ -57,23 +52,23 @@ func TestInterrupts(t *testing.T) {
[5]string{"W", "W", "W", "W", "I"},
},
{
- []models.ContactID{models.CathyID}, nil, nil,
+ []models.ContactID{testdata.Cathy.ID}, nil, nil,
[5]string{"I", "W", "W", "W", "I"},
},
{
- []models.ContactID{models.CathyID, models.GeorgeID}, nil, nil,
+ []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, nil, nil,
[5]string{"I", "I", "W", "W", "I"},
},
{
- nil, []models.ChannelID{models.TwilioChannelID}, nil,
+ nil, []models.ChannelID{testdata.TwilioChannel.ID}, nil,
[5]string{"W", "W", "I", "W", "I"},
},
{
- nil, nil, []models.FlowID{models.PickNumberFlowID},
+ nil, nil, []models.FlowID{testdata.PickANumber.ID},
[5]string{"W", "W", "W", "I", "I"},
},
{
- []models.ContactID{models.CathyID, models.GeorgeID}, []models.ChannelID{models.TwilioChannelID}, []models.FlowID{models.PickNumberFlowID},
+ []models.ContactID{testdata.Cathy.ID, testdata.George.ID}, []models.ChannelID{testdata.TwilioChannel.ID}, []models.FlowID{testdata.PickANumber.ID},
[5]string{"I", "I", "I", "I", "I"},
},
}
@@ -83,18 +78,18 @@ func TestInterrupts(t *testing.T) {
db.MustExec(`UPDATE flows_flowsession SET status='C', ended_on=NOW() WHERE status = 'W';`)
// twilio connection
- twilioConnectionID := insertConnection(models.Org1, models.TwilioChannelID, models.AlexandriaID, models.AlexandriaURNID)
+ twilioConnectionID := insertConnection(testdata.Org1.ID, testdata.TwilioChannel.ID, testdata.Alexandria.ID, testdata.Alexandria.URNID)
sessionIDs := make([]models.SessionID, 5)
// insert our dummy contact sessions
- sessionIDs[0] = insertSession(models.Org1, models.CathyID, models.NilConnectionID, models.FavoritesFlowID)
- sessionIDs[1] = insertSession(models.Org1, models.GeorgeID, models.NilConnectionID, models.FavoritesFlowID)
- sessionIDs[2] = insertSession(models.Org1, models.AlexandriaID, twilioConnectionID, models.FavoritesFlowID)
- sessionIDs[3] = insertSession(models.Org1, models.BobID, models.NilConnectionID, models.PickNumberFlowID)
+ sessionIDs[0] = insertSession(testdata.Org1.ID, testdata.Cathy.ID, models.NilConnectionID, testdata.Favorites.ID)
+ sessionIDs[1] = insertSession(testdata.Org1.ID, testdata.George.ID, models.NilConnectionID, testdata.Favorites.ID)
+ sessionIDs[2] = insertSession(testdata.Org1.ID, testdata.Alexandria.ID, twilioConnectionID, testdata.Favorites.ID)
+ sessionIDs[3] = insertSession(testdata.Org1.ID, testdata.Bob.ID, models.NilConnectionID, testdata.PickANumber.ID)
// a session we always end explicitly
- sessionIDs[4] = insertSession(models.Org1, models.BobID, models.NilConnectionID, models.FavoritesFlowID)
+ sessionIDs[4] = insertSession(testdata.Org1.ID, testdata.Bob.ID, models.NilConnectionID, testdata.Favorites.ID)
// create our task
task := &InterruptSessionsTask{
@@ -105,7 +100,7 @@ func TestInterrupts(t *testing.T) {
}
// execute it
- err := task.Perform(ctx, mr, models.Org1)
+ err := task.Perform(ctx, rt, testdata.Org1.ID)
assert.NoError(t, err)
// check session statuses are as expected
@@ -116,12 +111,8 @@ func TestInterrupts(t *testing.T) {
assert.Equal(t, tc.StatusesAfter[j], status, "%d: status mismatch for session #%d", i, j)
// check for runs with a different status to the session
- testsuite.AssertQueryCount(
- t, db,
- `SELECT count(*) FROM flows_flowrun WHERE session_id = $1 AND status != $2`,
- []interface{}{sID, tc.StatusesAfter[j]}, 0,
- "%d: unexpected un-interrupted runs for session #%d", i, j,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE session_id = $1 AND status != $2`, sID, tc.StatusesAfter[j]).
+ Returns(0, "%d: unexpected un-interrupted runs for session #%d", i, j)
}
}
}
diff --git a/core/tasks/ivr/cron.go b/core/tasks/ivr/cron.go
index 26a23a089..181d51cb4 100644
--- a/core/tasks/ivr/cron.go
+++ b/core/tasks/ivr/cron.go
@@ -2,6 +2,7 @@ package ivr
import (
"context"
+ "sync"
"time"
"github.com/nyaruka/goflow/flows"
@@ -9,6 +10,7 @@ import (
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/ivr"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/gomodule/redigo/redis"
@@ -27,20 +29,20 @@ func init() {
}
// StartIVRCron starts our cron job of retrying errored calls
-func StartIVRCron(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, retryIVRLock, time.Minute,
+func StartIVRCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, retryIVRLock, time.Minute,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return retryCalls(ctx, mr.Config, mr.DB, mr.RP, retryIVRLock, lockValue)
+ return retryCalls(ctx, rt.Config, rt.DB, rt.RP, retryIVRLock, lockValue)
},
)
- cron.StartCron(mr.Quit, mr.RP, expireIVRLock, time.Minute,
+ cron.StartCron(quit, rt.RP, expireIVRLock, time.Minute,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return expireCalls(ctx, mr.Config, mr.DB, mr.RP, expireIVRLock, lockValue)
+ return expireCalls(ctx, rt.Config, rt.DB, rt.RP, expireIVRLock, lockValue)
},
)
diff --git a/core/tasks/ivr/cron_test.go b/core/tasks/ivr/cron_test.go
index c37a5b62e..9ff686f63 100644
--- a/core/tasks/ivr/cron_test.go
+++ b/core/tasks/ivr/cron_test.go
@@ -10,13 +10,13 @@ import (
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/core/tasks/starts"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestRetries(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
- models.FlushCache()
-
+ ctx, _, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
@@ -24,11 +24,11 @@ func TestRetries(t *testing.T) {
ivr.RegisterClientType(models.ChannelType("ZZ"), newMockClient)
// update our twilio channel to be of type 'ZZ' and set max_concurrent_events to 1
- db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, testdata.TwilioChannel.ID)
// create a flow start for cathy
- start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive).
- WithContactIDs([]models.ContactID{models.CathyID})
+ start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive).
+ WithContactIDs([]models.ContactID{testdata.Cathy.ID})
// call our master starter
err := starts.CreateFlowBatches(ctx, db, rp, nil, start)
@@ -45,7 +45,8 @@ func TestRetries(t *testing.T) {
client.callID = ivr.CallID("call1")
err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusWired, "call1"}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
+ testdata.Cathy.ID, models.ConnectionStatusWired, "call1").Returns(1)
// change our call to be errored instead of wired
db.MustExec(`UPDATE channels_channelconnection SET status = 'E', next_attempt = NOW() WHERE external_id = 'call1';`)
@@ -55,16 +56,18 @@ func TestRetries(t *testing.T) {
assert.NoError(t, err)
// should now be in wired state
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusWired, "call1"}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
+ testdata.Cathy.ID, models.ConnectionStatusWired, "call1").Returns(1)
// back to retry and make the channel inactive
db.MustExec(`UPDATE channels_channelconnection SET status = 'E', next_attempt = NOW() WHERE external_id = 'call1';`)
- db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, testdata.TwilioChannel.ID)
models.FlushCache()
err = retryCalls(ctx, config.Mailroom, db, rp, "retry_test", "retry_test")
assert.NoError(t, err)
// this time should be failed
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusFailed, "call1"}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
+ testdata.Cathy.ID, models.ConnectionStatusFailed, "call1").Returns(1)
}
diff --git a/core/tasks/ivr/worker.go b/core/tasks/ivr/worker.go
index c4a00858b..603f9f366 100644
--- a/core/tasks/ivr/worker.go
+++ b/core/tasks/ivr/worker.go
@@ -12,6 +12,7 @@ import (
"github.com/nyaruka/mailroom/core/ivr"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
@@ -20,7 +21,7 @@ func init() {
mailroom.AddTaskFunction(queue.StartIVRFlowBatch, handleFlowStartTask)
}
-func handleFlowStartTask(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
+func handleFlowStartTask(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
// decode our task body
if task.Type != queue.StartIVRFlowBatch {
return errors.Errorf("unknown event type passed to ivr worker: %s", task.Type)
@@ -31,7 +32,7 @@ func handleFlowStartTask(ctx context.Context, mr *mailroom.Mailroom, task *queue
return errors.Wrapf(err, "error unmarshalling flow start batch: %s", string(task.Task))
}
- return HandleFlowStartBatch(ctx, mr.Config, mr.DB, mr.RP, batch)
+ return HandleFlowStartBatch(ctx, rt.Config, rt.DB, rt.RP, batch)
}
// HandleFlowStartBatch starts a batch of contacts in an IVR flow
diff --git a/core/tasks/ivr/worker_test.go b/core/tasks/ivr/worker_test.go
index 6a4151591..6ae035b59 100644
--- a/core/tasks/ivr/worker_test.go
+++ b/core/tasks/ivr/worker_test.go
@@ -6,8 +6,6 @@ import (
"net/http"
"testing"
- "github.com/gomodule/redigo/redis"
- "github.com/jmoiron/sqlx"
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/mailroom/config"
@@ -15,16 +13,17 @@ import (
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/core/tasks/starts"
- "github.com/pkg/errors"
-
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
+ "github.com/gomodule/redigo/redis"
+ "github.com/jmoiron/sqlx"
+ "github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)
func TestIVR(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
- models.FlushCache()
-
+ ctx, _, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
@@ -32,11 +31,11 @@ func TestIVR(t *testing.T) {
ivr.RegisterClientType(models.ChannelType("ZZ"), newMockClient)
// update our twilio channel to be of type 'ZZ' and set max_concurrent_events to 1
- db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET channel_type = 'ZZ', config = '{"max_concurrent_events": 1}' WHERE id = $1`, testdata.TwilioChannel.ID)
// create a flow start for cathy
- start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive).
- WithContactIDs([]models.ContactID{models.CathyID})
+ start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive).
+ WithContactIDs([]models.ContactID{testdata.Cathy.ID})
// call our master starter
err := starts.CreateFlowBatches(ctx, db, rp, nil, start)
@@ -52,20 +51,20 @@ func TestIVR(t *testing.T) {
client.callError = errors.Errorf("unable to create call")
err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2`, []interface{}{models.CathyID, models.ConnectionStatusFailed}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2`, testdata.Cathy.ID, models.ConnectionStatusFailed).Returns(1)
client.callError = nil
client.callID = ivr.CallID("call1")
err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, []interface{}{models.CathyID, models.ConnectionStatusWired, "call1"}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`, testdata.Cathy.ID, models.ConnectionStatusWired, "call1").Returns(1)
// trying again should put us in a throttled state (queued)
client.callError = nil
client.callID = ivr.CallID("call1")
err = HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND next_attempt IS NOT NULL;`, []interface{}{models.CathyID, models.ConnectionStatusQueued}, 1)
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND next_attempt IS NOT NULL;`, testdata.Cathy.ID, models.ConnectionStatusQueued).Returns(1)
}
var client = &MockClient{}
diff --git a/core/tasks/schedules/cron.go b/core/tasks/schedules/cron.go
index afad0527f..5f5c30304 100644
--- a/core/tasks/schedules/cron.go
+++ b/core/tasks/schedules/cron.go
@@ -2,6 +2,7 @@ package schedules
import (
"context"
+ "sync"
"time"
"github.com/gomodule/redigo/redis"
@@ -9,6 +10,7 @@ import (
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
@@ -23,15 +25,15 @@ func init() {
}
// StartCheckSchedules starts our cron job of firing schedules every minute
-func StartCheckSchedules(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, scheduleLock, time.Minute*1,
+func StartCheckSchedules(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, scheduleLock, time.Minute*1,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
// we sleep 1 second since we fire right on the minute and want to make sure to fire
// things that are schedules right at the minute as well (and DB time may be slightly drifted)
time.Sleep(time.Second * 1)
- return checkSchedules(ctx, mr.DB, mr.RP, lockName, lockValue)
+ return checkSchedules(ctx, rt.DB, rt.RP, lockName, lockValue)
},
)
return nil
diff --git a/core/tasks/schedules/cron_test.go b/core/tasks/schedules/cron_test.go
index 064c4e317..1cda0267c 100644
--- a/core/tasks/schedules/cron_test.go
+++ b/core/tasks/schedules/cron_test.go
@@ -3,47 +3,36 @@ package schedules
import (
"testing"
+ "github.com/nyaruka/goflow/envs"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
)
func TestCheckSchedules(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
-
+ ctx, _, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
// add a schedule and tie a broadcast to it
- db := testsuite.DB()
var s1 models.ScheduleID
err := db.Get(
&s1,
`INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id)
VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '1 DAY', 1, 1, $1) RETURNING id`,
- models.Org1,
- )
- assert.NoError(t, err)
- var b1 models.BroadcastID
- err = db.Get(
- &b1,
- `INSERT INTO msgs_broadcast(status, text, base_language, is_active, created_on, modified_on, send_all, created_by_id, modified_by_id, org_id, schedule_id)
- VALUES('P', hstore(ARRAY['eng','Test message', 'fra', 'Un Message']), 'eng', TRUE, NOW(), NOW(), TRUE, 1, 1, $1, $2) RETURNING id`,
- models.Org1, s1,
+ testdata.Org1.ID,
)
assert.NoError(t, err)
- // add a few contacts to the broadcast
- db.MustExec(`INSERT INTO msgs_broadcast_contacts(broadcast_id, contact_id) VALUES($1, $2),($1, $3)`, b1, models.CathyID, models.GeorgeID)
-
- // and a group
- db.MustExec(`INSERT INTO msgs_broadcast_groups(broadcast_id, contactgroup_id) VALUES($1, $2)`, b1, models.DoctorsGroupID)
+ b1 := testdata.InsertBroadcast(db, testdata.Org1, "eng", map[envs.Language]string{"eng": "Test message", "fra": "Un Message"}, s1,
+ []*testdata.Contact{testdata.Cathy, testdata.George}, []*testdata.Group{testdata.DoctorsGroup},
+ )
- // and a URN
- db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, models.CathyURNID)
+ // add a URN
+ db.MustExec(`INSERT INTO msgs_broadcast_urns(broadcast_id, contacturn_id) VALUES($1, $2)`, b1, testdata.Cathy.URNID)
// add another and tie a trigger to it
var s2 models.ScheduleID
@@ -51,7 +40,7 @@ func TestCheckSchedules(t *testing.T) {
&s2,
`INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id)
VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '2 DAY', 1, 1, $1) RETURNING id`,
- models.Org1,
+ testdata.Org1.ID,
)
assert.NoError(t, err)
var t1 models.TriggerID
@@ -59,22 +48,22 @@ func TestCheckSchedules(t *testing.T) {
&t1,
`INSERT INTO triggers_trigger(is_active, created_on, modified_on, is_archived, trigger_type, created_by_id, modified_by_id, org_id, flow_id, schedule_id)
VALUES(TRUE, NOW(), NOW(), FALSE, 'S', 1, 1, $1, $2, $3) RETURNING id`,
- models.Org1, models.FavoritesFlowID, s2,
+ testdata.Org1.ID, testdata.Favorites.ID, s2,
)
assert.NoError(t, err)
// add a few contacts to the trigger
- db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, models.CathyID, models.GeorgeID)
+ db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2),($1, $3)`, t1, testdata.Cathy.ID, testdata.George.ID)
// and a group
- db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, models.DoctorsGroupID)
+ db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, t1, testdata.DoctorsGroup.ID)
var s3 models.ScheduleID
err = db.Get(
&s3,
`INSERT INTO schedules_schedule(is_active, repeat_period, created_on, modified_on, next_fire, created_by_id, modified_by_id, org_id)
VALUES(TRUE, 'O', NOW(), NOW(), NOW()- INTERVAL '3 DAY', 1, 1, $1) RETURNING id`,
- models.Org1,
+ testdata.Org1.ID,
)
assert.NoError(t, err)
@@ -83,29 +72,23 @@ func TestCheckSchedules(t *testing.T) {
assert.NoError(t, err)
// should have one flow start added to our DB ready to go
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P';`,
- []interface{}{models.FavoritesFlowID}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart WHERE flow_id = $1 AND start_type = 'T' AND status = 'P'`, testdata.Favorites.ID).Returns(1)
// with the right count of groups and contacts
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowstart_contacts WHERE flowstart_id = 1`, nil, 2)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from flows_flowstart_groups WHERE flowstart_id = 1`, nil, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowstart_contacts WHERE flowstart_id = 1`).Returns(2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from flows_flowstart_groups WHERE flowstart_id = 1`).Returns(1)
// and one broadcast as well
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_broadcast WHERE org_id = $1 AND parent_id = $2 AND text = hstore(ARRAY['eng','Test message', 'fra', 'Un Message'])
- AND status = 'Q' AND base_language = 'eng';`,
- []interface{}{models.Org1, b1}, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_broadcast WHERE org_id = $1 AND parent_id = $2
+ AND text = hstore(ARRAY['eng','Test message', 'fra', 'Un Message']) AND status = 'Q' AND base_language = 'eng'`, testdata.Org1.ID, b1).Returns(1)
// with the right count of groups, contacts, urns
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_broadcast_urns WHERE broadcast_id = 2`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_broadcast_contacts WHERE broadcast_id = 2`, nil, 2)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) from msgs_broadcast_groups WHERE broadcast_id = 2`, nil, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_broadcast_urns WHERE broadcast_id = 2`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_broadcast_contacts WHERE broadcast_id = 2`).Returns(2)
+ testsuite.AssertQuery(t, db, `SELECT count(*) from msgs_broadcast_groups WHERE broadcast_id = 2`).Returns(1)
// we shouldn't have any pending schedules since there were all one time fires, but all should have last fire
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM schedules_schedule WHERE next_fire IS NULL and last_fire < NOW();`,
- nil, 3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM schedules_schedule WHERE next_fire IS NULL and last_fire < NOW();`).Returns(3)
// check the tasks created
task, err := queue.PopNextTask(rc, queue.BatchQueue)
diff --git a/core/tasks/starts/worker.go b/core/tasks/starts/worker.go
index 4d4f19d9f..8d09d5e95 100644
--- a/core/tasks/starts/worker.go
+++ b/core/tasks/starts/worker.go
@@ -11,6 +11,7 @@ import (
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/core/runner"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/gomodule/redigo/redis"
"github.com/jmoiron/sqlx"
@@ -30,7 +31,7 @@ func init() {
}
// handleFlowStart creates all the batches of contacts to start in a flow
-func handleFlowStart(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
+func handleFlowStart(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute*60)
defer cancel()
@@ -44,9 +45,9 @@ func handleFlowStart(ctx context.Context, mr *mailroom.Mailroom, task *queue.Tas
return errors.Wrapf(err, "error unmarshalling flow start task: %s", string(task.Task))
}
- err = CreateFlowBatches(ctx, mr.DB, mr.RP, mr.ElasticClient, startTask)
+ err = CreateFlowBatches(ctx, rt.DB, rt.RP, rt.ES, startTask)
if err != nil {
- models.MarkStartFailed(ctx, mr.DB, startTask.ID())
+ models.MarkStartFailed(ctx, rt.DB, startTask.ID())
// if error is user created query error.. don't escalate error to sentry
isQueryError, _ := contactql.IsQueryError(err)
@@ -97,11 +98,11 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela
createdContactIDs = append(createdContactIDs, contact.ID())
}
- // now add all the ids for our groups
+ // if we have inclusion groups, add all the contact ids from those groups
if len(start.GroupIDs()) > 0 {
rows, err := db.QueryxContext(ctx, `SELECT contact_id FROM contacts_contactgroup_contacts WHERE contactgroup_id = ANY($1)`, pq.Array(start.GroupIDs()))
if err != nil {
- return errors.Wrapf(err, "error selecting contacts for groups")
+ return errors.Wrapf(err, "error querying contacts from inclusion groups")
}
defer rows.Close()
@@ -115,7 +116,7 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela
}
}
- // finally, if we have a query, add the contacts that match that as well
+ // if we have a query, add the contacts that match that as well
if start.Query() != "" {
matches, err := models.ContactIDsForQuery(ctx, ec, oa, start.Query())
if err != nil {
@@ -127,6 +128,24 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela
}
}
+ // finally, if we have exclusion groups, remove all the contact ids from those groups
+ if len(start.ExcludeGroupIDs()) > 0 {
+ rows, err := db.QueryxContext(ctx, `SELECT contact_id FROM contacts_contactgroup_contacts WHERE contactgroup_id = ANY($1)`, pq.Array(start.ExcludeGroupIDs()))
+ if err != nil {
+ return errors.Wrapf(err, "error querying contacts from exclusion groups")
+ }
+ defer rows.Close()
+
+ var contactID models.ContactID
+ for rows.Next() {
+ err := rows.Scan(&contactID)
+ if err != nil {
+ return errors.Wrapf(err, "error scanning contact id")
+ }
+ delete(contactIDs, contactID)
+ }
+ }
+
rc := rp.Get()
defer rc.Close()
@@ -185,7 +204,7 @@ func CreateFlowBatches(ctx context.Context, db *sqlx.DB, rp *redis.Pool, ec *ela
}
// HandleFlowStartBatch starts a batch of contacts in a flow
-func handleFlowStartBatch(ctx context.Context, mr *mailroom.Mailroom, task *queue.Task) error {
+func handleFlowStartBatch(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error {
ctx, cancel := context.WithTimeout(ctx, time.Minute*15)
defer cancel()
@@ -200,7 +219,7 @@ func handleFlowStartBatch(ctx context.Context, mr *mailroom.Mailroom, task *queu
}
// start these contacts in our flow
- _, err = runner.StartFlowBatch(ctx, mr.DB, mr.RP, startBatch)
+ _, err = runner.StartFlowBatch(ctx, rt, startBatch)
if err != nil {
return errors.Wrapf(err, "error starting flow batch: %s", string(task.Task))
}
diff --git a/core/tasks/starts/worker_test.go b/core/tasks/starts/worker_test.go
index 3c5500ce3..a60c82aab 100644
--- a/core/tasks/starts/worker_test.go
+++ b/core/tasks/starts/worker_test.go
@@ -6,13 +6,12 @@ import (
"testing"
"github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/mailroom"
- "github.com/nyaruka/mailroom/config"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
"github.com/nyaruka/mailroom/core/runner"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/olivere/elastic/v7"
"github.com/stretchr/testify/assert"
@@ -20,11 +19,8 @@ import (
)
func TestStarts(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- rp := testsuite.RP()
- db := testsuite.DB()
- rc := testsuite.RC()
+ ctx, rt, db, rp := testsuite.Reset()
+ rc := rp.Get()
defer rc.Close()
mes := testsuite.NewMockElasticServer()
@@ -36,22 +32,22 @@ func TestStarts(t *testing.T) {
elastic.SetSniff(false),
)
require.NoError(t, err)
-
- mr := &mailroom.Mailroom{Config: config.Mailroom, DB: db, RP: rp, ElasticClient: es}
+ rt.ES = es
// convert our single message flow to an actual background flow that shouldn't interrupt
- db.MustExec(`UPDATE flows_flow SET flow_type = 'B' WHERE id = $1`, models.SingleMessageFlowID)
+ db.MustExec(`UPDATE flows_flow SET flow_type = 'B' WHERE id = $1`, testdata.SingleMessage.ID)
// insert a flow run for one of our contacts
// TODO: can be replaced with a normal flow start of another flow once we support flows with waits
db.MustExec(
`INSERT INTO flows_flowrun(uuid, status, is_active, created_on, modified_on, responded, contact_id, flow_id, org_id)
- VALUES($1, 'W', TRUE, now(), now(), FALSE, $2, $3, 1);`, uuids.New(), models.GeorgeID, models.FavoritesFlowID)
+ VALUES($1, 'W', TRUE, now(), now(), FALSE, $2, $3, 1);`, uuids.New(), testdata.George.ID, testdata.Favorites.ID)
tcs := []struct {
label string
flowID models.FlowID
groupIDs []models.GroupID
+ excludeGroupIDs []models.GroupID
contactIDs []models.ContactID
createContact bool
query string
@@ -67,41 +63,41 @@ func TestStarts(t *testing.T) {
}{
{
label: "Empty flow start",
- flowID: models.FavoritesFlowID,
+ flowID: testdata.Favorites.ID,
queue: queue.BatchQueue,
expectedContactCount: 0,
expectedBatchCount: 0,
expectedTotalCount: 0,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 1, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 1, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Single group",
- flowID: models.FavoritesFlowID,
- groupIDs: []models.GroupID{models.DoctorsGroupID},
+ flowID: testdata.Favorites.ID,
+ groupIDs: []models.GroupID{testdata.DoctorsGroup.ID},
queue: queue.BatchQueue,
expectedContactCount: 121,
expectedBatchCount: 2,
expectedTotalCount: 121,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 122, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 122, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Group and Contact (but all already active)",
- flowID: models.FavoritesFlowID,
- groupIDs: []models.GroupID{models.DoctorsGroupID},
- contactIDs: []models.ContactID{models.CathyID},
+ flowID: testdata.Favorites.ID,
+ groupIDs: []models.GroupID{testdata.DoctorsGroup.ID},
+ contactIDs: []models.ContactID{testdata.Cathy.ID},
queue: queue.BatchQueue,
expectedContactCount: 121,
expectedBatchCount: 2,
expectedTotalCount: 0,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 122, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 122, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Contact restart",
- flowID: models.FavoritesFlowID,
- contactIDs: []models.ContactID{models.CathyID},
+ flowID: testdata.Favorites.ID,
+ contactIDs: []models.ContactID{testdata.Cathy.ID},
restartParticipants: true,
includeActive: true,
queue: queue.HandlerQueue,
@@ -109,47 +105,47 @@ func TestStarts(t *testing.T) {
expectedBatchCount: 1,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 122, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 122, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Previous group and one new contact",
- flowID: models.FavoritesFlowID,
- groupIDs: []models.GroupID{models.DoctorsGroupID},
- contactIDs: []models.ContactID{models.BobID},
+ flowID: testdata.Favorites.ID,
+ groupIDs: []models.GroupID{testdata.DoctorsGroup.ID},
+ contactIDs: []models.ContactID{testdata.Bob.ID},
queue: queue.BatchQueue,
expectedContactCount: 122,
expectedBatchCount: 2,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Single contact, no restart",
- flowID: models.FavoritesFlowID,
- contactIDs: []models.ContactID{models.BobID},
+ flowID: testdata.Favorites.ID,
+ contactIDs: []models.ContactID{testdata.Bob.ID},
queue: queue.HandlerQueue,
expectedContactCount: 1,
expectedBatchCount: 1,
expectedTotalCount: 0,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Single contact, include active, but no restart",
- flowID: models.FavoritesFlowID,
- contactIDs: []models.ContactID{models.BobID},
+ flowID: testdata.Favorites.ID,
+ contactIDs: []models.ContactID{testdata.Bob.ID},
includeActive: true,
queue: queue.HandlerQueue,
expectedContactCount: 1,
expectedBatchCount: 1,
expectedTotalCount: 0,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Single contact, include active and restart",
- flowID: models.FavoritesFlowID,
- contactIDs: []models.ContactID{models.BobID},
+ flowID: testdata.Favorites.ID,
+ contactIDs: []models.ContactID{testdata.Bob.ID},
restartParticipants: true,
includeActive: true,
queue: queue.HandlerQueue,
@@ -157,11 +153,11 @@ func TestStarts(t *testing.T) {
expectedBatchCount: 1,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Query start",
- flowID: models.FavoritesFlowID,
+ flowID: testdata.Favorites.ID,
query: "bob",
queryResponse: fmt.Sprintf(`{
"_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAbgc0WS1hqbHlfb01SM2lLTWJRMnVOSVZDdw==",
@@ -189,7 +185,7 @@ func TestStarts(t *testing.T) {
}
]
}
- }`, models.BobID),
+ }`, testdata.Bob.ID),
restartParticipants: true,
includeActive: true,
queue: queue.HandlerQueue,
@@ -197,11 +193,11 @@ func TestStarts(t *testing.T) {
expectedBatchCount: 1,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Query start with invalid query",
- flowID: models.FavoritesFlowID,
+ flowID: testdata.Favorites.ID,
query: "xyz = 45",
restartParticipants: true,
includeActive: true,
@@ -210,42 +206,56 @@ func TestStarts(t *testing.T) {
expectedBatchCount: 0,
expectedTotalCount: 0,
expectedStatus: models.StartStatusFailed,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "New Contact",
- flowID: models.FavoritesFlowID,
+ flowID: testdata.Favorites.ID,
createContact: true,
queue: queue.HandlerQueue,
expectedContactCount: 1,
expectedBatchCount: 1,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 124, models.PickNumberFlowID: 0, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 124, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
{
label: "Other messaging flow",
- flowID: models.PickNumberFlowID,
- contactIDs: []models.ContactID{models.BobID},
+ flowID: testdata.PickANumber.ID,
+ contactIDs: []models.ContactID{testdata.Bob.ID},
includeActive: true,
queue: queue.HandlerQueue,
expectedContactCount: 1,
expectedBatchCount: 1,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 1, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 1, testdata.SingleMessage.ID: 0},
},
{
label: "Background flow",
- flowID: models.SingleMessageFlowID,
- contactIDs: []models.ContactID{models.BobID},
+ flowID: testdata.SingleMessage.ID,
+ contactIDs: []models.ContactID{testdata.Bob.ID},
+ includeActive: true,
+ queue: queue.HandlerQueue,
+ expectedContactCount: 1,
+ expectedBatchCount: 1,
+ expectedTotalCount: 1,
+ expectedStatus: models.StartStatusComplete,
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 123, testdata.PickANumber.ID: 1, testdata.SingleMessage.ID: 0},
+ },
+ {
+ label: "Exclude group",
+ flowID: testdata.Favorites.ID,
+ contactIDs: []models.ContactID{testdata.Cathy.ID, testdata.Bob.ID},
+ excludeGroupIDs: []models.GroupID{testdata.DoctorsGroup.ID}, // should exclude Cathy
+ restartParticipants: true,
includeActive: true,
queue: queue.HandlerQueue,
expectedContactCount: 1,
expectedBatchCount: 1,
expectedTotalCount: 1,
expectedStatus: models.StartStatusComplete,
- expectedActiveRuns: map[models.FlowID]int{models.FavoritesFlowID: 123, models.PickNumberFlowID: 1, models.SingleMessageFlowID: 0},
+ expectedActiveRuns: map[models.FlowID]int{testdata.Favorites.ID: 124, testdata.PickANumber.ID: 0, testdata.SingleMessage.ID: 0},
},
}
@@ -253,8 +263,9 @@ func TestStarts(t *testing.T) {
mes.NextResponse = tc.queryResponse
// handle our start task
- start := models.NewFlowStart(models.Org1, models.StartTypeManual, models.FlowTypeMessaging, tc.flowID, tc.restartParticipants, tc.includeActive).
+ start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeManual, models.FlowTypeMessaging, tc.flowID, tc.restartParticipants, tc.includeActive).
WithGroupIDs(tc.groupIDs).
+ WithExcludeGroupIDs(tc.excludeGroupIDs).
WithContactIDs(tc.contactIDs).
WithQuery(tc.query).
WithCreateContact(tc.createContact)
@@ -265,7 +276,7 @@ func TestStarts(t *testing.T) {
startJSON, err := json.Marshal(start)
require.NoError(t, err)
- err = handleFlowStart(ctx, mr, &queue.Task{Type: queue.StartFlow, Task: startJSON})
+ err = handleFlowStart(ctx, rt, &queue.Task{Type: queue.StartFlow, Task: startJSON})
assert.NoError(t, err)
// pop all our tasks and execute them
@@ -284,7 +295,7 @@ func TestStarts(t *testing.T) {
err = json.Unmarshal(task.Task, batch)
assert.NoError(t, err)
- _, err = runner.StartFlowBatch(ctx, db, rp, batch)
+ _, err = runner.StartFlowBatch(ctx, rt, batch)
assert.NoError(t, err)
}
@@ -292,22 +303,20 @@ func TestStarts(t *testing.T) {
assert.Equal(t, tc.expectedBatchCount, count, "unexpected batch count in '%s'", tc.label)
// assert our count of total flow runs created
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE flow_id = $1 AND start_id = $2`,
- []interface{}{tc.flowID, start.ID()}, tc.expectedTotalCount, "unexpected total run count in '%s'", tc.label)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE flow_id = $1 AND start_id = $2`, tc.flowID, start.ID()).Returns(tc.expectedTotalCount, "unexpected total run count in '%s'", tc.label)
// assert final status
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where status = $2 AND id = $1`,
- []interface{}{start.ID(), tc.expectedStatus}, 1, "status mismatch in '%s'", tc.label)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart where status = $2 AND id = $1`, start.ID(), tc.expectedStatus).Returns(1, "status mismatch in '%s'", tc.label)
// assert final contact count
if tc.expectedStatus != models.StartStatusFailed {
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowstart where contact_count = $2 AND id = $1`,
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowstart where contact_count = $2 AND id = $1`,
[]interface{}{start.ID(), tc.expectedContactCount}, 1, "contact count mismatch in '%s'", tc.label)
}
// assert count of active runs by flow
for flowID, activeRuns := range tc.expectedActiveRuns {
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM flows_flowrun WHERE status = 'W' AND flow_id = $1`, []interface{}{flowID}, activeRuns, "active runs mismatch for flow #%d in '%s'", flowID, tc.label)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE status = 'W' AND flow_id = $1`, flowID).Returns(activeRuns, "active runs mismatch for flow #%d in '%s'", flowID, tc.label)
}
}
}
diff --git a/core/tasks/stats/cron.go b/core/tasks/stats/cron.go
index eaa539805..7cada0365 100644
--- a/core/tasks/stats/cron.go
+++ b/core/tasks/stats/cron.go
@@ -2,11 +2,13 @@ package stats
import (
"context"
+ "sync"
"time"
"github.com/nyaruka/librato"
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/sirupsen/logrus"
@@ -23,12 +25,12 @@ func init() {
}
// StartStatsCron starts our cron job of posting stats every minute
-func StartStatsCron(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, expirationLock, time.Second*60,
+func StartStatsCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, expirationLock, time.Second*60,
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return dumpStats(ctx, mr.DB, mr.RP, lockName, lockValue)
+ return dumpStats(ctx, rt.DB, rt.RP, lockName, lockValue)
},
)
return nil
diff --git a/core/tasks/timeouts/cron.go b/core/tasks/timeouts/cron.go
index 2db269218..dd6d6c4d8 100644
--- a/core/tasks/timeouts/cron.go
+++ b/core/tasks/timeouts/cron.go
@@ -3,11 +3,13 @@ package timeouts
import (
"context"
"fmt"
+ "sync"
"time"
"github.com/nyaruka/mailroom"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks/handler"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/utils/cron"
"github.com/nyaruka/mailroom/utils/marker"
@@ -26,13 +28,13 @@ func init() {
mailroom.AddInitFunction(StartTimeoutCron)
}
-// StartTimeoutCron starts our cron job of continuing timed out sessions every defined interval in config TimeoutTime
-func StartTimeoutCron(mr *mailroom.Mailroom) error {
- cron.StartCron(mr.Quit, mr.RP, timeoutLock, time.Second*time.Duration(mr.Config.TimeoutTime),
+// StartTimeoutCron starts our cron job of continuing timed out sessions every minute
+func StartTimeoutCron(rt *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error {
+ cron.StartCron(quit, rt.RP, timeoutLock, time.Second*time.Duration(rt.Config.TimeoutTime),
func(lockName string, lockValue string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
- return timeoutSessions(ctx, mr.DB, mr.RP, lockName, lockValue)
+ return timeoutSessions(ctx, rt.DB, rt.RP, lockName, lockValue)
},
)
return nil
@@ -77,7 +79,7 @@ func timeoutSessions(ctx context.Context, db *sqlx.DB, rp *redis.Pool, lockName
// ok, queue this task
task := handler.NewTimeoutTask(timeout.OrgID, timeout.ContactID, timeout.SessionID, timeout.TimeoutOn)
- err = handler.AddHandleTask(rc, timeout.ContactID, task)
+ err = handler.QueueHandleTask(rc, timeout.ContactID, task)
if err != nil {
return errors.Wrapf(err, "error adding new handle task")
}
diff --git a/core/tasks/timeouts/cron_test.go b/core/tasks/timeouts/cron_test.go
index 4d8691d9a..fa42398f2 100644
--- a/core/tasks/timeouts/cron_test.go
+++ b/core/tasks/timeouts/cron_test.go
@@ -5,8 +5,6 @@ import (
"testing"
"time"
- "github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/goflow/flows"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/queue"
@@ -19,11 +17,8 @@ import (
)
func TestTimeouts(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
- rc := testsuite.RC()
+ ctx, _, db, rp := testsuite.Reset()
+ rc := rp.Get()
defer rc.Close()
err := marker.ClearTasks(rc, timeoutLock)
@@ -31,9 +26,9 @@ func TestTimeouts(t *testing.T) {
// need to create a session that has an expired timeout
s1TimeoutOn := time.Now()
- testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.CathyID, models.SessionStatusWaiting, &s1TimeoutOn)
+ testdata.InsertFlowSession(db, testdata.Org1, testdata.Cathy, models.SessionStatusWaiting, &s1TimeoutOn)
s2TimeoutOn := time.Now().Add(time.Hour * 24)
- testdata.InsertFlowSession(t, db, flows.SessionUUID(uuids.New()), models.Org1, models.GeorgeID, models.SessionStatusWaiting, &s2TimeoutOn)
+ testdata.InsertFlowSession(db, testdata.Org1, testdata.George, models.SessionStatusWaiting, &s2TimeoutOn)
time.Sleep(10 * time.Millisecond)
@@ -52,7 +47,7 @@ func TestTimeouts(t *testing.T) {
assert.NoError(t, err)
// assert its the right contact
- assert.Equal(t, models.CathyID, eventTask.ContactID)
+ assert.Equal(t, testdata.Cathy.ID, eventTask.ContactID)
// no other
task, err = queue.PopNextTask(rc, queue.HandlerQueue)
diff --git a/go.mod b/go.mod
index 67b103412..cc1d736bb 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/nyaruka/mailroom
require (
github.com/Masterminds/semver v1.5.0
github.com/apex/log v1.1.4
+ github.com/aws/aws-sdk-go v1.35.20
github.com/buger/jsonparser v1.0.0
github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible
@@ -17,8 +18,8 @@ require (
github.com/lib/pq v1.4.0
github.com/mattn/go-sqlite3 v1.10.0 // indirect
github.com/nyaruka/ezconf v0.2.1
- github.com/nyaruka/gocommon v1.8.0
- github.com/nyaruka/goflow v0.115.2
+ github.com/nyaruka/gocommon v1.13.0
+ github.com/nyaruka/goflow v0.130.1
github.com/nyaruka/librato v1.0.0
github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d
github.com/nyaruka/null v1.2.0
@@ -30,7 +31,7 @@ require (
github.com/prometheus/common v0.9.1
github.com/shopspring/decimal v1.2.0
github.com/sirupsen/logrus v1.5.0
- github.com/stretchr/testify v1.6.1
+ github.com/stretchr/testify v1.7.0
gopkg.in/go-playground/validator.v9 v9.31.0
)
diff --git a/go.sum b/go.sum
index 76d54edca..751a73d9c 100644
--- a/go.sum
+++ b/go.sum
@@ -132,10 +132,10 @@ github.com/naoina/toml v0.1.1 h1:PT/lllxVVN0gzzSqSlHEmP8MJB4MY2U7STGxiouV4X8=
github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E=
github.com/nyaruka/ezconf v0.2.1 h1:TDXWoqjqYya1uhou1mAJZg7rgFYL98EB0Tb3+BWtUh0=
github.com/nyaruka/ezconf v0.2.1/go.mod h1:ey182kYkw2MIi4XiWe1FR/mzI33WCmTWuceDYYxgnQw=
-github.com/nyaruka/gocommon v1.8.0 h1:cO+iYi1hjuaso1IvCC5QA7quAh6ThWALn4DTp1ajeYM=
-github.com/nyaruka/gocommon v1.8.0/go.mod h1:r5UqoAdoP9VLb/wmtF1O0v73PQc79tZaVjbXlO16PUA=
-github.com/nyaruka/goflow v0.115.2 h1:K3gVtXB6XACnSDAEgPhxd230hS18SBQnYam0reUYN1w=
-github.com/nyaruka/goflow v0.115.2/go.mod h1:l+T3dqlKk2RB9yxYHSRhbW7elUZcyFOrYoeqXX1kyrI=
+github.com/nyaruka/gocommon v1.13.0 h1:WPL//ekajA30KinYRr6IrdP1igNZpcUAfABleHCuxPQ=
+github.com/nyaruka/gocommon v1.13.0/go.mod h1:Jn7UIE8zwIr4JaviDf4PZrrQlN8r6QGVhOuaF/JoKus=
+github.com/nyaruka/goflow v0.130.1 h1:ai84idJkjgn2XJdv735/4qz72L0AuOYtEX8+GpL4xs0=
+github.com/nyaruka/goflow v0.130.1/go.mod h1:Xp1p21TyYiMM/fVQNWQRok/fZ1ZeNoeQGUd//LvYxq4=
github.com/nyaruka/librato v1.0.0 h1:Vznj9WCeC1yZXbBYyYp40KnbmXLbEkjKmHesV/v2SR0=
github.com/nyaruka/librato v1.0.0/go.mod h1:pkRNLFhFurOz0QqBz6/DuTFhHHxAubWxs4Jx+J7yUgg=
github.com/nyaruka/logrus_sentry v0.8.2-0.20190129182604-c2962b80ba7d h1:hyp9u36KIwbTCo2JAJ+TuJcJBc+UZzEig7RI/S5Dvkc=
@@ -190,8 +190,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
diff --git a/mailroom.go b/mailroom.go
index 91fc2ae69..ff28ef888 100644
--- a/mailroom.go
+++ b/mailroom.go
@@ -12,6 +12,7 @@ import (
"github.com/nyaruka/gocommon/storage"
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/gomodule/redigo/redis"
@@ -22,7 +23,7 @@ import (
)
// InitFunction is a function that will be called when mailroom starts
-type InitFunction func(mr *Mailroom) error
+type InitFunction func(runtime *runtime.Runtime, wg *sync.WaitGroup, quit chan bool) error
var initFunctions = make([]InitFunction, 0)
@@ -32,7 +33,7 @@ func AddInitFunction(initFunc InitFunction) {
}
// TaskFunction is the function that will be called for a type of task
-type TaskFunction func(ctx context.Context, mr *Mailroom, task *queue.Task) error
+type TaskFunction func(ctx context.Context, rt *runtime.Runtime, task *queue.Task) error
var taskFunctions = make(map[string]TaskFunction)
@@ -43,16 +44,12 @@ func AddTaskFunction(taskType string, taskFunc TaskFunction) {
// Mailroom is a service for handling RapidPro events
type Mailroom struct {
- Config *config.Config
- DB *sqlx.DB
- RP *redis.Pool
- ElasticClient *elastic.Client
- Storage storage.Storage
+ ctx context.Context
+ cancel context.CancelFunc
- Quit chan bool
- CTX context.Context
- Cancel context.CancelFunc
- WaitGroup *sync.WaitGroup
+ rt *runtime.Runtime
+ wg *sync.WaitGroup
+ quit chan bool
batchForeman *Foreman
handlerForeman *Foreman
@@ -63,48 +60,50 @@ type Mailroom struct {
// NewMailroom creates and returns a new mailroom instance
func NewMailroom(config *config.Config) *Mailroom {
mr := &Mailroom{
- Config: config,
- Quit: make(chan bool),
- WaitGroup: &sync.WaitGroup{},
+ rt: &runtime.Runtime{Config: config},
+ quit: make(chan bool),
+ wg: &sync.WaitGroup{},
}
- mr.CTX, mr.Cancel = context.WithCancel(context.Background())
- mr.batchForeman = NewForeman(mr, queue.BatchQueue, config.BatchWorkers)
- mr.handlerForeman = NewForeman(mr, queue.HandlerQueue, config.HandlerWorkers)
+ mr.ctx, mr.cancel = context.WithCancel(context.Background())
+ mr.batchForeman = NewForeman(mr.rt, mr.wg, queue.BatchQueue, config.BatchWorkers)
+ mr.handlerForeman = NewForeman(mr.rt, mr.wg, queue.HandlerQueue, config.HandlerWorkers)
return mr
}
// Start starts the mailroom service
func (mr *Mailroom) Start() error {
+ c := mr.rt.Config
+
log := logrus.WithFields(logrus.Fields{
"state": "starting",
})
// parse and test our db config
- dbURL, err := url.Parse(mr.Config.DB)
+ dbURL, err := url.Parse(c.DB)
if err != nil {
- return fmt.Errorf("unable to parse DB URL '%s': %s", mr.Config.DB, err)
+ return fmt.Errorf("unable to parse DB URL '%s': %s", c.DB, err)
}
if dbURL.Scheme != "postgres" {
- return fmt.Errorf("invalid DB URL: '%s', only postgres is supported", mr.Config.DB)
+ return fmt.Errorf("invalid DB URL: '%s', only postgres is supported", c.DB)
}
// build our db
- db, err := sqlx.Open("postgres", mr.Config.DB)
+ db, err := sqlx.Open("postgres", c.DB)
if err != nil {
- return fmt.Errorf("unable to open DB with config: '%s': %s", mr.Config.DB, err)
+ return fmt.Errorf("unable to open DB with config: '%s': %s", c.DB, err)
}
// configure our pool
- mr.DB = db
- mr.DB.SetMaxIdleConns(8)
- mr.DB.SetMaxOpenConns(mr.Config.DBPoolSize)
- mr.DB.SetConnMaxLifetime(time.Minute * 30)
+ db.SetMaxIdleConns(8)
+ db.SetMaxOpenConns(c.DBPoolSize)
+ db.SetConnMaxLifetime(time.Minute * 30)
+ mr.rt.DB = db
// try connecting
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
- err = mr.DB.PingContext(ctx)
+ err = db.PingContext(ctx)
cancel()
if err != nil {
log.Error("db not reachable")
@@ -113,9 +112,9 @@ func (mr *Mailroom) Start() error {
}
// parse and test our redis config
- redisURL, err := url.Parse(mr.Config.Redis)
+ redisURL, err := url.Parse(mr.rt.Config.Redis)
if err != nil {
- return fmt.Errorf("unable to parse Redis URL '%s': %s", mr.Config.Redis, err)
+ return fmt.Errorf("unable to parse Redis URL '%s': %s", c.Redis, err)
}
// create our pool
@@ -125,7 +124,7 @@ func (mr *Mailroom) Start() error {
MaxIdle: 4, // only keep up to this many idle
IdleTimeout: 240 * time.Second, // how long to wait before reaping a connection
Dial: func() (redis.Conn, error) {
- conn, err := redis.Dial("tcp", fmt.Sprintf("%s", redisURL.Host))
+ conn, err := redis.Dial("tcp", redisURL.Host)
if err != nil {
return nil, err
}
@@ -146,7 +145,7 @@ func (mr *Mailroom) Start() error {
return conn, err
},
}
- mr.RP = redisPool
+ mr.rt.RP = redisPool
// test our redis connection
conn := redisPool.Get()
@@ -159,33 +158,48 @@ func (mr *Mailroom) Start() error {
}
// create our storage (S3 or file system)
- if mr.Config.AWSAccessKeyID != "" {
+ if mr.rt.Config.AWSAccessKeyID != "" {
s3Client, err := storage.NewS3Client(&storage.S3Options{
- AWSAccessKeyID: mr.Config.AWSAccessKeyID,
- AWSSecretAccessKey: mr.Config.AWSSecretAccessKey,
- Endpoint: mr.Config.S3Endpoint,
- Region: mr.Config.S3Region,
- DisableSSL: mr.Config.S3DisableSSL,
- ForcePathStyle: mr.Config.S3ForcePathStyle,
+ AWSAccessKeyID: c.AWSAccessKeyID,
+ AWSSecretAccessKey: c.AWSSecretAccessKey,
+ Endpoint: c.S3Endpoint,
+ Region: c.S3Region,
+ DisableSSL: c.S3DisableSSL,
+ ForcePathStyle: c.S3ForcePathStyle,
})
if err != nil {
return err
}
- mr.Storage = storage.NewS3(s3Client, mr.Config.S3MediaBucket)
+ mr.rt.MediaStorage = storage.NewS3(s3Client, mr.rt.Config.S3MediaBucket, c.S3Region, 32)
+ mr.rt.SessionStorage = storage.NewS3(s3Client, mr.rt.Config.S3SessionBucket, c.S3Region, 32)
} else {
- mr.Storage = storage.NewFS("_storage")
+ mr.rt.MediaStorage = storage.NewFS("_storage")
+ mr.rt.SessionStorage = storage.NewFS("_storage")
}
- // test our storage
- err = mr.Storage.Test()
+ // test our media storage
+ ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
+ err = mr.rt.MediaStorage.Test(ctx)
+ cancel()
+
+ if err != nil {
+ log.WithError(err).Error(mr.rt.MediaStorage.Name() + " media storage not available")
+ } else {
+ log.Info(mr.rt.MediaStorage.Name() + " media storage ok")
+ }
+
+ ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
+ err = mr.rt.SessionStorage.Test(ctx)
+ cancel()
+
if err != nil {
- log.WithError(err).Error(mr.Storage.Name() + " storage not available")
+ log.WithError(err).Warn(mr.rt.SessionStorage.Name() + " session storage not available")
} else {
- log.Info(mr.Storage.Name() + " storage ok")
+ log.Info(mr.rt.SessionStorage.Name() + " session storage ok")
}
// initialize our elastic client
- mr.ElasticClient, err = newElasticClient(mr.Config.Elastic)
+ mr.rt.ES, err = newElasticClient(c.Elastic)
if err != nil {
log.WithError(err).Error("unable to connect to elastic, check configuration")
} else {
@@ -193,18 +207,18 @@ func (mr *Mailroom) Start() error {
}
// warn if we won't be doing FCM syncing
- if config.Mailroom.FCMKey == "" {
+ if c.FCMKey == "" {
logrus.Error("fcm not configured, no syncing of android channels")
}
for _, initFunc := range initFunctions {
- initFunc(mr)
+ initFunc(mr.rt, mr.wg, mr.quit)
}
// if we have a librato token, configure it
- if mr.Config.LibratoToken != "" {
+ if c.LibratoToken != "" {
host, _ := os.Hostname()
- librato.Configure(mr.Config.LibratoUsername, mr.Config.LibratoToken, host, time.Second, mr.WaitGroup)
+ librato.Configure(c.LibratoUsername, c.LibratoToken, host, time.Second, mr.wg)
librato.Start()
}
@@ -213,7 +227,7 @@ func (mr *Mailroom) Start() error {
mr.handlerForeman.Start()
// start our web server
- mr.webserver = web.NewServer(mr.CTX, mr.Config, mr.DB, mr.RP, mr.Storage, mr.ElasticClient, mr.WaitGroup)
+ mr.webserver = web.NewServer(mr.ctx, c, mr.rt.DB, mr.rt.RP, mr.rt.MediaStorage, mr.rt.ES, mr.wg)
mr.webserver.Start()
logrus.Info("mailroom started")
@@ -227,14 +241,14 @@ func (mr *Mailroom) Stop() error {
mr.batchForeman.Stop()
mr.handlerForeman.Stop()
librato.Stop()
- close(mr.Quit)
- mr.Cancel()
+ close(mr.quit)
+ mr.cancel()
// stop our web server
mr.webserver.Stop()
- mr.WaitGroup.Wait()
- mr.ElasticClient.Stop()
+ mr.wg.Wait()
+ mr.rt.ES.Stop()
logrus.Info("mailroom stopped")
return nil
}
diff --git a/mailroom_test.dump b/mailroom_test.dump
index 7ab0873bf..f17efbfcf 100644
Binary files a/mailroom_test.dump and b/mailroom_test.dump differ
diff --git a/runtime/runtime.go b/runtime/runtime.go
new file mode 100644
index 000000000..69ae2ed6c
--- /dev/null
+++ b/runtime/runtime.go
@@ -0,0 +1,20 @@
+package runtime
+
+import (
+ "github.com/gomodule/redigo/redis"
+ "github.com/jmoiron/sqlx"
+ "github.com/nyaruka/gocommon/storage"
+ "github.com/nyaruka/mailroom/config"
+ "github.com/olivere/elastic/v7"
+)
+
+// Runtime represents the set of services required to run many Mailroom functions. Used as a wrapper for
+// those services to simplify call signatures but not create a direct dependency to Mailroom or Server
+type Runtime struct {
+ DB *sqlx.DB
+ RP *redis.Pool
+ ES *elastic.Client
+ MediaStorage storage.Storage
+ SessionStorage storage.Storage
+ Config *config.Config
+}
diff --git a/services/tickets/intern/service.go b/services/tickets/intern/service.go
index 4bd11db29..42c103b6c 100644
--- a/services/tickets/intern/service.go
+++ b/services/tickets/intern/service.go
@@ -4,9 +4,9 @@ import (
"net/http"
"github.com/nyaruka/gocommon/httpx"
- "github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
)
@@ -23,13 +23,13 @@ type service struct {
}
// NewService creates a new internal ticket service
-func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
+func NewService(rtCfg *config.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
return &service{ticketer: ticketer}, nil
}
// Open just returns a new ticket - no external service to notify
func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) {
- return flows.NewTicket(flows.TicketUUID(uuids.New()), s.ticketer.Reference(), subject, body, ""), nil
+ return flows.OpenTicket(s.ticketer, subject, body), nil
}
// Forward is a noop
diff --git a/services/tickets/intern/service_test.go b/services/tickets/intern/service_test.go
index 108bd8b97..a7836ac0d 100644
--- a/services/tickets/intern/service_test.go
+++ b/services/tickets/intern/service_test.go
@@ -13,12 +13,16 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
intern "github.com/nyaruka/mailroom/services/tickets/intern"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAndForward(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone)
require.NoError(t, err)
@@ -28,6 +32,7 @@ func TestOpenAndForward(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "internal"))
svc, err := intern.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -39,17 +44,13 @@ func TestOpenAndForward(t *testing.T) {
ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log)
assert.NoError(t, err)
- assert.Equal(t, &flows.Ticket{
- UUID: flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"),
- Ticketer: ticketer.Reference(),
- Subject: "Need help",
- Body: "Where are my cookies?",
- ExternalID: "",
- }, ticket)
-
+ assert.Equal(t, flows.TicketUUID("e7187099-7d38-4f60-955c-325957214c42"), ticket.UUID())
+ assert.Equal(t, "Need help", ticket.Subject())
+ assert.Equal(t, "Where are my cookies?", ticket.Body())
+ assert.Equal(t, "", ticket.ExternalID())
assert.Equal(t, 0, len(logger.Logs))
- dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.InternalID, "", "Need help", "Where are my cookies?", nil)
+ dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Internal.ID, "", "Need help", "Where are my cookies?", models.NilUserID, nil)
logger = &flows.HTTPLogger{}
err = svc.Forward(
@@ -66,16 +67,18 @@ func TestOpenAndForward(t *testing.T) {
}
func TestCloseAndReopen(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
defer uuids.SetGenerator(uuids.DefaultGenerator)
uuids.SetGenerator(uuids.NewSeededGenerator(12345))
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "internal"))
- svc, err := intern.NewService(http.DefaultClient, nil, ticketer, nil)
+ svc, err := intern.NewService(rt.Config, http.DefaultClient, nil, ticketer, nil)
require.NoError(t, err)
logger := &flows.HTTPLogger{}
- ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.InternalID, "12", "New ticket", "Where my cookies?", nil)
- ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.InternalID, "14", "Second ticket", "Where my shoes?", nil)
+ ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.Internal.ID, "12", "New ticket", "Where my cookies?", models.NilUserID, nil)
+ ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.Internal.ID, "14", "Second ticket", "Where my shoes?", models.NilUserID, nil)
err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log)
diff --git a/services/tickets/mailgun/service.go b/services/tickets/mailgun/service.go
index 17015cbcc..3dd435da8 100644
--- a/services/tickets/mailgun/service.go
+++ b/services/tickets/mailgun/service.go
@@ -8,9 +8,9 @@ import (
"text/template"
"github.com/nyaruka/gocommon/httpx"
- "github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/services/tickets"
@@ -83,7 +83,7 @@ type service struct {
}
// NewService creates a new mailgun email-based ticket service
-func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
+func NewService(rtCfg *config.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
domain := config[configDomain]
apiKey := config[configAPIKey]
toAddress := config[configToAddress]
@@ -108,10 +108,10 @@ func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, tickete
// Open opens a ticket which for mailgun means just sending an initial email
func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) {
- ticketUUID := flows.TicketUUID(uuids.New())
+ ticket := flows.OpenTicket(s.ticketer, subject, body)
contactDisplay := tickets.GetContactDisplay(session.Environment(), session.Contact())
- from := s.ticketAddress(contactDisplay, ticketUUID)
+ from := s.ticketAddress(contactDisplay, ticket.UUID())
context := s.templateContext(subject, body, "", string(session.Contact().UUID()), contactDisplay)
fullBody := evaluateTemplate(openBodyTemplate, context)
@@ -123,7 +123,8 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow
return nil, errors.Wrap(err, "error calling mailgun API")
}
- return flows.NewTicket(ticketUUID, s.ticketer.Reference(), subject, body, msgID), nil
+ ticket.SetExternalID(msgID)
+ return ticket, nil
}
func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error {
diff --git a/services/tickets/mailgun/service_test.go b/services/tickets/mailgun/service_test.go
index e9c1a0ee3..26636fdc2 100644
--- a/services/tickets/mailgun/service_test.go
+++ b/services/tickets/mailgun/service_test.go
@@ -16,12 +16,16 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/services/tickets/mailgun"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAndForward(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone)
require.NoError(t, err)
@@ -51,6 +55,7 @@ func TestOpenAndForward(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "mailgun"))
_, err = mailgun.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -59,6 +64,7 @@ func TestOpenAndForward(t *testing.T) {
assert.EqualError(t, err, "missing domain or api_key or to_address or url_base in mailgun config")
svc, err := mailgun.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -81,19 +87,15 @@ func TestOpenAndForward(t *testing.T) {
ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log)
assert.NoError(t, err)
- assert.Equal(t, &flows.Ticket{
- UUID: flows.TicketUUID("9688d21d-95aa-4bed-afc7-f31b35731a3d"),
- Ticketer: ticketer.Reference(),
- Subject: "Need help",
- Body: "Where are my cookies?",
- ExternalID: "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>",
- }, ticket)
-
+ assert.Equal(t, flows.TicketUUID("9688d21d-95aa-4bed-afc7-f31b35731a3d"), ticket.UUID())
+ assert.Equal(t, "Need help", ticket.Subject())
+ assert.Equal(t, "Where are my cookies?", ticket.Body())
+ assert.Equal(t, "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>", ticket.ExternalID())
assert.Equal(t, 1, len(logger.Logs))
test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request)
- dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.MailgunID, "", "Need help", "Where are my cookies?", map[string]interface{}{
- "contact-uuid": string(models.CathyUUID),
+ dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Mailgun.ID, "", "Need help", "Where are my cookies?", models.NilUserID, map[string]interface{}{
+ "contact-uuid": string(testdata.Cathy.UUID),
"contact-display": "Cathy",
})
@@ -112,6 +114,8 @@ func TestOpenAndForward(t *testing.T) {
}
func TestCloseAndReopen(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
defer uuids.SetGenerator(uuids.DefaultGenerator)
defer httpx.SetRequestor(httpx.DefaultRequestor)
@@ -135,6 +139,7 @@ func TestCloseAndReopen(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "mailgun"))
svc, err := mailgun.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -149,8 +154,8 @@ func TestCloseAndReopen(t *testing.T) {
require.NoError(t, err)
logger := &flows.HTTPLogger{}
- ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.ZendeskID, "12", "New ticket", "Where my cookies?", nil)
- ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.ZendeskID, "14", "Second ticket", "Where my shoes?", nil)
+ ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.Zendesk.ID, "12", "New ticket", "Where my cookies?", models.NilUserID, nil)
+ ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.Zendesk.ID, "14", "Second ticket", "Where my shoes?", models.NilUserID, nil)
err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log)
diff --git a/services/tickets/mailgun/testdata/receive.json b/services/tickets/mailgun/testdata/receive.json
index 30ba19d46..78564d881 100644
--- a/services/tickets/mailgun/testdata/receive.json
+++ b/services/tickets/mailgun/testdata/receive.json
@@ -46,7 +46,7 @@
"body": [
{
"name": "recipient",
- "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com"
+ "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com"
},
{
"name": "sender",
@@ -178,7 +178,7 @@
"body": [
{
"name": "recipient",
- "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com"
+ "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com"
},
{
"name": "sender",
@@ -213,18 +213,18 @@
"status": 200,
"response": {
"action": "rejected",
- "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef"
+ "ticket_uuid": "$cathy_ticket_uuid$"
}
},
{
"label": "forwarded response if message was created (no attachments, request sent as urlencoded form)",
"method": "POST",
"path": "/mr/tickets/types/mailgun/receive",
- "body": "recipient=ticket%2Bc69f103c-db64-4481-815b-1112890419ef%40mr.nyaruka.com&sender=bob%40acme.com&subject=Re%3A%20%5BRapidPro-Tickets%5D%20New%20ticket&Message-Id=%3C12345%40mail.gmail.com%3E&stripped-text=Hello×tamp=1590088411&token=987654321&signature=3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e",
+ "body": "recipient=ticket%2B$cathy_ticket_uuid$%40mr.nyaruka.com&sender=bob%40acme.com&subject=Re%3A%20%5BRapidPro-Tickets%5D%20New%20ticket&Message-Id=%3C12345%40mail.gmail.com%3E&stripped-text=Hello×tamp=1590088411&token=987654321&signature=3300d885d266c13e8804f032f8f7eb34c3b1abb071c8a8d9fb8dfb7d2184107e",
"status": 200,
"response": {
"action": "forwarded",
- "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef",
+ "ticket_uuid": "$cathy_ticket_uuid$",
"msg_uuid": "692926ea-09d6-4942-bd38-d266ec8d3716"
},
"db_assertions": [
@@ -245,7 +245,7 @@
"body": [
{
"name": "recipient",
- "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com"
+ "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com"
},
{
"name": "sender",
@@ -296,12 +296,12 @@
"status": 200,
"response": {
"action": "forwarded",
- "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef",
+ "ticket_uuid": "$cathy_ticket_uuid$",
"msg_uuid": "5802813d-6c58-4292-8228-9728778b6c98"
},
"db_assertions": [
{
- "query": "select count(*) from msgs_msg where direction = 'O' AND uuid = '5802813d-6c58-4292-8228-9728778b6c98' AND attachments = '{text/plain:https:///_test_storage/media/1/8720/f157/8720f157-ca1c-432f-9c0b-2014ddc77094.txt,image/jpeg:https:///_test_storage/media/1/c34b/6c7d/c34b6c7d-fa06-4563-92a3-d648ab64bccb.jpg}'",
+ "query": "select count(*) from msgs_msg where direction = 'O' AND uuid = '5802813d-6c58-4292-8228-9728778b6c98' AND attachments = '{text/plain:https:///_test_media_storage/media/1/8720/f157/8720f157-ca1c-432f-9c0b-2014ddc77094.txt,image/jpeg:https:///_test_media_storage/media/1/c34b/6c7d/c34b6c7d-fa06-4563-92a3-d648ab64bccb.jpg}'",
"count": 1
},
{
@@ -325,7 +325,7 @@
"body": [
{
"name": "recipient",
- "data": "ticket+c69f103c-db64-4481-815b-1112890419ef@mr.nyaruka.com"
+ "data": "ticket+$cathy_ticket_uuid$@mr.nyaruka.com"
},
{
"name": "sender",
@@ -360,7 +360,7 @@
"status": 200,
"response": {
"action": "closed",
- "ticket_uuid": "c69f103c-db64-4481-815b-1112890419ef"
+ "ticket_uuid": "$cathy_ticket_uuid$"
},
"db_assertions": [
{
diff --git a/services/tickets/mailgun/web.go b/services/tickets/mailgun/web.go
index c56c817cf..df8bfdbd0 100644
--- a/services/tickets/mailgun/web.go
+++ b/services/tickets/mailgun/web.go
@@ -12,6 +12,7 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/services/tickets"
"github.com/nyaruka/mailroom/web"
@@ -60,13 +61,13 @@ type receiveResponse struct {
var addressRegex = regexp.MustCompile(`^ticket\+([0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})@.*$`)
-func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+func handleReceive(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
request := &receiveRequest{}
if err := web.DecodeAndValidateForm(request, r); err != nil {
return errors.Wrapf(err, "error decoding form"), http.StatusBadRequest, nil
}
- if !request.verify(s.Config.MailgunSigningKey) {
+ if !request.verify(rt.Config.MailgunSigningKey) {
return errors.New("request signature validation failed"), http.StatusForbidden, nil
}
@@ -87,7 +88,7 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model
}
// look up the ticket and ticketer
- ticket, ticketer, svc, err := tickets.FromTicketUUID(s.CTX, s.DB, flows.TicketUUID(match[0][1]), typeMailgun)
+ ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, rt.DB, flows.TicketUUID(match[0][1]), typeMailgun)
if err != nil {
return err, http.StatusBadRequest, nil
}
@@ -103,10 +104,14 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model
return &receiveResponse{Action: "rejected", TicketUUID: ticket.UUID()}, http.StatusOK, nil
}
+ oa, err := models.GetOrgAssets(ctx, rt.DB, ticket.OrgID())
+ if err != nil {
+ return err, http.StatusBadRequest, nil
+ }
+
// check if reply is actually a command
if strings.ToLower(strings.TrimSpace(request.StrippedText)) == "close" {
- org, _ := models.GetOrgAssets(ctx, s.DB, ticket.OrgID())
- err = models.CloseTickets(ctx, s.DB, org, []*models.Ticket{ticket}, true, l)
+ err = tickets.CloseTicket(ctx, rt, oa, ticket, true, l)
if err != nil {
return errors.Wrapf(err, "error closing ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil
}
@@ -114,16 +119,21 @@ func handleReceive(ctx context.Context, s *web.Server, r *http.Request, l *model
return &receiveResponse{Action: "closed", TicketUUID: ticket.UUID()}, http.StatusOK, nil
}
- // update our ticket
- config := map[string]string{
- ticketConfigLastMessageID: request.MessageID,
- }
- err = models.UpdateAndKeepOpenTicket(ctx, s.DB, ticket, config)
+ // update our ticket config
+ err = models.UpdateTicketConfig(ctx, rt.DB, ticket, map[string]string{ticketConfigLastMessageID: request.MessageID})
if err != nil {
return errors.Wrapf(err, "error updating ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil
}
- msg, err := tickets.SendReply(ctx, s.DB, s.RP, s.Storage, ticket, request.StrippedText, files)
+ // reopen ticket if necessary
+ if ticket.Status() != models.TicketStatusOpen {
+ err = tickets.ReopenTicket(ctx, rt, oa, ticket, false, nil)
+ if err != nil {
+ return errors.Wrapf(err, "error reopening ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil
+ }
+ }
+
+ msg, err := tickets.SendReply(ctx, rt, ticket, request.StrippedText, files)
if err != nil {
return err, http.StatusInternalServerError, nil
}
diff --git a/services/tickets/mailgun/web_test.go b/services/tickets/mailgun/web_test.go
index 53087d9fb..ec8d1cb02 100644
--- a/services/tickets/mailgun/web_test.go
+++ b/services/tickets/mailgun/web_test.go
@@ -3,18 +3,22 @@ package mailgun
import (
"testing"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
)
func TestReceive(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Reset()
+
+ defer func() {
+ db.MustExec(`DELETE FROM msgs_msg`)
+ db.MustExec(`DELETE FROM tickets_ticketevent`)
+ db.MustExec(`DELETE FROM tickets_ticket`)
+ }()
// create a mailgun ticket for Cathy
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "")
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "", nil)
- web.RunWebTests(t, "testdata/receive.json")
+ web.RunWebTests(t, "testdata/receive.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)})
}
diff --git a/services/tickets/rocketchat/service.go b/services/tickets/rocketchat/service.go
index c2a841637..66e09bb2d 100644
--- a/services/tickets/rocketchat/service.go
+++ b/services/tickets/rocketchat/service.go
@@ -1,17 +1,18 @@
package rocketchat
import (
+ "net/http"
+ "strconv"
+ "time"
+
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/jsonx"
"github.com/nyaruka/gocommon/urns"
- "github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/pkg/errors"
- "net/http"
- "strconv"
- "time"
)
const (
@@ -34,7 +35,7 @@ type service struct {
}
// NewService creates a new RocketChat ticket service
-func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
+func NewService(rtCfg *config.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
baseURL := config[configBaseURL]
secret := config[configSecret]
@@ -53,6 +54,7 @@ type VisitorToken models.ContactID
// Open opens a ticket which for RocketChat means open a room associated to a visitor user
func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) {
+ ticket := flows.OpenTicket(s.ticketer, subject, body)
contact := session.Contact()
email := ""
phone := ""
@@ -70,7 +72,6 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow
}
}
- ticketUUID := flows.TicketUUID(uuids.New())
room := &Room{
Visitor: Visitor{
Token: VisitorToken(contact.ID()).String(),
@@ -79,7 +80,7 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow
Email: email,
Phone: phone,
},
- TicketID: string(ticketUUID),
+ TicketID: string(ticket.UUID()),
}
room.SessionStart = session.Runs()[0].CreatedOn().Add(-time.Minute).Format(time.RFC3339)
@@ -101,7 +102,8 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow
return nil, errors.Wrap(err, "error calling RocketChat")
}
- return flows.NewTicket(ticketUUID, s.ticketer.Reference(), subject, body, roomID), nil
+ ticket.SetExternalID(roomID)
+ return ticket, nil
}
func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error {
diff --git a/services/tickets/rocketchat/service_test.go b/services/tickets/rocketchat/service_test.go
index b4b92692d..2e6a191bc 100644
--- a/services/tickets/rocketchat/service_test.go
+++ b/services/tickets/rocketchat/service_test.go
@@ -1,6 +1,10 @@
package rocketchat_test
import (
+ "net/http"
+ "testing"
+ "time"
+
"github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/uuids"
@@ -12,14 +16,16 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/services/tickets/rocketchat"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "net/http"
- "testing"
- "time"
)
func TestOpenAndForward(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
defer dates.SetNowSource(dates.DefaultNowSource)
dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2019, 10, 7, 15, 21, 30, 0, time.UTC)))
@@ -44,6 +50,7 @@ func TestOpenAndForward(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "rocketchat"))
_, err = rocketchat.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -52,6 +59,7 @@ func TestOpenAndForward(t *testing.T) {
assert.EqualError(t, err, "missing base_url or secret config")
svc, err := rocketchat.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -69,20 +77,15 @@ func TestOpenAndForward(t *testing.T) {
logger = &flows.HTTPLogger{}
ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log)
assert.NoError(t, err)
-
- assert.Equal(t, &flows.Ticket{
- UUID: flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"),
- Ticketer: ticketer.Reference(),
- Subject: "Need help",
- Body: "Where are my cookies?",
- ExternalID: "uiF7ybjsv7PSJGSw6",
- }, ticket)
-
+ assert.Equal(t, flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), ticket.UUID())
+ assert.Equal(t, "Need help", ticket.Subject())
+ assert.Equal(t, "Where are my cookies?", ticket.Body())
+ assert.Equal(t, "uiF7ybjsv7PSJGSw6", ticket.ExternalID())
assert.Equal(t, 1, len(logger.Logs))
test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request)
- dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.RocketChatID, "", "Need help", "Where are my cookies?", map[string]interface{}{
- "contact-uuid": string(models.CathyUUID),
+ dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.RocketChat.ID, "", "Need help", "Where are my cookies?", models.NilUserID, map[string]interface{}{
+ "contact-uuid": string(testdata.Cathy.UUID),
"contact-display": "Cathy",
})
logger = &flows.HTTPLogger{}
@@ -96,11 +99,14 @@ func TestOpenAndForward(t *testing.T) {
"audio/ogg:https://link.to/audio.ogg",
}
err = svc.Forward(dbTicket, flows.MsgUUID("4fa340ae-1fb0-4666-98db-2177fe9bf31c"), "It's urgent", attachments, logger.Log)
+ require.NoError(t, err)
assert.Equal(t, 1, len(logger.Logs))
test.AssertSnapshot(t, "forward_message", logger.Logs[0].Request)
}
func TestCloseAndReopen(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
defer uuids.SetGenerator(uuids.DefaultGenerator)
defer httpx.SetRequestor(httpx.DefaultRequestor)
@@ -115,6 +121,7 @@ func TestCloseAndReopen(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "rocketchat"))
svc, err := rocketchat.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -125,8 +132,8 @@ func TestCloseAndReopen(t *testing.T) {
)
require.NoError(t, err)
- ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.RocketChatID, "X5gwXeaxbnGDaq8Q3", "New ticket", "Where my cookies?", nil)
- ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.RocketChatID, "cq7AokJHKkGhAMoBK", "Second ticket", "Where my shoes?", nil)
+ ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.RocketChat.ID, "X5gwXeaxbnGDaq8Q3", "New ticket", "Where my cookies?", models.NilUserID, nil)
+ ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.RocketChat.ID, "cq7AokJHKkGhAMoBK", "Second ticket", "Where my shoes?", models.NilUserID, nil)
logger := &flows.HTTPLogger{}
err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log)
diff --git a/services/tickets/rocketchat/testdata/event_callback.json b/services/tickets/rocketchat/testdata/event_callback.json
index a8d952911..75add7bfd 100644
--- a/services/tickets/rocketchat/testdata/event_callback.json
+++ b/services/tickets/rocketchat/testdata/event_callback.json
@@ -111,7 +111,7 @@
},
"body": {
"type": "other",
- "ticketID": "c69f103c-db64-4481-815b-1112890419ef",
+ "ticketID": "$cathy_ticket_uuid$",
"visitor": {
"token": "1234"
},
@@ -133,7 +133,7 @@
},
"body": {
"type": "agent-message",
- "ticketID": "c69f103c-db64-4481-815b-1112890419ef",
+ "ticketID": "$cathy_ticket_uuid$",
"visitor": {
"token": "1234"
},
@@ -165,15 +165,17 @@
},
"body": {
"type": "agent-message",
- "ticketID": "c69f103c-db64-4481-815b-1112890419ef",
+ "ticketID": "$cathy_ticket_uuid$",
"visitor": {
"token": "1234"
},
"data": {
- "attachments": [{
- "type": "image/jpg",
- "url": "https://link.to/image.jpg"
- }]
+ "attachments": [
+ {
+ "type": "image/jpg",
+ "url": "https://link.to/image.jpg"
+ }
+ ]
}
},
"http_mocks": {
@@ -190,7 +192,7 @@
},
"db_assertions": [
{
- "query": "select count(*) from msgs_msg where direction = 'O' and attachments = '{text/plain:https:///_test_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'",
+ "query": "select count(*) from msgs_msg where direction = 'O' and attachments = '{text/plain:https:///_test_media_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'",
"count": 1
}
]
@@ -204,7 +206,7 @@
},
"body": {
"type": "close-room",
- "ticketID": "c69f103c-db64-4481-815b-1112890419ef",
+ "ticketID": "$cathy_ticket_uuid$",
"visitor": {
"token": "1234"
}
diff --git a/services/tickets/rocketchat/web.go b/services/tickets/rocketchat/web.go
index 1b2e0d11c..d3424f37b 100644
--- a/services/tickets/rocketchat/web.go
+++ b/services/tickets/rocketchat/web.go
@@ -4,15 +4,17 @@ import (
"context"
"encoding/json"
"fmt"
+ "net/http"
+
"github.com/go-chi/chi"
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/services/tickets"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
- "net/http"
)
func init() {
@@ -35,11 +37,11 @@ type agentMessageData struct {
} `json:"attachments"`
}
-func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer"))
// look up ticketer
- ticketer, _, err := tickets.FromTicketerUUID(ctx, s.DB, ticketerUUID, typeRocketChat)
+ ticketer, _, err := tickets.FromTicketerUUID(ctx, rt.DB, ticketerUUID, typeRocketChat)
if err != nil {
return errors.Errorf("no such ticketer %s", ticketerUUID), http.StatusNotFound, nil
}
@@ -56,7 +58,7 @@ func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l
}
// look up ticket
- ticket, _, _, err := tickets.FromTicketUUID(ctx, s.DB, flows.TicketUUID(request.TicketID), typeRocketChat)
+ ticket, _, _, err := tickets.FromTicketUUID(ctx, rt.DB, flows.TicketUUID(request.TicketID), typeRocketChat)
if err != nil {
return errors.Errorf("no such ticket %s", request.TicketID), http.StatusNotFound, nil
}
@@ -88,10 +90,10 @@ func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l
attachments = append(attachments, attachment.URL)
}
- _, err = tickets.SendReply(ctx, s.DB, s.RP, s.Storage, ticket, data.Text, files)
+ _, err = tickets.SendReply(ctx, rt, ticket, data.Text, files)
case "close-room":
- err = models.CloseTickets(ctx, s.DB, nil, []*models.Ticket{ticket}, false, l)
+ err = tickets.CloseTicket(ctx, rt, nil, ticket, false, l)
default:
err = errors.New("invalid event type")
diff --git a/services/tickets/rocketchat/web_test.go b/services/tickets/rocketchat/web_test.go
index c9f9fd53d..20cc9f3f0 100644
--- a/services/tickets/rocketchat/web_test.go
+++ b/services/tickets/rocketchat/web_test.go
@@ -1,11 +1,11 @@
package rocketchat_test
import (
- "github.com/nyaruka/mailroom/core/models"
+ "testing"
+
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
- "testing"
)
func TestEventCallback(t *testing.T) {
@@ -13,7 +13,7 @@ func TestEventCallback(t *testing.T) {
db := testsuite.DB()
// create a rocketchat ticket for Cathy
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.RocketChatID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234")
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.RocketChat, "Need help", "Have you seen my cookies?", "1234", nil)
- web.RunWebTests(t, "testdata/event_callback.json")
+ web.RunWebTests(t, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)})
}
diff --git a/services/tickets/utils.go b/services/tickets/utils.go
index 7f45f4f4c..35563e10b 100644
--- a/services/tickets/utils.go
+++ b/services/tickets/utils.go
@@ -10,18 +10,19 @@ import (
"path/filepath"
"time"
+ "github.com/jmoiron/sqlx"
"github.com/nyaruka/gocommon/httpx"
- "github.com/nyaruka/gocommon/storage"
"github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/envs"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/msgio"
+ "github.com/nyaruka/mailroom/core/tasks/handler"
+ "github.com/nyaruka/mailroom/runtime"
- "github.com/gomodule/redigo/redis"
- "github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
@@ -55,7 +56,7 @@ func FromTicketUUID(ctx context.Context, db *sqlx.DB, uuid flows.TicketUUID, tic
}
// and load it as a service
- svc, err := ticketer.AsService(flows.NewTicketer(ticketer))
+ svc, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer))
if err != nil {
return nil, nil, nil, errors.Wrap(err, "error loading ticketer service")
}
@@ -71,7 +72,7 @@ func FromTicketerUUID(ctx context.Context, db *sqlx.DB, uuid assets.TicketerUUID
}
// and load it as a service
- svc, err := ticketer.AsService(flows.NewTicketer(ticketer))
+ svc, err := ticketer.AsService(config.Mailroom, flows.NewTicketer(ticketer))
if err != nil {
return nil, nil, errors.Wrap(err, "error loading ticketer service")
}
@@ -80,9 +81,9 @@ func FromTicketerUUID(ctx context.Context, db *sqlx.DB, uuid assets.TicketerUUID
}
// SendReply sends a message reply from the ticket system user to the contact
-func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.Storage, ticket *models.Ticket, text string, files []*File) (*models.Msg, error) {
+func SendReply(ctx context.Context, rt *runtime.Runtime, ticket *models.Ticket, text string, files []*File) (*models.Msg, error) {
// look up our assets
- oa, err := models.GetOrgAssets(ctx, db, ticket.OrgID())
+ oa, err := models.GetOrgAssets(ctx, rt.DB, ticket.OrgID())
if err != nil {
return nil, errors.Wrapf(err, "error looking up org #%d", ticket.OrgID())
}
@@ -92,7 +93,7 @@ func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.S
for i, file := range files {
filename := string(uuids.New()) + filepath.Ext(file.URL)
- attachments[i], err = oa.Org().StoreAttachment(store, filename, file.ContentType, file.Body)
+ attachments[i], err = oa.Org().StoreAttachment(ctx, rt.MediaStorage, filename, file.ContentType, file.Body)
if err != nil {
return nil, errors.Wrapf(err, "error storing attachment %s for ticket reply", file.URL)
}
@@ -103,14 +104,14 @@ func SendReply(ctx context.Context, db *sqlx.DB, rp *redis.Pool, store storage.S
translations := map[envs.Language]*models.BroadcastTranslation{envs.Language("base"): base}
// we'll use a broadcast to send this message
- bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil)
+ bcast := models.NewBroadcast(oa.OrgID(), models.NilBroadcastID, translations, models.TemplateStateEvaluated, envs.Language("base"), nil, nil, nil, ticket.ID())
batch := bcast.CreateBatch([]models.ContactID{ticket.ContactID()})
- msgs, err := models.CreateBroadcastMessages(ctx, db, rp, oa, batch)
+ msgs, err := models.CreateBroadcastMessages(ctx, rt.DB, rt.RP, oa, batch)
if err != nil {
return nil, errors.Wrapf(err, "error creating message batch")
}
- msgio.SendMessages(ctx, db, rp, nil, msgs)
+ msgio.SendMessages(ctx, rt.DB, rt.RP, nil, msgs)
return msgs[0], nil
}
@@ -139,3 +140,29 @@ func FetchFile(url string, headers map[string]string) (*File, error) {
return &File{URL: url, ContentType: contentType, Body: ioutil.NopCloser(bytes.NewReader(trace.ResponseBody))}, nil
}
+
+// CloseTicket closes the given ticket, and creates and queues a closed event
+func CloseTicket(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, ticket *models.Ticket, externally bool, l *models.HTTPLogger) error {
+ events, err := models.CloseTickets(ctx, rt.DB, oa, models.NilUserID, []*models.Ticket{ticket}, externally, l)
+ if err != nil {
+ return errors.Wrap(err, "error closing ticket")
+ }
+
+ if len(events) == 1 {
+ rc := rt.RP.Get()
+ defer rc.Close()
+
+ err = handler.QueueTicketEvent(rc, ticket.ContactID(), events[ticket])
+ if err != nil {
+ return errors.Wrapf(err, "error queueing ticket closed event")
+ }
+ }
+
+ return nil
+}
+
+// ReopenTicket reopens the given ticket
+func ReopenTicket(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, ticket *models.Ticket, externally bool, l *models.HTTPLogger) error {
+ _, err := models.ReopenTickets(ctx, rt.DB, oa, models.NilUserID, []*models.Ticket{ticket}, externally, l)
+ return err
+}
diff --git a/services/tickets/utils_test.go b/services/tickets/utils_test.go
index 97feebb4e..d433101c0 100644
--- a/services/tickets/utils_test.go
+++ b/services/tickets/utils_test.go
@@ -3,10 +3,12 @@ package tickets_test
import (
"os"
"testing"
+ "time"
+ "github.com/nyaruka/gocommon/dates"
+ "github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/envs"
- "github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/services/tickets"
@@ -20,13 +22,12 @@ import (
)
func TestGetContactDisplay(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
- contact, err := models.LoadContact(ctx, db, oa, models.CathyID)
+ contact, err := models.LoadContact(ctx, db, oa, testdata.Cathy.ID)
require.NoError(t, err)
flowContact, err := contact.FlowContact(oa)
@@ -46,19 +47,14 @@ func TestGetContactDisplay(t *testing.T) {
}
func TestFromTicketUUID(t *testing.T) {
- testsuite.ResetDB()
- ctx := testsuite.CTX()
- db := testsuite.DB()
-
- ticket1UUID := flows.TicketUUID("f7358870-c3dd-450d-b5ae-db2eb50216ba")
- ticket2UUID := flows.TicketUUID("44b7d9b5-6ddd-4a6a-a1c0-8b70ecd06339")
+ ctx, _, db, _ := testsuite.Reset()
// create some tickets
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, ticket1UUID, "Need help", "Have you seen my cookies?", "")
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, ticket2UUID, "Need help", "Have you seen my shoes?", "")
+ ticket1 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "", nil)
+ ticket2 := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my shoes?", "", nil)
// break mailgun configuration
- db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, models.MailgunID)
+ db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, testdata.Mailgun.ID)
models.FlushCache()
@@ -67,18 +63,19 @@ func TestFromTicketUUID(t *testing.T) {
assert.EqualError(t, err, "error looking up ticket 33c54d0c-bd49-4edf-87a9-c391a75a630c")
// err if no ticketer type doesn't match
- _, _, _, err = tickets.FromTicketUUID(ctx, db, ticket1UUID, "zendesk")
+ _, _, _, err = tickets.FromTicketUUID(ctx, db, ticket1.UUID, "zendesk")
assert.EqualError(t, err, "error looking up ticketer #1")
// err if ticketer isn't configured correctly and can't be loaded as a service
- _, _, _, err = tickets.FromTicketUUID(ctx, db, ticket1UUID, "mailgun")
+ _, _, _, err = tickets.FromTicketUUID(ctx, db, ticket1.UUID, "mailgun")
assert.EqualError(t, err, "error loading ticketer service: missing domain or api_key or to_address or url_base in mailgun config")
// if all is correct, returns the ticket, ticketer asset, and ticket service
- ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, db, ticket2UUID, "zendesk")
+ ticket, ticketer, svc, err := tickets.FromTicketUUID(ctx, db, ticket2.UUID, "zendesk")
- assert.Equal(t, ticket2UUID, ticket.UUID())
- assert.Equal(t, models.ZendeskUUID, ticketer.UUID())
+ assert.NoError(t, err)
+ assert.Equal(t, ticket2.UUID, ticket.UUID())
+ assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID())
assert.Implements(t, (*models.TicketService)(nil), svc)
testsuite.ResetDB()
@@ -86,29 +83,28 @@ func TestFromTicketUUID(t *testing.T) {
}
func TestFromTicketerUUID(t *testing.T) {
- testsuite.ResetDB()
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Reset()
// break mailgun configuration
- db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, models.MailgunID)
+ db.MustExec(`UPDATE tickets_ticketer SET config = '{"foo":"bar"}'::jsonb WHERE id = $1`, testdata.Mailgun.ID)
// err if no ticketer with UUID
_, _, err := tickets.FromTicketerUUID(ctx, db, "33c54d0c-bd49-4edf-87a9-c391a75a630c", "mailgun")
assert.EqualError(t, err, "error looking up ticketer 33c54d0c-bd49-4edf-87a9-c391a75a630c")
// err if no ticketer type doesn't match
- _, _, err = tickets.FromTicketerUUID(ctx, db, models.MailgunUUID, "zendesk")
+ _, _, err = tickets.FromTicketerUUID(ctx, db, testdata.Mailgun.UUID, "zendesk")
assert.EqualError(t, err, "error looking up ticketer f9c9447f-a291-4f3c-8c79-c089bbd4e713")
// err if ticketer isn't configured correctly and can't be loaded as a service
- _, _, err = tickets.FromTicketerUUID(ctx, db, models.MailgunUUID, "mailgun")
+ _, _, err = tickets.FromTicketerUUID(ctx, db, testdata.Mailgun.UUID, "mailgun")
assert.EqualError(t, err, "error loading ticketer service: missing domain or api_key or to_address or url_base in mailgun config")
// if all is correct, returns the ticketer asset and ticket service
- ticketer, svc, err := tickets.FromTicketerUUID(ctx, db, models.ZendeskUUID, "zendesk")
+ ticketer, svc, err := tickets.FromTicketerUUID(ctx, db, testdata.Zendesk.UUID, "zendesk")
- assert.Equal(t, models.ZendeskUUID, ticketer.UUID())
+ assert.NoError(t, err)
+ assert.Equal(t, testdata.Zendesk.UUID, ticketer.UUID())
assert.Implements(t, (*models.TicketService)(nil), svc)
testsuite.ResetDB()
@@ -116,10 +112,8 @@ func TestFromTicketerUUID(t *testing.T) {
}
func TestSendReply(t *testing.T) {
- testsuite.ResetDB()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
+ ctx, rt, db, _ := testsuite.Reset()
+
defer testsuite.ResetStorage()
defer uuids.SetGenerator(uuids.DefaultGenerator)
@@ -130,23 +124,68 @@ func TestSendReply(t *testing.T) {
image := &tickets.File{URL: "http://coolfiles.com/a.jpg", ContentType: "image/jpeg", Body: imageBody}
- ticketUUID := flows.TicketUUID("f7358870-c3dd-450d-b5ae-db2eb50216ba")
-
// create a ticket
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, ticketUUID, "Need help", "Have you seen my cookies?", "")
-
- ticket, err := models.LookupTicketByUUID(ctx, db, ticketUUID)
- require.NoError(t, err)
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "", nil)
+ modelTicket := ticket.Load(db)
- msg, err := tickets.SendReply(ctx, db, rp, testsuite.Storage(), ticket, "I'll get back to you", []*tickets.File{image})
+ msg, err := tickets.SendReply(ctx, rt, modelTicket, "I'll get back to you", []*tickets.File{image})
require.NoError(t, err)
assert.Equal(t, "I'll get back to you", msg.Text())
- assert.Equal(t, models.CathyID, msg.ContactID())
- assert.Equal(t, []utils.Attachment{"image/jpeg:https:///_test_storage/media/1/1ae9/6956/1ae96956-4b34-433e-8d1a-f05fe6923d6d.jpg"}, msg.Attachments())
- assert.FileExists(t, "_test_storage/media/1/1ae9/6956/1ae96956-4b34-433e-8d1a-f05fe6923d6d.jpg")
+ assert.Equal(t, testdata.Cathy.ID, msg.ContactID())
+ assert.Equal(t, []utils.Attachment{"image/jpeg:https:///_test_media_storage/media/1/e718/7099/e7187099-7d38-4f60-955c-325957214c42.jpg"}, msg.Attachments())
+ assert.FileExists(t, "_test_media_storage/media/1/e718/7099/e7187099-7d38-4f60-955c-325957214c42.jpg")
// try with file that can't be read (i.e. same file again which is already closed)
- _, err = tickets.SendReply(ctx, db, rp, testsuite.Storage(), ticket, "I'll get back to you", []*tickets.File{image})
+ _, err = tickets.SendReply(ctx, rt, modelTicket, "I'll get back to you", []*tickets.File{image})
assert.EqualError(t, err, "error storing attachment http://coolfiles.com/a.jpg for ticket reply: unable to read attachment content: read ../../core/models/testdata/test.jpg: file already closed")
}
+
+func TestCloseTicket(t *testing.T) {
+ ctx, rt, db, _ := testsuite.Reset()
+
+ defer dates.SetNowSource(dates.DefaultNowSource)
+ defer httpx.SetRequestor(httpx.DefaultRequestor)
+
+ dates.SetNowSource(dates.NewSequentialNowSource(time.Date(2021, 6, 8, 16, 40, 30, 0, time.UTC)))
+
+ httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
+ "https://api.mailgun.net/v3/tickets.rapidpro.io/messages": {
+ httpx.NewMockResponse(200, nil, `{
+ "id": "<20200426161758.1.590432020254B2BF@tickets.rapidpro.io>",
+ "message": "Queued. Thank you."
+ }`),
+ },
+ }))
+
+ // create an open ticket
+ ticket1 := models.NewTicket(
+ "2ef57efc-d85f-4291-b330-e4afe68af5fe",
+ testdata.Org1.ID,
+ testdata.Cathy.ID,
+ testdata.Mailgun.ID,
+ "EX12345",
+ "New Ticket",
+ "Where are my cookies?",
+ models.NilUserID,
+ map[string]interface{}{
+ "contact-display": "Cathy",
+ },
+ )
+ err := models.InsertTickets(ctx, db, []*models.Ticket{ticket1})
+ require.NoError(t, err)
+
+ // create a close ticket trigger
+ testdata.InsertTicketClosedTrigger(db, testdata.Org1, testdata.Favorites)
+
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
+ require.NoError(t, err)
+
+ logger := &models.HTTPLogger{}
+
+ err = tickets.CloseTicket(ctx, rt, oa, ticket1, true, logger)
+ require.NoError(t, err)
+
+ testsuite.AssertContactTasks(t, 1, testdata.Cathy.ID,
+ []string{`{"type":"ticket_closed","org_id":1,"task":{"id":1,"org_id":1,"contact_id":10000,"ticket_id":1,"event_type":"C","created_on":"2021-06-08T16:40:31Z"},"queued_on":"2021-06-08T16:40:34Z"}`})
+}
diff --git a/services/tickets/zendesk/service.go b/services/tickets/zendesk/service.go
index c18edabdd..51af565f9 100644
--- a/services/tickets/zendesk/service.go
+++ b/services/tickets/zendesk/service.go
@@ -8,7 +8,6 @@ import (
"github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/httpx"
- "github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/config"
@@ -38,6 +37,7 @@ func init() {
}
type service struct {
+ rtConfig *config.Config
restClient *RESTClient
pushClient *PushClient
ticketer *flows.Ticketer
@@ -49,7 +49,7 @@ type service struct {
}
// NewService creates a new zendesk ticket service
-func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
+func NewService(rtCfg *config.Config, httpClient *http.Client, httpRetries *httpx.RetryConfig, ticketer *flows.Ticketer, config map[string]string) (models.TicketService, error) {
subdomain := config[configSubdomain]
secret := config[configSecret]
oAuthToken := config[configOAuthToken]
@@ -60,6 +60,7 @@ func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, tickete
if subdomain != "" && secret != "" && oAuthToken != "" && instancePushID != "" && pushToken != "" {
return &service{
+ rtConfig: rtCfg,
restClient: NewRESTClient(httpClient, httpRetries, subdomain, oAuthToken),
pushClient: NewPushClient(httpClient, httpRetries, subdomain, pushToken),
ticketer: ticketer,
@@ -75,13 +76,13 @@ func NewService(httpClient *http.Client, httpRetries *httpx.RetryConfig, tickete
// Open opens a ticket which for mailgun means just sending an initial email
func (s *service) Open(session flows.Session, subject, body string, logHTTP flows.HTTPLogCallback) (*flows.Ticket, error) {
- ticketUUID := flows.TicketUUID(uuids.New())
+ ticket := flows.OpenTicket(s.ticketer, subject, body)
contactDisplay := session.Contact().Format(session.Environment())
msg := &ExternalResource{
- ExternalID: string(ticketUUID), // there's no local msg so use ticket UUID instead
+ ExternalID: string(ticket.UUID()), // there's no local msg so use ticket UUID instead
Message: body,
- ThreadID: string(ticketUUID),
+ ThreadID: string(ticket.UUID()),
CreatedAt: dates.Now(),
Author: Author{
ExternalID: string(session.Contact().UUID()),
@@ -94,7 +95,7 @@ func (s *service) Open(session flows.Session, subject, body string, logHTTP flow
return nil, err
}
- return flows.NewTicket(ticketUUID, s.ticketer.Reference(), subject, body, ""), nil
+ return ticket, nil
}
func (s *service) Forward(ticket *models.Ticket, msgUUID flows.MsgUUID, text string, attachments []utils.Attachment, logHTTP flows.HTTPLogCallback) error {
@@ -250,7 +251,7 @@ func (s *service) push(msg *ExternalResource, logHTTP flows.HTTPLogCallback) err
// which it will request as POST https://textit.com/tickets/types/zendesk/file/1/01c1/1aa4/01c11aa4-770a-4783.jpg
//
func (s *service) convertAttachments(attachments []utils.Attachment) ([]string, error) {
- prefix := config.Mailroom.S3MediaPrefix
+ prefix := s.rtConfig.S3MediaPrefix
if !strings.HasPrefix(prefix, "/") {
prefix = "/" + prefix
}
diff --git a/services/tickets/zendesk/service_test.go b/services/tickets/zendesk/service_test.go
index 4fb11f62e..5f423abf1 100644
--- a/services/tickets/zendesk/service_test.go
+++ b/services/tickets/zendesk/service_test.go
@@ -16,12 +16,16 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/services/tickets/zendesk"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOpenAndForward(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
session, _, err := test.CreateTestSession("", envs.RedactionPolicyNone)
require.NoError(t, err)
@@ -56,6 +60,7 @@ func TestOpenAndForward(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "zendesk"))
_, err = zendesk.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -64,6 +69,7 @@ func TestOpenAndForward(t *testing.T) {
assert.EqualError(t, err, "missing subdomain or secret or oauth_token or push_id or push_token in zendesk config")
svc, err := zendesk.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -86,21 +92,16 @@ func TestOpenAndForward(t *testing.T) {
logger = &flows.HTTPLogger{}
ticket, err := svc.Open(session, "Need help", "Where are my cookies?", logger.Log)
-
assert.NoError(t, err)
- assert.Equal(t, &flows.Ticket{
- UUID: flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"),
- Ticketer: ticketer.Reference(),
- Subject: "Need help",
- Body: "Where are my cookies?",
- ExternalID: "",
- }, ticket)
-
+ assert.Equal(t, flows.TicketUUID("59d74b86-3e2f-4a93-aece-b05d2fdcde0c"), ticket.UUID())
+ assert.Equal(t, "Need help", ticket.Subject())
+ assert.Equal(t, "Where are my cookies?", ticket.Body())
+ assert.Equal(t, "", ticket.ExternalID())
assert.Equal(t, 1, len(logger.Logs))
test.AssertSnapshot(t, "open_ticket", logger.Logs[0].Request)
- dbTicket := models.NewTicket(ticket.UUID, models.Org1, models.CathyID, models.ZendeskID, "", "Need help", "Where are my cookies?", map[string]interface{}{
- "contact-uuid": string(models.CathyUUID),
+ dbTicket := models.NewTicket(ticket.UUID(), testdata.Org1.ID, testdata.Cathy.ID, testdata.Zendesk.ID, "", "Need help", "Where are my cookies?", models.NilUserID, map[string]interface{}{
+ "contact-uuid": string(testdata.Cathy.UUID),
"contact-display": "Cathy",
})
@@ -119,6 +120,8 @@ func TestOpenAndForward(t *testing.T) {
}
func TestCloseAndReopen(t *testing.T) {
+ _, rt, _, _ := testsuite.Get()
+
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
"https://nyaruka.zendesk.com/api/v2/tickets/update_many.json?ids=12,14": {
@@ -143,6 +146,7 @@ func TestCloseAndReopen(t *testing.T) {
ticketer := flows.NewTicketer(types.NewTicketer(assets.TicketerUUID(uuids.New()), "Support", "zendesk"))
svc, err := zendesk.NewService(
+ rt.Config,
http.DefaultClient,
nil,
ticketer,
@@ -157,8 +161,8 @@ func TestCloseAndReopen(t *testing.T) {
require.NoError(t, err)
logger := &flows.HTTPLogger{}
- ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", models.Org1, models.CathyID, models.ZendeskID, "12", "New ticket", "Where my cookies?", nil)
- ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", models.Org1, models.BobID, models.ZendeskID, "14", "Second ticket", "Where my shoes?", nil)
+ ticket1 := models.NewTicket("88bfa1dc-be33-45c2-b469-294ecb0eba90", testdata.Org1.ID, testdata.Cathy.ID, testdata.Zendesk.ID, "12", "New ticket", "Where my cookies?", models.NilUserID, nil)
+ ticket2 := models.NewTicket("645eee60-7e84-4a9e-ade3-4fce01ae28f1", testdata.Org1.ID, testdata.Bob.ID, testdata.Zendesk.ID, "14", "Second ticket", "Where my shoes?", models.NilUserID, nil)
err = svc.Close([]*models.Ticket{ticket1, ticket2}, logger.Log)
diff --git a/services/tickets/zendesk/testdata/channelback.json b/services/tickets/zendesk/testdata/channelback.json
index 95e3c27a4..8247c6fe3 100644
--- a/services/tickets/zendesk/testdata/channelback.json
+++ b/services/tickets/zendesk/testdata/channelback.json
@@ -23,7 +23,7 @@
"label": "error response if passed secret is incorrect",
"method": "POST",
"path": "/mr/tickets/types/zendesk/channelback",
- "body": "message=We%20can%20help&recipient_id=1234&thread_id=c69f103c-db64-4481-815b-1112890419ef&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesxyz%22%7D",
+ "body": "message=We%20can%20help&recipient_id=1234&thread_id=$cathy_ticket_uuid$&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesxyz%22%7D",
"status": 401,
"response": {
"error": "ticketer secret mismatch"
@@ -33,7 +33,7 @@
"label": "create message and send to contact if everything correct",
"method": "POST",
"path": "/mr/tickets/types/zendesk/channelback",
- "body": "message=We%20can%20help&recipient_id=1234&thread_id=c69f103c-db64-4481-815b-1112890419ef&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D",
+ "body": "message=We%20can%20help&recipient_id=1234&thread_id=$cathy_ticket_uuid$&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D",
"status": 200,
"response": {
"external_id": "1",
@@ -50,7 +50,7 @@
"label": "create message with attachments",
"method": "POST",
"path": "/mr/tickets/types/zendesk/channelback",
- "body": "file_urls%5B%5D=https%3A%2F%2Fd3v-nyaruka.zendesk.com%2Fattachments%2Ftoken%2FEWTWEGWE%2F%3Fname%3DIhCY7aKs_400x400.jpg&message=Like%20this&recipient_id=1234&thread_id=c69f103c-db64-4481-815b-1112890419ef&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D",
+ "body": "file_urls%5B%5D=https%3A%2F%2Fd3v-nyaruka.zendesk.com%2Fattachments%2Ftoken%2FEWTWEGWE%2F%3Fname%3DIhCY7aKs_400x400.jpg&message=Like%20this&recipient_id=1234&thread_id=$cathy_ticket_uuid$&metadata=%7B%22ticketer%22%3A%224ee6d4f3-f92b-439b-9718-8da90c05490c%22%2C%22secret%22%3A%22sesame%22%7D",
"http_mocks": {
"https://d3v-nyaruka.zendesk.com/attachments/token/EWTWEGWE/?name=IhCY7aKs_400x400.jpg": [
{
@@ -66,7 +66,7 @@
},
"db_assertions": [
{
- "query": "select count(*) from msgs_msg where direction = 'O' and text = 'Like this' and attachments = '{text/plain:https:///_test_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'",
+ "query": "select count(*) from msgs_msg where direction = 'O' and text = 'Like this' and attachments = '{text/plain:https:///_test_media_storage/media/1/6929/26ea/692926ea-09d6-4942-bd38-d266ec8d3716.jpg}'",
"count": 1
}
]
diff --git a/services/tickets/zendesk/testdata/event_callback.json b/services/tickets/zendesk/testdata/event_callback.json
index e7c88bd73..f35d8fb67 100644
--- a/services/tickets/zendesk/testdata/event_callback.json
+++ b/services/tickets/zendesk/testdata/event_callback.json
@@ -185,7 +185,7 @@
"resource_events": [
{
"type_id": "comment_on_new_ticket",
- "external_id": "c69f103c-db64-4481-815b-1112890419ef",
+ "external_id": "$cathy_ticket_uuid$",
"comment_id": 111,
"ticket_id": 222
}
diff --git a/services/tickets/zendesk/web.go b/services/tickets/zendesk/web.go
index 46afdaf36..0f7c9bdae 100644
--- a/services/tickets/zendesk/web.go
+++ b/services/tickets/zendesk/web.go
@@ -13,6 +13,7 @@ import (
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/services/tickets"
"github.com/nyaruka/mailroom/web"
@@ -48,7 +49,7 @@ type channelbackResponse struct {
AllowChannelback bool `json:"allow_channelback"`
}
-func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleChannelback(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &channelbackRequest{}
if err := web.DecodeAndValidateForm(request, r); err != nil {
return errors.Wrapf(err, "error decoding form"), http.StatusBadRequest, nil
@@ -61,7 +62,7 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int
}
// lookup the ticket and ticketer
- ticket, ticketer, _, err := tickets.FromTicketUUID(ctx, s.DB, flows.TicketUUID(request.ThreadID), typeZendesk)
+ ticket, ticketer, _, err := tickets.FromTicketUUID(ctx, rt.DB, flows.TicketUUID(request.ThreadID), typeZendesk)
if err != nil {
return err, http.StatusBadRequest, nil
}
@@ -71,9 +72,17 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int
return errors.New("ticketer secret mismatch"), http.StatusUnauthorized, nil
}
- err = models.UpdateAndKeepOpenTicket(ctx, s.DB, ticket, nil)
- if err != nil {
- return errors.Wrapf(err, "error updating ticket: %s", ticket.UUID()), http.StatusBadRequest, nil
+ // reopen ticket if necessary
+ if ticket.Status() != models.TicketStatusOpen {
+ oa, err := models.GetOrgAssets(ctx, rt.DB, ticket.OrgID())
+ if err != nil {
+ return err, http.StatusBadRequest, nil
+ }
+
+ err = tickets.ReopenTicket(ctx, rt, oa, ticket, false, nil)
+ if err != nil {
+ return errors.Wrapf(err, "error reopening ticket: %s", ticket.UUID()), http.StatusInternalServerError, nil
+ }
}
// fetch files
@@ -85,7 +94,7 @@ func handleChannelback(ctx context.Context, s *web.Server, r *http.Request) (int
}
}
- msg, err := tickets.SendReply(ctx, s.DB, s.RP, s.Storage, ticket, request.Message, files)
+ msg, err := tickets.SendReply(ctx, rt, ticket, request.Message, files)
if err != nil {
return err, http.StatusBadRequest, nil
}
@@ -123,14 +132,14 @@ type eventCallbackRequest struct {
Events []*channelEvent `json:"events" validate:"required"`
}
-func handleEventCallback(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+func handleEventCallback(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
request := &eventCallbackRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return err, http.StatusBadRequest, nil
}
for _, e := range request.Events {
- if err := processChannelEvent(ctx, s.DB, e, l); err != nil {
+ if err := processChannelEvent(ctx, rt.DB, e, l); err != nil {
return err, http.StatusBadRequest, nil
}
}
@@ -244,11 +253,11 @@ type targetRequest struct {
Status string `json:"status"`
}
-func handleTicketerTarget(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+func handleTicketerTarget(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
ticketerUUID := assets.TicketerUUID(chi.URLParam(r, "ticketer"))
// look up our ticketer
- ticketer, _, err := tickets.FromTicketerUUID(ctx, s.DB, ticketerUUID, typeZendesk)
+ ticketer, _, err := tickets.FromTicketerUUID(ctx, rt.DB, ticketerUUID, typeZendesk)
if err != nil || ticketer == nil {
return errors.Errorf("no such ticketer %s", ticketerUUID), http.StatusNotFound, nil
}
@@ -266,7 +275,7 @@ func handleTicketerTarget(ctx context.Context, s *web.Server, r *http.Request, l
}
// lookup ticket
- ticket, err := models.LookupTicketByExternalID(ctx, s.DB, ticketer.ID(), fmt.Sprintf("%d", request.ID))
+ ticket, err := models.LookupTicketByExternalID(ctx, rt.DB, ticketer.ID(), fmt.Sprintf("%d", request.ID))
if err != nil || ticket == nil {
// we don't return an error here, because ticket might just belong to a different ticketer
return map[string]string{"status": "ignored"}, http.StatusOK, nil
@@ -275,9 +284,9 @@ func handleTicketerTarget(ctx context.Context, s *web.Server, r *http.Request, l
if request.Event == "status_changed" {
switch strings.ToLower(request.Status) {
case statusSolved, statusClosed:
- err = models.CloseTickets(ctx, s.DB, nil, []*models.Ticket{ticket}, false, l)
+ err = tickets.CloseTicket(ctx, rt, nil, ticket, false, l)
case statusOpen:
- err = models.ReopenTickets(ctx, s.DB, nil, []*models.Ticket{ticket}, false, l)
+ err = tickets.ReopenTicket(ctx, rt, nil, ticket, false, l)
}
if err != nil {
diff --git a/services/tickets/zendesk/web_test.go b/services/tickets/zendesk/web_test.go
index 47da4f24c..c00c0a17c 100644
--- a/services/tickets/zendesk/web_test.go
+++ b/services/tickets/zendesk/web_test.go
@@ -3,38 +3,34 @@ package zendesk
import (
"testing"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
"github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
)
func TestChannelback(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Reset()
// create a zendesk ticket for Cathy
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234")
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my cookies?", "1234", nil)
- web.RunWebTests(t, "testdata/channelback.json")
+ web.RunWebTests(t, "testdata/channelback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)})
}
func TestEventCallback(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Reset()
// create a zendesk ticket for Cathy
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234")
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my cookies?", "1234", nil)
- web.RunWebTests(t, "testdata/event_callback.json")
+ web.RunWebTests(t, "testdata/event_callback.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)})
}
func TestTarget(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
+ _, _, db, _ := testsuite.Reset()
// create a zendesk ticket for Cathy
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, "c69f103c-db64-4481-815b-1112890419ef", "Need help", "Have you seen my cookies?", "1234")
+ ticket := testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Need help", "Have you seen my cookies?", "1234", nil)
- web.RunWebTests(t, "testdata/target.json")
+ web.RunWebTests(t, "testdata/target.json", map[string]string{"cathy_ticket_uuid": string(ticket.UUID)})
}
diff --git a/testsuite/assert.go b/testsuite/assert.go
new file mode 100644
index 000000000..ae7e89721
--- /dev/null
+++ b/testsuite/assert.go
@@ -0,0 +1,103 @@
+package testsuite
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/gomodule/redigo/redis"
+ "github.com/jmoiron/sqlx"
+ "github.com/nyaruka/gocommon/jsonx"
+ "github.com/nyaruka/goflow/test"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// AssertCourierQueues asserts the sizes of message batches in the named courier queues
+func AssertCourierQueues(t *testing.T, expected map[string][]int, errMsg ...interface{}) {
+ rc := RC()
+ defer rc.Close()
+
+ queueKeys, err := redis.Strings(rc.Do("KEYS", "msgs:????????-*"))
+ require.NoError(t, err)
+
+ actual := make(map[string][]int, len(queueKeys))
+ for _, queueKey := range queueKeys {
+ size, err := redis.Int64(rc.Do("ZCARD", queueKey))
+ require.NoError(t, err)
+ actual[queueKey] = make([]int, size)
+
+ if size > 0 {
+ results, err := redis.Values(rc.Do("ZPOPMAX", queueKey, size))
+ require.NoError(t, err)
+ require.Equal(t, int(size*2), len(results)) // result is (item, score, item, score, ...)
+
+ // unmarshal each item in the queue as a batch of messages
+ for i := 0; i < int(size); i++ {
+ batchJSON := results[i*2].([]byte)
+ var batch []map[string]interface{}
+ err = json.Unmarshal(batchJSON, &batch)
+ require.NoError(t, err)
+
+ actual[queueKey][i] = len(batch)
+ }
+ }
+ }
+
+ assert.Equal(t, expected, actual, errMsg...)
+}
+
+// AssertContactTasks asserts that the given contact has the given tasks queued for them
+func AssertContactTasks(t *testing.T, orgID models.OrgID, contactID models.ContactID, expected []string, msgAndArgs ...interface{}) {
+ rc := RC()
+ defer rc.Close()
+
+ tasks, err := redis.Strings(rc.Do("LRANGE", fmt.Sprintf("c:%d:%d", orgID, contactID), 0, -1))
+ require.NoError(t, err)
+
+ expectedJSON := jsonx.MustMarshal(expected)
+ actualJSON := jsonx.MustMarshal(tasks)
+
+ test.AssertEqualJSON(t, expectedJSON, actualJSON, "")
+}
+
+// AssertQuery creates a new query on which one can assert things
+func AssertQuery(t *testing.T, db *sqlx.DB, sql string, args ...interface{}) *Query {
+ return &Query{t, db, sql, args}
+}
+
+type Query struct {
+ t *testing.T
+ db *sqlx.DB
+ sql string
+ args []interface{}
+}
+
+func (q *Query) Returns(expected interface{}, msgAndArgs ...interface{}) {
+ q.t.Helper()
+
+ // get a variable of same type to hold actual result
+ actual := expected
+
+ err := q.db.Get(&actual, q.sql, q.args...)
+ assert.NoError(q.t, err, msgAndArgs...)
+
+ // not sure why but if you pass an int you get back an int64..
+ switch expected.(type) {
+ case int:
+ actual = int(actual.(int64))
+ }
+
+ assert.Equal(q.t, expected, actual, msgAndArgs...)
+}
+
+func (q *Query) Columns(expected map[string]interface{}, msgAndArgs ...interface{}) {
+ q.t.Helper()
+
+ actual := make(map[string]interface{}, len(expected))
+
+ err := q.db.QueryRowx(q.sql, q.args...).MapScan(actual)
+ assert.NoError(q.t, err, msgAndArgs...)
+ assert.Equal(q.t, expected, actual, msgAndArgs...)
+}
diff --git a/testsuite/testdata/channels.go b/testsuite/testdata/channels.go
index bc41db26b..0abf0b191 100644
--- a/testsuite/testdata/channels.go
+++ b/testsuite/testdata/channels.go
@@ -1,24 +1,27 @@
package testdata
import (
- "testing"
-
"github.com/nyaruka/gocommon/uuids"
+ "github.com/nyaruka/goflow/assets"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/null"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
- "github.com/stretchr/testify/require"
)
+type Channel struct {
+ ID models.ChannelID
+ UUID assets.ChannelUUID
+}
+
// InsertChannel inserts a channel
-func InsertChannel(t *testing.T, db *sqlx.DB, orgID models.OrgID, channelType, name string, schemes []string, role string, config map[string]interface{}) models.ChannelID {
+func InsertChannel(db *sqlx.DB, org *Org, channelType, name string, schemes []string, role string, config map[string]interface{}) *Channel {
+ uuid := assets.ChannelUUID(uuids.New())
var id models.ChannelID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO channels_channel(uuid, org_id, channel_type, name, schemes, role, config, last_seen, is_active, created_on, modified_on, created_by_id, modified_by_id)
- VALUES($1, $2, $3, $4, $5, $6, $7, NOW(), TRUE, NOW(), NOW(), 1, 1) RETURNING id`, uuids.New(), orgID, channelType, name, pq.Array(schemes), role, null.NewMap(config),
- )
- require.NoError(t, err)
- return id
+ VALUES($1, $2, $3, $4, $5, $6, $7, NOW(), TRUE, NOW(), NOW(), 1, 1) RETURNING id`, uuid, org.ID, channelType, name, pq.Array(schemes), role, null.NewMap(config),
+ ))
+ return &Channel{id, uuid}
}
diff --git a/testsuite/testdata/constants.go b/testsuite/testdata/constants.go
new file mode 100644
index 000000000..a8cce2cc6
--- /dev/null
+++ b/testsuite/testdata/constants.go
@@ -0,0 +1,104 @@
+package testdata
+
+import (
+ "github.com/nyaruka/gocommon/uuids"
+ "github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/mailroom/core/models"
+)
+
+// Constants used in tests, these are tied to the DB created by the RapidPro `mailroom_db` management command.
+
+type Org struct {
+ ID models.OrgID
+ UUID uuids.UUID
+}
+
+type User struct {
+ ID models.UserID
+ Email string
+}
+
+type Classifier struct {
+ ID models.ClassifierID
+ UUID assets.ClassifierUUID
+}
+
+type Campaign struct {
+ ID models.CampaignID
+ UUID models.CampaignUUID
+}
+
+type CampaignEvent struct {
+ ID models.CampaignEventID
+}
+
+var Org1 = &Org{1, "bf0514a5-9407-44c9-b0f9-3f36f9c18414"}
+var Admin = &User{3, "admin1@nyaruka.com"}
+var Editor = &User{4, "editor1@nyaruka.com"}
+var Viewer = &User{5, "viewer1@nyaruka.com"}
+var Agent = &User{6, "agent1@nyaruka.com"}
+var Surveyor = &User{7, "surveyor1@nyaruka.com"}
+
+var TwilioChannel = &Channel{10000, "74729f45-7f29-4868-9dc4-90e491e3c7d8"}
+var VonageChannel = &Channel{10001, "19012bfd-3ce3-4cae-9bb9-76cf92c73d49"}
+var TwitterChannel = &Channel{10002, "0f661e8b-ea9d-4bd3-9953-d368340acf91"}
+
+var Cathy = &Contact{10000, "6393abc0-283d-4c9b-a1b3-641a035c34bf", "tel:+16055741111", 10000}
+var Bob = &Contact{10001, "b699a406-7e44-49be-9f01-1a82893e8a10", "tel:+16055742222", 10001}
+var George = &Contact{10002, "8d024bcd-f473-4719-a00a-bd0bb1190135", "tel:+16055743333", 10002}
+var Alexandria = &Contact{10003, "9709c157-4606-4d41-9df3-9e9c9b4ae2d4", "tel:+16055744444", 10003}
+
+var Favorites = &Flow{10000, "9de3663f-c5c5-4c92-9f45-ecbc09abcc85"}
+var PickANumber = &Flow{10001, "5890fe3a-f204-4661-b74d-025be4ee019c"}
+var SingleMessage = &Flow{10004, "a7c11d68-f008-496f-b56d-2d5cf4cf16a5"}
+var IVRFlow = &Flow{10003, "2f81d0ea-4d75-4843-9371-3f7465311cce"}
+var SurveyorFlow = &Flow{10005, "ed8cf8d4-a42c-4ce1-a7e3-44a2918e3cec"}
+var IncomingExtraFlow = &Flow{10006, "376d3de6-7f0e-408c-80d6-b1919738bc80"}
+var ParentTimeoutFlow = &Flow{10007, "81c0f323-7e06-4e0c-a960-19c20f17117c"}
+var CampaignFlow = &Flow{10009, "3a92a964-3a8d-420b-9206-2cd9d884ac30"}
+
+var CreatedOnField = &Field{3, "fd18a69d-7514-4b76-9fad-072641995e17"}
+var LastSeenOnField = &Field{5, "660ebe03-b717-4a80-aebf-9b7c718266e1"}
+var GenderField = &Field{6, "3a5891e4-756e-4dc9-8e12-b7a766168824"}
+var AgeField = &Field{7, "903f51da-2717-47c7-a0d3-f2f32877013d"}
+var JoinedField = &Field{8, "d83aae24-4bbf-49d0-ab85-6bfd201eac6d"}
+
+var AllContactsGroup = &Group{1, "d1ee73f0-bdb5-47ce-99dd-0c95d4ebf008"}
+var BlockedContactsGroup = &Group{2, "9295ebab-5c2d-4eb1-86f9-7c15ed2f3219"}
+var DoctorsGroup = &Group{10000, "c153e265-f7c9-4539-9dbc-9b358714b638"}
+var TestersGroup = &Group{10001, "5e9d8fab-5e7e-4f51-b533-261af5dea70d"}
+
+var ReportingLabel = &Label{10000, "ebc4dedc-91c4-4ed4-9dd6-daa05ea82698"}
+var TestingLabel = &Label{10001, "a6338cdc-7938-4437-8b05-2d5d785e3a08"}
+
+var Mailgun = &Ticketer{1, "f9c9447f-a291-4f3c-8c79-c089bbd4e713"}
+var Zendesk = &Ticketer{2, "4ee6d4f3-f92b-439b-9718-8da90c05490b"}
+var RocketChat = &Ticketer{3, "6c50665f-b4ff-4e37-9625-bc464fe6a999"}
+var Internal = &Ticketer{4, "8bd48029-6ca1-46a8-aa14-68f7213b82b3"}
+
+var Luis = &Classifier{1, "097e026c-ae79-4740-af67-656dbedf0263"}
+var Wit = &Classifier{2, "ff2a817c-040a-4eb2-8404-7d92e8b79dd0"}
+var Bothub = &Classifier{3, "859b436d-3005-4e43-9ad5-3de5f26ede4c"}
+
+var RemindersCampaign = &Campaign{10000, "72aa12c5-cc11-4bc7-9406-044047845c70"}
+var RemindersEvent1 = &CampaignEvent{10000}
+var RemindersEvent2 = &CampaignEvent{10001}
+
+// secondary org.. only a few things
+var Org2 = &Org{2, "3ae7cdeb-fd96-46e5-abc4-a4622f349921"}
+var Org2Admin = &User{8, "admin2@nyaruka.com"}
+var Org2Channel = &Channel{20000, "a89bc872-3763-4b95-91d9-31d4e56c6651"}
+var Org2Contact = &Contact{20000, "26d20b72-f7d8-44dc-87f2-aae046dbff95", "tel:+250700000005", 20000}
+var Org2Favorites = &Flow{20000, "f161bd16-3c60-40bd-8c92-228ce815b9cd"}
+var Org2SingleMessage = &Flow{20001, "5277916d-6011-41ac-a4a4-f6ac6a4f1dd9"}
+
+func must(err error, checks ...bool) {
+ if err != nil {
+ panic(err)
+ }
+ for _, check := range checks {
+ if !check {
+ panic("check failed")
+ }
+ }
+}
diff --git a/testsuite/testdata/contacts.go b/testsuite/testdata/contacts.go
index 359f41dd9..558509873 100644
--- a/testsuite/testdata/contacts.go
+++ b/testsuite/testdata/contacts.go
@@ -1,7 +1,7 @@
package testdata
import (
- "testing"
+ "context"
"github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/goflow/assets"
@@ -11,46 +11,81 @@ import (
"github.com/nyaruka/null"
"github.com/jmoiron/sqlx"
- "github.com/stretchr/testify/require"
)
+type Contact struct {
+ ID models.ContactID
+ UUID flows.ContactUUID
+ URN urns.URN
+ URNID models.URNID
+}
+
+func (c *Contact) Load(db *sqlx.DB, oa *models.OrgAssets) (*models.Contact, *flows.Contact) {
+ contacts, err := models.LoadContacts(context.Background(), db, oa, []models.ContactID{c.ID})
+ must(err, len(contacts) == 1)
+
+ flowContact, err := contacts[0].FlowContact(oa)
+ must(err)
+
+ return contacts[0], flowContact
+}
+
+type Group struct {
+ ID models.GroupID
+ UUID assets.GroupUUID
+}
+
+func (g *Group) Add(db *sqlx.DB, contacts ...*Contact) {
+ for _, c := range contacts {
+ db.MustExec(`INSERT INTO contacts_contactgroup_contacts(contactgroup_id, contact_id) VALUES($1, $2)`, g.ID, c.ID)
+ }
+}
+
+type Field struct {
+ ID models.FieldID
+ UUID assets.FieldUUID
+}
+
// InsertContact inserts a contact
-func InsertContact(t *testing.T, db *sqlx.DB, orgID models.OrgID, uuid flows.ContactUUID, name string, language envs.Language) models.ContactID {
+func InsertContact(db *sqlx.DB, org *Org, uuid flows.ContactUUID, name string, language envs.Language) *Contact {
var id models.ContactID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO contacts_contact (org_id, is_active, status, uuid, name, language, created_on, modified_on, created_by_id, modified_by_id)
- VALUES($1, TRUE, 'A', $2, $3, $4, NOW(), NOW(), 1, 1) RETURNING id`, orgID, uuid, name, language,
- )
- require.NoError(t, err)
- return id
+ VALUES($1, TRUE, 'A', $2, $3, $4, NOW(), NOW(), 1, 1) RETURNING id`, org.ID, uuid, name, language,
+ ))
+ return &Contact{id, uuid, "", models.NilURNID}
}
// InsertContactGroup inserts a contact group
-func InsertContactGroup(t *testing.T, db *sqlx.DB, orgID models.OrgID, uuid assets.GroupUUID, name, query string) models.GroupID {
+func InsertContactGroup(db *sqlx.DB, org *Org, uuid assets.GroupUUID, name, query string) *Group {
var id models.GroupID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO contacts_contactgroup(uuid, org_id, group_type, name, query, status, is_active, created_by_id, created_on, modified_by_id, modified_on)
- VALUES($1, $2, 'U', $3, $4, 'R', TRUE, 1, NOW(), 1, NOW()) RETURNING id`, uuid, models.Org1, name, null.String(query),
- )
- require.NoError(t, err)
- return id
+ VALUES($1, $2, 'U', $3, $4, 'R', TRUE, 1, NOW(), 1, NOW()) RETURNING id`, uuid, org.ID, name, null.String(query),
+ ))
+ return &Group{id, uuid}
}
// InsertContactURN inserts a contact URN
-func InsertContactURN(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, urn urns.URN, priority int) models.URNID {
+func InsertContactURN(db *sqlx.DB, org *Org, contact *Contact, urn urns.URN, priority int) models.URNID {
scheme, path, _, _ := urn.ToParts()
+ contactID := models.NilContactID
+ if contact != nil {
+ contactID = contact.ID
+ }
+
var id models.URNID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO contacts_contacturn(org_id, contact_id, scheme, path, identity, priority)
- VALUES($1, $2, $3, $4, $5, $6) RETURNING id`, orgID, contactID, scheme, path, urn.Identity(), priority,
- )
- require.NoError(t, err)
+ VALUES($1, $2, $3, $4, $5, $6) RETURNING id`, org.ID, contactID, scheme, path, urn.Identity(), priority,
+ ))
return id
}
// DeleteContactsAndURNs deletes all contacts and URNs
-func DeleteContactsAndURNs(t *testing.T, db *sqlx.DB) {
+func DeleteContactsAndURNs(db *sqlx.DB) {
+ db.MustExec(`DELETE FROM msgs_msg`)
db.MustExec(`DELETE FROM contacts_contacturn`)
db.MustExec(`DELETE FROM contacts_contactgroup_contacts`)
db.MustExec(`DELETE FROM contacts_contact`)
diff --git a/testsuite/testdata/flows.go b/testsuite/testdata/flows.go
index 9ae8fdff6..fdccee3db 100644
--- a/testsuite/testdata/flows.go
+++ b/testsuite/testdata/flows.go
@@ -1,54 +1,55 @@
package testdata
import (
- "testing"
"time"
"github.com/nyaruka/gocommon/uuids"
+ "github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/null"
"github.com/jmoiron/sqlx"
- "github.com/stretchr/testify/require"
)
+type Flow struct {
+ ID models.FlowID
+ UUID assets.FlowUUID
+}
+
// InsertFlowStart inserts a flow start
-func InsertFlowStart(t *testing.T, db *sqlx.DB, orgID models.OrgID, flowID models.FlowID, contactIDs []models.ContactID) models.StartID {
+func InsertFlowStart(db *sqlx.DB, org *Org, flow *Flow, contacts []*Contact) models.StartID {
var id models.StartID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO flows_flowstart(uuid, org_id, flow_id, start_type, created_on, modified_on, restart_participants, include_active, contact_count, status, created_by_id)
- VALUES($1, $2, $3, 'M', NOW(), NOW(), TRUE, TRUE, 2, 'P', 1) RETURNING id`, uuids.New(), orgID, flowID,
- )
- require.NoError(t, err)
+ VALUES($1, $2, $3, 'M', NOW(), NOW(), TRUE, TRUE, 2, 'P', 1) RETURNING id`, uuids.New(), org.ID, flow.ID,
+ ))
- for i := range contactIDs {
- db.MustExec(`INSERT INTO flows_flowstart_contacts(flowstart_id, contact_id) VALUES($1, $2)`, id, contactIDs[i])
+ for _, c := range contacts {
+ db.MustExec(`INSERT INTO flows_flowstart_contacts(flowstart_id, contact_id) VALUES($1, $2)`, id, c.ID)
}
return id
}
// InsertFlowSession inserts a flow session
-func InsertFlowSession(t *testing.T, db *sqlx.DB, uuid flows.SessionUUID, orgID models.OrgID, contactID models.ContactID, status models.SessionStatus, timeoutOn *time.Time) models.SessionID {
+func InsertFlowSession(db *sqlx.DB, org *Org, contact *Contact, status models.SessionStatus, timeoutOn *time.Time) models.SessionID {
var id models.SessionID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO flows_flowsession(uuid, org_id, contact_id, status, responded, created_on, timeout_on, session_type)
- VALUES($1, $2, $3, $4, TRUE, NOW(), $5, 'M') RETURNING id`, uuid, orgID, contactID, status, timeoutOn,
- )
- require.NoError(t, err)
+ VALUES($1, $2, $3, $4, TRUE, NOW(), $5, 'M') RETURNING id`, uuids.New(), org.ID, contact.ID, status, timeoutOn,
+ ))
return id
}
// InsertFlowRun inserts a flow run
-func InsertFlowRun(t *testing.T, db *sqlx.DB, uuid flows.RunUUID, orgID models.OrgID, sessionID models.SessionID, contactID models.ContactID, flowID models.FlowID, status models.RunStatus, parent flows.RunUUID, expiresOn *time.Time) models.FlowRunID {
+func InsertFlowRun(db *sqlx.DB, org *Org, sessionID models.SessionID, contact *Contact, flow *Flow, status models.RunStatus, parent flows.RunUUID, expiresOn *time.Time) models.FlowRunID {
isActive := status == models.RunStatusActive || status == models.RunStatusWaiting
var id models.FlowRunID
- err := db.Get(&id,
+ must(db.Get(&id,
`INSERT INTO flows_flowrun(uuid, org_id, session_id, contact_id, flow_id, status, is_active, parent_uuid, responded, created_on, modified_on, expires_on)
- VALUES($1, $2, $3, $4, $5, $6, $7, $8, TRUE, NOW(), NOW(), $9) RETURNING id`, uuid, orgID, null.Int(sessionID), contactID, flowID, status, isActive, null.String(parent), expiresOn,
- )
- require.NoError(t, err)
+ VALUES($1, $2, $3, $4, $5, $6, $7, $8, TRUE, NOW(), NOW(), $9) RETURNING id`, uuids.New(), org.ID, null.Int(sessionID), contact.ID, flow.ID, status, isActive, null.String(parent), expiresOn,
+ ))
return id
}
diff --git a/testsuite/testdata/imports.go b/testsuite/testdata/imports.go
index 366b9663e..dae4bc614 100644
--- a/testsuite/testdata/imports.go
+++ b/testsuite/testdata/imports.go
@@ -2,34 +2,31 @@ package testdata
import (
"encoding/json"
- "testing"
"github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/jsonx"
"github.com/nyaruka/mailroom/core/models"
"github.com/jmoiron/sqlx"
- "github.com/stretchr/testify/require"
)
// InsertContactImport inserts a contact import
-func InsertContactImport(t *testing.T, db *sqlx.DB, orgID models.OrgID) models.ContactImportID {
+func InsertContactImport(db *sqlx.DB, org *Org) models.ContactImportID {
var importID models.ContactImportID
- err := db.Get(&importID, `INSERT INTO contacts_contactimport(org_id, file, original_filename, headers, mappings, num_records, group_id, started_on, created_on, created_by_id, modified_on, modified_by_id, is_active)
- VALUES($1, 'contact_imports/1234.xlsx', 'contacts.xlsx', '{"Name", "URN:Tel"}', '{}', 30, NULL, $2, $2, 1, $2, 1, TRUE) RETURNING id`, models.Org1, dates.Now())
- require.NoError(t, err)
+ must(db.Get(&importID, `INSERT INTO contacts_contactimport(org_id, file, original_filename, headers, mappings, num_records, group_id, started_on, created_on, created_by_id, modified_on, modified_by_id, is_active)
+ VALUES($1, 'contact_imports/1234.xlsx', 'contacts.xlsx', '{"Name", "URN:Tel"}', '{}', 30, NULL, $2, $2, 1, $2, 1, TRUE) RETURNING id`, org.ID, dates.Now(),
+ ))
return importID
}
// InsertContactImportBatch inserts a contact import batch
-func InsertContactImportBatch(t *testing.T, db *sqlx.DB, importID models.ContactImportID, specs json.RawMessage) models.ContactImportBatchID {
+func InsertContactImportBatch(db *sqlx.DB, importID models.ContactImportID, specs json.RawMessage) models.ContactImportBatchID {
var splitSpecs []json.RawMessage
- err := jsonx.Unmarshal(specs, &splitSpecs)
- require.NoError(t, err)
+ must(jsonx.Unmarshal(specs, &splitSpecs))
var batchID models.ContactImportBatchID
- err = db.Get(&batchID, `INSERT INTO contacts_contactimportbatch(contact_import_id, status, specs, record_start, record_end, num_created, num_updated, num_errored, errors, finished_on)
- VALUES($1, 'P', $2, 0, $3, 0, 0, 0, '[]', NULL) RETURNING id`, importID, specs, len(splitSpecs))
- require.NoError(t, err)
+ must(db.Get(&batchID, `INSERT INTO contacts_contactimportbatch(contact_import_id, status, specs, record_start, record_end, num_created, num_updated, num_errored, errors, finished_on)
+ VALUES($1, 'P', $2, 0, $3, 0, 0, 0, '[]', NULL) RETURNING id`, importID, specs, len(splitSpecs),
+ ))
return batchID
}
diff --git a/testsuite/testdata/msgs.go b/testsuite/testdata/msgs.go
index f4fc62964..2614f1a05 100644
--- a/testsuite/testdata/msgs.go
+++ b/testsuite/testdata/msgs.go
@@ -1,27 +1,78 @@
package testdata
import (
- "testing"
+ "database/sql"
+ "time"
- "github.com/nyaruka/gocommon/urns"
+ "github.com/lib/pq"
+ "github.com/lib/pq/hstore"
+ "github.com/nyaruka/gocommon/dates"
"github.com/nyaruka/gocommon/uuids"
+ "github.com/nyaruka/goflow/assets"
+ "github.com/nyaruka/goflow/envs"
"github.com/nyaruka/goflow/flows"
+ "github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
"github.com/jmoiron/sqlx"
- "github.com/stretchr/testify/require"
)
+type Label struct {
+ ID models.LabelID
+ UUID assets.LabelUUID
+}
+
// InsertIncomingMsg inserts an incoming message
-func InsertIncomingMsg(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, urn urns.URN, urnID models.URNID, text string) *flows.MsgIn {
+func InsertIncomingMsg(db *sqlx.DB, org *Org, channel *Channel, contact *Contact, text string, status models.MsgStatus) *flows.MsgIn {
msgUUID := flows.MsgUUID(uuids.New())
var id flows.MsgID
- err := db.Get(&id,
- `INSERT INTO msgs_msg(uuid, text, created_on, direction, status, visibility, msg_count, error_count, next_attempt, contact_id, contact_urn_id, org_id)
- VALUES($1, $2, NOW(), 'I', 'P', 'V', 1, 0, NOW(), $3, $4, $5) RETURNING id`, msgUUID, text, contactID, urnID, orgID)
- require.NoError(t, err)
+ must(db.Get(&id,
+ `INSERT INTO msgs_msg(uuid, text, created_on, direction, status, visibility, msg_count, error_count, next_attempt, contact_id, contact_urn_id, org_id, channel_id)
+ VALUES($1, $2, NOW(), 'I', $3, 'V', 1, 0, NOW(), $4, $5, $6, $7) RETURNING id`, msgUUID, text, status, contact.ID, contact.URNID, org.ID, channel.ID,
+ ))
+
+ msg := flows.NewMsgIn(msgUUID, contact.URN, assets.NewChannelReference(channel.UUID, ""), text, nil)
+ msg.SetID(id)
+ return msg
+}
+
+// InsertOutgoingMsg inserts an outgoing message
+func InsertOutgoingMsg(db *sqlx.DB, org *Org, channel *Channel, contact *Contact, text string, attachments []utils.Attachment, status models.MsgStatus) *flows.MsgOut {
+ msg := flows.NewMsgOut(contact.URN, assets.NewChannelReference(channel.UUID, ""), text, attachments, nil, nil, flows.NilMsgTopic)
- msg := flows.NewMsgIn(msgUUID, urn, nil, text, nil)
+ var sentOn *time.Time
+ if status == models.MsgStatusWired || status == models.MsgStatusSent || status == models.MsgStatusDelivered {
+ t := dates.Now()
+ sentOn = &t
+ }
+
+ var id flows.MsgID
+ must(db.Get(&id,
+ `INSERT INTO msgs_msg(uuid, text, attachments, created_on, direction, status, visibility, msg_count, error_count, next_attempt, contact_id, contact_urn_id, org_id, channel_id, sent_on)
+ VALUES($1, $2, $3, NOW(), 'O', $4, 'V', 1, 0, NOW(), $5, $6, $7, $8, $9) RETURNING id`, msg.UUID(), text, pq.Array(attachments), status, contact.ID, contact.URNID, org.ID, channel.ID, sentOn,
+ ))
msg.SetID(id)
return msg
}
+
+func InsertBroadcast(db *sqlx.DB, org *Org, baseLanguage envs.Language, text map[envs.Language]string, schedID models.ScheduleID, contacts []*Contact, groups []*Group) models.BroadcastID {
+ textMap := make(map[string]sql.NullString, len(text))
+ for lang, t := range text {
+ textMap[string(lang)] = sql.NullString{String: t, Valid: true}
+ }
+
+ var id models.BroadcastID
+ must(db.Get(&id,
+ `INSERT INTO msgs_broadcast(org_id, base_language, text, schedule_id, status, send_all, created_on, modified_on, created_by_id, modified_by_id)
+ VALUES($1, $2, $3, $4, 'P', TRUE, NOW(), NOW(), 1, 1) RETURNING id`, org.ID, baseLanguage, hstore.Hstore{Map: textMap}, schedID,
+ ))
+
+ for _, contact := range contacts {
+ db.MustExec(`INSERT INTO msgs_broadcast_contacts(broadcast_id, contact_id) VALUES($1, $2)`, id, contact.ID)
+ }
+ for _, group := range groups {
+ db.MustExec(`INSERT INTO msgs_broadcast_groups(broadcast_id, contactgroup_id) VALUES($1, $2)`, id, group.ID)
+ }
+
+ return id
+}
diff --git a/testsuite/testdata/tickets.go b/testsuite/testdata/tickets.go
index 477357aad..c6dba78c4 100644
--- a/testsuite/testdata/tickets.go
+++ b/testsuite/testdata/tickets.go
@@ -1,33 +1,60 @@
package testdata
import (
- "testing"
+ "context"
+ "time"
+ "github.com/nyaruka/gocommon/dates"
+ "github.com/nyaruka/gocommon/uuids"
+ "github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom/core/models"
"github.com/jmoiron/sqlx"
- "github.com/stretchr/testify/require"
)
+type Ticket struct {
+ ID models.TicketID
+ UUID flows.TicketUUID
+}
+
+func (k *Ticket) Load(db *sqlx.DB) *models.Ticket {
+ tickets, err := models.LoadTickets(context.Background(), db, []models.TicketID{k.ID})
+ must(err, len(tickets) == 1)
+ return tickets[0]
+}
+
+type Ticketer struct {
+ ID models.TicketerID
+ UUID assets.TicketerUUID
+}
+
// InsertOpenTicket inserts an open ticket
-func InsertOpenTicket(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, ticketerID models.TicketerID, uuid flows.TicketUUID, subject, body, externalID string) models.TicketID {
- var id models.TicketID
- err := db.Get(&id,
- `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, subject, body, external_id, opened_on, modified_on)
- VALUES($1, $2, $3, $4, 'O', $5, $6, $7, NOW(), NOW()) RETURNING id`, uuid, orgID, contactID, ticketerID, subject, body, externalID,
- )
- require.NoError(t, err)
- return id
+func InsertOpenTicket(db *sqlx.DB, org *Org, contact *Contact, ticketer *Ticketer, subject, body, externalID string, assignee *User) *Ticket {
+ return insertTicket(db, org, contact, ticketer, models.TicketStatusOpen, subject, body, externalID, assignee)
}
// InsertClosedTicket inserts a closed ticket
-func InsertClosedTicket(t *testing.T, db *sqlx.DB, orgID models.OrgID, contactID models.ContactID, ticketerID models.TicketerID, uuid flows.TicketUUID, subject, body, externalID string) models.TicketID {
+func InsertClosedTicket(db *sqlx.DB, org *Org, contact *Contact, ticketer *Ticketer, subject, body, externalID string, assignee *User) *Ticket {
+ return insertTicket(db, org, contact, ticketer, models.TicketStatusClosed, subject, body, externalID, assignee)
+}
+
+func insertTicket(db *sqlx.DB, org *Org, contact *Contact, ticketer *Ticketer, status models.TicketStatus, subject, body, externalID string, assignee *User) *Ticket {
+ uuid := flows.TicketUUID(uuids.New())
+ var closedOn *time.Time
+ if status == models.TicketStatusClosed {
+ t := dates.Now()
+ closedOn = &t
+ }
+ assigneeID := models.NilUserID
+ if assignee != nil {
+ assigneeID = assignee.ID
+ }
+
var id models.TicketID
- err := db.Get(&id,
- `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, subject, body, external_id, opened_on, modified_on, closed_on)
- VALUES($1, $2, $3, $4, 'C', $5, $6, $7, NOW(), NOW(), NOW()) RETURNING id`, uuid, orgID, contactID, ticketerID, subject, body, externalID,
- )
- require.NoError(t, err)
- return id
+ must(db.Get(&id,
+ `INSERT INTO tickets_ticket(uuid, org_id, contact_id, ticketer_id, status, subject, body, external_id, opened_on, modified_on, closed_on, last_activity_on, assignee_id)
+ VALUES($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW(), $9, NOW(), $10) RETURNING id`, uuid, org.ID, contact.ID, ticketer.ID, status, subject, body, externalID, closedOn, assigneeID,
+ ))
+ return &Ticket{id, uuid}
}
diff --git a/testsuite/testdata/triggers.go b/testsuite/testdata/triggers.go
new file mode 100644
index 000000000..a83b612d3
--- /dev/null
+++ b/testsuite/testdata/triggers.go
@@ -0,0 +1,68 @@
+package testdata
+
+import (
+ "github.com/nyaruka/mailroom/core/models"
+
+ "github.com/jmoiron/sqlx"
+)
+
+func InsertKeywordTrigger(db *sqlx.DB, org *Org, flow *Flow, keyword string, matchType models.MatchType, includeGroups []*Group, excludeGroups []*Group) models.TriggerID {
+ return insertTrigger(db, org, models.KeywordTriggerType, flow, keyword, matchType, includeGroups, excludeGroups, nil, "", nil)
+}
+
+func InsertIncomingCallTrigger(db *sqlx.DB, org *Org, flow *Flow, includeGroups, excludeGroups []*Group) models.TriggerID {
+ return insertTrigger(db, org, models.IncomingCallTriggerType, flow, "", "", includeGroups, excludeGroups, nil, "", nil)
+}
+
+func InsertMissedCallTrigger(db *sqlx.DB, org *Org, flow *Flow) models.TriggerID {
+ return insertTrigger(db, org, models.MissedCallTriggerType, flow, "", "", nil, nil, nil, "", nil)
+}
+
+func InsertNewConversationTrigger(db *sqlx.DB, org *Org, flow *Flow, channel *Channel) models.TriggerID {
+ return insertTrigger(db, org, models.NewConversationTriggerType, flow, "", "", nil, nil, nil, "", channel)
+}
+
+func InsertReferralTrigger(db *sqlx.DB, org *Org, flow *Flow, referrerID string, channel *Channel) models.TriggerID {
+ return insertTrigger(db, org, models.ReferralTriggerType, flow, "", "", nil, nil, nil, referrerID, channel)
+}
+
+func InsertCatchallTrigger(db *sqlx.DB, org *Org, flow *Flow, includeGroups, excludeGroups []*Group) models.TriggerID {
+ return insertTrigger(db, org, models.CatchallTriggerType, flow, "", "", includeGroups, excludeGroups, nil, "", nil)
+}
+
+func InsertScheduledTrigger(db *sqlx.DB, org *Org, flow *Flow, includeGroups, excludeGroups []*Group, includeContacts []*Contact) models.TriggerID {
+ return insertTrigger(db, org, models.ScheduleTriggerType, flow, "", "", includeGroups, excludeGroups, includeContacts, "", nil)
+}
+
+func InsertTicketClosedTrigger(db *sqlx.DB, org *Org, flow *Flow) models.TriggerID {
+ return insertTrigger(db, org, models.TicketClosedTriggerType, flow, "", "", nil, nil, nil, "", nil)
+}
+
+func insertTrigger(db *sqlx.DB, org *Org, triggerType models.TriggerType, flow *Flow, keyword string, matchType models.MatchType, includeGroups, excludeGroups []*Group, contactIDs []*Contact, referrerID string, channel *Channel) models.TriggerID {
+ channelID := models.NilChannelID
+ if channel != nil {
+ channelID = channel.ID
+ }
+
+ var id models.TriggerID
+ must(db.Get(&id,
+ `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, referrer_id, is_archived,
+ flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id, channel_id)
+ VALUES(TRUE, now(), now(), $1, $5, false, $2, $3, $4, 1, 1, $7, $6) RETURNING id`, keyword, flow.ID, triggerType, matchType, referrerID, channelID, org.ID,
+ ))
+
+ // insert group associations
+ for _, g := range includeGroups {
+ db.MustExec(`INSERT INTO triggers_trigger_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, id, g.ID)
+ }
+ for _, g := range excludeGroups {
+ db.MustExec(`INSERT INTO triggers_trigger_exclude_groups(trigger_id, contactgroup_id) VALUES($1, $2)`, id, g.ID)
+ }
+
+ // insert contact associations
+ for _, c := range contactIDs {
+ db.MustExec(`INSERT INTO triggers_trigger_contacts(trigger_id, contact_id) VALUES($1, $2)`, id, c.ID)
+ }
+
+ return id
+}
diff --git a/testsuite/testsuite.go b/testsuite/testsuite.go
index 10d880c12..1b67bccca 100644
--- a/testsuite/testsuite.go
+++ b/testsuite/testsuite.go
@@ -7,25 +7,44 @@ import (
"os/exec"
"path"
"strings"
- "testing"
"github.com/nyaruka/gocommon/storage"
+ "github.com/nyaruka/mailroom/config"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/gomodule/redigo/redis"
"github.com/jmoiron/sqlx"
"github.com/sirupsen/logrus"
- "github.com/stretchr/testify/assert"
)
-const storageDir = "_test_storage"
+const MediaStorageDir = "_test_media_storage"
+const SessionStorageDir = "_test_session_storage"
// Reset clears out both our database and redis DB
-func Reset() (context.Context, *sqlx.DB, *redis.Pool) {
- logrus.SetLevel(logrus.DebugLevel)
+func Reset() (context.Context, *runtime.Runtime, *sqlx.DB, *redis.Pool) {
ResetDB()
ResetRP()
- return CTX(), DB(), RP()
+ models.FlushCache()
+ logrus.SetLevel(logrus.DebugLevel)
+
+ return Get()
+}
+
+// Get returns the various runtime things a test might need
+func Get() (context.Context, *runtime.Runtime, *sqlx.DB, *redis.Pool) {
+ db := DB()
+ rp := RP()
+ rt := &runtime.Runtime{
+ RP: rp,
+ DB: db,
+ ES: nil,
+ MediaStorage: MediaStorage(),
+ SessionStorage: SessionStorage(),
+ Config: config.NewMailroomConfig(),
+ }
+ return context.Background(), rt, db, rp
}
// ResetDB resets our database to our base state from our RapidPro dump
@@ -38,6 +57,7 @@ func Reset() (context.Context, *sqlx.DB, *redis.Pool) {
func ResetDB() {
db := sqlx.MustOpen("postgres", "postgres://mailroom_test:temba@localhost/mailroom_test?sslmode=disable&Timezone=UTC")
defer db.Close()
+
db.MustExec("drop owned by mailroom_test cascade")
dir, _ := os.Getwd()
@@ -52,8 +72,7 @@ func ResetDB() {
// DB returns an open test database pool
func DB() *sqlx.DB {
- db := sqlx.MustOpen("postgres", "postgres://mailroom_test:temba@localhost/mailroom_test?sslmode=disable&Timezone=UTC")
- return db
+ return sqlx.MustOpen("postgres", "postgres://mailroom_test:temba@localhost/mailroom_test?sslmode=disable&Timezone=UTC")
}
// ResetRP resets our redis database
@@ -96,19 +115,23 @@ func RC() redis.Conn {
return conn
}
-// CTX returns our background testing context
-func CTX() context.Context {
- return context.Background()
+// MediaStorage returns our media storage for tests
+func MediaStorage() storage.Storage {
+ return storage.NewFS(MediaStorageDir)
}
-// Storage returns our storage for tests
-func Storage() storage.Storage {
- return storage.NewFS(storageDir)
+// SessionStorage returns our session storage for tests
+func SessionStorage() storage.Storage {
+ return storage.NewFS(SessionStorageDir)
}
// ResetStorage clears our storage for tests
func ResetStorage() {
- if err := os.RemoveAll(storageDir); err != nil {
+ if err := os.RemoveAll(MediaStorageDir); err != nil {
+ panic(err)
+ }
+
+ if err := os.RemoveAll(SessionStorageDir); err != nil {
panic(err)
}
}
@@ -121,13 +144,3 @@ func mustExec(command string, args ...string) {
panic(fmt.Sprintf("error restoring database: %s: %s", err, string(output)))
}
}
-
-// AssertQueryCount can be used to assert that a query returns the expected number of
-func AssertQueryCount(t *testing.T, db *sqlx.DB, sql string, args []interface{}, count int, errMsg ...interface{}) {
- var c int
- err := db.Get(&c, sql, args...)
- if err != nil {
- assert.Fail(t, "error performing query: %s - %s", sql, err)
- }
- assert.Equal(t, count, c, errMsg...)
-}
diff --git a/utils/cron/cron.go b/utils/cron/cron.go
index 9fb81f002..6c9f68c5f 100644
--- a/utils/cron/cron.go
+++ b/utils/cron/cron.go
@@ -28,7 +28,7 @@ func StartCron(quit chan bool, rp *redis.Pool, name string, interval time.Durati
defer log.Info("exiting")
// we run expiration every minute on the minute
- for true {
+ for {
select {
case <-quit:
// we are exiting, return so our goroutine can exit
diff --git a/utils/dbutil/errors.go b/utils/dbutil/errors.go
index fb20bd441..ebb316e48 100644
--- a/utils/dbutil/errors.go
+++ b/utils/dbutil/errors.go
@@ -1,6 +1,12 @@
package dbutil
-import "github.com/lib/pq"
+import (
+ "errors"
+ "fmt"
+
+ "github.com/lib/pq"
+ "github.com/sirupsen/logrus"
+)
// IsUniqueViolation returns true if the given error is a violation of unique constraint
func IsUniqueViolation(err error) bool {
@@ -9,3 +15,41 @@ func IsUniqueViolation(err error) bool {
}
return false
}
+
+// QueryError is an error type for failed SQL queries
+type QueryError struct {
+ cause error
+ message string
+ sql string
+ sqlArgs []interface{}
+}
+
+func (e *QueryError) Error() string {
+ return e.message + ": " + e.cause.Error()
+}
+
+func (e *QueryError) Unwrap() error {
+ return e.cause
+}
+
+func (e *QueryError) Fields() logrus.Fields {
+ return logrus.Fields{
+ "sql": fmt.Sprintf("%.1000s", e.sql),
+ "sql_args": e.sqlArgs,
+ }
+}
+
+func NewQueryErrorf(cause error, sql string, sqlArgs []interface{}, message string, msgArgs ...interface{}) error {
+ return &QueryError{
+ cause: cause,
+ message: fmt.Sprintf(message, msgArgs...),
+ sql: sql,
+ sqlArgs: sqlArgs,
+ }
+}
+
+func AsQueryError(err error) *QueryError {
+ var qerr *QueryError
+ errors.As(err, &qerr)
+ return qerr
+}
diff --git a/utils/dbutil/errors_test.go b/utils/dbutil/errors_test.go
index f06b2ce32..712e13a4c 100644
--- a/utils/dbutil/errors_test.go
+++ b/utils/dbutil/errors_test.go
@@ -5,6 +5,7 @@ import (
"github.com/lib/pq"
"github.com/nyaruka/mailroom/utils/dbutil"
+ "github.com/sirupsen/logrus"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
@@ -16,3 +17,27 @@ func TestIsUniqueViolation(t *testing.T) {
assert.True(t, dbutil.IsUniqueViolation(err))
assert.False(t, dbutil.IsUniqueViolation(errors.New("boom")))
}
+
+func TestQueryError(t *testing.T) {
+ var err error = &pq.Error{Code: pq.ErrorCode("22025"), Message: "unsupported Unicode escape sequence"}
+
+ qerr := dbutil.NewQueryErrorf(err, "SELECT * FROM foo WHERE id = $1", []interface{}{234}, "error selecting foo %d", 234)
+ assert.Error(t, qerr)
+ assert.Equal(t, `error selecting foo 234: pq: unsupported Unicode escape sequence`, qerr.Error())
+
+ // can unwrap to the original error
+ var pqerr *pq.Error
+ assert.True(t, errors.As(qerr, &pqerr))
+ assert.Equal(t, err, pqerr)
+
+ // can unwrap a wrapped error to find the first query error
+ wrapped := errors.Wrap(errors.Wrap(qerr, "error doing this"), "error doing that")
+ unwrapped := dbutil.AsQueryError(wrapped)
+ assert.Equal(t, qerr, unwrapped)
+
+ // nil if error was never a query error
+ wrapped = errors.Wrap(errors.New("error doing this"), "error doing that")
+ assert.Nil(t, dbutil.AsQueryError(wrapped))
+
+ assert.Equal(t, logrus.Fields{"sql": "SELECT * FROM foo WHERE id = $1", "sql_args": []interface{}{234}}, unwrapped.Fields())
+}
diff --git a/utils/dbutil/json_test.go b/utils/dbutil/json_test.go
index 6441eb239..70901a8f4 100644
--- a/utils/dbutil/json_test.go
+++ b/utils/dbutil/json_test.go
@@ -3,18 +3,17 @@ package dbutil_test
import (
"testing"
- "github.com/jmoiron/sqlx"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/utils/dbutil"
+ "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestReadJSONRow(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
type group struct {
UUID string `json:"uuid"`
@@ -29,7 +28,7 @@ func TestReadJSONRow(t *testing.T) {
}
// if query returns valid JSON which can be unmarshaled into our struct, all good
- rows := queryRows(`SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, models.TestersGroupID)
+ rows := queryRows(`SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, testdata.TestersGroup.ID)
g := &group{}
err := dbutil.ReadJSONRow(rows, g)
@@ -38,12 +37,12 @@ func TestReadJSONRow(t *testing.T) {
assert.Equal(t, "Testers", g.Name)
// error if row value is not JSON
- rows = queryRows(`SELECT id FROM contacts_contactgroup g WHERE id = $1`, models.TestersGroupID)
+ rows = queryRows(`SELECT id FROM contacts_contactgroup g WHERE id = $1`, testdata.TestersGroup.ID)
err = dbutil.ReadJSONRow(rows, g)
assert.EqualError(t, err, "error unmarshalling row JSON: json: cannot unmarshal number into Go value of type dbutil_test.group")
// error if rows aren't ready to be scanned - e.g. next hasn't been called
- rows, err = db.QueryxContext(ctx, `SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, models.TestersGroupID)
+ rows, err = db.QueryxContext(ctx, `SELECT ROW_TO_JSON(r) FROM (SELECT g.uuid as uuid, g.name AS name FROM contacts_contactgroup g WHERE id = $1) r`, testdata.TestersGroup.ID)
require.NoError(t, err)
err = dbutil.ReadJSONRow(rows, g)
assert.EqualError(t, err, "error scanning row JSON: sql: Scan called without calling Next")
diff --git a/utils/dbutil/query.go b/utils/dbutil/query.go
index c7b00bbf1..4b2ade841 100644
--- a/utils/dbutil/query.go
+++ b/utils/dbutil/query.go
@@ -29,7 +29,7 @@ func BulkQuery(ctx context.Context, tx Queryer, query string, structs []interfac
rows, err := tx.QueryxContext(ctx, bulkQuery, args...)
if err != nil {
- return errors.Wrapf(err, "error making bulk query: %.100s", bulkQuery)
+ return NewQueryErrorf(err, bulkQuery, args, "error making bulk query")
}
defer rows.Close()
diff --git a/utils/dbutil/query_test.go b/utils/dbutil/query_test.go
index 5c3801335..c28859565 100644
--- a/utils/dbutil/query_test.go
+++ b/utils/dbutil/query_test.go
@@ -24,11 +24,11 @@ func TestBulkSQL(t *testing.T) {
sql := `INSERT INTO contacts_contact (id, name) VALUES(:id, :name)`
// try with zero structs
- query, args, err := dbutil.BulkSQL(db, sql, []interface{}{})
+ _, _, err = dbutil.BulkSQL(db, sql, []interface{}{})
assert.EqualError(t, err, "can't generate bulk sql with zero structs")
// try with one struct
- query, args, err = dbutil.BulkSQL(db, sql, []interface{}{contact{ID: 1, Name: "Bob"}})
+ query, args, err := dbutil.BulkSQL(db, sql, []interface{}{contact{ID: 1, Name: "Bob"}})
assert.NoError(t, err)
assert.Equal(t, `INSERT INTO contacts_contact (id, name) VALUES($1, $2)`, query)
assert.Equal(t, []interface{}{1, "Bob"}, args)
@@ -41,11 +41,11 @@ func TestBulkSQL(t *testing.T) {
}
func TestBulkQuery(t *testing.T) {
- ctx := testsuite.CTX()
- db := testsuite.DB()
+ ctx, _, db, _ := testsuite.Get()
+
defer testsuite.Reset()
- db.MustExec(`CREATE TABLE foo (id serial NOT NULL PRIMARY KEY, name TEXT, age INT)`)
+ db.MustExec(`CREATE TABLE foo (id serial NOT NULL PRIMARY KEY, name VARCHAR(3), age INT)`)
type foo struct {
ID int `db:"id"`
@@ -66,13 +66,19 @@ func TestBulkQuery(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 1, foo1.ID)
assert.Equal(t, 2, foo2.ID)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'Bob' AND age = 64`, nil, 1)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'Jon' AND age = 34`, nil, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'Bob' AND age = 64`).Returns(1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'Jon' AND age = 34`).Returns(1)
// returning ids is optional
foo3 := &foo{Name: "Jim", Age: 54}
err = dbutil.BulkQuery(ctx, db, `INSERT INTO foo (name, age) VALUES(:name, :age)`, []interface{}{foo3})
assert.NoError(t, err)
assert.Equal(t, 0, foo3.ID)
- testsuite.AssertQueryCount(t, db, `SELECT count(*) FROM foo WHERE name = 'Jim' AND age = 54`, nil, 1)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM foo WHERE name = 'Jim' AND age = 54`).Returns(1)
+
+ // try with a struct that is invalid
+ foo4 := &foo{Name: "Jonny", Age: 34}
+ err = dbutil.BulkQuery(ctx, db, `INSERT INTO foo (name, age) VALUES(:name, :age)`, []interface{}{foo4})
+ assert.EqualError(t, err, "error making bulk query: pq: value too long for type character varying(3)")
+ assert.Equal(t, 0, foo4.ID)
}
diff --git a/utils/locker/locker.go b/utils/locker/locker.go
index e2191ee62..36340f9d8 100644
--- a/utils/locker/locker.go
+++ b/utils/locker/locker.go
@@ -9,8 +9,6 @@ import (
"github.com/pkg/errors"
)
-const sleep = time.Second * 1
-
// GrabLock grabs the passed in lock from redis in an atomic operation. It returns the lock value
// if successful. It will retry until the retry period, returning empty string if not acquired
// in that time.
diff --git a/web/contact/contact.go b/web/contact/contact.go
index 9c9c2b397..da97d6524 100644
--- a/web/contact/contact.go
+++ b/web/contact/contact.go
@@ -10,6 +10,7 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
@@ -42,14 +43,14 @@ type createRequest struct {
}
// handles a request to create the given contact
-func handleCreate(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleCreate(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &createRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
// grab our org
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}
@@ -59,13 +60,13 @@ func handleCreate(ctx context.Context, s *web.Server, r *http.Request) (interfac
return err, http.StatusBadRequest, nil
}
- _, contact, err := models.CreateContact(ctx, s.DB, oa, request.UserID, c.Name, c.Language, c.URNs)
+ _, contact, err := models.CreateContact(ctx, rt.DB, oa, request.UserID, c.Name, c.Language, c.URNs)
if err != nil {
return err, http.StatusBadRequest, nil
}
modifiersByContact := map[*flows.Contact][]flows.Modifier{contact: c.Mods}
- _, err = models.ApplyModifiers(ctx, s.DB, s.RP, oa, modifiersByContact)
+ _, err = models.ApplyModifiers(ctx, rt.DB, rt.RP, oa, modifiersByContact)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrap(err, "error modifying new contact")
}
@@ -118,14 +119,14 @@ type modifyResult struct {
}
// handles a request to apply the passed in actions
-func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleModify(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &modifyRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
// grab our org assets
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}
@@ -137,7 +138,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac
}
// load our contacts
- contacts, err := models.LoadContacts(ctx, s.DB, oa, request.ContactIDs)
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, request.ContactIDs)
if err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load contact")
}
@@ -153,7 +154,7 @@ func handleModify(ctx context.Context, s *web.Server, r *http.Request) (interfac
modifiersByContact[flowContact] = mods
}
- eventsByContact, err := models.ApplyModifiers(ctx, s.DB, s.RP, oa, modifiersByContact)
+ eventsByContact, err := models.ApplyModifiers(ctx, rt.DB, rt.RP, oa, modifiersByContact)
if err != nil {
return nil, http.StatusBadRequest, err
}
@@ -185,19 +186,19 @@ type resolveRequest struct {
}
// handles a request to resolve a contact
-func handleResolve(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleResolve(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &resolveRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
// grab our org
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}
- _, contact, created, err := models.GetOrCreateContact(ctx, s.DB, oa, []urns.URN{request.URN}, request.ChannelID)
+ _, contact, created, err := models.GetOrCreateContact(ctx, rt.DB, oa, []urns.URN{request.URN}, request.ChannelID)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error getting or creating contact")
}
diff --git a/web/contact/contact_test.go b/web/contact/contact_test.go
index 857baa93d..e35c40f54 100644
--- a/web/contact/contact_test.go
+++ b/web/contact/contact_test.go
@@ -8,6 +8,7 @@ import (
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
)
@@ -16,11 +17,11 @@ func TestCreateContacts(t *testing.T) {
db := testsuite.DB()
// detach Cathy's tel URN
- db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID)
+ db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Cathy.ID)
db.MustExec(`ALTER SEQUENCE contacts_contact_id_seq RESTART WITH 30000`)
- web.RunWebTests(t, "testdata/create.json")
+ web.RunWebTests(t, "testdata/create.json", nil)
}
func TestModifyContacts(t *testing.T) {
@@ -28,27 +29,27 @@ func TestModifyContacts(t *testing.T) {
db := testsuite.DB()
// to be deterministic, update the creation date on cathy
- db.MustExec(`UPDATE contacts_contact SET created_on = $1 WHERE id = $2`, time.Date(2018, 7, 6, 12, 30, 0, 123456789, time.UTC), models.CathyID)
+ db.MustExec(`UPDATE contacts_contact SET created_on = $1 WHERE id = $2`, time.Date(2018, 7, 6, 12, 30, 0, 123456789, time.UTC), testdata.Cathy.ID)
// make our campaign group dynamic
- db.MustExec(`UPDATE contacts_contactgroup SET query = 'age > 18' WHERE id = $1`, models.DoctorsGroupID)
+ db.MustExec(`UPDATE contacts_contactgroup SET query = 'age > 18' WHERE id = $1`, testdata.DoctorsGroup.ID)
// insert an event on our campaign that is based on created on
db.MustExec(
`INSERT INTO campaigns_campaignevent(is_active, created_on, modified_on, uuid, "offset", unit, event_type, delivery_hour,
campaign_id, created_by_id, modified_by_id, flow_id, relative_to_id, start_mode)
VALUES(TRUE, NOW(), NOW(), $1, 1000, 'W', 'F', -1, $2, 1, 1, $3, $4, 'I')`,
- uuids.New(), models.DoctorRemindersCampaignID, models.FavoritesFlowID, models.CreatedOnFieldID)
+ uuids.New(), testdata.RemindersCampaign.ID, testdata.Favorites.ID, testdata.CreatedOnField.ID)
// for simpler tests we clear out cathy's fields and groups to start
- db.MustExec(`UPDATE contacts_contact SET fields = NULL WHERE id = $1`, models.CathyID)
- db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, models.CathyID)
- db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID)
+ db.MustExec(`UPDATE contacts_contact SET fields = NULL WHERE id = $1`, testdata.Cathy.ID)
+ db.MustExec(`DELETE FROM contacts_contactgroup_contacts WHERE contact_id = $1`, testdata.Cathy.ID)
+ db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Cathy.ID)
// because we made changes to a group above, need to make sure we don't use stale org assets
models.FlushCache()
- web.RunWebTests(t, "testdata/modify.json")
+ web.RunWebTests(t, "testdata/modify.json", nil)
models.FlushCache()
}
@@ -58,9 +59,9 @@ func TestResolveContacts(t *testing.T) {
db := testsuite.DB()
// detach Cathy's tel URN
- db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, models.CathyID)
+ db.MustExec(`UPDATE contacts_contacturn SET contact_id = NULL WHERE contact_id = $1`, testdata.Cathy.ID)
db.MustExec(`ALTER SEQUENCE contacts_contact_id_seq RESTART WITH 30000`)
- web.RunWebTests(t, "testdata/resolve.json")
+ web.RunWebTests(t, "testdata/resolve.json", nil)
}
diff --git a/web/contact/search.go b/web/contact/search.go
index 9be8880ad..33a57fc12 100644
--- a/web/contact/search.go
+++ b/web/contact/search.go
@@ -8,6 +8,7 @@ import (
"github.com/nyaruka/goflow/contactql"
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
@@ -65,7 +66,7 @@ type searchResponse struct {
}
// handles a contact search request
-func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleSearch(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &searchRequest{
Offset: 0,
PageSize: 50,
@@ -76,13 +77,13 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac
}
// grab our org assets
- oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, rt.DB, request.OrgID, models.RefreshFields|models.RefreshGroups)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}
// perform our search
- parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, s.ElasticClient, oa,
+ parsed, hits, total, err := models.ContactIDsForQueryPage(ctx, rt.ES, oa,
request.GroupUUID, request.ExcludeIDs, request.Query, request.Sort, request.Offset, request.PageSize)
if err != nil {
@@ -135,6 +136,7 @@ func handleSearch(ctx context.Context, s *web.Server, r *http.Request) (interfac
type parseRequest struct {
OrgID models.OrgID `json:"org_id" validate:"required"`
Query string `json:"query" validate:"required"`
+ ParseOnly bool `json:"parse_only"`
GroupUUID assets.GroupUUID `json:"group_uuid"`
}
@@ -154,28 +156,28 @@ type parseResponse struct {
Query string `json:"query"`
ElasticQuery interface{} `json:"elastic_query"`
Metadata *contactql.Inspection `json:"metadata,omitempty"`
-
- // deprecated
- Fields []string `json:"fields"`
- AllowAsGroup bool `json:"allow_as_group"`
}
// handles a query parsing request
-func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleParseQuery(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &parseRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
// grab our org assets
- oa, err := models.GetOrgAssetsWithRefresh(s.CTX, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, rt.DB, request.OrgID, models.RefreshFields|models.RefreshGroups)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
}
env := oa.Env()
- parsed, err := contactql.ParseQuery(env, request.Query, oa.SessionAssets())
+ var resolver contactql.Resolver
+ if !request.ParseOnly {
+ resolver = oa.SessionAssets()
+ }
+ parsed, err := contactql.ParseQuery(env, request.Query, resolver)
if err != nil {
isQueryError, qerr := contactql.IsQueryError(err)
if isQueryError {
@@ -185,34 +187,23 @@ func handleParseQuery(ctx context.Context, s *web.Server, r *http.Request) (inte
}
// normalize and inspect the query
- normalized := ""
- var metadata *contactql.Inspection
- allowAsGroup := false
- fields := make([]string, 0)
-
- if parsed != nil {
- normalized = parsed.String()
- metadata = contactql.Inspect(parsed)
- fields = append(fields, metadata.Attributes...)
- for _, f := range metadata.Fields {
- fields = append(fields, f.Key)
+ normalized := parsed.String()
+ metadata := contactql.Inspect(parsed)
+
+ var elasticSource interface{}
+ if !request.ParseOnly {
+ eq := models.BuildElasticQuery(oa, request.GroupUUID, models.NilContactStatus, nil, parsed)
+ elasticSource, err = eq.Source()
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrap(err, "error getting elastic source")
}
- allowAsGroup = metadata.AllowAsGroup
- }
-
- eq := models.BuildElasticQuery(oa, request.GroupUUID, models.NilContactStatus, nil, parsed)
- eqj, err := eq.Source()
- if err != nil {
- return nil, http.StatusInternalServerError, err
}
// build our response
response := &parseResponse{
Query: normalized,
- ElasticQuery: eqj,
+ ElasticQuery: elasticSource,
Metadata: metadata,
- Fields: fields,
- AllowAsGroup: allowAsGroup,
}
return response, http.StatusOK, nil
diff --git a/web/contact/search_test.go b/web/contact/search_test.go
index 180adcfb6..b884e96f4 100644
--- a/web/contact/search_test.go
+++ b/web/contact/search_test.go
@@ -16,6 +16,7 @@ import (
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
"github.com/olivere/elastic/v7"
@@ -23,10 +24,8 @@ import (
)
func TestSearch(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
+ ctx, _, db, rp := testsuite.Reset()
+
wg := &sync.WaitGroup{}
es := testsuite.NewMockElasticServer()
@@ -73,7 +72,7 @@ func TestSearch(t *testing.T) {
}
]
}
- }`, models.CathyID)
+ }`, testdata.Cathy.ID)
tcs := []struct {
URL string
@@ -96,34 +95,34 @@ func TestSearch(t *testing.T) {
{
Method: "POST",
URL: "/mr/contact/search",
- Body: fmt.Sprintf(`{"org_id": 1, "query": "birthday = tomorrow", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
+ Body: fmt.Sprintf(`{"org_id": 1, "query": "birthday = tomorrow", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID),
ExpectedStatus: 400,
ExpectedError: "can't resolve 'birthday' to attribute, scheme or field",
},
{
Method: "POST",
URL: "/mr/contact/search",
- Body: fmt.Sprintf(`{"org_id": 1, "query": "age > tomorrow", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
+ Body: fmt.Sprintf(`{"org_id": 1, "query": "age > tomorrow", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID),
ExpectedStatus: 400,
ExpectedError: "can't convert 'tomorrow' to a number",
},
{
Method: "POST",
URL: "/mr/contact/search",
- Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
+ Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID),
ESResponse: singleESResponse,
ExpectedStatus: 200,
- ExpectedHits: []models.ContactID{models.CathyID},
+ ExpectedHits: []models.ContactID{testdata.Cathy.ID},
ExpectedQuery: `name ~ "Cathy"`,
ExpectedFields: []string{"name"},
},
{
Method: "POST",
URL: "/mr/contact/search",
- Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s", "exclude_ids": [%d, %d]}`, models.AllContactsGroupUUID, models.BobID, models.GeorgeID),
+ Body: fmt.Sprintf(`{"org_id": 1, "query": "Cathy", "group_uuid": "%s", "exclude_ids": [%d, %d]}`, testdata.AllContactsGroup.UUID, testdata.Bob.ID, testdata.George.ID),
ESResponse: singleESResponse,
ExpectedStatus: 200,
- ExpectedHits: []models.ContactID{models.CathyID},
+ ExpectedHits: []models.ContactID{testdata.Cathy.ID},
ExpectedQuery: `name ~ "Cathy"`,
ExpectedFields: []string{"name"},
ExpectedESRequest: `{
@@ -179,20 +178,20 @@ func TestSearch(t *testing.T) {
{
Method: "POST",
URL: "/mr/contact/search",
- Body: fmt.Sprintf(`{"org_id": 1, "query": "AGE = 10 and gender = M", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
+ Body: fmt.Sprintf(`{"org_id": 1, "query": "AGE = 10 and gender = M", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID),
ESResponse: singleESResponse,
ExpectedStatus: 200,
- ExpectedHits: []models.ContactID{models.CathyID},
+ ExpectedHits: []models.ContactID{testdata.Cathy.ID},
ExpectedQuery: `age = 10 AND gender = "M"`,
ExpectedFields: []string{"age", "gender"},
},
{
Method: "POST",
URL: "/mr/contact/search",
- Body: fmt.Sprintf(`{"org_id": 1, "query": "", "group_uuid": "%s"}`, models.AllContactsGroupUUID),
+ Body: fmt.Sprintf(`{"org_id": 1, "query": "", "group_uuid": "%s"}`, testdata.AllContactsGroup.UUID),
ESResponse: singleESResponse,
ExpectedStatus: 200,
- ExpectedHits: []models.ContactID{models.CathyID},
+ ExpectedHits: []models.ContactID{testdata.Cathy.ID},
ExpectedQuery: ``,
ExpectedFields: []string{},
},
@@ -238,8 +237,8 @@ func TestSearch(t *testing.T) {
}
}
-func TestParse(t *testing.T) {
+func TestParseQuery(t *testing.T) {
testsuite.Reset()
- web.RunWebTests(t, "testdata/parse_query.json")
+ web.RunWebTests(t, "testdata/parse_query.json", nil)
}
diff --git a/web/contact/testdata/parse_query.json b/web/contact/testdata/parse_query.json
index 19b5aed1d..a4e0d0b64 100644
--- a/web/contact/testdata/parse_query.json
+++ b/web/contact/testdata/parse_query.json
@@ -43,6 +43,35 @@
}
}
},
+ {
+ "label": "query with invalid property but parse_only = true",
+ "method": "POST",
+ "path": "/mr/contact/parse_query",
+ "body": {
+ "org_id": 1,
+ "query": "birthday = tomorrow AND tel = 12345",
+ "parse_only": true
+ },
+ "status": 200,
+ "response": {
+ "query": "birthday = \"tomorrow\" AND tel = 12345",
+ "elastic_query": null,
+ "metadata": {
+ "attributes": [],
+ "schemes": [
+ "tel"
+ ],
+ "fields": [
+ {
+ "key": "birthday",
+ "name": ""
+ }
+ ],
+ "groups": [],
+ "allow_as_group": true
+ }
+ }
+ },
{
"label": "valid query without group",
"method": "POST",
@@ -107,11 +136,7 @@
],
"groups": [],
"allow_as_group": true
- },
- "fields": [
- "age"
- ],
- "allow_as_group": true
+ }
}
},
{
@@ -184,11 +209,7 @@
],
"groups": [],
"allow_as_group": true
- },
- "fields": [
- "age"
- ],
- "allow_as_group": true
+ }
}
},
{
@@ -236,11 +257,7 @@
}
],
"allow_as_group": false
- },
- "fields": [
- "group"
- ],
- "allow_as_group": false
+ }
}
}
]
\ No newline at end of file
diff --git a/web/contact/testdata/resolve.json b/web/contact/testdata/resolve.json
index 534a75a3b..0299cdb14 100644
--- a/web/contact/testdata/resolve.json
+++ b/web/contact/testdata/resolve.json
@@ -35,7 +35,7 @@
"name": "Bob",
"status": "active",
"timezone": "America/Los_Angeles",
- "created_on": "2021-04-09T14:51:56.517774Z",
+ "created_on": "2020-12-31T16:45:30Z",
"urns": [
"tel:+16055742222?id=10001&priority=1000"
],
diff --git a/web/contact/utils_test.go b/web/contact/utils_test.go
index 8218886e5..736e9b478 100644
--- a/web/contact/utils_test.go
+++ b/web/contact/utils_test.go
@@ -7,6 +7,7 @@ import (
"github.com/nyaruka/goflow/envs"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web/contact"
"github.com/stretchr/testify/assert"
@@ -14,11 +15,9 @@ import (
)
func TestSpecToCreation(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
- ctx := testsuite.CTX()
+ ctx, _, db, _ := testsuite.Get()
- oa, err := models.GetOrgAssets(ctx, db, models.Org1)
+ oa, err := models.GetOrgAssets(ctx, db, testdata.Org1.ID)
require.NoError(t, err)
sa := oa.SessionAssets()
diff --git a/web/docs/docs.go b/web/docs/docs.go
index a6568f30d..f4e00b499 100644
--- a/web/docs/docs.go
+++ b/web/docs/docs.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
)
@@ -13,8 +14,8 @@ func init() {
docServer = http.StripPrefix("/mr/docs", http.FileServer(http.Dir("docs")))
// redirect non slashed docs to slashed version so relative URLs work
- web.RegisterRoute(http.MethodGet, "/mr/docs", func(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error {
- http.Redirect(rawW, r, "/mr/docs/", 301)
+ web.RegisterRoute(http.MethodGet, "/mr/docs", func(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error {
+ http.Redirect(rawW, r, "/mr/docs/", http.StatusMovedPermanently)
return nil
})
@@ -22,7 +23,7 @@ func init() {
web.RegisterRoute(http.MethodGet, "/mr/docs/*", handleDocs)
}
-func handleDocs(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error {
+func handleDocs(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error {
docServer.ServeHTTP(rawW, r)
return nil
}
diff --git a/web/expression/expression.go b/web/expression/expression.go
index de136fb5d..a83ec2d4b 100644
--- a/web/expression/expression.go
+++ b/web/expression/expression.go
@@ -6,6 +6,7 @@ import (
"github.com/nyaruka/goflow/flows/definition/legacy/expressions"
"github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
@@ -29,7 +30,7 @@ type migrateResponse struct {
Migrated string `json:"migrated"`
}
-func handleMigrate(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleMigrate(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &migrateRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
diff --git a/web/expression/expression_test.go b/web/expression/expression_test.go
index 45bacda0d..f376187aa 100644
--- a/web/expression/expression_test.go
+++ b/web/expression/expression_test.go
@@ -9,5 +9,5 @@ import (
func TestServer(t *testing.T) {
testsuite.Reset()
- web.RunWebTests(t, "testdata/migrate.json")
+ web.RunWebTests(t, "testdata/migrate.json", nil)
}
diff --git a/web/flow/flow.go b/web/flow/flow.go
index 73ce5f5bf..ab5fcf390 100644
--- a/web/flow/flow.go
+++ b/web/flow/flow.go
@@ -11,6 +11,7 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/Masterminds/semver"
@@ -36,20 +37,20 @@ type migrateRequest struct {
ToVersion *semver.Version `json:"to_version"`
}
-func handleMigrate(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleMigrate(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &migrateRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
// do a JSON to JSON migration of the definition
- migrated, err := goflow.MigrateDefinition(request.Flow, request.ToVersion)
+ migrated, err := goflow.MigrateDefinition(rt.Config, request.Flow, request.ToVersion)
if err != nil {
return errors.Wrapf(err, "unable to migrate flow"), http.StatusUnprocessableEntity, nil
}
// try to read result to check that it's valid
- _, err = goflow.ReadFlow(migrated)
+ _, err = goflow.ReadFlow(rt.Config, migrated)
if err != nil {
return errors.Wrapf(err, "unable to read migrated flow"), http.StatusUnprocessableEntity, nil
}
@@ -71,13 +72,13 @@ type inspectRequest struct {
OrgID models.OrgID `json:"org_id"`
}
-func handleInspect(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleInspect(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &inspectRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
- flow, err := goflow.ReadFlow(request.Flow)
+ flow, err := goflow.ReadFlow(rt.Config, request.Flow)
if err != nil {
return errors.Wrapf(err, "unable to read flow"), http.StatusUnprocessableEntity, nil
}
@@ -85,7 +86,7 @@ func handleInspect(ctx context.Context, s *web.Server, r *http.Request) (interfa
var sa flows.SessionAssets
// if we have an org ID, create session assets to look for missing dependencies
if request.OrgID != models.NilOrgID {
- oa, err := models.GetOrgAssetsWithRefresh(ctx, s.DB, request.OrgID, models.RefreshFields|models.RefreshGroups|models.RefreshFlows)
+ oa, err := models.GetOrgAssetsWithRefresh(ctx, rt.DB, request.OrgID, models.RefreshFields|models.RefreshGroups|models.RefreshFlows)
if err != nil {
return nil, 0, err
}
@@ -110,7 +111,7 @@ type cloneRequest struct {
Flow json.RawMessage `json:"flow" validate:"required"`
}
-func handleClone(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleClone(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &cloneRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
@@ -123,7 +124,7 @@ func handleClone(ctx context.Context, s *web.Server, r *http.Request) (interface
}
// read flow to check that cloning produced something valid
- _, err = goflow.ReadFlow(cloneJSON)
+ _, err = goflow.ReadFlow(rt.Config, cloneJSON)
if err != nil {
return errors.Wrapf(err, "unable to clone flow"), http.StatusUnprocessableEntity, nil
}
@@ -143,13 +144,13 @@ type changeLanguageRequest struct {
Flow json.RawMessage `json:"flow" validate:"required"`
}
-func handleChangeLanguage(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleChangeLanguage(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &changeLanguageRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
}
- flow, err := goflow.ReadFlow(request.Flow)
+ flow, err := goflow.ReadFlow(rt.Config, request.Flow)
if err != nil {
return errors.Wrapf(err, "unable to read flow"), http.StatusUnprocessableEntity, nil
}
diff --git a/web/flow/flow_test.go b/web/flow/flow_test.go
index 84c20faec..2017392d1 100644
--- a/web/flow/flow_test.go
+++ b/web/flow/flow_test.go
@@ -7,8 +7,8 @@ import (
)
func TestServer(t *testing.T) {
- web.RunWebTests(t, "testdata/change_language.json")
- web.RunWebTests(t, "testdata/clone.json")
- web.RunWebTests(t, "testdata/inspect.json")
- web.RunWebTests(t, "testdata/migrate.json")
+ web.RunWebTests(t, "testdata/change_language.json", nil)
+ web.RunWebTests(t, "testdata/clone.json", nil)
+ web.RunWebTests(t, "testdata/inspect.json", nil)
+ web.RunWebTests(t, "testdata/migrate.json", nil)
}
diff --git a/web/ivr/ivr.go b/web/ivr/ivr.go
index b18a3bb76..c77b256b3 100644
--- a/web/ivr/ivr.go
+++ b/web/ivr/ivr.go
@@ -16,6 +16,7 @@ import (
"github.com/nyaruka/mailroom/core/ivr"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/core/tasks/handler"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/go-chi/chi"
@@ -29,10 +30,10 @@ func init() {
web.RegisterRoute(http.MethodPost, "/mr/ivr/c/{uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}/incoming", newIVRHandler(handleIncomingCall))
}
-type ivrHandlerFn func(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error)
+type ivrHandlerFn func(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error)
func newIVRHandler(handler ivrHandlerFn) web.Handler {
- return func(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) error {
+ return func(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) error {
recorder := httpx.NewRecorder(r, w)
// immediately save our request body so we have a complete channel log
@@ -42,7 +43,7 @@ func newIVRHandler(handler ivrHandlerFn) web.Handler {
}
ww := recorder.ResponseWriter
- channel, connection, rerr := handler(ctx, s, r, ww)
+ channel, connection, rerr := handler(ctx, rt, r, ww)
if channel != nil {
trace, err := recorder.End()
@@ -58,7 +59,7 @@ func newIVRHandler(handler ivrHandlerFn) web.Handler {
}
log := models.NewChannelLog(trace, isError, desc, channel, connection)
- err = models.InsertChannelLogs(ctx, s.DB, []*models.ChannelLog{log})
+ err = models.InsertChannelLogs(ctx, rt.DB, []*models.ChannelLog{log})
if err != nil {
logrus.WithError(err).WithField("http_request", r).Error("error writing ivr channel log")
}
@@ -68,17 +69,17 @@ func newIVRHandler(handler ivrHandlerFn) web.Handler {
}
}
-func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) {
+func handleIncomingCall(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) {
channelUUID := assets.ChannelUUID(chi.URLParam(r, "uuid"))
// load the org id for this UUID (we could load the entire channel here but we want to take the same paths through everything else)
- orgID, err := models.OrgIDForChannelUUID(ctx, s.DB, channelUUID)
+ orgID, err := models.OrgIDForChannelUUID(ctx, rt.DB, channelUUID)
if err != nil {
return nil, nil, writeClientError(w, err)
}
// load our org assets
- oa, err := models.GetOrgAssets(ctx, s.DB, orgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, orgID)
if err != nil {
return nil, nil, writeClientError(w, errors.Wrapf(err, "error loading org assets"))
}
@@ -108,12 +109,12 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h
}
// get the contact for this URN
- contact, _, _, err := models.GetOrCreateContact(ctx, s.DB, oa, []urns.URN{urn}, channel.ID())
+ contact, _, _, err := models.GetOrCreateContact(ctx, rt.DB, oa, []urns.URN{urn}, channel.ID())
if err != nil {
return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "unable to get contact by urn"))
}
- urn, err = models.URNForURN(ctx, s.DB, oa, urn)
+ urn, err = models.URNForURN(ctx, rt.DB, oa, urn)
if err != nil {
return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load urn"))
}
@@ -134,7 +135,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h
// create our connection
conn, err := models.InsertIVRConnection(
- ctx, s.DB, oa.OrgID(), channel.ID(), models.NilStartID, contact.ID(), urnID,
+ ctx, rt.DB, oa.OrgID(), channel.ID(), models.NilStartID, contact.ID(), urnID,
models.ConnectionDirectionIn, models.ConnectionStatusInProgress, externalID,
)
if err != nil {
@@ -142,7 +143,7 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h
}
// try to handle this event
- session, err := handler.HandleChannelEvent(ctx, s.DB, s.RP, models.MOCallEventType, event, conn)
+ session, err := handler.HandleChannelEvent(ctx, rt, models.MOCallEventType, event, conn)
if err != nil {
logrus.WithError(err).WithField("http_request", r).Error("error handling incoming call")
@@ -152,10 +153,10 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h
// we got a session back so we have an active call trigger
if session != nil {
// build our resume URL
- resumeURL := buildResumeURL(channel, conn, urn)
+ resumeURL := buildResumeURL(rt.Config, channel, conn, urn)
// have our client output our session status
- err = client.WriteSessionResponse(ctx, s.RP, channel, conn, session, urn, resumeURL, r, w)
+ err = client.WriteSessionResponse(ctx, rt.RP, channel, conn, session, urn, resumeURL, r, w)
if err != nil {
return channel, conn, errors.Wrapf(err, "error writing ivr response for start")
}
@@ -166,13 +167,13 @@ func handleIncomingCall(ctx context.Context, s *web.Server, r *http.Request, w h
// no session means no trigger, create a missed call event instead
// we first create an incoming call channel event and see if that matches
event = models.NewChannelEvent(models.MOMissEventType, oa.OrgID(), channel.ID(), contact.ID(), urnID, nil, false)
- err = event.Insert(ctx, s.DB)
+ err = event.Insert(ctx, rt.DB)
if err != nil {
return channel, conn, client.WriteErrorResponse(w, errors.Wrapf(err, "error inserting channel event"))
}
// try to handle it, this time looking for a missed call event
- session, err = handler.HandleChannelEvent(ctx, s.DB, s.RP, models.MOMissEventType, event, nil)
+ session, err = handler.HandleChannelEvent(ctx, rt, models.MOMissEventType, event, nil)
if err != nil {
logrus.WithError(err).WithField("http_request", r).Error("error handling missed call")
return channel, conn, client.WriteErrorResponse(w, errors.Wrapf(err, "error handling missed call"))
@@ -210,8 +211,8 @@ func writeClientError(w http.ResponseWriter, err error) error {
return errors.Wrapf(err, "error writing error")
}
-func buildResumeURL(channel *models.Channel, conn *models.ChannelConnection, urn urns.URN) string {
- domain := channel.ConfigValue(models.ChannelConfigCallbackDomain, config.Mailroom.Domain)
+func buildResumeURL(cfg *config.Config, channel *models.Channel, conn *models.ChannelConnection, urn urns.URN) string {
+ domain := channel.ConfigValue(models.ChannelConfigCallbackDomain, cfg.Domain)
form := url.Values{
"action": []string{actionResume},
"connection": []string{fmt.Sprintf("%d", conn.ID())},
@@ -222,7 +223,7 @@ func buildResumeURL(channel *models.Channel, conn *models.ChannelConnection, urn
}
// handleFlow handles all incoming IVR requests related to a flow (status is handled elsewhere)
-func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) {
+func handleFlow(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*55)
defer cancel()
@@ -232,13 +233,13 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp
}
// load our connection
- conn, err := models.SelectChannelConnection(ctx, s.DB, request.ConnectionID)
+ conn, err := models.SelectChannelConnection(ctx, rt.DB, request.ConnectionID)
if err != nil {
return nil, nil, errors.Wrapf(err, "unable to load channel connection with id: %d", request.ConnectionID)
}
// load our org assets
- oa, err := models.GetOrgAssets(ctx, s.DB, conn.OrgID())
+ oa, err := models.GetOrgAssets(ctx, rt.DB, conn.OrgID())
if err != nil {
return nil, nil, writeClientError(w, errors.Wrapf(err, "error loading org assets"))
}
@@ -262,7 +263,7 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp
}
// load our contact
- contacts, err := models.LoadContacts(ctx, s.DB, oa, []models.ContactID{conn.ContactID()})
+ contacts, err := models.LoadContacts(ctx, rt.DB, oa, []models.ContactID{conn.ContactID()})
if err != nil {
return channel, conn, client.WriteErrorResponse(w, errors.Wrapf(err, "no such contact"))
}
@@ -274,7 +275,7 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp
}
// load the URN for this connection
- urn, err := models.URNForID(ctx, s.DB, oa, conn.ContactURNID())
+ urn, err := models.URNForID(ctx, rt.DB, oa, conn.ContactURNID())
if err != nil {
return channel, conn, client.WriteErrorResponse(w, errors.Errorf("unable to find connection urn: %d", conn.ContactURNID()))
}
@@ -290,27 +291,27 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp
return channel, conn, client.WriteErrorResponse(w, errors.Errorf("unable to find URN: %s on contact: %d", urn, conn.ContactID()))
}
- resumeURL := buildResumeURL(channel, conn, urn)
+ resumeURL := buildResumeURL(rt.Config, channel, conn, urn)
// if this a start, start our contact
switch request.Action {
case actionStart:
err = ivr.StartIVRFlow(
- ctx, s.DB, s.RP, client, resumeURL,
+ ctx, rt, client, resumeURL,
oa, channel, conn, contacts[0], urn, conn.StartID(),
r, w,
)
case actionResume:
err = ivr.ResumeIVRFlow(
- ctx, s.Config, s.DB, s.RP, s.Storage, resumeURL, client,
+ ctx, rt, resumeURL, client,
oa, channel, conn, contacts[0], urn,
r, w,
)
case actionStatus:
err = ivr.HandleIVRStatus(
- ctx, s.DB, s.RP, oa, client, conn,
+ ctx, rt, oa, client, conn,
r, w,
)
@@ -321,27 +322,27 @@ func handleFlow(ctx context.Context, s *web.Server, r *http.Request, w http.Resp
// had an error? mark our connection as errored and log it
if err != nil {
logrus.WithError(err).WithField("http_request", r).Error("error while handling IVR")
- return channel, conn, ivr.WriteErrorResponse(ctx, s.DB, client, conn, w, err)
+ return channel, conn, ivr.WriteErrorResponse(ctx, rt.DB, client, conn, w, err)
}
return channel, conn, nil
}
// handleStatus handles all incoming IVR events / status updates
-func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) {
+func handleStatus(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) (*models.Channel, *models.ChannelConnection, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*55)
defer cancel()
channelUUID := assets.ChannelUUID(chi.URLParam(r, "uuid"))
// load the org id for this UUID (we could load the entire channel here but we want to take the same paths through everything else)
- orgID, err := models.OrgIDForChannelUUID(ctx, s.DB, channelUUID)
+ orgID, err := models.OrgIDForChannelUUID(ctx, rt.DB, channelUUID)
if err != nil {
return nil, nil, writeClientError(w, err)
}
// load our org assets
- oa, err := models.GetOrgAssets(ctx, s.DB, orgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, orgID)
if err != nil {
return nil, nil, writeClientError(w, errors.Wrapf(err, "error loading org assets"))
}
@@ -365,7 +366,7 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.Re
}
// preprocess this status
- body, err := client.PreprocessStatus(ctx, s.DB, s.RP, r)
+ body, err := client.PreprocessStatus(ctx, rt.DB, rt.RP, r)
if err != nil {
return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "error while preprocessing status"))
}
@@ -383,7 +384,7 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.Re
}
// load our connection
- conn, err := models.SelectChannelConnectionByExternalID(ctx, s.DB, channel.ID(), models.ConnectionTypeIVR, externalID)
+ conn, err := models.SelectChannelConnectionByExternalID(ctx, rt.DB, channel.ID(), models.ConnectionTypeIVR, externalID)
if errors.Cause(err) == sql.ErrNoRows {
return channel, nil, client.WriteEmptyResponse(w, "unknown connection, ignoring")
}
@@ -391,12 +392,12 @@ func handleStatus(ctx context.Context, s *web.Server, r *http.Request, w http.Re
return channel, nil, client.WriteErrorResponse(w, errors.Wrapf(err, "unable to load channel connection with id: %s", externalID))
}
- err = ivr.HandleIVRStatus(ctx, s.DB, s.RP, oa, client, conn, r, w)
+ err = ivr.HandleIVRStatus(ctx, rt, oa, client, conn, r, w)
// had an error? mark our connection as errored and log it
if err != nil {
logrus.WithError(err).WithField("http_request", r).Error("error while handling status")
- return channel, conn, ivr.WriteErrorResponse(ctx, s.DB, client, conn, w, err)
+ return channel, conn, ivr.WriteErrorResponse(ctx, rt.DB, client, conn, w, err)
}
return channel, conn, nil
diff --git a/web/ivr/ivr_test.go b/web/ivr/ivr_test.go
index 0b29a4bb8..0b43f31ae 100644
--- a/web/ivr/ivr_test.go
+++ b/web/ivr/ivr_test.go
@@ -20,7 +20,9 @@ import (
"github.com/sirupsen/logrus"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
_ "github.com/nyaruka/mailroom/core/handlers"
"github.com/nyaruka/mailroom/core/ivr/twiml"
@@ -29,9 +31,10 @@ import (
)
func TestTwilioIVR(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
+ ctx, _, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
+
defer testsuite.ResetStorage()
// start test server
@@ -60,18 +63,18 @@ func TestTwilioIVR(t *testing.T) {
twiml.IgnoreSignatures = true
wg := &sync.WaitGroup{}
- server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.Storage(), nil, wg)
+ server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.MediaStorage(), nil, wg)
server.Start()
defer server.Stop()
// add auth tokens
- db.MustExec(`UPDATE channels_channel SET config = '{"auth_token": "token", "account_sid": "sid", "callback_domain": "localhost:8090"}' WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET config = '{"auth_token": "token", "account_sid": "sid", "callback_domain": "localhost:8090"}' WHERE id = $1`, testdata.TwilioChannel.ID)
// create a flow start for cathy and george
parentSummary := json.RawMessage(`{"flow": {"name": "IVR Flow", "uuid": "2f81d0ea-4d75-4843-9371-3f7465311cce"}, "uuid": "8bc73097-ac57-47fb-82e5-184f8ec6dbef", "status": "active", "contact": {"id": 10000, "name": "Cathy", "urns": ["tel:+16055741111?id=10000&priority=50"], "uuid": "6393abc0-283d-4c9b-a1b3-641a035c34bf", "fields": {"gender": {"text": "F"}}, "groups": [{"name": "Doctors", "uuid": "c153e265-f7c9-4539-9dbc-9b358714b638"}], "timezone": "America/Los_Angeles", "created_on": "2019-07-23T09:35:01.439614-07:00"}, "results": {}}`)
- start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive).
- WithContactIDs([]models.ContactID{models.CathyID, models.GeorgeID}).
+ start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive).
+ WithContactIDs([]models.ContactID{testdata.Cathy.ID, testdata.George.ID}).
WithParentSummary(parentSummary)
err := models.InsertFlowStarts(ctx, db, []*models.FlowStart{start})
@@ -92,16 +95,12 @@ func TestTwilioIVR(t *testing.T) {
err = ivr_tasks.HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
- []interface{}{models.CathyID, models.ConnectionStatusWired, "Call1"},
- 1,
- )
- testsuite.AssertQueryCount(t, db,
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
+ testdata.Cathy.ID, models.ConnectionStatusWired, "Call1").Returns(1)
+
+ testsuite.AssertQuery(t, db,
`SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
- []interface{}{models.GeorgeID, models.ConnectionStatusWired, "Call2"},
- 1,
- )
+ testdata.George.ID, models.ConnectionStatusWired, "Call2").Returns(1)
tcs := []struct {
Action string
@@ -113,7 +112,7 @@ func TestTwilioIVR(t *testing.T) {
}{
{
Action: "start",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: nil,
StatusCode: 200,
@@ -121,7 +120,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallStatus": []string{"in-progress"},
@@ -133,7 +132,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallStatus": []string{"in-progress"},
@@ -145,7 +144,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallStatus": []string{"in-progress"},
@@ -157,7 +156,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallStatus": []string{"in-progress"},
@@ -169,7 +168,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallStatus": []string{"in-progress"},
@@ -185,7 +184,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallStatus": []string{"in-progress"},
@@ -200,7 +199,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "status",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"CallSid": []string{"Call1"},
@@ -212,7 +211,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "start",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(2),
Form: nil,
StatusCode: 200,
@@ -220,7 +219,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "resume",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(2),
Form: url.Values{
"CallStatus": []string{"completed"},
@@ -232,7 +231,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "incoming",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(3),
Form: url.Values{
"CallSid": []string{"Call2"},
@@ -244,7 +243,7 @@ func TestTwilioIVR(t *testing.T) {
},
{
Action: "status",
- ChannelUUID: models.TwilioChannelUUID,
+ ChannelUUID: testdata.TwilioChannel.UUID,
ConnectionID: models.ConnectionID(3),
Form: url.Values{
"CallSid": []string{"Call2"},
@@ -273,6 +272,7 @@ func TestTwilioIVR(t *testing.T) {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
assert.Equal(t, tc.StatusCode, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
@@ -283,80 +283,38 @@ func TestTwilioIVR(t *testing.T) {
}
// check our final state of sessions, runs, msgs, connections
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'W' AND direction = 'O'`,
- []interface{}{models.CathyID},
- 8,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`,
- []interface{}{},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'H' AND direction = 'I'`,
- []interface{}{models.CathyID},
- 5,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`,
- []interface{}{},
- 9,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 2 AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`,
- []interface{}{models.GeorgeID},
- 2,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`,
- []interface{}{models.GeorgeID},
- 1,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`, testdata.Cathy.ID).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`, testdata.Cathy.ID).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, testdata.Cathy.ID).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'W' AND direction = 'O'`, testdata.Cathy.ID).Returns(8)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'H' AND direction = 'I'`, testdata.Cathy.ID).Returns(5)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`).Returns(9)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 2
+ AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`, testdata.George.ID).Returns(2)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`, testdata.George.ID).Returns(1)
}
func TestVonageIVR(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
+ ctx, _, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
+
defer testsuite.ResetStorage()
- models.FlushCache()
// deactivate our twilio channel
- db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, models.TwilioChannelID)
+ db.MustExec(`UPDATE channels_channel SET is_active = FALSE WHERE id = $1`, testdata.TwilioChannel.ID)
// add auth tokens
- db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, models.VonageChannelID)
+ db.MustExec(`UPDATE channels_channel SET config = '{"nexmo_app_id": "app_id", "nexmo_app_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP\nHRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t\nKvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE\nnUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf\nJB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4\nf63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK\nI8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx\nTjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+\noAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6\nHD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG\nGcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP\niukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF\n6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH\nx6XroMXsmbnsEw==\n-----END PRIVATE KEY-----", "callback_domain": "localhost:8090"}', role='SRCA' WHERE id = $1`, testdata.VonageChannel.ID)
// start test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -396,7 +354,7 @@ func TestVonageIVR(t *testing.T) {
defer ts.Close()
wg := &sync.WaitGroup{}
- server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.Storage(), nil, wg)
+ server := web.NewServer(ctx, config.Mailroom, db, rp, testsuite.MediaStorage(), nil, wg)
server.Start()
defer server.Stop()
@@ -405,8 +363,8 @@ func TestVonageIVR(t *testing.T) {
// create a flow start for cathy and george
extra := json.RawMessage(`{"ref_id":"123"}`)
- start := models.NewFlowStart(models.Org1, models.StartTypeTrigger, models.FlowTypeVoice, models.IVRFlowID, models.DoRestartParticipants, models.DoIncludeActive).
- WithContactIDs([]models.ContactID{models.CathyID, models.GeorgeID}).
+ start := models.NewFlowStart(testdata.Org1.ID, models.StartTypeTrigger, models.FlowTypeVoice, testdata.IVRFlow.ID, models.DoRestartParticipants, models.DoIncludeActive).
+ WithContactIDs([]models.ContactID{testdata.Cathy.ID, testdata.George.ID}).
WithExtra(extra)
models.InsertFlowStarts(ctx, db, []*models.FlowStart{start})
@@ -425,16 +383,11 @@ func TestVonageIVR(t *testing.T) {
err = ivr_tasks.HandleFlowStartBatch(ctx, config.Mailroom, db, rp, batch)
assert.NoError(t, err)
- testsuite.AssertQueryCount(t, db,
- `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
- []interface{}{models.CathyID, models.ConnectionStatusWired, "Call1"},
- 1,
- )
- testsuite.AssertQueryCount(t, db,
- `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
- []interface{}{models.GeorgeID, models.ConnectionStatusWired, "Call2"},
- 1,
- )
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
+ testdata.Cathy.ID, models.ConnectionStatusWired, "Call1").Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT COUNT(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = $2 AND external_id = $3`,
+ testdata.George.ID, models.ConnectionStatusWired, "Call2").Returns(1)
tcs := []struct {
Label string
@@ -449,7 +402,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "start and prompt",
Action: "start",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Body: `{"from":"12482780345","to":"12067799294","uuid":"80c9a606-717e-48b9-ae22-ce00269cbb08","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c"}`,
StatusCode: 200,
@@ -458,7 +411,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "invalid dtmf",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"gather"},
@@ -470,7 +423,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "dtmf 1",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"gather"},
@@ -482,7 +435,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "dtmf too large",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"gather"},
@@ -494,7 +447,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "dtmf 56",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"gather"},
@@ -506,7 +459,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "recording callback",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"recording_url"},
@@ -519,7 +472,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "resume with recording",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"record"},
@@ -532,7 +485,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "transfer answered",
Action: "status",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Body: `{"uuid": "Call3", "status": "answered"}`,
StatusCode: 200,
@@ -541,7 +494,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "transfer completed",
Action: "status",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Body: `{"uuid": "Call3", "duration": "25", "status": "completed"}`,
StatusCode: 200,
@@ -550,7 +503,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "transfer callback",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Form: url.Values{
"wait_type": []string{"dial"},
@@ -563,7 +516,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "call complete",
Action: "status",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(1),
Body: `{"end_time":"2019-04-01T21:08:56.000Z","uuid":"Call1","network":"310260","duration":"50","start_time":"2019-04-01T21:08:42.000Z","rate":"0.01270000","price":"0.00296333","from":"12482780345","to":"12067799294","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c","status":"completed","direction":"outbound","timestamp":"2019-04-01T21:08:56.342Z"}`,
StatusCode: 200,
@@ -572,7 +525,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "new call",
Action: "start",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(2),
Body: `{"from":"12482780345","to":"12067799294","uuid":"Call2","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c"}`,
StatusCode: 200,
@@ -581,7 +534,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "new call dtmf 1",
Action: "resume",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(2),
Form: url.Values{
"wait_type": []string{"gather"},
@@ -593,7 +546,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "new call ended",
Action: "status",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(2),
Body: `{"end_time":"2019-04-01T21:08:56.000Z","uuid":"Call2","network":"310260","duration":"50","start_time":"2019-04-01T21:08:42.000Z","rate":"0.01270000","price":"0.00296333","from":"12482780345","to":"12067799294","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c","status":"completed","direction":"outbound","timestamp":"2019-04-01T21:08:56.342Z"}`,
StatusCode: 200,
@@ -602,7 +555,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "incoming call",
Action: "incoming",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(3),
Body: `{"from":"12482780345","to":"12067799294","uuid":"Call4","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c"}`,
StatusCode: 200,
@@ -611,7 +564,7 @@ func TestVonageIVR(t *testing.T) {
{
Label: "failed call",
Action: "status",
- ChannelUUID: models.VonageChannelUUID,
+ ChannelUUID: testdata.VonageChannel.UUID,
ConnectionID: models.ConnectionID(3),
Body: `{"end_time":"2019-04-01T21:08:56.000Z","uuid":"Call4","network":"310260","duration":"50","start_time":"2019-04-01T21:08:42.000Z","rate":"0.01270000","price":"0.00296333","from":"12482780345","to":"12067799294","conversation_uuid":"CON-f90649c3-cbf3-42d6-9ab1-01503befac1c","status":"failed","direction":"outbound","timestamp":"2019-04-01T21:08:56.342Z"}`,
StatusCode: 200,
@@ -639,6 +592,7 @@ func TestVonageIVR(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
assert.Equal(t, tc.StatusCode, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
@@ -650,64 +604,24 @@ func TestVonageIVR(t *testing.T) {
}
// check our final state of sessions, runs, msgs, connections
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`,
- []interface{}{models.CathyID},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'W' AND direction = 'O'`,
- []interface{}{models.CathyID},
- 9,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`,
- []interface{}{},
- 1,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 1 AND status = 'H' AND direction = 'I'`,
- []interface{}{models.CathyID},
- 5,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`,
- []interface{}{},
- 10,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V' AND connection_id = 2 AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`,
- []interface{}{models.GeorgeID},
- 3,
- )
-
- testsuite.AssertQueryCount(t, db,
- `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`,
- []interface{}{models.GeorgeID},
- 1,
- )
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowsession WHERE contact_id = $1 AND status = 'C'`, testdata.Cathy.ID).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM flows_flowrun WHERE contact_id = $1 AND is_active = FALSE`, testdata.Cathy.ID).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE contact_id = $1 AND status = 'D' AND duration = 50`, testdata.Cathy.ID).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V'
+ AND connection_id = 1 AND status = 'W' AND direction = 'O'`, testdata.Cathy.ID).Returns(9)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'F' AND direction = 'I'`).Returns(1)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V'
+ AND connection_id = 1 AND status = 'H' AND direction = 'I'`, testdata.Cathy.ID).Returns(5)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channellog WHERE connection_id = 1 AND channel_id IS NOT NULL`).Returns(10)
+
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM msgs_msg WHERE contact_id = $1 AND msg_type = 'V'
+ AND connection_id = 2 AND ((status = 'H' AND direction = 'I') OR (status = 'W' AND direction = 'O'))`, testdata.George.ID).Returns(3)
+ testsuite.AssertQuery(t, db, `SELECT count(*) FROM channels_channelconnection WHERE status = 'D' AND contact_id = $1`, testdata.George.ID).Returns(1)
}
diff --git a/web/msg/msg.go b/web/msg/msg.go
new file mode 100644
index 000000000..949ffb6d2
--- /dev/null
+++ b/web/msg/msg.go
@@ -0,0 +1,64 @@
+package msg
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/nyaruka/goflow/flows"
+ "github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/core/msgio"
+ "github.com/nyaruka/mailroom/runtime"
+ "github.com/nyaruka/mailroom/web"
+
+ "github.com/pkg/errors"
+)
+
+func init() {
+ web.RegisterJSONRoute(http.MethodPost, "/mr/msg/resend", web.RequireAuthToken(handleResend))
+}
+
+// Request to resend failed messages.
+//
+// {
+// "org_id": 1,
+// "msg_ids": [123456, 345678]
+// }
+//
+type resendRequest struct {
+ OrgID models.OrgID `json:"org_id" validate:"required"`
+ MsgIDs []models.MsgID `json:"msg_ids" validate:"required"`
+}
+
+// handles a request to resend the given messages
+func handleResend(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
+ request := &resendRequest{}
+ if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
+ return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
+ }
+
+ // grab our org
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
+ }
+
+ msgs, err := models.LoadMessages(ctx, rt.DB, request.OrgID, models.DirectionOut, request.MsgIDs)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrap(err, "error loading messages to resend")
+ }
+
+ err = models.ResendMessages(ctx, rt.DB, rt.RP, oa, msgs)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrap(err, "error resending messages")
+ }
+
+ msgio.SendMessages(ctx, rt.DB, rt.RP, nil, msgs)
+
+ // response is the ids of the messages that were actually resent
+ resentMsgIDs := make([]flows.MsgID, len(msgs))
+ for i, m := range msgs {
+ resentMsgIDs[i] = m.ID()
+ }
+ return map[string]interface{}{"msg_ids": resentMsgIDs}, http.StatusOK, nil
+}
diff --git a/web/msg/msg_test.go b/web/msg/msg_test.go
new file mode 100644
index 000000000..83f2363e3
--- /dev/null
+++ b/web/msg/msg_test.go
@@ -0,0 +1,25 @@
+package msg_test
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+ "github.com/nyaruka/mailroom/web"
+)
+
+func TestServer(t *testing.T) {
+ _, _, db, _ := testsuite.Get()
+
+ cathyIn := testdata.InsertIncomingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "hello", models.MsgStatusHandled)
+ cathyOut := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.TwilioChannel, testdata.Cathy, "how can we help", nil, models.MsgStatusSent)
+ bobOut := testdata.InsertOutgoingMsg(db, testdata.Org1, testdata.VonageChannel, testdata.Bob, "this failed", nil, models.MsgStatusFailed)
+
+ web.RunWebTests(t, "testdata/resend.json", map[string]string{
+ "cathy_msgin_id": fmt.Sprintf("%d", cathyIn.ID()),
+ "cathy_msgout_id": fmt.Sprintf("%d", cathyOut.ID()),
+ "bob_msgout_id": fmt.Sprintf("%d", bobOut.ID()),
+ })
+}
diff --git a/web/msg/testdata/resend.json b/web/msg/testdata/resend.json
new file mode 100644
index 000000000..605a991b3
--- /dev/null
+++ b/web/msg/testdata/resend.json
@@ -0,0 +1,52 @@
+[
+ {
+ "label": "illegal method",
+ "method": "GET",
+ "path": "/mr/msg/resend",
+ "status": 405,
+ "response": {
+ "error": "illegal method: GET"
+ }
+ },
+ {
+ "label": "invalid org_id",
+ "method": "POST",
+ "path": "/mr/msg/resend",
+ "body": {
+ "org_id": 1234,
+ "msg_ids": [
+ 1234
+ ]
+ },
+ "status": 500,
+ "response": {
+ "error": "unable to load org assets: error loading environment for org 1234: no org with id: 1234"
+ }
+ },
+ {
+ "label": "response is the ids of the messages that were actually resent",
+ "method": "POST",
+ "path": "/mr/msg/resend",
+ "body": {
+ "org_id": 1,
+ "msg_ids": [
+ $cathy_msgin_id$,
+ $cathy_msgout_id$,
+ $bob_msgout_id$
+ ]
+ },
+ "status": 200,
+ "response": {
+ "msg_ids": [
+ $cathy_msgout_id$,
+ $bob_msgout_id$
+ ]
+ },
+ "db_assertions": [
+ {
+ "query": "SELECT count(*) FROM msgs_msg WHERE status = 'P'",
+ "count": 2
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/web/org/metrics.go b/web/org/metrics.go
index 5d7d5e325..bcfe6be68 100644
--- a/web/org/metrics.go
+++ b/web/org/metrics.go
@@ -9,6 +9,7 @@ import (
"github.com/nyaruka/gocommon/uuids"
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
dto "github.com/prometheus/client_model/go"
@@ -47,8 +48,8 @@ type groupCountRow struct {
Count int64 `db:"count"`
}
-func calculateGroupCounts(ctx context.Context, s *web.Server, org *models.OrgReference) (*dto.MetricFamily, error) {
- rows, err := s.DB.QueryxContext(ctx, groupCountsSQL, org.ID)
+func calculateGroupCounts(ctx context.Context, rt *runtime.Runtime, org *models.OrgReference) (*dto.MetricFamily, error) {
+ rows, err := rt.DB.QueryxContext(ctx, groupCountsSQL, org.ID)
if err != nil {
return nil, errors.Wrapf(err, "error querying group counts for org")
}
@@ -144,8 +145,8 @@ type channelStats struct {
Counts map[string]int64
}
-func calculateChannelCounts(ctx context.Context, s *web.Server, org *models.OrgReference) (*dto.MetricFamily, error) {
- rows, err := s.DB.QueryxContext(ctx, channelCountsSQL, org.ID)
+func calculateChannelCounts(ctx context.Context, rt *runtime.Runtime, org *models.OrgReference) (*dto.MetricFamily, error) {
+ rows, err := rt.DB.QueryxContext(ctx, channelCountsSQL, org.ID)
if err != nil {
return nil, errors.Wrapf(err, "error querying channel counts for org")
}
@@ -260,7 +261,7 @@ func calculateChannelCounts(ctx context.Context, s *web.Server, org *models.OrgR
return family, err
}
-func handleMetrics(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error {
+func handleMetrics(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error {
// we should have basic auth headers, username should be metrics
username, token, ok := r.BasicAuth()
if !ok || username != "metrics" {
@@ -270,7 +271,7 @@ func handleMetrics(ctx context.Context, s *web.Server, r *http.Request, rawW htt
}
orgUUID := uuids.UUID(chi.URLParam(r, "uuid"))
- org, err := models.LookupOrgByUUIDAndToken(ctx, s.DB, orgUUID, "Prometheus", token)
+ org, err := models.LookupOrgByUUIDAndToken(ctx, rt.DB, orgUUID, "Prometheus", token)
if err != nil {
return errors.Wrapf(err, "error looking up org for token")
}
@@ -281,12 +282,12 @@ func handleMetrics(ctx context.Context, s *web.Server, r *http.Request, rawW htt
return nil
}
- groups, err := calculateGroupCounts(ctx, s, org)
+ groups, err := calculateGroupCounts(ctx, rt, org)
if err != nil {
return errors.Wrapf(err, "error calculating group counts for org: %d", org.ID)
}
- channels, err := calculateChannelCounts(ctx, s, org)
+ channels, err := calculateChannelCounts(ctx, rt, org)
if err != nil {
return errors.Wrapf(err, "error calculating channel counts for org: %d", org.ID)
}
diff --git a/web/org/metrics_test.go b/web/org/metrics_test.go
index 30743a8e5..10943425e 100644
--- a/web/org/metrics_test.go
+++ b/web/org/metrics_test.go
@@ -1,4 +1,4 @@
-package org
+package org_test
import (
"fmt"
@@ -9,20 +9,21 @@ import (
"time"
"github.com/nyaruka/mailroom/config"
- "github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
+
"github.com/stretchr/testify/assert"
)
func TestMetrics(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
+ ctx, _, db, rp := testsuite.Reset()
promToken := "2d26a50841ff48237238bbdd021150f6a33a4196"
- db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 12, 1);`, models.Org1, promToken)
+ db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 12, 1);`, testdata.Org1.ID, promToken)
adminToken := "5c26a50841ff48237238bbdd021150f6a33a4199"
- db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 8, 1);`, models.Org1, adminToken)
+ db.MustExec(`INSERT INTO api_apitoken(is_active, org_id, created, key, role_id, user_id) VALUES(TRUE, $1, NOW(), $2, 8, 1);`, testdata.Org1.ID, adminToken)
wg := &sync.WaitGroup{}
server := web.NewServer(ctx, config.Mailroom, db, rp, nil, nil, wg)
@@ -42,46 +43,46 @@ func TestMetrics(t *testing.T) {
}{
{
Label: "no username",
- URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID),
+ URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID),
Username: "",
Password: "",
Response: `{"error": "invalid authentication"}`,
},
{
Label: "invalid password",
- URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID),
+ URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID),
Username: "metrics",
Password: "invalid",
Response: `{"error": "invalid authentication"}`,
},
{
Label: "invalid username",
- URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID),
+ URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID),
Username: "invalid",
Password: promToken,
Response: `{"error": "invalid authentication"}`,
},
{
Label: "valid login, wrong org",
- URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org2UUID),
+ URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org2.UUID),
Username: "metrics",
Password: promToken,
Response: `{"error": "invalid authentication"}`,
},
{
Label: "valid login, invalid user",
- URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID),
+ URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID),
Username: "metrics",
Password: adminToken,
Response: `{"error": "invalid authentication"}`,
},
{
Label: "valid",
- URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", models.Org1UUID),
+ URL: fmt.Sprintf("http://localhost:8090/mr/org/%s/metrics", testdata.Org1.UUID),
Username: "metrics",
Password: promToken,
Contains: []string{
- `rapidpro_group_contact_count{group_name="Active",group_uuid="b97f69f7-5edf-45c7-9fda-d37066eae91d",group_type="system",org="UNICEF"} 124`,
+ `rapidpro_group_contact_count{group_name="Active",group_uuid="14f6ea01-456b-4417-b0b8-35e942f549f1",group_type="system",org="UNICEF"} 124`,
`rapidpro_group_contact_count{group_name="Doctors",group_uuid="c153e265-f7c9-4539-9dbc-9b358714b638",group_type="user",org="UNICEF"} 121`,
`rapidpro_channel_msg_count{channel_name="Vonage",channel_uuid="19012bfd-3ce3-4cae-9bb9-76cf92c73d49",channel_type="NX",msg_direction="out",msg_type="message",org="UNICEF"} 0`,
},
diff --git a/web/po/po.go b/web/po/po.go
index 20f5badfa..be71c2f8f 100644
--- a/web/po/po.go
+++ b/web/po/po.go
@@ -10,6 +10,7 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/goflow/utils/i18n"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/go-chi/chi/middleware"
@@ -38,13 +39,13 @@ type exportRequest struct {
ExcludeArguments bool `json:"exclude_arguments"`
}
-func handleExport(ctx context.Context, s *web.Server, r *http.Request, rawW http.ResponseWriter) error {
+func handleExport(ctx context.Context, rt *runtime.Runtime, r *http.Request, rawW http.ResponseWriter) error {
request := &exportRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return errors.Wrapf(err, "request failed validation")
}
- flows, err := loadFlows(ctx, s.DB, request.OrgID, request.FlowIDs)
+ flows, err := loadFlows(ctx, rt.DB, request.OrgID, request.FlowIDs)
if err != nil {
return err
}
@@ -80,7 +81,7 @@ type importForm struct {
Language envs.Language `form:"language" validate:"required"`
}
-func handleImport(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleImport(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
form := &importForm{}
if err := web.DecodeAndValidateForm(form, r); err != nil {
return err, http.StatusBadRequest, nil
@@ -96,7 +97,7 @@ func handleImport(ctx context.Context, s *web.Server, r *http.Request) (interfac
return errors.Wrapf(err, "invalid po file"), http.StatusBadRequest, nil
}
- flows, err := loadFlows(ctx, s.DB, form.OrgID, form.FlowIDs)
+ flows, err := loadFlows(ctx, rt.DB, form.OrgID, form.FlowIDs)
if err != nil {
return err, http.StatusBadRequest, nil
}
diff --git a/web/po/po_test.go b/web/po/po_test.go
index 00cc880c9..011018e47 100644
--- a/web/po/po_test.go
+++ b/web/po/po_test.go
@@ -7,6 +7,6 @@ import (
)
func TestServer(t *testing.T) {
- web.RunWebTests(t, "testdata/export.json")
- web.RunWebTests(t, "testdata/import.json")
+ web.RunWebTests(t, "testdata/export.json", nil)
+ web.RunWebTests(t, "testdata/import.json", nil)
}
diff --git a/web/po/testdata/favorites.po b/web/po/testdata/favorites.po
index a9ffb4c2f..25ad7f8b6 100644
--- a/web/po/testdata/favorites.po
+++ b/web/po/testdata/favorites.po
@@ -10,88 +10,88 @@ msgstr ""
"Language-3: \n"
"Source-Flows: 9de3663f-c5c5-4c92-9f45-ecbc09abcc85\n"
-#: Favorites/491f3ed1-9154-4acb-8fdd-0a37567e0574/name:0
+#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/name:0
msgid "All Responses"
msgstr ""
-#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/name:0
-#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/arguments:0
+#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0
+#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/name:0
msgid "Blue"
msgstr ""
-#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/name:0
-#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/arguments:0
+#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/arguments:0
+#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/name:0
msgid "Cyan"
msgstr ""
-#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/text:0
+#: Favorites/4cadf512-1299-468f-85e4-26af9edec193/text:0
msgid "Good choice, I like @results.color.category_localized too! What is your favorite beer?"
msgstr ""
-#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/name:0
-#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/arguments:0
+#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/arguments:0
+#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/name:0
msgid "Green"
msgstr ""
-#: Favorites/9631dddf-0dd7-4310-b263-5f7cad4795e0/text:0
+#: Favorites/66c38ec3-0acd-4bf7-a5d5-278af1bee492/text:0
msgid "I don't know that color. Try again."
msgstr ""
-#: Favorites/aac779a9-e2a6-4a11-9efa-9670e081a33a/text:0
+#: Favorites/0f0e66a8-9062-444f-b636-3d5374466e31/text:0
msgid "I don't know that one, try again please."
msgstr ""
-#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/text:0
+#: Favorites/fc551cb4-e797-4076-b40a-433c44ad492b/text:0
msgid "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?"
msgstr ""
-#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0
-#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/arguments:0
+#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/arguments:0
+#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/name:0
msgid "Mutzig"
msgstr ""
-#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0
+#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/arguments:0
msgid "Navy"
msgstr ""
-#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/name:0
+#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/name:0
msgid "No Response"
msgstr ""
-#: Favorites/5563a722-9680-419c-a792-b1fa9df92e06/name:0
-#: Favorites/87b850ff-ddc5-4add-8a4f-c395c3a9ac38/name:0
+#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0
+#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0
msgid "Other"
msgstr ""
-#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/name:0
-#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/arguments:0
+#: Favorites/58119801-ed31-4538-888d-23779a01707f/arguments:0
+#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/name:0
msgid "Primus"
msgstr ""
-#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0
-#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/arguments:0
+#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/name:0
+#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/arguments:0
msgid "Red"
msgstr ""
-#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/name:0
-#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/arguments:0
+#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/name:0
+#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/arguments:0
msgid "Skol"
msgstr ""
-#: Favorites/cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48/text:0
+#: Favorites/1470d5e6-08dd-479b-a207-9b2b27b924d3/text:0
msgid "Sorry you can't participate right now, I'll try again later."
msgstr ""
-#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/text:0
+#: Favorites/e92b12c5-1817-468e-aa2f-8791fb6247e9/text:0
msgid "Thanks @results.name, we are all done!"
msgstr ""
-#: Favorites/58119801-ed31-4538-888d-23779a01707f/name:0
-#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/arguments:0
+#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/arguments:0
+#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/name:0
msgid "Turbo King"
msgstr ""
-#: Favorites/f4495f19-37ee-4e51-a7d5-d99ef6be147a/text:0
+#: Favorites/943f85bb-50bc-40c3-8d6f-57dbe34c87f7/text:0
msgid "What is your favorite color?"
msgstr ""
diff --git a/web/po/testdata/import.json b/web/po/testdata/import.json
index d7b9988a9..a0826ed51 100644
--- a/web/po/testdata/import.json
+++ b/web/po/testdata/import.json
@@ -45,13 +45,13 @@
"expire_after_minutes": 720,
"localization": {
"spa": {
- "34a421ac-34cb-49d8-a2a5-534f52c60851": {
- "name": [
+ "8d2e259c-bc3c-464f-8c15-985bc736e212": {
+ "arguments": [
"Azul"
]
},
"baf07ebb-8a2a-4e63-aa08-d19aa408cd45": {
- "arguments": [
+ "name": [
"Azul"
]
}
@@ -59,186 +59,186 @@
},
"nodes": [
{
- "uuid": "10c9c241-777f-4010-a841-6e87abed8520",
+ "uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542",
"actions": [
{
"type": "send_msg",
- "uuid": "f4495f19-37ee-4e51-a7d5-d99ef6be147a",
+ "uuid": "943f85bb-50bc-40c3-8d6f-57dbe34c87f7",
"text": "What is your favorite color?"
}
],
"exits": [
{
- "uuid": "943f85bb-50bc-40c3-8d6f-57dbe34c87f7",
- "destination_uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542"
+ "uuid": "9631dddf-0dd7-4310-b263-5f7cad4795e0",
+ "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991"
}
]
},
{
- "uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93",
+ "uuid": "f4495f19-37ee-4e51-a7d5-d99ef6be147a",
"actions": [
{
"type": "send_msg",
- "uuid": "9631dddf-0dd7-4310-b263-5f7cad4795e0",
+ "uuid": "66c38ec3-0acd-4bf7-a5d5-278af1bee492",
"text": "I don't know that color. Try again."
}
],
"exits": [
{
- "uuid": "66c38ec3-0acd-4bf7-a5d5-278af1bee492",
- "destination_uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542"
+ "uuid": "eb048bdf-17ee-4334-a52b-5e82a20189ac",
+ "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991"
}
]
},
{
- "uuid": "5253c207-46e8-42a9-998e-a3e54e0e0542",
+ "uuid": "333fa9a0-85a3-47c5-817e-153a1a124991",
"router": {
"type": "switch",
"wait": {
"type": "msg",
"timeout": {
"seconds": 300,
- "category_uuid": "3e2dcf45-ffc0-4197-b5ab-25ed974ea612"
+ "category_uuid": "7624633a-01a9-48f0-abca-957e7290df0a"
}
},
"result_name": "Color",
"categories": [
{
- "uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8",
+ "uuid": "58284598-805a-4740-8966-dcb09e3b670a",
"name": "Red",
- "exit_uuid": "eb048bdf-17ee-4334-a52b-5e82a20189ac"
+ "exit_uuid": "1349bebf-4653-407a-ad25-9fa60e7d7464"
},
{
- "uuid": "b0c29972-6fd4-485e-83c2-057a3f7a04da",
+ "uuid": "c102acfc-8cc5-41fa-89ed-41cbfa362ba6",
"name": "Green",
- "exit_uuid": "1349bebf-4653-407a-ad25-9fa60e7d7464"
+ "exit_uuid": "37491e99-f4d3-40ae-9ed1-bff62b0e2529"
},
{
- "uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851",
+ "uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45",
"name": "Blue",
- "exit_uuid": "37491e99-f4d3-40ae-9ed1-bff62b0e2529"
+ "exit_uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c"
},
{
- "uuid": "3b400f91-db69-42b9-9fe2-24ad556b067a",
+ "uuid": "6e367c0c-65ab-479a-82e3-c597d8e35eef",
"name": "Cyan",
- "exit_uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c"
+ "exit_uuid": "405cf157-1e43-46d8-a0d1-49adcb539267"
},
{
- "uuid": "5563a722-9680-419c-a792-b1fa9df92e06",
+ "uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8",
"name": "Other",
- "exit_uuid": "405cf157-1e43-46d8-a0d1-49adcb539267"
+ "exit_uuid": "c169352e-1944-4451-8d32-eb39c41cb3ae"
},
{
- "uuid": "3e2dcf45-ffc0-4197-b5ab-25ed974ea612",
+ "uuid": "7624633a-01a9-48f0-abca-957e7290df0a",
"name": "No Response",
- "exit_uuid": "c169352e-1944-4451-8d32-eb39c41cb3ae"
+ "exit_uuid": "5563a722-9680-419c-a792-b1fa9df92e06"
}
],
"operand": "@input",
"cases": [
{
- "uuid": "58284598-805a-4740-8966-dcb09e3b670a",
+ "uuid": "b0c29972-6fd4-485e-83c2-057a3f7a04da",
"type": "has_any_word",
"arguments": [
"Red"
],
- "category_uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8"
+ "category_uuid": "58284598-805a-4740-8966-dcb09e3b670a"
},
{
- "uuid": "c102acfc-8cc5-41fa-89ed-41cbfa362ba6",
+ "uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851",
"type": "has_any_word",
"arguments": [
"Green"
],
- "category_uuid": "b0c29972-6fd4-485e-83c2-057a3f7a04da"
+ "category_uuid": "c102acfc-8cc5-41fa-89ed-41cbfa362ba6"
},
{
- "uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45",
+ "uuid": "8d2e259c-bc3c-464f-8c15-985bc736e212",
"type": "has_any_word",
"arguments": [
"Blue"
],
- "category_uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851"
+ "category_uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45"
},
{
- "uuid": "8d2e259c-bc3c-464f-8c15-985bc736e212",
+ "uuid": "3b400f91-db69-42b9-9fe2-24ad556b067a",
"type": "has_any_word",
"arguments": [
"Navy"
],
- "category_uuid": "34a421ac-34cb-49d8-a2a5-534f52c60851"
+ "category_uuid": "baf07ebb-8a2a-4e63-aa08-d19aa408cd45"
},
{
- "uuid": "6e367c0c-65ab-479a-82e3-c597d8e35eef",
+ "uuid": "3e2dcf45-ffc0-4197-b5ab-25ed974ea612",
"type": "has_any_word",
"arguments": [
"Cyan"
],
- "category_uuid": "3b400f91-db69-42b9-9fe2-24ad556b067a"
+ "category_uuid": "6e367c0c-65ab-479a-82e3-c597d8e35eef"
}
],
- "default_category_uuid": "5563a722-9680-419c-a792-b1fa9df92e06"
+ "default_category_uuid": "3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8"
},
"exits": [
- {
- "uuid": "eb048bdf-17ee-4334-a52b-5e82a20189ac",
- "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991"
- },
{
"uuid": "1349bebf-4653-407a-ad25-9fa60e7d7464",
- "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991"
+ "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
},
{
"uuid": "37491e99-f4d3-40ae-9ed1-bff62b0e2529",
- "destination_uuid": "333fa9a0-85a3-47c5-817e-153a1a124991"
+ "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
},
{
- "uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c"
+ "uuid": "456e75bd-32cc-40c1-a5ef-ffef2e57642c",
+ "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
},
{
- "uuid": "405cf157-1e43-46d8-a0d1-49adcb539267",
- "destination_uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93"
+ "uuid": "405cf157-1e43-46d8-a0d1-49adcb539267"
},
{
"uuid": "c169352e-1944-4451-8d32-eb39c41cb3ae",
- "destination_uuid": "1b828e78-e478-4357-9472-47a30ec1f60b"
+ "destination_uuid": "f4495f19-37ee-4e51-a7d5-d99ef6be147a"
+ },
+ {
+ "uuid": "5563a722-9680-419c-a792-b1fa9df92e06",
+ "destination_uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93"
}
]
},
{
- "uuid": "333fa9a0-85a3-47c5-817e-153a1a124991",
+ "uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0",
"actions": [
{
"type": "send_msg",
- "uuid": "7624633a-01a9-48f0-abca-957e7290df0a",
+ "uuid": "4cadf512-1299-468f-85e4-26af9edec193",
"text": "Good choice, I like @results.color.category_localized too! What is your favorite beer?"
}
],
"exits": [
{
- "uuid": "4cadf512-1299-468f-85e4-26af9edec193",
- "destination_uuid": "a84399b1-0e7b-42ee-8759-473137b510db"
+ "uuid": "aac779a9-e2a6-4a11-9efa-9670e081a33a",
+ "destination_uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf"
}
]
},
{
- "uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434",
+ "uuid": "a84399b1-0e7b-42ee-8759-473137b510db",
"actions": [
{
"type": "send_msg",
- "uuid": "aac779a9-e2a6-4a11-9efa-9670e081a33a",
+ "uuid": "0f0e66a8-9062-444f-b636-3d5374466e31",
"text": "I don't know that one, try again please."
}
],
"exits": [
{
- "uuid": "0f0e66a8-9062-444f-b636-3d5374466e31",
- "destination_uuid": "a84399b1-0e7b-42ee-8759-473137b510db"
+ "uuid": "0891f63c-9e82-42bb-a815-8b44aff33046",
+ "destination_uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf"
}
]
},
{
- "uuid": "a84399b1-0e7b-42ee-8759-473137b510db",
+ "uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf",
"router": {
"type": "switch",
"wait": {
@@ -247,109 +247,109 @@
"result_name": "Beer",
"categories": [
{
- "uuid": "a813de57-c92a-4128-804d-56e80b332142",
+ "uuid": "b9d718d3-b5e0-4d26-998e-2da31b24f2f9",
"name": "Mutzig",
- "exit_uuid": "0891f63c-9e82-42bb-a815-8b44aff33046"
+ "exit_uuid": "b341b58e-58fe-41bf-b26e-6274765ccc0e"
},
{
- "uuid": "a03dceb1-7ac1-491d-93ef-23d3e099633b",
+ "uuid": "f1ca9ac8-d0aa-4758-a969-195be7330267",
"name": "Primus",
- "exit_uuid": "b341b58e-58fe-41bf-b26e-6274765ccc0e"
+ "exit_uuid": "e4697b6f-12a9-47ae-a927-96d95d9f8f77"
},
{
- "uuid": "58119801-ed31-4538-888d-23779a01707f",
+ "uuid": "dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1",
"name": "Turbo King",
- "exit_uuid": "e4697b6f-12a9-47ae-a927-96d95d9f8f77"
+ "exit_uuid": "d03c8f97-9f3b-4a6a-8ba9-bdc82a6f09b8"
},
{
- "uuid": "2ba89eb6-6981-4c0d-a19d-3cf1fde52a43",
+ "uuid": "52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91",
"name": "Skol",
- "exit_uuid": "d03c8f97-9f3b-4a6a-8ba9-bdc82a6f09b8"
+ "exit_uuid": "e0ec2076-2746-43b4-a410-c3af47d6a121"
},
{
- "uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38",
+ "uuid": "a813de57-c92a-4128-804d-56e80b332142",
"name": "Other",
- "exit_uuid": "e0ec2076-2746-43b4-a410-c3af47d6a121"
+ "exit_uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38"
}
],
"operand": "@input",
"cases": [
{
- "uuid": "b9d718d3-b5e0-4d26-998e-2da31b24f2f9",
+ "uuid": "a03dceb1-7ac1-491d-93ef-23d3e099633b",
"type": "has_any_word",
"arguments": [
"Mutzig"
],
- "category_uuid": "a813de57-c92a-4128-804d-56e80b332142"
+ "category_uuid": "b9d718d3-b5e0-4d26-998e-2da31b24f2f9"
},
{
- "uuid": "f1ca9ac8-d0aa-4758-a969-195be7330267",
+ "uuid": "58119801-ed31-4538-888d-23779a01707f",
"type": "has_any_word",
"arguments": [
"Primus"
],
- "category_uuid": "a03dceb1-7ac1-491d-93ef-23d3e099633b"
+ "category_uuid": "f1ca9ac8-d0aa-4758-a969-195be7330267"
},
{
- "uuid": "dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1",
+ "uuid": "2ba89eb6-6981-4c0d-a19d-3cf1fde52a43",
"type": "has_any_word",
"arguments": [
"Turbo King"
],
- "category_uuid": "58119801-ed31-4538-888d-23779a01707f"
+ "category_uuid": "dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1"
},
{
- "uuid": "52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91",
+ "uuid": "ada3d96a-a1a2-41eb-aac7-febdb98a9b4c",
"type": "has_any_word",
"arguments": [
"Skol"
],
- "category_uuid": "2ba89eb6-6981-4c0d-a19d-3cf1fde52a43"
+ "category_uuid": "52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91"
}
],
- "default_category_uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38"
+ "default_category_uuid": "a813de57-c92a-4128-804d-56e80b332142"
},
"exits": [
- {
- "uuid": "0891f63c-9e82-42bb-a815-8b44aff33046",
- "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
- },
{
"uuid": "b341b58e-58fe-41bf-b26e-6274765ccc0e",
- "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
+ "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434"
},
{
"uuid": "e4697b6f-12a9-47ae-a927-96d95d9f8f77",
- "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
+ "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434"
},
{
"uuid": "d03c8f97-9f3b-4a6a-8ba9-bdc82a6f09b8",
- "destination_uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0"
+ "destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434"
},
{
"uuid": "e0ec2076-2746-43b4-a410-c3af47d6a121",
"destination_uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434"
+ },
+ {
+ "uuid": "87b850ff-ddc5-4add-8a4f-c395c3a9ac38",
+ "destination_uuid": "a84399b1-0e7b-42ee-8759-473137b510db"
}
]
},
{
- "uuid": "48fd5325-d660-4404-bdf3-05ad1b024cc0",
+ "uuid": "48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434",
"actions": [
{
"type": "send_msg",
- "uuid": "ada3d96a-a1a2-41eb-aac7-febdb98a9b4c",
+ "uuid": "fc551cb4-e797-4076-b40a-433c44ad492b",
"text": "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?"
}
],
"exits": [
{
- "uuid": "fc551cb4-e797-4076-b40a-433c44ad492b",
- "destination_uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf"
+ "uuid": "e87aeeab-8ede-4173-bc76-8f5583ea7207",
+ "destination_uuid": "1b828e78-e478-4357-9472-47a30ec1f60b"
}
]
},
{
- "uuid": "b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf",
+ "uuid": "1b828e78-e478-4357-9472-47a30ec1f60b",
"router": {
"type": "switch",
"wait": {
@@ -358,49 +358,49 @@
"result_name": "Name",
"categories": [
{
- "uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574",
+ "uuid": "a602e75e-0814-4034-bb95-770906ddfe34",
"name": "All Responses",
- "exit_uuid": "e87aeeab-8ede-4173-bc76-8f5583ea7207"
+ "exit_uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574"
}
],
"operand": "@input",
"cases": [],
- "default_category_uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574"
+ "default_category_uuid": "a602e75e-0814-4034-bb95-770906ddfe34"
},
"exits": [
{
- "uuid": "e87aeeab-8ede-4173-bc76-8f5583ea7207",
- "destination_uuid": "b4664fbd-3495-4fc6-aa8b-b397857dcd68"
+ "uuid": "491f3ed1-9154-4acb-8fdd-0a37567e0574",
+ "destination_uuid": "10c9c241-777f-4010-a841-6e87abed8520"
}
]
},
{
- "uuid": "b4664fbd-3495-4fc6-aa8b-b397857dcd68",
+ "uuid": "10c9c241-777f-4010-a841-6e87abed8520",
"actions": [
{
"type": "send_msg",
- "uuid": "a602e75e-0814-4034-bb95-770906ddfe34",
+ "uuid": "e92b12c5-1817-468e-aa2f-8791fb6247e9",
"text": "Thanks @results.name, we are all done!"
}
],
"exits": [
{
- "uuid": "e92b12c5-1817-468e-aa2f-8791fb6247e9"
+ "uuid": "cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48"
}
]
},
{
- "uuid": "1b828e78-e478-4357-9472-47a30ec1f60b",
+ "uuid": "8c2504ef-0acc-405f-9efe-d5fc2c434a93",
"actions": [
{
"type": "send_msg",
- "uuid": "cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48",
+ "uuid": "1470d5e6-08dd-479b-a207-9b2b27b924d3",
"text": "Sorry you can't participate right now, I'll try again later."
}
],
"exits": [
{
- "uuid": "1470d5e6-08dd-479b-a207-9b2b27b924d3"
+ "uuid": "cc711204-3dd4-499d-9d37-b477bf5c5458"
}
]
}
@@ -410,71 +410,71 @@
"10c9c241-777f-4010-a841-6e87abed8520": {
"type": "execute_actions",
"position": {
- "top": 0,
- "left": 100
+ "top": 805,
+ "left": 191
}
},
"1b828e78-e478-4357-9472-47a30ec1f60b": {
- "type": "execute_actions",
+ "type": "wait_for_response",
"position": {
- "top": 1278,
- "left": 752
+ "top": 702,
+ "left": 191
}
},
"333fa9a0-85a3-47c5-817e-153a1a124991": {
- "type": "execute_actions",
+ "type": "wait_for_response",
"position": {
- "top": 237,
- "left": 131
+ "top": 129,
+ "left": 98
}
},
"48f2ecb3-8e8e-4f7b-9510-1ee08bd6a434": {
"type": "execute_actions",
"position": {
- "top": 265,
- "left": 512
+ "top": 535,
+ "left": 191
}
},
"48fd5325-d660-4404-bdf3-05ad1b024cc0": {
"type": "execute_actions",
"position": {
- "top": 535,
- "left": 191
+ "top": 237,
+ "left": 131
}
},
"5253c207-46e8-42a9-998e-a3e54e0e0542": {
- "type": "wait_for_response",
+ "type": "execute_actions",
"position": {
- "top": 129,
- "left": 98
+ "top": 0,
+ "left": 100
}
},
"8c2504ef-0acc-405f-9efe-d5fc2c434a93": {
"type": "execute_actions",
"position": {
- "top": 8,
- "left": 456
+ "top": 1278,
+ "left": 752
}
},
"a84399b1-0e7b-42ee-8759-473137b510db": {
- "type": "wait_for_response",
+ "type": "execute_actions",
"position": {
- "top": 387,
- "left": 112
+ "top": 265,
+ "left": 512
}
},
"b0ae4ad9-5def-4778-8b0a-818d0f4bd3cf": {
"type": "wait_for_response",
"position": {
- "top": 702,
- "left": 191
+ "top": 387,
+ "left": 112
}
},
- "b4664fbd-3495-4fc6-aa8b-b397857dcd68": {
+ "f4495f19-37ee-4e51-a7d5-d99ef6be147a": {
"type": "execute_actions",
"position": {
- "top": 805,
- "left": 191
+ "top": 8,
+ "left": 456
}
}
},
diff --git a/web/po/testdata/multiple_flows.es.po b/web/po/testdata/multiple_flows.es.po
index 802df8126..5a91e2e83 100644
--- a/web/po/testdata/multiple_flows.es.po
+++ b/web/po/testdata/multiple_flows.es.po
@@ -10,110 +10,110 @@ msgstr ""
"Language-3: spa\n"
"Source-Flows: 9de3663f-c5c5-4c92-9f45-ecbc09abcc85; 5890fe3a-f204-4661-b74d-025be4ee019c\n"
-#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/arguments:0
+#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/arguments:0
msgid "1"
msgstr ""
-#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0
+#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/name:0
msgid "1-10"
msgstr ""
-#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/arguments:1
+#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/arguments:1
msgid "10"
msgstr ""
-#: Favorites/491f3ed1-9154-4acb-8fdd-0a37567e0574/name:0
-#: Pick+a+Number/5bf24536-9ae1-466a-9b76-5c82626d3153/name:0
+#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/name:0
+#: Pick+a+Number/ee9c1a1d-3426-4f07-83c8-dc3c1949fe6c/name:0
msgid "All Responses"
msgstr ""
-#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/name:0
-#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/arguments:0
+#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0
+#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/name:0
msgid "Blue"
msgstr ""
-#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/name:0
-#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/arguments:0
+#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/arguments:0
+#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/name:0
msgid "Cyan"
msgstr ""
-#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/text:0
+#: Favorites/4cadf512-1299-468f-85e4-26af9edec193/text:0
msgid "Good choice, I like @results.color.category_localized too! What is your favorite beer?"
msgstr ""
-#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/name:0
-#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/arguments:0
+#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/arguments:0
+#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/name:0
msgid "Green"
msgstr ""
-#: Favorites/9631dddf-0dd7-4310-b263-5f7cad4795e0/text:0
+#: Favorites/66c38ec3-0acd-4bf7-a5d5-278af1bee492/text:0
msgid "I don't know that color. Try again."
msgstr ""
-#: Favorites/aac779a9-e2a6-4a11-9efa-9670e081a33a/text:0
+#: Favorites/0f0e66a8-9062-444f-b636-3d5374466e31/text:0
msgid "I don't know that one, try again please."
msgstr ""
-#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/text:0
+#: Favorites/fc551cb4-e797-4076-b40a-433c44ad492b/text:0
msgid "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?"
msgstr ""
-#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0
-#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/arguments:0
+#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/arguments:0
+#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/name:0
msgid "Mutzig"
msgstr ""
-#: Favorites/8d2e259c-bc3c-464f-8c15-985bc736e212/arguments:0
+#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/arguments:0
msgid "Navy"
msgstr ""
-#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/name:0
+#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/name:0
msgid "No Response"
msgstr ""
-#: Favorites/5563a722-9680-419c-a792-b1fa9df92e06/name:0
-#: Favorites/87b850ff-ddc5-4add-8a4f-c395c3a9ac38/name:0
-#: Pick+a+Number/0d15ae52-5ad9-4d64-9c64-e27545d48a19/name:0
+#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0
+#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0
+#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0
msgid "Other"
msgstr ""
-#: Pick+a+Number/bb3276d9-543b-427b-9d00-a926dabc8e24/text:0
+#: Pick+a+Number/1b0564e8-c806-4b08-9e3d-06370d9c064c/text:0
msgid "Pick a number between 1-10."
msgstr ""
-#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/name:0
-#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/arguments:0
+#: Favorites/58119801-ed31-4538-888d-23779a01707f/arguments:0
+#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/name:0
msgid "Primus"
msgstr ""
-#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0
-#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/arguments:0
+#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/name:0
+#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/arguments:0
msgid "Red"
msgstr ""
-#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/name:0
-#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/arguments:0
+#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/name:0
+#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/arguments:0
msgid "Skol"
msgstr ""
-#: Favorites/cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48/text:0
+#: Favorites/1470d5e6-08dd-479b-a207-9b2b27b924d3/text:0
msgid "Sorry you can't participate right now, I'll try again later."
msgstr ""
-#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/text:0
+#: Favorites/e92b12c5-1817-468e-aa2f-8791fb6247e9/text:0
msgid "Thanks @results.name, we are all done!"
msgstr ""
-#: Favorites/58119801-ed31-4538-888d-23779a01707f/name:0
-#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/arguments:0
+#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/arguments:0
+#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/name:0
msgid "Turbo King"
msgstr ""
-#: Favorites/f4495f19-37ee-4e51-a7d5-d99ef6be147a/text:0
+#: Favorites/943f85bb-50bc-40c3-8d6f-57dbe34c87f7/text:0
msgid "What is your favorite color?"
msgstr ""
-#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/text:0
+#: Pick+a+Number/41f97c7a-3397-4076-95ab-3f1aa9e2acb2/text:0
msgid "You picked @results.number!"
msgstr ""
diff --git a/web/po/testdata/multiple_flows_noargs.es.po b/web/po/testdata/multiple_flows_noargs.es.po
index 81d0a3ea4..6ed3ab183 100644
--- a/web/po/testdata/multiple_flows_noargs.es.po
+++ b/web/po/testdata/multiple_flows_noargs.es.po
@@ -10,90 +10,90 @@ msgstr ""
"Language-3: spa\n"
"Source-Flows: 9de3663f-c5c5-4c92-9f45-ecbc09abcc85; 5890fe3a-f204-4661-b74d-025be4ee019c\n"
-#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0
+#: Pick+a+Number/f90c9734-3e58-4c07-96cc-315266c8ecfd/name:0
msgid "1-10"
msgstr ""
-#: Favorites/491f3ed1-9154-4acb-8fdd-0a37567e0574/name:0
-#: Pick+a+Number/5bf24536-9ae1-466a-9b76-5c82626d3153/name:0
+#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/name:0
+#: Pick+a+Number/ee9c1a1d-3426-4f07-83c8-dc3c1949fe6c/name:0
msgid "All Responses"
msgstr ""
-#: Favorites/34a421ac-34cb-49d8-a2a5-534f52c60851/name:0
+#: Favorites/baf07ebb-8a2a-4e63-aa08-d19aa408cd45/name:0
msgid "Blue"
msgstr ""
-#: Favorites/3b400f91-db69-42b9-9fe2-24ad556b067a/name:0
+#: Favorites/6e367c0c-65ab-479a-82e3-c597d8e35eef/name:0
msgid "Cyan"
msgstr ""
-#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/text:0
+#: Favorites/4cadf512-1299-468f-85e4-26af9edec193/text:0
msgid "Good choice, I like @results.color.category_localized too! What is your favorite beer?"
msgstr ""
-#: Favorites/b0c29972-6fd4-485e-83c2-057a3f7a04da/name:0
+#: Favorites/c102acfc-8cc5-41fa-89ed-41cbfa362ba6/name:0
msgid "Green"
msgstr ""
-#: Favorites/9631dddf-0dd7-4310-b263-5f7cad4795e0/text:0
+#: Favorites/66c38ec3-0acd-4bf7-a5d5-278af1bee492/text:0
msgid "I don't know that color. Try again."
msgstr ""
-#: Favorites/aac779a9-e2a6-4a11-9efa-9670e081a33a/text:0
+#: Favorites/0f0e66a8-9062-444f-b636-3d5374466e31/text:0
msgid "I don't know that one, try again please."
msgstr ""
-#: Favorites/ada3d96a-a1a2-41eb-aac7-febdb98a9b4c/text:0
+#: Favorites/fc551cb4-e797-4076-b40a-433c44ad492b/text:0
msgid "Mmmmm... delicious @results.beer.category_localized. If only they made @(lower(results.color)) @results.beer.category_localized! Lastly, what is your name?"
msgstr ""
-#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0
+#: Favorites/b9d718d3-b5e0-4d26-998e-2da31b24f2f9/name:0
msgid "Mutzig"
msgstr ""
-#: Favorites/3e2dcf45-ffc0-4197-b5ab-25ed974ea612/name:0
+#: Favorites/7624633a-01a9-48f0-abca-957e7290df0a/name:0
msgid "No Response"
msgstr ""
-#: Favorites/5563a722-9680-419c-a792-b1fa9df92e06/name:0
-#: Favorites/87b850ff-ddc5-4add-8a4f-c395c3a9ac38/name:0
-#: Pick+a+Number/0d15ae52-5ad9-4d64-9c64-e27545d48a19/name:0
+#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0
+#: Favorites/a813de57-c92a-4128-804d-56e80b332142/name:0
+#: Pick+a+Number/f3087862-dca9-4eaf-8cea-13f85cb52353/name:0
msgid "Other"
msgstr ""
-#: Pick+a+Number/bb3276d9-543b-427b-9d00-a926dabc8e24/text:0
+#: Pick+a+Number/1b0564e8-c806-4b08-9e3d-06370d9c064c/text:0
msgid "Pick a number between 1-10."
msgstr ""
-#: Favorites/a03dceb1-7ac1-491d-93ef-23d3e099633b/name:0
+#: Favorites/f1ca9ac8-d0aa-4758-a969-195be7330267/name:0
msgid "Primus"
msgstr ""
-#: Favorites/3ffb6f24-2ed8-4fd5-bcc0-b2e2668672a8/name:0
+#: Favorites/58284598-805a-4740-8966-dcb09e3b670a/name:0
msgid "Red"
msgstr ""
-#: Favorites/2ba89eb6-6981-4c0d-a19d-3cf1fde52a43/name:0
+#: Favorites/52d7a9ab-52b7-4e82-ba7f-672fb8d6ec91/name:0
msgid "Skol"
msgstr ""
-#: Favorites/cb6fc9b4-d6e9-4ed3-8a11-3f4d19654a48/text:0
+#: Favorites/1470d5e6-08dd-479b-a207-9b2b27b924d3/text:0
msgid "Sorry you can't participate right now, I'll try again later."
msgstr ""
-#: Favorites/a602e75e-0814-4034-bb95-770906ddfe34/text:0
+#: Favorites/e92b12c5-1817-468e-aa2f-8791fb6247e9/text:0
msgid "Thanks @results.name, we are all done!"
msgstr ""
-#: Favorites/58119801-ed31-4538-888d-23779a01707f/name:0
+#: Favorites/dbc3b9d2-e6ce-4ebe-9552-8ddce482c1d1/name:0
msgid "Turbo King"
msgstr ""
-#: Favorites/f4495f19-37ee-4e51-a7d5-d99ef6be147a/text:0
+#: Favorites/943f85bb-50bc-40c3-8d6f-57dbe34c87f7/text:0
msgid "What is your favorite color?"
msgstr ""
-#: Pick+a+Number/b634f07f-7b2d-47bd-8795-051e56cf2609/text:0
+#: Pick+a+Number/41f97c7a-3397-4076-95ab-3f1aa9e2acb2/text:0
msgid "You picked @results.number!"
msgstr ""
diff --git a/web/server.go b/web/server.go
index 3fa07747b..fd68fa6b3 100644
--- a/web/server.go
+++ b/web/server.go
@@ -3,7 +3,6 @@ package web
import (
"compress/flate"
"context"
- "encoding/json"
"fmt"
"net/http"
"sync"
@@ -12,6 +11,7 @@ import (
"github.com/nyaruka/gocommon/jsonx"
"github.com/nyaruka/gocommon/storage"
"github.com/nyaruka/mailroom/config"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
@@ -33,8 +33,8 @@ const (
MaxRequestBytes int64 = 1048576 * 32 // 32MB
)
-type JSONHandler func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error)
-type Handler func(ctx context.Context, s *Server, r *http.Request, w http.ResponseWriter) error
+type JSONHandler func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error)
+type Handler func(ctx context.Context, rt *runtime.Runtime, r *http.Request, w http.ResponseWriter) error
type jsonRoute struct {
method string
@@ -61,14 +61,16 @@ func RegisterRoute(method string, pattern string, handler Handler) {
}
// NewServer creates a new web server, it will need to be started after being created
-func NewServer(ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, store storage.Storage, elasticClient *elastic.Client, wg *sync.WaitGroup) *Server {
+func NewServer(ctx context.Context, config *config.Config, db *sqlx.DB, rp *redis.Pool, store storage.Storage, es *elastic.Client, wg *sync.WaitGroup) *Server {
s := &Server{
- CTX: ctx,
- RP: rp,
- DB: db,
- Storage: store,
- ElasticClient: elasticClient,
- Config: config,
+ ctx: ctx,
+ rt: &runtime.Runtime{
+ RP: rp,
+ DB: db,
+ ES: es,
+ MediaStorage: store,
+ Config: config,
+ },
wg: wg,
}
@@ -115,7 +117,7 @@ func (s *Server) WrapJSONHandler(handler JSONHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-type", "application/json")
- value, status, err := handler(r.Context(), s, r)
+ value, status, err := handler(r.Context(), s.rt, r)
// handler errored (a hard error)
if err != nil {
@@ -151,16 +153,15 @@ func (s *Server) WrapJSONHandler(handler JSONHandler) http.HandlerFunc {
// WrapHandler wraps a simple Handler, taking care of passing down server and handling errors
func (s *Server) WrapHandler(handler Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- err := handler(r.Context(), s, r, w)
+ err := handler(r.Context(), s.rt, r, w)
if err == nil {
return
}
logrus.WithError(err).WithField("http_request", r).Error("error handling request")
w.WriteHeader(http.StatusInternalServerError)
- serialized, _ := json.Marshal(NewErrorResponse(err))
+ serialized := jsonx.MustMarshal(NewErrorResponse(err))
w.Write(serialized)
- return
}
}
@@ -181,7 +182,7 @@ func (s *Server) Start() {
}
}()
- logrus.WithField("address", s.Config.Address).WithField("port", s.Config.Port).Info("server started")
+ logrus.WithField("address", s.rt.Config.Address).WithField("port", s.rt.Config.Port).Info("server started")
}
// Stop stops our web server
@@ -192,30 +193,26 @@ func (s *Server) Stop() {
}
}
-func handleIndex(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) {
+func handleIndex(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
response := map[string]string{
"url": fmt.Sprintf("%s", r.URL),
"component": "mailroom",
- "version": s.Config.Version,
+ "version": rt.Config.Version,
}
return response, http.StatusOK, nil
}
-func handle404(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) {
+func handle404(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
return errors.Errorf("not found: %s", r.URL.String()), http.StatusNotFound, nil
}
-func handle405(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) {
+func handle405(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
return errors.Errorf("illegal method: %s", r.Method), http.StatusMethodNotAllowed, nil
}
type Server struct {
- CTX context.Context
- RP *redis.Pool
- DB *sqlx.DB
- Storage storage.Storage
- Config *config.Config
- ElasticClient *elastic.Client
+ ctx context.Context
+ rt *runtime.Runtime
wg *sync.WaitGroup
diff --git a/web/server_test.go b/web/server_test.go
index b5df99b60..1748fb144 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -5,5 +5,5 @@ import (
)
func TestServer(t *testing.T) {
- RunWebTests(t, "testdata/server.json")
+ RunWebTests(t, "testdata/server.json", nil)
}
diff --git a/web/simulation/simulation.go b/web/simulation/simulation.go
index 10713b144..4d6793a2c 100644
--- a/web/simulation/simulation.go
+++ b/web/simulation/simulation.go
@@ -5,7 +5,7 @@ import (
"encoding/json"
"net/http"
- "github.com/jmoiron/sqlx"
+ "github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/assets/static/types"
"github.com/nyaruka/goflow/excellent/tools"
@@ -17,10 +17,14 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
)
+var testChannel = assets.NewChannelReference("440099cf-200c-4d45-a8e7-4a564f4a0e8b", "Test Channel")
+var testURN = urns.URN("tel:+12065551212")
+
func init() {
web.RegisterJSONRoute(http.MethodPost, "/mr/sim/start", web.RequireAuthToken(handleStart))
web.RegisterJSONRoute(http.MethodPost, "/mr/sim/resume", web.RequireAuthToken(handleResume))
@@ -114,20 +118,20 @@ func handleSimulationEvents(ctx context.Context, db models.Queryer, oa *models.O
}
// handles a request to /start
-func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleStart(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &startRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "request failed validation")
}
// grab our org assets
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
if err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load org assets")
}
// create clone of assets for simulation
- oa, err = oa.CloneForSimulation(s.CTX, s.DB, request.flows(), request.channels())
+ oa, err = oa.CloneForSimulation(ctx, rt.DB, request.flows(), request.channels())
if err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to clone org")
}
@@ -138,18 +142,18 @@ func handleStart(ctx context.Context, s *web.Server, r *http.Request) (interface
return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to read trigger")
}
- return triggerFlow(ctx, s.DB, oa, trigger)
+ return triggerFlow(ctx, rt, oa, trigger)
}
// triggerFlow creates a new session with the passed in trigger, returning our standard response
-func triggerFlow(ctx context.Context, db *sqlx.DB, oa *models.OrgAssets, trigger flows.Trigger) (interface{}, int, error) {
+func triggerFlow(ctx context.Context, rt *runtime.Runtime, oa *models.OrgAssets, trigger flows.Trigger) (interface{}, int, error) {
// start our flow session
- session, sprint, err := goflow.Simulator().NewSession(oa.SessionAssets(), trigger)
+ session, sprint, err := goflow.Simulator(rt.Config).NewSession(oa.SessionAssets(), trigger)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting session")
}
- err = handleSimulationEvents(ctx, db, oa, sprint.Events())
+ err = handleSimulationEvents(ctx, rt.DB, oa, sprint.Events())
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error handling simulation events")
}
@@ -177,25 +181,25 @@ type resumeRequest struct {
Resume json.RawMessage `json:"resume" validate:"required"`
}
-func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleResume(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &resumeRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return nil, http.StatusBadRequest, err
}
// grab our org assets
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
if err != nil {
return nil, http.StatusBadRequest, err
}
// create clone of assets for simulation
- oa, err = oa.CloneForSimulation(s.CTX, s.DB, request.flows(), request.channels())
+ oa, err = oa.CloneForSimulation(ctx, rt.DB, request.flows(), request.channels())
if err != nil {
return nil, http.StatusBadRequest, err
}
- session, err := goflow.Simulator().ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing)
+ session, err := goflow.Simulator(rt.Config).ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing)
if err != nil {
return nil, http.StatusBadRequest, err
}
@@ -230,8 +234,18 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac
}
if triggeredFlow != nil {
- trigger := triggers.NewBuilder(oa.Env(), triggeredFlow.FlowReference(), resume.Contact()).Msg(msgResume.Msg()).WithMatch(trigger.Match()).Build()
- return triggerFlow(ctx, s.DB, oa, trigger)
+ tb := triggers.NewBuilder(oa.Env(), triggeredFlow.FlowReference(), resume.Contact())
+
+ var sessionTrigger flows.Trigger
+ if triggeredFlow.FlowType() == models.FlowTypeVoice {
+ // TODO this should trigger a msg trigger with a connection but first we need to rework
+ // non-simulation IVR triggers to use that so that this is consistent.
+ sessionTrigger = tb.Manual().WithConnection(testChannel, testURN).Build()
+ } else {
+ sessionTrigger = tb.Msg(msgResume.Msg()).WithMatch(trigger.Match()).Build()
+ }
+
+ return triggerFlow(ctx, rt, oa, sessionTrigger)
}
}
}
@@ -248,7 +262,7 @@ func handleResume(ctx context.Context, s *web.Server, r *http.Request) (interfac
return nil, http.StatusInternalServerError, err
}
- err = handleSimulationEvents(ctx, s.DB, oa, sprint.Events())
+ err = handleSimulationEvents(ctx, rt.DB, oa, sprint.Events())
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error handling simulation events")
}
diff --git a/web/simulation/simulation_test.go b/web/simulation/simulation_test.go
index 8c256febc..bc7033772 100644
--- a/web/simulation/simulation_test.go
+++ b/web/simulation/simulation_test.go
@@ -11,10 +11,13 @@ import (
"testing"
"time"
+ "github.com/nyaruka/gocommon/jsonx"
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
+
"github.com/stretchr/testify/assert"
)
@@ -85,60 +88,7 @@ const (
"name": "Twitter",
"uuid": "0f661e8b-ea9d-4bd3-9953-d368340acf91"
},
- "text": "I like blue!",
- "urn": "tel:+12065551212",
- "uuid": "9bf91c2b-ce58-4cef-aacc-281e03f69ab5"
- },
- "resumed_on": "2000-01-01T00:00:00.000000000-00:00",
- "type": "msg"
- },
- "assets": {
- "channels": [
- {
- "uuid": "440099cf-200c-4d45-a8e7-4a564f4a0e8b",
- "name": "Test Channel",
- "address": "+18005551212",
- "schemes": ["tel"],
- "roles": ["send", "receive", "call"],
- "country": "US"
- }
- ]
- },
- "session": $$SESSION$$
- }`
-
- triggerResumeBody = `
- {
- "org_id": 1,
- "resume": {
- "contact": {
- "created_on": "2000-01-01T00:00:00.000000000-00:00",
- "fields": {},
- "id": 1234567,
- "language": "eng",
- "name": "Ben Haggerty",
- "timezone": "America/Guayaquil",
- "urns": [
- "tel:+12065551212"
- ],
- "uuid": "ba96bf7f-bc2a-4873-a7c7-254d1927c4e3"
- },
- "environment": {
- "allowed_languages": [
- "eng",
- "fra"
- ],
- "date_format": "YYYY-MM-DD",
- "default_language": "eng",
- "time_format": "hh:mm",
- "timezone": "America/New_York"
- },
- "msg": {
- "channel": {
- "name": "Twitter",
- "uuid": "0f661e8b-ea9d-4bd3-9953-d368340acf91"
- },
- "text": "trigger",
+ "text": "$$MESSAGE$$",
"urn": "tel:+12065551212",
"uuid": "9bf91c2b-ce58-4cef-aacc-281e03f69ab5"
},
@@ -249,10 +199,8 @@ const (
)
func TestServer(t *testing.T) {
- testsuite.Reset()
- ctx := testsuite.CTX()
- db := testsuite.DB()
- rp := testsuite.RP()
+ ctx, _, db, rp := testsuite.Reset()
+
wg := &sync.WaitGroup{}
server := web.NewServer(ctx, config.Mailroom, db, rp, nil, nil, wg)
@@ -262,51 +210,52 @@ func TestServer(t *testing.T) {
time.Sleep(time.Second)
defer server.Stop()
- session := ""
+
+ var session json.RawMessage
// add a trigger for our campaign flow with 'trigger'
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), 'trigger', false, $1, 'K', 'O', 1, 1, 1) RETURNING id`,
- models.CampaignFlowID,
- )
+ testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.CampaignFlow, "trigger", models.MatchOnly, nil, nil)
+
+ // and a trigger which will trigger an IVR flow
+ testdata.InsertKeywordTrigger(db, testdata.Org1, testdata.IVRFlow, "ivr", models.MatchOnly, nil, nil)
// also add a catch all
- db.MustExec(
- `INSERT INTO triggers_trigger(is_active, created_on, modified_on, keyword, is_archived,
- flow_id, trigger_type, match_type, created_by_id, modified_by_id, org_id)
- VALUES(TRUE, now(), now(), NULL, false, $1, 'C', NULL, 1, 1, 1) RETURNING id`,
- models.CampaignFlowID,
- )
+ testdata.InsertCatchallTrigger(db, testdata.Org1, testdata.CampaignFlow, nil, nil)
tcs := []struct {
- URL string
- Method string
- Body string
- Status int
- Response string
+ URL string
+ Method string
+ Body string
+ Message string
+ ExpectedStatus int
+ ExpectedResponse string
}{
- {"/mr/sim/start", "GET", "", 405, "illegal"},
- {"/mr/sim/start", "POST", startBody, 200, "What is your favorite color?"},
- {"/mr/sim/resume", "GET", "", 405, "illegal"},
- {"/mr/sim/resume", "POST", resumeBody, 200, "Good choice, I like Blue too! What is your favorite beer?"},
- {"/mr/sim/start", "POST", customStartBody, 200, "Your channel is Test Channel"},
- {"/mr/sim/start", "POST", startBody, 200, "What is your favorite color?"},
- {"/mr/sim/resume", "POST", triggerResumeBody, 200, "it is time to consult with your patients"},
- {"/mr/sim/resume", "POST", resumeBody, 200, "it is time to consult with your patients"},
+ {"/mr/sim/start", "GET", "", "", 405, "illegal"},
+ {"/mr/sim/start", "POST", startBody, "", 200, "What is your favorite color?"},
+ {"/mr/sim/resume", "POST", resumeBody, "I like blue!", 200, "Good choice, I like Blue too! What is your favorite beer?"},
+
+ // start with a definition of the flow to override what we have in assets
+ {"/mr/sim/start", "POST", customStartBody, "", 200, "Your channel is Test Channel"},
+
+ // start regular flow again but resume with a message that matches the campaign flow trigger
+ {"/mr/sim/start", "POST", startBody, "", 200, "What is your favorite color?"},
+ {"/mr/sim/resume", "POST", resumeBody, "trigger", 200, "it is time to consult with your patients"},
+ {"/mr/sim/resume", "POST", resumeBody, "I like blue!", 200, "it is time to consult with your patients"},
+
+ // start favorties again but this time resume with a message that matches the IVR flow trigger
+ {"/mr/sim/start", "POST", startBody, "", 200, "What is your favorite color?"},
+ {"/mr/sim/resume", "POST", resumeBody, "ivr", 200, "Hello there. Please enter one or two."},
}
for i, tc := range tcs {
- var body io.Reader
+ bodyStr := strings.Replace(tc.Body, "$$MESSAGE$$", tc.Message, -1)
// in the case of a resume, we have to sub in our session body from our start
- if strings.Contains(tc.Body, "$$SESSION$$") {
- tc.Body = strings.Replace(tc.Body, "$$SESSION$$", session, -1)
- }
+ bodyStr = strings.Replace(bodyStr, "$$SESSION$$", string(session), -1)
+ var body io.Reader
if tc.Body != "" {
- body = bytes.NewReader([]byte(tc.Body))
+ body = bytes.NewReader([]byte(bodyStr))
}
req, err := http.NewRequest(tc.Method, "http://localhost:8090"+tc.URL, body)
@@ -315,7 +264,7 @@ func TestServer(t *testing.T) {
resp, err := http.DefaultClient.Do(req)
assert.NoError(t, err, "%d: error making request", i)
- assert.Equal(t, tc.Status, resp.StatusCode, "%d: unexpected status", i)
+ assert.Equal(t, tc.ExpectedStatus, resp.StatusCode, "%d: unexpected status", i)
content, err := ioutil.ReadAll(resp.Body)
assert.NoError(t, err, "%d: error reading body", i)
@@ -324,9 +273,8 @@ func TestServer(t *testing.T) {
if resp.StatusCode == 200 {
// save the session for use in a resume
parsed := make(map[string]interface{})
- json.Unmarshal(content, &parsed)
- sessionJSON, _ := json.Marshal(parsed["session"])
- session = string(sessionJSON)
+ jsonx.MustUnmarshal(content, &parsed)
+ session = jsonx.MustMarshal(parsed["session"])
context, hasContext := parsed["context"]
if hasContext {
@@ -335,6 +283,6 @@ func TestServer(t *testing.T) {
}
}
- assert.True(t, strings.Contains(string(content), tc.Response), "%d: did not find string: %s in body: %s", i, tc.Response, string(content))
+ assert.Contains(t, string(content), tc.ExpectedResponse, "%d: did not find expected response content")
}
}
diff --git a/web/surveyor/surveyor.go b/web/surveyor/surveyor.go
index 9f846fa51..322f89779 100644
--- a/web/surveyor/surveyor.go
+++ b/web/surveyor/surveyor.go
@@ -14,6 +14,7 @@ import (
"github.com/nyaruka/goflow/utils"
"github.com/nyaruka/mailroom/core/goflow"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/web"
"github.com/pkg/errors"
@@ -49,7 +50,7 @@ type submitResponse struct {
}
// handles a surveyor request
-func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interface{}, int, error) {
+func handleSubmit(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
request := &submitRequest{}
if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "request failed validation")
@@ -57,7 +58,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac
// grab our org assets
orgID := ctx.Value(web.OrgIDKey).(models.OrgID)
- oa, err := models.GetOrgAssets(s.CTX, s.DB, orgID)
+ oa, err := models.GetOrgAssets(ctx, rt.DB, orgID)
if err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "unable to load org assets")
}
@@ -68,7 +69,7 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac
return nil, http.StatusInternalServerError, errors.Errorf("missing request user")
}
- fs, err := goflow.Engine().ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing)
+ fs, err := goflow.Engine(rt.Config).ReadSession(oa.SessionAssets(), request.Session, assets.IgnoreMissing)
if err != nil {
return nil, http.StatusBadRequest, errors.Wrapf(err, "error reading session")
}
@@ -96,12 +97,12 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac
// create / fetch our contact based on the highest priority URN
urn := fs.Contact().URNs()[0].URN()
- _, flowContact, _, err = models.GetOrCreateContact(ctx, s.DB, oa, []urns.URN{urn}, models.NilChannelID)
+ _, flowContact, _, err = models.GetOrCreateContact(ctx, rt.DB, oa, []urns.URN{urn}, models.NilChannelID)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to look up contact")
}
} else {
- _, flowContact, err = models.CreateContact(ctx, s.DB, oa, models.NilUserID, "", envs.NilLanguage, nil)
+ _, flowContact, err = models.CreateContact(ctx, rt.DB, oa, models.NilUserID, "", envs.NilLanguage, nil)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to create contact")
}
@@ -121,19 +122,17 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac
fs.SetContact(flowContact)
// append our session events to our modifiers events, the union will be used to update the db/contact
- for _, e := range sessionEvents {
- modifierEvents = append(modifierEvents, e)
- }
+ modifierEvents = append(modifierEvents, sessionEvents...)
// create our sprint
sprint := engine.NewSprint(mods, modifierEvents)
// write our session out
- tx, err := s.DB.BeginTxx(ctx, nil)
+ tx, err := rt.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting transaction for session write")
}
- sessions, err := models.WriteSessions(ctx, tx, s.RP, oa, []flows.Session{fs}, []flows.Sprint{sprint}, nil)
+ sessions, err := models.WriteSessions(ctx, tx, rt.RP, rt.MediaStorage, oa, []flows.Session{fs}, []flows.Sprint{sprint}, nil)
if err == nil && len(sessions) == 0 {
err = errors.Errorf("no sessions written")
}
@@ -146,13 +145,13 @@ func handleSubmit(ctx context.Context, s *web.Server, r *http.Request) (interfac
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error committing sessions")
}
- tx, err = s.DB.BeginTxx(ctx, nil)
+ tx, err = rt.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error starting transaction for post commit hooks")
}
// write our post commit hooks
- err = models.ApplyEventPostCommitHooks(ctx, tx, s.RP, oa, []*models.Scene{sessions[0].Scene()})
+ err = models.ApplyEventPostCommitHooks(ctx, tx, rt.RP, oa, []*models.Scene{sessions[0].Scene()})
if err != nil {
tx.Rollback()
return nil, http.StatusInternalServerError, errors.Wrapf(err, "error applying post commit hooks")
diff --git a/web/surveyor/surveyor_test.go b/web/surveyor/surveyor_test.go
index 6060beeca..3ad89b463 100644
--- a/web/surveyor/surveyor_test.go
+++ b/web/surveyor/surveyor_test.go
@@ -14,6 +14,7 @@ import (
"github.com/nyaruka/mailroom/config"
"github.com/nyaruka/mailroom/core/models"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
"github.com/buger/jsonparser"
@@ -22,7 +23,7 @@ import (
)
func TestSurveyor(t *testing.T) {
- ctx, db, rp := testsuite.Reset()
+ ctx, _, db, rp := testsuite.Reset()
rc := rp.Get()
defer rc.Close()
@@ -48,48 +49,48 @@ func TestSurveyor(t *testing.T) {
}{
{"contact_surveyor_submission.json", "", 401, "missing authorization", nil},
{"contact_surveyor_submission.json", "invalid", 401, "invalid authorization", []Assertion{
- Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 0},
+ {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 0},
}},
// new contact is created (our test db already has a bob, he should be unaffected)
{"contact_surveyor_submission.json", "sesame", 201, `"status": "C"`, []Assertion{
- Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id AND is_active = FALSE`, 1},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND org_id = 1`, 2},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1},
- Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1},
- Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1},
- Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 4},
- Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 3},
+ {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id AND is_active = FALSE`, 1},
+ {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND org_id = 1`, 2},
+ {`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0},
+ {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1},
+ {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1},
+ {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1},
+ {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 4},
+ {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 3},
}},
// dupe submission should fail due to run UUIDs being duplicated
{"contact_surveyor_submission.json", "sesame", 500, `error writing runs`, []Assertion{
- Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 1},
+ {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id`, 1},
}},
// but submission with new UUIDs should succeed, new run is created but not contact
{"contact_surveyor_submission2.json", "sesame", 201, `"status": "C"`, []Assertion{
- Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 2},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1},
- Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1},
- Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1},
- Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 8},
- Assertion{`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 6},
+ {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 2},
+ {`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0},
+ {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1},
+ {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1},
+ {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 1},
+ {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'O' AND org_id = :org_id`, 8},
+ {`SELECT count(*) FROM msgs_msg WHERE contact_id = :contact_id AND contact_urn_id IS NULL AND direction = 'I' AND org_id = :org_id`, 6},
}},
// group removal is ONLY in the modifier
{"remove_group.json", "sesame", 201, `"status": "C"`, []Assertion{
- Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 3},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1},
- Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1},
- Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0},
+ {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 3},
+ {`SELECT count(*) FROM contacts_contact WHERE uuid = 'bdfe862c-84f8-422e-8fdc-ebfaaae0697a'`, 0},
+ {`SELECT count(*) FROM contacts_contact WHERE name = 'Bob' AND fields -> :age_field_uuid = jsonb_build_object('text', '37', 'number', 37)`, 1},
+ {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123456' AND contact_id = :contact_id`, 1},
+ {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0},
}},
// new contact, new session, group and field no longer exist
{"missing_group_field.json", "sesame", 201, `"status": "C"`, []Assertion{
- Assertion{`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 1},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE uuid = 'c7fa24ca-48f9-45bf-b923-f95aa49c3cd2'`, 0},
- Assertion{`SELECT count(*) FROM contacts_contact WHERE name = 'Fred' AND fields = jsonb_build_object()`, 1},
- Assertion{`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123488' AND contact_id = :contact_id`, 1},
- Assertion{`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0},
+ {`SELECT count(*) FROM flows_flowrun WHERE flow_id = :flow_id AND contact_id = :contact_id`, 1},
+ {`SELECT count(*) FROM contacts_contact WHERE uuid = 'c7fa24ca-48f9-45bf-b923-f95aa49c3cd2'`, 0},
+ {`SELECT count(*) FROM contacts_contact WHERE name = 'Fred' AND fields = jsonb_build_object()`, 1},
+ {`SELECT count(*) FROM contacts_contacturn WHERE identity = 'tel::+593979123488' AND contact_id = :contact_id`, 1},
+ {`SELECT count(*) FROM contacts_contactgroup_contacts WHERE contact_id = :contact_id and contactgroup_id = :testers_group_id`, 0},
}},
}
@@ -102,10 +103,10 @@ func TestSurveyor(t *testing.T) {
}
args := &AssertionArgs{
- FlowID: models.SurveyorFlowID,
- OrgID: models.Org1,
- AgeFieldUUID: models.AgeFieldUUID,
- TestersGroupID: models.TestersGroupID,
+ FlowID: testdata.SurveyorFlow.ID,
+ OrgID: testdata.Org1.ID,
+ AgeFieldUUID: testdata.AgeField.UUID,
+ TestersGroupID: testdata.TestersGroup.ID,
}
for i, tc := range tcs {
@@ -114,7 +115,7 @@ func TestSurveyor(t *testing.T) {
submission, err := ioutil.ReadFile(path)
assert.NoError(t, err)
- url := fmt.Sprintf("http://localhost:8090/mr/surveyor/submit")
+ url := "http://localhost:8090/mr/surveyor/submit"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(submission))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
@@ -124,6 +125,7 @@ func TestSurveyor(t *testing.T) {
}
resp, err := http.DefaultClient.Do(req)
+ assert.NoError(t, err)
assert.Equal(t, tc.StatusCode, resp.StatusCode, "unexpected status code for %s", testID)
body, _ := ioutil.ReadAll(resp.Body)
diff --git a/web/testing.go b/web/testing.go
index 1073d3635..65e506af4 100644
--- a/web/testing.go
+++ b/web/testing.go
@@ -28,7 +28,7 @@ import (
)
// RunWebTests runs the tests in the passed in filename, optionally updating them if the update flag is set
-func RunWebTests(t *testing.T, truthFile string) {
+func RunWebTests(t *testing.T, truthFile string, substitutions map[string]string) {
rp := testsuite.RP()
db := testsuite.DB()
wg := &sync.WaitGroup{}
@@ -40,7 +40,7 @@ func RunWebTests(t *testing.T, truthFile string) {
defer testsuite.ResetStorage()
- server := NewServer(context.Background(), config.Mailroom, db, rp, testsuite.Storage(), nil, wg)
+ server := NewServer(context.Background(), config.Mailroom, db, rp, testsuite.MediaStorage(), nil, wg)
server.Start()
defer server.Stop()
@@ -69,6 +69,10 @@ func RunWebTests(t *testing.T, truthFile string) {
tcJSON, err := ioutil.ReadFile(truthFile)
require.NoError(t, err)
+ for key, value := range substitutions {
+ tcJSON = bytes.ReplaceAll(tcJSON, []byte("$"+key+"$"), []byte(value))
+ }
+
err = json.Unmarshal(tcJSON, &tcs)
require.NoError(t, err)
@@ -148,7 +152,7 @@ func RunWebTests(t *testing.T, truthFile string) {
}
for _, dba := range tc.DBAssertions {
- testsuite.AssertQueryCount(t, db, dba.Query, nil, dba.Count, "%s: '%s' returned wrong count", tc.Label, dba.Query)
+ testsuite.AssertQuery(t, db, dba.Query).Returns(dba.Count, "%s: '%s' returned wrong count", tc.Label, dba.Query)
}
} else {
diff --git a/web/ticket/assign.go b/web/ticket/assign.go
new file mode 100644
index 000000000..271ca7af8
--- /dev/null
+++ b/web/ticket/assign.go
@@ -0,0 +1,58 @@
+package ticket
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
+ "github.com/nyaruka/mailroom/web"
+ "github.com/pkg/errors"
+)
+
+func init() {
+ web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/assign", web.RequireAuthToken(web.WithHTTPLogs(handleAssign)))
+}
+
+type assignRequest struct {
+ bulkTicketRequest
+
+ AssigneeID models.UserID `json:"assignee_id"`
+ Note string `json:"note"`
+}
+
+// Assigns the tickets with the given ids to the given user
+//
+// {
+// "org_id": 123,
+// "user_id": 234,
+// "ticket_ids": [1234, 2345],
+// "assignee_id": 567,
+// "note": "please look at these"
+// }
+//
+func handleAssign(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+ request := &assignRequest{}
+ if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
+ return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
+ }
+
+ // grab our org assets
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
+ }
+
+ tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs)
+ if err != nil {
+ return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID)
+ }
+
+ evts, err := models.AssignTickets(ctx, rt.DB, oa, request.UserID, tickets, request.AssigneeID, request.Note)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrap(err, "error assigning tickets")
+ }
+
+ return newBulkResponse(evts), http.StatusOK, nil
+}
diff --git a/web/ticket/base.go b/web/ticket/base.go
new file mode 100644
index 000000000..d67a2907d
--- /dev/null
+++ b/web/ticket/base.go
@@ -0,0 +1,28 @@
+package ticket
+
+import (
+ "sort"
+
+ "github.com/nyaruka/mailroom/core/models"
+)
+
+type bulkTicketRequest struct {
+ OrgID models.OrgID `json:"org_id" validate:"required"`
+ UserID models.UserID `json:"user_id" validate:"required"`
+ TicketIDs []models.TicketID `json:"ticket_ids"`
+}
+
+type bulkTicketResponse struct {
+ ChangedIDs []models.TicketID `json:"changed_ids"`
+}
+
+func newBulkResponse(changed map[*models.Ticket]*models.TicketEvent) *bulkTicketResponse {
+ ids := make([]models.TicketID, 0, len(changed))
+ for t := range changed {
+ ids = append(ids, t.ID())
+ }
+
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+
+ return &bulkTicketResponse{ChangedIDs: ids}
+}
diff --git a/web/ticket/base_test.go b/web/ticket/base_test.go
new file mode 100644
index 000000000..f94f8846a
--- /dev/null
+++ b/web/ticket/base_test.go
@@ -0,0 +1,56 @@
+package ticket
+
+import (
+ "testing"
+
+ _ "github.com/nyaruka/mailroom/services/tickets/mailgun"
+ _ "github.com/nyaruka/mailroom/services/tickets/zendesk"
+ "github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
+ "github.com/nyaruka/mailroom/web"
+)
+
+func TestTicketAssign(t *testing.T) {
+ _, _, db, _ := testsuite.Reset()
+
+ // create 2 open tickets and 1 closed one for Cathy across two different ticketers
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", testdata.Agent)
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", nil)
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Bob, testdata.Zendesk, "Problem", "", "", nil)
+
+ web.RunWebTests(t, "testdata/assign.json", nil)
+}
+
+func TestTicketNote(t *testing.T) {
+ _, _, db, _ := testsuite.Reset()
+
+ // create 2 open tickets and 1 closed one for Cathy across two different ticketers
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", testdata.Agent)
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", nil)
+
+ web.RunWebTests(t, "testdata/note.json", nil)
+}
+
+func TestTicketClose(t *testing.T) {
+ _, _, db, _ := testsuite.Reset()
+
+ // create 2 open tickets and 1 closed one for Cathy across two different ticketers
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", nil)
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", testdata.Editor)
+
+ web.RunWebTests(t, "testdata/close.json", nil)
+}
+
+func TestTicketReopen(t *testing.T) {
+ _, _, db, _ := testsuite.Reset()
+
+ // create 2 closed tickets and 1 open one for Cathy
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Mailgun, "Need help", "Have you seen my cookies?", "17", testdata.Admin)
+ testdata.InsertClosedTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "More help", "Have you seen my cookies?", "21", nil)
+ testdata.InsertOpenTicket(db, testdata.Org1, testdata.Cathy, testdata.Zendesk, "Old question", "Have you seen my cookies?", "34", testdata.Editor)
+
+ web.RunWebTests(t, "testdata/reopen.json", nil)
+}
diff --git a/web/ticket/close.go b/web/ticket/close.go
new file mode 100644
index 000000000..3979297ba
--- /dev/null
+++ b/web/ticket/close.go
@@ -0,0 +1,60 @@
+package ticket
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/core/tasks/handler"
+ "github.com/nyaruka/mailroom/runtime"
+ "github.com/nyaruka/mailroom/web"
+ "github.com/pkg/errors"
+)
+
+func init() {
+ web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/close", web.RequireAuthToken(web.WithHTTPLogs(handleClose)))
+}
+
+// Closes any open tickets with the given ids
+//
+// {
+// "org_id": 123,
+// "user_id": 234,
+// "ticket_ids": [1234, 2345]
+// }
+//
+func handleClose(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+ request := &bulkTicketRequest{}
+ if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
+ return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
+ }
+
+ // grab our org assets
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
+ }
+
+ tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs)
+ if err != nil {
+ return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID)
+ }
+
+ evts, err := models.CloseTickets(ctx, rt.DB, oa, request.UserID, tickets, true, l)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrap(err, "error closing tickets")
+ }
+
+ rc := rt.RP.Get()
+ defer rc.Close()
+
+ for t, e := range evts {
+ err = handler.QueueTicketEvent(rc, t.ContactID(), e)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrapf(err, "error queueing ticket event for ticket %d", t.ID())
+ }
+ }
+
+ return newBulkResponse(evts), http.StatusOK, nil
+}
diff --git a/web/ticket/note.go b/web/ticket/note.go
new file mode 100644
index 000000000..e6c3c3a4f
--- /dev/null
+++ b/web/ticket/note.go
@@ -0,0 +1,56 @@
+package ticket
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
+ "github.com/nyaruka/mailroom/web"
+ "github.com/pkg/errors"
+)
+
+func init() {
+ web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/note", web.RequireAuthToken(web.WithHTTPLogs(handleNote)))
+}
+
+type noteRequest struct {
+ bulkTicketRequest
+
+ Note string `json:"note"`
+}
+
+// Assigns the tickets with the given ids to the given user
+//
+// {
+// "org_id": 123,
+// "user_id": 234,
+// "ticket_ids": [1234, 2345],
+// "note": "spam"
+// }
+//
+func handleNote(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+ request := ¬eRequest{}
+ if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
+ return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
+ }
+
+ // grab our org assets
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
+ }
+
+ tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs)
+ if err != nil {
+ return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID)
+ }
+
+ evts, err := models.NoteTickets(ctx, rt.DB, oa, request.UserID, tickets, request.Note)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrap(err, "error adding notes to tickets")
+ }
+
+ return newBulkResponse(evts), http.StatusOK, nil
+}
diff --git a/web/ticket/reopen.go b/web/ticket/reopen.go
new file mode 100644
index 000000000..11c1dba6d
--- /dev/null
+++ b/web/ticket/reopen.go
@@ -0,0 +1,50 @@
+package ticket
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/nyaruka/goflow/utils"
+ "github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
+ "github.com/nyaruka/mailroom/web"
+
+ "github.com/pkg/errors"
+)
+
+func init() {
+ web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/reopen", web.RequireAuthToken(web.WithHTTPLogs(handleReopen)))
+}
+
+// Reopens any closed tickets with the given ids
+//
+// {
+// "org_id": 123,
+// "user_id": 234,
+// "ticket_ids": [1234, 2345]
+// }
+//
+func handleReopen(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+ request := &bulkTicketRequest{}
+ if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
+ return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
+ }
+
+ // grab our org assets
+ oa, err := models.GetOrgAssets(ctx, rt.DB, request.OrgID)
+ if err != nil {
+ return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
+ }
+
+ tickets, err := models.LoadTickets(ctx, rt.DB, request.TicketIDs)
+ if err != nil {
+ return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID)
+ }
+
+ evts, err := models.ReopenTickets(ctx, rt.DB, oa, request.UserID, tickets, true, l)
+ if err != nil {
+ return nil, http.StatusBadRequest, errors.Wrapf(err, "error reopening tickets for org: %d", request.OrgID)
+ }
+
+ return newBulkResponse(evts), http.StatusOK, nil
+}
diff --git a/web/ticket/testdata/assign.json b/web/ticket/testdata/assign.json
new file mode 100644
index 000000000..2cde0f7f5
--- /dev/null
+++ b/web/ticket/testdata/assign.json
@@ -0,0 +1,64 @@
+[
+ {
+ "label": "assigns the given tickets to the given user",
+ "method": "POST",
+ "path": "/mr/ticket/assign",
+ "body": {
+ "org_id": 1,
+ "user_id": 3,
+ "ticket_ids": [
+ 1,
+ 2,
+ 3
+ ],
+ "assignee_id": 6,
+ "note": "please handle"
+ },
+ "status": 200,
+ "response": {
+ "changed_ids": [
+ 1,
+ 3
+ ]
+ },
+ "db_assertions": [
+ {
+ "query": "SELECT count(*) FROM tickets_ticket WHERE assignee_id = 6",
+ "count": 3
+ },
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'A' AND created_by_id = 3 AND note = 'please handle'",
+ "count": 2
+ }
+ ]
+ },
+ {
+ "label": "unassigns the given tickets if user is null",
+ "method": "POST",
+ "path": "/mr/ticket/assign",
+ "body": {
+ "org_id": 1,
+ "user_id": 3,
+ "ticket_ids": [
+ 1
+ ],
+ "assignee_id": null
+ },
+ "status": 200,
+ "response": {
+ "changed_ids": [
+ 1
+ ]
+ },
+ "db_assertions": [
+ {
+ "query": "SELECT count(*) FROM tickets_ticket WHERE id = 1 AND assignee_id IS NULL",
+ "count": 1
+ },
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'A' AND created_by_id = 3",
+ "count": 3
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/web/ticket/testdata/close.json b/web/ticket/testdata/close.json
index 42959e6f3..9b3916e13 100644
--- a/web/ticket/testdata/close.json
+++ b/web/ticket/testdata/close.json
@@ -13,6 +13,7 @@
"path": "/mr/ticket/close",
"body": {
"org_id": 1,
+ "user_id": 3,
"ticket_ids": [
1
]
@@ -31,6 +32,10 @@
{
"query": "SELECT count(*) FROM tickets_ticket WHERE status = 'C'",
"count": 2
+ },
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'C' AND created_by_id = 3",
+ "count": 1
}
]
},
@@ -48,6 +53,7 @@
"path": "/mr/ticket/close",
"body": {
"org_id": 1,
+ "user_id": 3,
"ticket_ids": [
1,
2
@@ -67,6 +73,10 @@
{
"query": "SELECT count(*) FROM tickets_ticket WHERE status = 'C'",
"count": 3
+ },
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'C' AND created_by_id = 3",
+ "count": 2
}
]
}
diff --git a/web/ticket/testdata/note.json b/web/ticket/testdata/note.json
new file mode 100644
index 000000000..4b0191437
--- /dev/null
+++ b/web/ticket/testdata/note.json
@@ -0,0 +1,29 @@
+[
+ {
+ "label": "adds a note to the given tickets",
+ "method": "POST",
+ "path": "/mr/ticket/note",
+ "body": {
+ "org_id": 1,
+ "user_id": 3,
+ "ticket_ids": [
+ 1,
+ 3
+ ],
+ "note": "please handle"
+ },
+ "status": 200,
+ "response": {
+ "changed_ids": [
+ 1,
+ 3
+ ]
+ },
+ "db_assertions": [
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'N' AND created_by_id = 3 AND note = 'please handle'",
+ "count": 2
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/web/ticket/testdata/reopen.json b/web/ticket/testdata/reopen.json
index 98a4e2b3d..c995abecc 100644
--- a/web/ticket/testdata/reopen.json
+++ b/web/ticket/testdata/reopen.json
@@ -13,6 +13,7 @@
"path": "/mr/ticket/reopen",
"body": {
"org_id": 1,
+ "user_id": 3,
"ticket_ids": [
1,
3
@@ -32,6 +33,10 @@
{
"query": "SELECT count(*) FROM tickets_ticket WHERE status = 'O'",
"count": 2
+ },
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'R' AND created_by_id = 3",
+ "count": 1
}
]
},
@@ -49,6 +54,7 @@
"path": "/mr/ticket/reopen",
"body": {
"org_id": 1,
+ "user_id": 3,
"ticket_ids": [
1,
2
@@ -68,6 +74,10 @@
{
"query": "SELECT count(*) FROM tickets_ticket WHERE status = 'O'",
"count": 3
+ },
+ {
+ "query": "SELECT count(*) FROM tickets_ticketevent WHERE event_type = 'R' AND created_by_id = 3",
+ "count": 2
}
]
}
diff --git a/web/ticket/ticket.go b/web/ticket/ticket.go
deleted file mode 100644
index 8c16ad67f..000000000
--- a/web/ticket/ticket.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package ticket
-
-import (
- "context"
- "net/http"
-
- "github.com/nyaruka/goflow/utils"
- "github.com/nyaruka/mailroom/core/models"
- "github.com/nyaruka/mailroom/web"
-
- "github.com/pkg/errors"
-)
-
-func init() {
- web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/close", web.RequireAuthToken(web.WithHTTPLogs(handleClose)))
- web.RegisterJSONRoute(http.MethodPost, "/mr/ticket/reopen", web.RequireAuthToken(web.WithHTTPLogs(handleReopen)))
-}
-
-type bulkTicketRequest struct {
- OrgID models.OrgID `json:"org_id" validate:"required"`
- TicketIDs []models.TicketID `json:"ticket_ids"`
-}
-
-type bulkTicketResponse struct {
- ChangedIDs []models.TicketID `json:"changed_ids"`
-}
-
-func newBulkResponse(changed []*models.Ticket) *bulkTicketResponse {
- ids := make([]models.TicketID, len(changed))
- for i := range changed {
- ids[i] = changed[i].ID()
- }
- return &bulkTicketResponse{ChangedIDs: ids}
-}
-
-// Closes any open tickets with the given ids
-//
-// {
-// "org_id": 123,
-// "ticket_ids": [1234, 2345]
-// }
-//
-func handleClose(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
- request := &bulkTicketRequest{}
- if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
- return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
- }
-
- // grab our org assets
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
- if err != nil {
- return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
- }
-
- tickets, err := models.LoadTickets(ctx, s.DB, request.OrgID, request.TicketIDs, models.TicketStatusOpen)
- if err != nil {
- return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID)
- }
-
- err = models.CloseTickets(ctx, s.DB, oa, tickets, true, l)
- if err != nil {
- return nil, http.StatusBadRequest, errors.Wrapf(err, "error closing tickets for org: %d", request.OrgID)
- }
-
- return newBulkResponse(tickets), http.StatusOK, nil
-}
-
-// Reopens any closed tickets with the given ids
-//
-// {
-// "org_id": 123,
-// "ticket_ids": [1234, 2345]
-// }
-//
-func handleReopen(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
- request := &bulkTicketRequest{}
- if err := utils.UnmarshalAndValidateWithLimit(r.Body, request, web.MaxRequestBytes); err != nil {
- return errors.Wrapf(err, "request failed validation"), http.StatusBadRequest, nil
- }
-
- // grab our org assets
- oa, err := models.GetOrgAssets(s.CTX, s.DB, request.OrgID)
- if err != nil {
- return nil, http.StatusInternalServerError, errors.Wrapf(err, "unable to load org assets")
- }
-
- tickets, err := models.LoadTickets(ctx, s.DB, request.OrgID, request.TicketIDs, models.TicketStatusClosed)
- if err != nil {
- return nil, http.StatusBadRequest, errors.Wrapf(err, "error loading tickets for org: %d", request.OrgID)
- }
-
- err = models.ReopenTickets(ctx, s.DB, oa, tickets, true, l)
- if err != nil {
- return nil, http.StatusBadRequest, errors.Wrapf(err, "error reopening tickets for org: %d", request.OrgID)
- }
-
- return newBulkResponse(tickets), http.StatusOK, nil
-}
diff --git a/web/ticket/ticket_test.go b/web/ticket/ticket_test.go
deleted file mode 100644
index 37615c5b1..000000000
--- a/web/ticket/ticket_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package ticket
-
-import (
- "testing"
-
- "github.com/nyaruka/gocommon/uuids"
- "github.com/nyaruka/goflow/flows"
- "github.com/nyaruka/mailroom/core/models"
- _ "github.com/nyaruka/mailroom/services/tickets/mailgun"
- _ "github.com/nyaruka/mailroom/services/tickets/zendesk"
- "github.com/nyaruka/mailroom/testsuite"
- "github.com/nyaruka/mailroom/testsuite/testdata"
- "github.com/nyaruka/mailroom/web"
-)
-
-func TestTicketClose(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
-
- // create 2 open tickets and 1 closed one for Cathy across two different ticketers
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.MailgunID, flows.TicketUUID(uuids.New()), "Need help", "Have you seen my cookies?", "17")
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "More help", "Have you seen my cookies?", "21")
- testdata.InsertClosedTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "Old question", "Have you seen my cookies?", "34")
-
- web.RunWebTests(t, "testdata/close.json")
-}
-
-func TestTicketReopen(t *testing.T) {
- testsuite.Reset()
- db := testsuite.DB()
-
- // create 2 closed tickets and 1 open one for Cathy
- testdata.InsertClosedTicket(t, db, models.Org1, models.CathyID, models.MailgunID, flows.TicketUUID(uuids.New()), "Need help", "Have you seen my cookies?", "17")
- testdata.InsertClosedTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "More help", "Have you seen my cookies?", "21")
- testdata.InsertOpenTicket(t, db, models.Org1, models.CathyID, models.ZendeskID, flows.TicketUUID(uuids.New()), "Old question", "Have you seen my cookies?", "34")
-
- web.RunWebTests(t, "testdata/reopen.json")
-}
diff --git a/web/wrappers.go b/web/wrappers.go
index ee8bb72d0..f5619ac1c 100644
--- a/web/wrappers.go
+++ b/web/wrappers.go
@@ -7,13 +7,14 @@ import (
"strings"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/pkg/errors"
)
// RequireUserToken wraps a JSON handler to require passing of an API token via the authorization header
func RequireUserToken(handler JSONHandler) JSONHandler {
- return func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) {
+ return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
token := r.Header.Get("authorization")
if !strings.HasPrefix(token, "Token ") {
return errors.New("missing authorization header"), http.StatusUnauthorized, nil
@@ -23,7 +24,7 @@ func RequireUserToken(handler JSONHandler) JSONHandler {
token = token[6:]
// try to look it up
- rows, err := s.DB.QueryContext(s.CTX, `
+ rows, err := rt.DB.QueryContext(ctx, `
SELECT
user_id,
org_id
@@ -58,34 +59,34 @@ func RequireUserToken(handler JSONHandler) JSONHandler {
// we are authenticated set our user id ang org id on our context and call our sub handler
ctx = context.WithValue(ctx, UserIDKey, userID)
ctx = context.WithValue(ctx, OrgIDKey, orgID)
- return handler(ctx, s, r)
+ return handler(ctx, rt, r)
}
}
// RequireAuthToken wraps a handler to require that our request to have our global authorization header
func RequireAuthToken(handler JSONHandler) JSONHandler {
- return func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) {
+ return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
auth := r.Header.Get("authorization")
- if s.Config.AuthToken != "" && fmt.Sprintf("Token %s", s.Config.AuthToken) != auth {
+ if rt.Config.AuthToken != "" && fmt.Sprintf("Token %s", rt.Config.AuthToken) != auth {
return fmt.Errorf("invalid or missing authorization header, denying"), http.StatusUnauthorized, nil
}
// we are authenticated, call our chain
- return handler(ctx, s, r)
+ return handler(ctx, rt, r)
}
}
// LoggingJSONHandler is a JSON web handler which logs HTTP logs
-type LoggingJSONHandler func(ctx context.Context, s *Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error)
+type LoggingJSONHandler func(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error)
// WithHTTPLogs wraps a handler to create a handler which can record and save HTTP logs
func WithHTTPLogs(handler LoggingJSONHandler) JSONHandler {
- return func(ctx context.Context, s *Server, r *http.Request) (interface{}, int, error) {
+ return func(ctx context.Context, rt *runtime.Runtime, r *http.Request) (interface{}, int, error) {
logger := &models.HTTPLogger{}
- response, status, err := handler(ctx, s, r, logger)
+ response, status, err := handler(ctx, rt, r, logger)
- if err := logger.Insert(ctx, s.DB); err != nil {
+ if err := logger.Insert(ctx, rt.DB); err != nil {
return nil, http.StatusInternalServerError, errors.Wrap(err, "error writing HTTP logs")
}
diff --git a/web/wrappers_test.go b/web/wrappers_test.go
index 8b7cca585..6e41a52e8 100644
--- a/web/wrappers_test.go
+++ b/web/wrappers_test.go
@@ -8,14 +8,16 @@ import (
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/mailroom/core/models"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/nyaruka/mailroom/testsuite"
+ "github.com/nyaruka/mailroom/testsuite/testdata"
"github.com/nyaruka/mailroom/web"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithHTTPLogs(t *testing.T) {
- testsuite.ResetDB()
+ ctx, rt, _, _ := testsuite.Reset()
defer httpx.SetRequestor(httpx.DefaultRequestor)
httpx.SetRequestor(httpx.NewMockRequestor(map[string][]httpx.MockResponse{
@@ -25,8 +27,8 @@ func TestWithHTTPLogs(t *testing.T) {
},
}))
- handler := func(ctx context.Context, s *web.Server, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
- ticketer, _ := models.LookupTicketerByUUID(ctx, s.DB, models.MailgunUUID)
+ handler := func(ctx context.Context, rt *runtime.Runtime, r *http.Request, l *models.HTTPLogger) (interface{}, int, error) {
+ ticketer, _ := models.LookupTicketerByUUID(ctx, rt.DB, testdata.Mailgun.UUID)
logger := l.Ticketer(ticketer)
@@ -47,9 +49,8 @@ func TestWithHTTPLogs(t *testing.T) {
}
// simulate handler being invoked by server
- server := &web.Server{DB: testsuite.DB()}
wrapped := web.WithHTTPLogs(handler)
- response, status, err := wrapped(testsuite.CTX(), server, nil)
+ response, status, err := wrapped(ctx, rt, nil)
// check response from handler
assert.Equal(t, map[string]string{"status": "OK"}, response)
@@ -57,5 +58,5 @@ func TestWithHTTPLogs(t *testing.T) {
assert.NoError(t, err)
// check HTTP logs were created
- testsuite.AssertQueryCount(t, testsuite.DB(), `select count(*) from request_logs_httplog where ticketer_id = $1;`, []interface{}{models.MailgunID}, 2)
+ testsuite.AssertQuery(t, testsuite.DB(), `select count(*) from request_logs_httplog where ticketer_id = $1;`, testdata.Mailgun.ID).Returns(2)
}
diff --git a/workers.go b/workers.go
index 3c0288134..71591015b 100644
--- a/workers.go
+++ b/workers.go
@@ -3,16 +3,19 @@ package mailroom
import (
"context"
"runtime/debug"
+ "sync"
"time"
"github.com/nyaruka/mailroom/core/queue"
+ "github.com/nyaruka/mailroom/runtime"
"github.com/sirupsen/logrus"
)
// Foreman takes care of managing our set of workers and assigns msgs for each to send
type Foreman struct {
- mr *Mailroom
+ rt *runtime.Runtime
+ wg *sync.WaitGroup
queue string
workers []*Worker
availableWorkers chan *Worker
@@ -20,9 +23,10 @@ type Foreman struct {
}
// NewForeman creates a new Foreman for the passed in server with the number of max workers
-func NewForeman(mr *Mailroom, queue string, maxWorkers int) *Foreman {
+func NewForeman(rt *runtime.Runtime, wg *sync.WaitGroup, queue string, maxWorkers int) *Foreman {
foreman := &Foreman{
- mr: mr,
+ rt: rt,
+ wg: wg,
queue: queue,
workers: make([]*Worker, maxWorkers),
availableWorkers: make(chan *Worker, maxWorkers),
@@ -56,8 +60,8 @@ func (f *Foreman) Stop() {
// Assign is our main loop for the Foreman, it takes care of popping the next outgoing task from our
// backend and assigning them to workers
func (f *Foreman) Assign() {
- f.mr.WaitGroup.Add(1)
- defer f.mr.WaitGroup.Done()
+ f.wg.Add(1)
+ defer f.wg.Done()
log := logrus.WithField("comp", "foreman")
log.WithFields(logrus.Fields{
@@ -68,7 +72,7 @@ func (f *Foreman) Assign() {
lastSleep := false
- for true {
+ for {
select {
// return if we have been told to stop
case <-f.quit:
@@ -78,7 +82,7 @@ func (f *Foreman) Assign() {
// otherwise, grab the next task and assign it to a worker
case worker := <-f.availableWorkers:
// see if we have a task to work on
- rc := f.mr.RP.Get()
+ rc := f.rt.RP.Get()
task, err := queue.PopNextTask(rc, f.queue)
rc.Close()
@@ -109,7 +113,6 @@ type Worker struct {
id int
foreman *Foreman
job chan *queue.Task
- log *logrus.Entry
}
// NewWorker creates a new worker responsible for working on events
@@ -124,14 +127,15 @@ func NewWorker(foreman *Foreman, id int) *Worker {
// Start starts our Worker's goroutine and has it start waiting for tasks from the foreman
func (w *Worker) Start() {
+ w.foreman.wg.Add(1)
+
go func() {
- w.foreman.mr.WaitGroup.Add(1)
- defer w.foreman.mr.WaitGroup.Done()
+ defer w.foreman.wg.Done()
log := logrus.WithField("queue", w.foreman.queue).WithField("worker_id", w.id)
log.Debug("started")
- for true {
+ for {
// list ourselves as available for work
w.foreman.availableWorkers <- w
@@ -166,7 +170,7 @@ func (w *Worker) handleTask(task *queue.Task) {
}
// mark our task as complete
- rc := w.foreman.mr.RP.Get()
+ rc := w.foreman.rt.RP.Get()
err := queue.MarkTaskComplete(rc, w.foreman.queue, task.OrgID)
if err != nil {
log.WithError(err)
@@ -179,7 +183,7 @@ func (w *Worker) handleTask(task *queue.Task) {
taskFunc, found := taskFunctions[task.Type]
if found {
- err := taskFunc(context.Background(), w.foreman.mr, task)
+ err := taskFunc(context.Background(), w.foreman.rt, task)
if err != nil {
log.WithError(err).WithField("task", string(task.Task)).WithField("task_type", task.Type).WithField("org_id", task.OrgID).Error("error running task")
}