DEV Community

Franz
Franz

Posted on Fully Autonomous

A customer maintenance form in Uniface 10, part 3 - a service, 30 automated tests, and the bug that came with them

At the end of part 2 the customer form worked and contained everything: e-mail rules, SQL strings, quote escaping, a number range, a duplicate query. All of it inside a form component, reachable only by a human clicking buttons.

This part does three things:

  1. Move the rules into a service (CUSTOMER_SVC).
  2. Write a test service (CUSTOMER_TST_SVC) that exercises them automatically and leaves the database untouched.
  3. Fix the bug that the refactoring exposed - 0129 - Error on field CUSTOMER_ID; subfield(s) are required - which took two attempts, because the first fix only moved it.

Why a service, concretely

A Uniface service is a component with no user interface, called with activate:

activate "CUSTOMER_SVC".VALIDATE(vLast, vFirst, vEmail, vPhone, vField, vError)
Enter fullscreen mode Exit fullscreen mode

Parameters are typed and directional (IN, OUT, INOUT), and $status carries the return value. That is enough to make the rules callable from anything - a second form, a batch job, and most importantly a test.

The split I ended up with:

Operation Parameters Responsibility
VALIDATE last/first/email/phone INOUT, pField OUT, pError OUT trim the values, enforce mandatory fields, check the e-mail
BUILD_SEARCH_WHERE pText IN, pWhere OUT build (and escape) the SQL WHERE clause
NEXT_ID pId OUT, pError OUT take the next number from the number range
COUNT_DUPLICATES id/name/e-mail IN, pCount OUT, pError OUT count possible duplicates

plus three private entries: CHECK_EMAIL, TRIM_TEXT, SQL_ESCAPE.

Note that VALIDATE has INOUT name parameters. It does not only judge the input, it normalises it - the caller gets the trimmed values back and writes them into the fields. One rule, one place.

Two things that will stop you when creating a service

1017 - Compiling a component without entities is not allowed.

A service needs at least one entity, even when it never touches data. Paint one non-database dummy entity in it:

SVC_DMY.NOMODEL
Enter fullscreen mode Exit fullscreen mode

You get a 1016 warning for it on every compile ("not found in application model, generating now..."), which is expected.

Component names are limited to 16 characters. CUSTOMER_TEST_SVC (17) was rejected with a distinctly 4GL error message:

subfield too large
Enter fullscreen mode Exit fullscreen mode

Hence CUSTOMER_TST_SVC.

The validation, in full

This is the part I most wanted under test, because e-mail checking is where everyone writes a regex they later regret. Uniface has no regex in the base language, so it is string functions - which turns out to be easy to read and easy to test:

public operation VALIDATE
params
    string pLastName : INOUT
    string pFirstName : INOUT
    string pEmail : INOUT
    string pPhone : INOUT
    string pField : OUT
    string pError : OUT
endparams
    pField = ""
    pError = ""
    call TRIM_TEXT(pLastName)
    call TRIM_TEXT(pFirstName)
    call TRIM_TEXT(pEmail)
    call TRIM_TEXT(pPhone)
    if (pLastName = "")
        pField = "LAST_NAME"
        pError = "Please enter a last name."
        return -1
    endif
    if (pFirstName = "")
        pField = "FIRST_NAME"
        pError = "Please enter a first name."
        return -1
    endif
    if (pEmail != "")
        call CHECK_EMAIL(pEmail, pError)
        if ($status < 0)
            pField = "EMAIL"
            return -1
        endif
    endif
    return 0
end

entry CHECK_EMAIL
params
    string pEmail : IN
    string pError : OUT
endparams
variables
    numeric vAt, vLen, vFrom, vCnt, vDot
    string vDomain
endvariables
    pError = "The e-mail address is not valid. Please use the format name@domain.tld."
    vAt = $scan(pEmail, "@")
    if (vAt = 0)
        pError = "The e-mail address must contain an @ character."
        return -1
    endif
    if ($scan(pEmail, " ") > 0)
        return -1
    endif
    vLen = $length(pEmail)
    if (vAt = 1 | vAt = vLen)
        return -1
    endif
    vFrom = vAt + 1
    vCnt = vLen - vAt
    vDomain = pEmail[vFrom:vCnt]
    if ($scan(vDomain, "@") > 0)
        return -1
    endif
    vDot = $scan(vDomain, ".")
    if (vDot < 2)
        return -1
    endif
    if (vDomain[vCnt:1] = ".")
        return -1
    endif
    if ($scan(vDomain, "..") > 0)
        return -1
    endif
    pError = ""
    return 0
end

entry TRIM_TEXT
params
    string pText : INOUT
endparams
    if (pText != "")
        pText = $rtrim($ltrim(pText, " "), " ")
    endif
    return 0
end
Enter fullscreen mode Exit fullscreen mode

ProcScript notes for anyone coming from another language:

  • $scan(haystack, needle) returns a 1-based position, 0 for "not found".
  • vString[vFrom:vCnt] is the substring syntax: start position and count, both 1-based.
  • $ltrim / $rtrim take the character to strip as the second argument.
  • | is "or", & is "and", ! is "not".
  • The requirement was only "the e-mail must contain an @". The extra rules (no spaces, @ not first or last, exactly one @, a dot inside the domain that is not the first or last character, no ..) are cheap once the check lives in one testable place - and the error message stays specific for the common case.

The test service

There is no xUnit here. The pattern is simple and works surprisingly well: a service whose exec operation runs everything, an assertion entry, and putmess for output.

public operation exec
    activate $instancename.RUN_ALL()
    return $status
end

public operation RUN_ALL
variables
    numeric vTests, vFailures
endvariables
    vTests = 0
    vFailures = 0
    putmess "Customer service tests started"
    call TEST_VALIDATE(vTests, vFailures)
    call TEST_SEARCH(vTests, vFailures)
    call TEST_NEXT_ID(vTests, vFailures)
    call TEST_DUPLICATES(vTests, vFailures)
    rollback
    putmess $concat("Customer service: ", vTests, " tests, ", vFailures, " failures")
    if (vFailures > 0)
        return -1
    endif
    return 0
end

entry CHECK
params
    string pName : IN
    boolean pOk : IN
    string pDetail : IN
    numeric pTests : INOUT
    numeric pFailures : INOUT
endparams
    pTests = pTests + 1
    if (pOk)
        putmess $concat("PASS: ", pName)
    else
        pFailures = pFailures + 1
        putmess $concat("FAIL: ", pName, " (", pDetail, ")")
    endif
    return 0
end
Enter fullscreen mode Exit fullscreen mode

putmess writes to the IDE log file, log\ide_<pid>.log. You start the whole suite with Compile & Test on the service and then read the tail of that file. The line you are looking for:

Customer service: 30 tests, 0 failures
Enter fullscreen mode Exit fullscreen mode

A table-driven test case

Most validation tests differ only in their input, so one parameterised entry removes all the noise:

entry VALIDATE_CASE
params
    string pName : IN
    string pLast : IN
    string pFirst : IN
    string pEmail : IN
    numeric pExpected : IN
    string pExpectedField : IN
    numeric pTests : INOUT
    numeric pFailures : INOUT
endparams
variables
    string vPhone, vField, vError, vDetail
    numeric vStatus
    boolean vOk
endvariables
    vPhone = ""
    activate "CUSTOMER_SVC".VALIDATE(pLast, pFirst, pEmail, vPhone, vField, vError)
    vStatus = $status
    vOk = (vStatus = pExpected & vField = pExpectedField)
    vDetail = "status %%(vStatus), field %%(vField), %%(vError)"
    call CHECK(pName, vOk, vDetail, pTests, pFailures)
    return 0
end
Enter fullscreen mode Exit fullscreen mode

which makes the actual test list readable:

entry TEST_VALIDATE
params
    numeric pTests : INOUT
    numeric pFailures : INOUT
endparams
    call VALIDATE_CASE("VALIDATE valid customer",        "Muster", "Max", "max@example.com",     0, "",           pTests, pFailures)
    call VALIDATE_CASE("VALIDATE empty e-mail allowed",  "Muster", "Max", "",                    0, "",           pTests, pFailures)
    call VALIDATE_CASE("VALIDATE subdomain e-mail",      "Muster", "Max", "a.b@mail.example.com", 0, "",           pTests, pFailures)
    call VALIDATE_CASE("VALIDATE missing last name",     "",       "Max", "max@example.com",    -1, "LAST_NAME",  pTests, pFailures)
    call VALIDATE_CASE("VALIDATE missing first name",    "Muster", "",    "max@example.com",    -1, "FIRST_NAME", pTests, pFailures)
    call VALIDATE_CASE("VALIDATE e-mail without @",      "Muster", "Max", "maxexample.com",     -1, "EMAIL",      pTests, pFailures)
    call VALIDATE_CASE("VALIDATE e-mail with space",     "Muster", "Max", "max @example.com",   -1, "EMAIL",      pTests, pFailures)
    call VALIDATE_CASE("VALIDATE two @ characters",      "Muster", "Max", "a@b@example.com",    -1, "EMAIL",      pTests, pFailures)
    call VALIDATE_CASE("VALIDATE domain without dot",    "Muster", "Max", "max@example",        -1, "EMAIL",      pTests, pFailures)
    return 0
end
Enter fullscreen mode Exit fullscreen mode

Assert on the field name too, not just on the status: that is what proves the cursor will land in the right box.

Testing the escaping

The one test that actually catches an injection-shaped bug:

    activate "CUSTOMER_SVC".BUILD_SEARCH_WHERE("O'Neil", vWhere)
    call CHECK("SEARCH escapes single quotes", ($scan(vWhere, "'%O''Neil%'") > 0), vWhere, pTests, pFailures)
Enter fullscreen mode Exit fullscreen mode

Testing things that write to the database

NEXT_ID increments a counter and COUNT_DUPLICATES needs a row to find. Both are tested for real - against the real database - and then undone:

  • TEST_NEXT_ID reads LAST_ID, calls NEXT_ID twice, asserts the two numbers differ by one and that the counter moved.
  • TEST_DUPLICATES inserts a customer 999999 with sql, then asserts that the same name is found, that a different name is not, that the check is case-insensitive, and that passing the record's own ID excludes it.
  • RUN_ALL ends with a single rollback.

After a full run, the customer table and the number range are exactly as they were. That is the property that makes it acceptable to run the suite against a development database whenever you like.

The bug the refactoring exposed

Everything compiled, the suite reported 30 tests, 0 failures, and the form passed a manual click-through. Then one specific sequence broke:

  1. Click New.
  2. Without typing anything, click a row in the list.
0129 - Error on field CUSTOMER_ID; subfield(s) are required.
Enter fullscreen mode Exit fullscreen mode

No dialog, just a message in the status line, and the focus stuck in the ID field. The "unsaved changes" prompt from part 2 never appeared.

Why

Since part 2, the customer number is taken from the number range inside DO_SAVE. So a freshly created occurrence has an empty primary key until it is saved. Uniface validates the occurrence when focus leaves it - and the key field is not allowed to be empty. That check runs before my getFocus trigger, so no amount of ProcScript in SELECT_ROW can get in front of it.

Fix attempt 1: assign the number when the record is created

entry DO_NEW
variables
    string vError
    numeric vNewId
endvariables
    if ($occdbmod(CUSTOMER) = 1)
        askmess/question "The current customer has unsaved changes. Do you want to discard them?~New customer", "Yes,No"
        if ($status != 1)
            return 0
        endif
        call LOAD_LIST
    endif
    activate "CUSTOMER_SVC".NEXT_ID(vNewId, vError)
    if ($status < 0)
        rollback
        message/error $concat(vError, " No new customer could be created.")
        return -1
    endif
    commit
    creocc "CUSTOMER", -1
    creocc "LIST_DMY", -1
    setocc "LIST_DMY", $curocc(CUSTOMER)
    CUSTOMER_ID.CUSTOMER = vNewId
    $prompt = LAST_NAME.CUSTOMER
    return 0
end
Enter fullscreen mode Exit fullscreen mode

The number is fetched before the occurrence is created, so a failure cannot leave a half-built record behind. The user now sees their customer number immediately, which is arguably better UX anyway. The price: abandoned "New" clicks leave gaps in the number range - normal for a number range.

This broke one thing elsewhere. DO_SAVE decided "is this a new record?" like this:

    vIsNew = (CUSTOMER_ID.CUSTOMER = "")
Enter fullscreen mode Exit fullscreen mode

which is now always false. The honest test is whether the occurrence exists in the database:

    vIsNew = ($dbocc(CUSTOMER) = 0)
Enter fullscreen mode Exit fullscreen mode

NEXT_ID stays in DO_SAVE as a fallback for a record that somehow has no number, and the error path no longer clears the ID (it is already reserved), only the timestamp.

And then the same error moved one field to the right

0129 - Error on field LAST_NAME; subfield(s) are required.
Enter fullscreen mode Exit fullscreen mode

Same mechanism, different cause: LAST_NAME and FIRST_NAME had the field syntax MAN (mandatory) in the application model. Uniface enforces MAN when the occurrence is left - again before my code, again with a message that means nothing to a user.

Since the mandatory-field rule now lives in CUSTOMER_SVC.VALIDATE, with a message that actually tells you what to do ("Please enter a last name."), MAN was removed and the syntax set to LEN(0-50). Saving still requires both names; you just find out in a sentence instead of an error number.

After that, the sequence behaves:

  • New + click a row -> the Save / Discard / Stay prompt.
  • Choosing Save on an empty record -> "Please enter a last name. The customer was not saved.", cursor in the last name field, selection unchanged.
  • Choosing Discard -> the clicked customer is loaded, the empty one is gone.

The general lesson

Two rules were being enforced in two places with two different qualities of message. The model-level checks (MAN, key completeness) fire earlier than any trigger, so whoever wins that race owns the user experience - and the platform always wins. Once a rule lives in a service with a good message, the duplicate in the model is not a safety net, it is a competitor.

It is also a good argument for the kind of test that clicks: the service suite was green the entire time this bug existed, because the bug lived exactly in the part that has no tests. Automated ProcScript tests cover the rules; a written click-through list covers the form.

What the whole thing looks like now

CUSTOMER_FRM                    CUSTOMER_SVC                CUSTOMER_TST_SVC
 exec, quit                      VALIDATE                    exec -> RUN_ALL
 LOAD_LIST / FILL_LIST           BUILD_SEARCH_WHERE          CHECK
 UPDATE_COUNT / SELECT_BY_ID     NEXT_ID                     VALIDATE_CASE
 SELECT_ROW / SORT_LIST          COUNT_DUPLICATES            TEST_VALIDATE
 DO_SEARCH / DO_NEW                                          TEST_SEARCH
 DO_SAVE / DO_DELETE             CHECK_EMAIL                 TEST_NEXT_ID
 DO_CANCEL / PROMPT_FIELD        TRIM_TEXT / SQL_ESCAPE      TEST_DUPLICATES
Enter fullscreen mode Exit fullscreen mode

Compile output, for the record:

Compile Form: 'CUSTOMER_FRM'
  warning: 1016 - (Fields for) entity SEARCH_DMY not found in application model, generating now...
  warning: 1016 - (Fields for) entity LIST_DMY  not found in application model, generating now...
  warning: 1016 - (Fields for) entity ACTION_DMY not found in application model, generating now...
  warning: 1076 - Path to painted entity CUSTOMER.CUSTOMER_MDL not found.
Compilation done: [info 2, warnings 4, errors 0]
Enter fullscreen mode Exit fullscreen mode

The next step for this application would be address fields - and that is the moment the split pays off: the address rules go into the service next to the existing ones, the tests grow by a handful of cases, and the form only learns where to paint them.

Takeaways

  • A service is the unit of testability in Uniface. Anything a form does that you would want to assert about belongs in one.
  • INOUT parameters let validation normalise, not just judge. Trim once, in the rule.
  • Return the field name with the error. It is what makes a message actionable.
  • putmess + a counter is a perfectly good test framework when the platform does not ship one. rollback at the end makes database tests repeatable.
  • Model-level syntax rules fire before your triggers. If your service owns a rule, remove the duplicate from the model - otherwise the platform's error message wins the race.
    In part 4 the application gets concurrency handling - and the platform turns out to have had it all along.

  • Error codes worth memorising: 1017 (service without an entity), 0129 (empty key or MAN field when leaving an occurrence), -300 UVALERR_SYNTAX (LEN(1-n) on an empty optional field), -2 UIOSERR_OCC_NOT_FOUND (throws in the generated read trigger).

Top comments (0)