Repository: apache/trafficcontrol
Pull request #6777 (2022-04-25 02:56:54)

Documentation fixes
<!--
Thank you for contributing! Please be sure to read our contribution guidelines: https://github.com/apache/trafficcontrol/blob/master/CONTRIBUTING.md
If this closes or relates to an existing issue, please reference it using one of the following:

Closes: N/A
Related: N/A

If this PR fixes a security vulnerability, DO NOT submit! Instead, contact
the Apache Traffic Control Security Team at security@trafficcontrol.apache.org and follow the
guidelines at https://apache.org/security regarding vulnerability disclosure.
-->

This commit fixes multiple documentation formatting issues in the Traffic Control documentation. 

<!-- **^ Add meaningful description above** --><hr/>

## Which Traffic Control components are affected by this PR?
<!-- Please delete all components from this list that are NOT affected by this PR.
Feel free to add the name of a tool or script that is affected but not on the list.
-->
- Documentation

## What is the best way to verify this PR?
<!-- Please include here ALL the steps necessary to test your PR.
If your PR has tests (and most should), provide the steps needed to run the tests.
If not, please provide step-by-step instructions to test the PR manually and explain why your PR does not need tests. -->

N/A -- Will edit once I have more guidance. 

## PR submission checklist
This is documentation. I do not believe that it fits the criteria of having a checklist at this time. (Though I could be wrong)

<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements.  See the NOTICE file
distributed with this work for additional information
regarding copyright ownership.  The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License.  You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied.  See the License for the
specific language governing permissions and limitations
under the License.
-->



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e99527e52..043163cbf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - [Traffic Ops | Traffic Go Clients | T3C] Add additional timestamp fields to server for queuing and dequeueing config and revalidate updates.
 - Added layered profile feature to 4.0 for `GET` /servers/, `POST` /servers/, `PUT` /servers/{id} and `DELETE` /servers/{id}.
 - Added a Traffic Ops endpoint and Traffic Portal page to view all CDNi configuration update requests and approve or deny.
+- Added layered profile feature to 4.0 for `GET` /servers/details.
 - Added layered profile feature to 4.0 for `GET` /deliveryservices/{id}/servers/ and /deliveryservices/{id}/servers/eligible.
 
 ### Fixed
@@ -42,6 +43,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - Fixed searching of the ds parameter merge_parent_groups slice.
 - Fixed TO API `PUT /servers/:id/status` to only queue updates on the same CDN as the updated server
 - t3c-generate fix for combining remapconfig and cachekeyconfig parameters for MakeRemapDotConfig call.
+- [#6780](https://github.com/apache/trafficcontrol/issues/6780) Fixed t3c to use secondary parents when there are no primary parents available.
 
 ### Removed
 - Remove traffic\_portal dependencies to mitigate `npm audit` issues, specifically `grunt-concurrent`, `grunt-contrib-concat`, `grunt-contrib-cssmin`, `grunt-contrib-jsmin`, `grunt-contrib-uglify`, `grunt-contrib-htmlmin`, `grunt-newer`, and `grunt-wiredep`
diff --git a/cache-config/t3c-request/t3c-request.go b/cache-config/t3c-request/t3c-request.go
index 4f94fc186..0d4142219 100644
--- a/cache-config/t3c-request/t3c-request.go
+++ b/cache-config/t3c-request/t3c-request.go
@@ -26,6 +26,7 @@ import (
 	"github.com/apache/trafficcontrol/cache-config/t3c-request/config"
 	"github.com/apache/trafficcontrol/cache-config/t3cutil"
 	"github.com/apache/trafficcontrol/cache-config/t3cutil/toreq"
+	"github.com/apache/trafficcontrol/cache-config/t3cutil/toreq/torequtil"
 	"github.com/apache/trafficcontrol/lib/go-log"
 )
 
@@ -68,5 +69,5 @@ func main() {
 			os.Exit(3)
 		}
 	}
-	cfg.TCCfg.TOClient.WriteFsCookie(toreq.FsCookiePath + cfg.TOUser + ".cookie")
+	cfg.TCCfg.TOClient.WriteFsCookie(torequtil.CookieCachePath(cfg.TOUser))
 }
diff --git a/cache-config/t3c-update/t3c-update.go b/cache-config/t3c-update/t3c-update.go
index 6b14cbaf3..98fcb1b59 100644
--- a/cache-config/t3c-update/t3c-update.go
+++ b/cache-config/t3c-update/t3c-update.go
@@ -27,6 +27,7 @@ import (
 	"github.com/apache/trafficcontrol/cache-config/t3c-update/config"
 	"github.com/apache/trafficcontrol/cache-config/t3cutil"
 	"github.com/apache/trafficcontrol/cache-config/t3cutil/toreq"
+	"github.com/apache/trafficcontrol/cache-config/t3cutil/toreq/torequtil"
 	"github.com/apache/trafficcontrol/lib/go-log"
 	"github.com/apache/trafficcontrol/lib/go-tc"
 )
@@ -89,5 +90,5 @@ func main() {
 	if cfg.RevalApplyTime != nil && !(*cfg.RevalApplyTime).Round(time.Microsecond).Equal((*cur_status.RevalidateApplyTime).Round(time.Microsecond)) {
 		log.Errorf("Failed to set reval_apply_time.\nSent: %v\nRecv: %v", *cfg.RevalApplyTime, *cur_status.RevalidateApplyTime)
 	}
-	cfg.TCCfg.TOClient.WriteFsCookie(toreq.FsCookiePath + cfg.TOUser + ".cookie")
+	cfg.TCCfg.TOClient.WriteFsCookie(torequtil.CookieCachePath(cfg.TOUser))
 }
diff --git a/cache-config/t3cutil/toreq/client.go b/cache-config/t3cutil/toreq/client.go
index 30e45637b..4125ca012 100644
--- a/cache-config/t3cutil/toreq/client.go
+++ b/cache-config/t3cutil/toreq/client.go
@@ -89,91 +89,109 @@ func (cl *TOClient) HTTPClient() *http.Client {
 	return cl.c.Client
 }
 
-const FsCookiePath = `/var/lib/trafficcontrol-cache-config/`
-
 // New logs into Traffic Ops, returning the TOClient which contains the logged-in client.
 func New(url *url.URL, user string, pass string, insecure bool, timeout time.Duration, userAgent string) (*TOClient, error) {
 	log.Infoln("URL: '" + url.String() + "' User: '" + user + "' Pass len: '" + strconv.Itoa(len(pass)) + "'")
 
-	client := &TOClient{}
-	fsCookie, err := torequtil.GetFsCookie(FsCookiePath + user + ".cookie")
+	cookiePath := torequtil.CookieCachePath(user)
+
+	fsCookie, err := torequtil.GetFsCookie(cookiePath)
 	if err != nil {
-		log.Infoln("Error retrieving cookie: ", err)
+		log.Infof("Failed to retrieve cached cookie for user '%v' at '%v', using password login: %v", user, cookiePath, err)
+		return newWithPassword(url, user, pass, insecure, timeout, userAgent)
 	}
-	toURLStr := url.Scheme + "://" + url.Host
-	log.Infoln("TO URL string: '" + toURLStr + "'")
-	log.Infoln("TO URL: '" + url.String() + "'")
-
-	latestSupported := false
-	if fsCookie.Cookies != nil {
-		toIP, err := net.LookupIP(url.Hostname())
-		if err != nil {
-			log.Warnln("error getting traffic ops IP: ", err)
-		}
-		log.Infof("Logging in to Traffic Ops '%s' with Cookie", toIP)
-		cookies := []*http.Cookie{}
-		for _, cookie := range fsCookie.Cookies {
-			cookies = append(cookies, cookie.Cookie)
-		}
-		toClient := toclient.NewSession(user, pass, toURLStr, userAgent, &http.Client{
-			Timeout: timeout,
-			Transport: &http.Transport{
-				TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
-			},
-		}, false)
-		toClient.Client.Jar, err = cookiejar.New(nil)
-		if err != nil {
-			log.Warnln("error creating cookie jar ", err)
-		}
-		toClient.Client.Jar.SetCookies(url, cookies)
-		client = &TOClient{c: toClient}
-	} else {
-		opts := toclient.Options{}
-		opts.Insecure = insecure
-		opts.UserAgent = userAgent
-		opts.RequestTimeout = timeout
-		toClient, inf, err := toclient.Login(toURLStr, user, pass, opts)
-		latestSupported = inf.StatusCode != 404 && inf.StatusCode != 501
-
-		if err != nil && latestSupported {
-			return nil, fmt.Errorf("Logging in to Traffic Ops '%v' code %v: %v", torequtil.MaybeIPStr(inf.RemoteAddr), inf.StatusCode, err)
-		} else if !latestSupported {
-			log.Infof("toreqnew.New Logged into in to Traffic Ops: got %v, falling back to older client\n", inf.StatusCode)
-		} else {
-			log.Infoln("toreqnew.New Logged into in to Traffic Ops '" + torequtil.MaybeIPStr(inf.RemoteAddr) + "'")
-		}
-		if latestSupported {
-			toAddr := net.Addr(nil)
-			latestSupported, toAddr, err = IsLatestSupported(toClient)
-			if err != nil {
-				return nil, errors.New("checking Traffic Ops '" + torequtil.MaybeIPStr(toAddr) + "' support: " + err.Error())
-			}
+
+	if fsCookie.Cookies == nil {
+		log.Infof("Cached cookie for user '%v' at '%v' not found, using password login", user, cookiePath)
+		return newWithPassword(url, user, pass, insecure, timeout, userAgent)
+	}
+
+	log.Infof("Cached cookie for user '%v' at '%v' found, attempting to reuse cookie to avoid login", user, cookiePath)
+	return newWithCookie(url, user, pass, insecure, timeout, userAgent, fsCookie)
+}
+
+func newWithPassword(url *url.URL, user string, pass string, insecure bool, timeout time.Duration, userAgent string) (*TOClient, error) {
+	opts := toclient.Options{}
+	opts.Insecure = insecure
+	opts.UserAgent = userAgent
+	opts.RequestTimeout = timeout
+
+	toURLStr := makeTOURLStr(url)
+	log.Infoln("Traffic Ops URL string: '" + toURLStr + "'")
+
+	toClient, inf, err := toclient.Login(toURLStr, user, pass, opts)
+	if err != nil {
+		if errIsUnsupportedVersion := inf.StatusCode == 404 || inf.StatusCode == 501; errIsUnsupportedVersion {
+			log.Infof("toreqnew.New logging into Traffic Ops '%v': got %v, falling back to older client\n", torequtil.MaybeIPStr(inf.RemoteAddr), inf.StatusCode)
+			return checkLatestAndFallBack(nil, url, user, pass, insecure, timeout, userAgent)
 		}
-		client = &TOClient{c: toClient}
+		return nil, fmt.Errorf("Logging in to Traffic Ops '%v' code %v: %v", torequtil.MaybeIPStr(inf.RemoteAddr), inf.StatusCode, err)
+	}
+
+	// we successfully logged in, but the login may not have used the latest API,
+	// double-check the client's API is supported.
+	return checkLatestAndFallBack(toClient, url, user, pass, insecure, timeout, userAgent)
+}
+
+func newWithCookie(url *url.URL, user string, pass string, insecure bool, timeout time.Duration, userAgent string, fsCookie torequtil.FsCookie) (*TOClient, error) {
+	toURLStr := makeTOURLStr(url)
+	log.Infoln("Traffic Ops URL string: '" + toURLStr + "'")
+
+	toClient := toclient.NewSession(user, pass, toURLStr, userAgent, &http.Client{
+		Timeout: timeout,
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
+		},
+	}, false)
+	err := error(nil)
+	toClient.Client.Jar, err = cookiejar.New(nil)
+	if err != nil {
+		return nil, errors.New("error creating cookie jar: " + err.Error())
 	}
+	toClient.Client.Jar.SetCookies(url, fsCookie.GetHTTPCookies())
+	return checkLatestAndFallBack(toClient, url, user, pass, insecure, timeout, userAgent)
+}
 
-	latestSupported, toAddr, err := IsLatestSupported(client.c)
+// checkLatestAndFallBack takes a client and checks if it supports the latest Traffic ops API.
+// If not, it attempts to fallback.
+//
+// The passed client.c may be nil if there was an error logging in, in which case it will be assumed that
+// the latest API isn't supported and fallback will be tried.
+//
+// Returns a TOClient which is the latest if supported or has fallen back to the previous API if not, and any error.
+func checkLatestAndFallBack(client *toclient.Session, url *url.URL, user string, pass string, insecure bool, timeout time.Duration, userAgent string) (*TOClient, error) {
+	latestSupported, toAddr, err := IsLatestSupported(client)
 	if err != nil {
 		return nil, errors.New("checking Traffic Ops '" + torequtil.MaybeIPStr(toAddr) + "' support: " + err.Error())
 	}
 
-	if !latestSupported {
-		log.Warnln("toreqnew.New Traffic Ops '" + torequtil.MaybeIPStr(toAddr) + "' does not support the latest client, falling back ot the previous")
+	if latestSupported {
+		log.Infof("Traffic Ops '%v' supports this client's latest API version %v, using latest client\n", torequtil.MaybeIPStr(toAddr), client.APIVersion())
+		return &TOClient{c: client}, nil
+	}
 
-		oldClient, err := toreqold.New(url, user, pass, insecure, timeout, userAgent)
-		if err != nil {
-			return nil, errors.New("logging into old client: " + err.Error())
-		}
-		client.c = nil
-		client.old = oldClient
+	log.Warnf("Traffic Ops '%v' does not support the latest client API version %v, falling back to the previous\n", LatestKnownAPIVersion(), torequtil.MaybeIPStr(toAddr))
 
-		{
-			newClient := toclient.NewNoAuthSession("", false, "", false, 0) // only used for the version, because toClient could be nil if it had an error
-			log.Infof("cache-config Traffic Ops (cookie login) client: %v not supported, falling back to %v\n", newClient.APIVersion(), oldClient.APIVersion())
-		}
+	oldClient, err := toreqold.New(url, user, pass, insecure, timeout, userAgent)
+	if err != nil {
+		return nil, errors.New("logging into old client: " + err.Error())
 	}
 
-	return client, nil
+	log.Warnf("Latest Traffic Ops client version %v not supported, falling back to %v\n", LatestKnownAPIVersion(), oldClient.APIVersion())
+
+	return &TOClient{old: oldClient}, nil
+}
+
+func LatestKnownAPIVersion() string {
+	newClient := toclient.NewNoAuthSession("", false, "", false, 0) // created for the version, because a real toClient could be nil if it had an error
+	return newClient.APIVersion()
+}
+
+// makeTOURLStr creates the Traffic Ops client URL string from uri.
+// It specifically returns the scheme and host, but drops any path in uri.
+// The uri must not be nil, and if the scheme or host is malformed the returned value will be malformed.
+func makeTOURLStr(uri *url.URL) string {
+	return uri.Scheme + "://" + uri.Host
 }
 
 // FellBack() returns whether the client fell back to the previous major version, because Traffic Ops didn't support the latest.
@@ -181,7 +199,14 @@ func (cl *TOClient) FellBack() bool {
 	return cl.c == nil
 }
 
+// IsLatestSupported returns whether toClient supports the latest API, the address of the Traffic Ops connected to (which may be nil), and any error.
+//
+// A nil toClient may be passed, in which case it will be assumed that there was an error creating it and the latest isn't supported,
+// and false will be returned with no address and no error.
 func IsLatestSupported(toClient *toclient.Session) (bool, net.Addr, error) {
+	if toClient == nil {
+		return false, nil, nil
+	}
 	_, inf, err := toClient.Ping(toclient.RequestOptions{})
 	if err != nil {
 		if errS := strings.ToLower(err.Error()); strings.Contains(errS, "not found") || strings.Contains(errS, "not implemented") {
diff --git a/cache-config/t3cutil/toreq/torequtil/torequtil.go b/cache-config/t3cutil/toreq/torequtil/torequtil.go
index 9208df465..8ea8c99c4 100644
--- a/cache-config/t3cutil/toreq/torequtil/torequtil.go
+++ b/cache-config/t3cutil/toreq/torequtil/torequtil.go
@@ -28,6 +28,7 @@ import (
 	"math"
 	"net"
 	"net/http"
+	"path/filepath"
 	"strings"
 	"time"
 
@@ -35,6 +36,16 @@ import (
 	"github.com/apache/trafficcontrol/lib/go-tc"
 )
 
+const CookieCacheDir = `/var/lib/trafficcontrol-cache-config/`
+
+func CookieCacheFileName(userName string) string {
+	return userName + ".cookie"
+}
+
+func CookieCachePath(userName string) string {
+	return filepath.Join(CookieCacheDir, CookieCacheFileName(userName))
+}
+
 type Cookie struct {
 	Cookie *http.Cookie `json:"cookie"`
 }
@@ -43,6 +54,14 @@ type FsCookie struct {
 	Cookies []Cookie `json:"cookies"`
 }
 
+func (fc *FsCookie) GetHTTPCookies() []*http.Cookie {
+	cookies := []*http.Cookie{}
+	for _, cookie := range fc.Cookies {
+		cookies = append(cookies, cookie.Cookie)
+	}
+	return cookies
+}
+
 // GetRetry attempts to get the given object, retrying with exponential backoff up to cfg.NumRetries.
 // The objName is not used in actual fetching or logic, but only for logging. It can be any printable string, but should be unique and reflect the object being fetched.
 func GetRetry(numRetries int, objName string, obj interface{}, getter func(obj interface{}) error) error {
diff --git a/docs/source/admin/quick_howto/ciab.rst b/docs/source/admin/quick_howto/ciab.rst
index 3dbfa2a8f..428152472 100644
--- a/docs/source/admin/quick_howto/ciab.rst
+++ b/docs/source/admin/quick_howto/ciab.rst
@@ -200,7 +200,7 @@ Importing the :abbr:`CA (Certificate Authority)` Certificate on OSX
 #. The CIAB root :abbr:`CA (Certificate Authority)` certificate appears in login.
 #. Copy the CIAB root :abbr:`CA (Certificate Authority)` certificate to System.
 #. Open the CIAB root :abbr:`CA (Certificate Authority)` certificate, expand :guilabel:`Trust`, select :guilabel:`Use System Defaults`, and save your changes.
-#. Reopen the CIAB root :abbr:`CA (Certificate Authority)` certificate, expand :guilabel:`Trust, select :guilabel:`Always Trust`, and save your changes.
+#. Reopen the CIAB root :abbr:`CA (Certificate Authority)` certificate, expand :guilabel:`Trust`, select :guilabel:`Always Trust`, and save your changes.
 #. Delete the CIAB root :abbr:`CA (Certificate Authority)` certificate from login.
 #. Repeat the previous steps with the Intermediate :abbr:`CA (Certificate Authority)` certificate to import it as well
 #. Restart all HTTPS clients (browsers, etc).
@@ -308,7 +308,7 @@ The TightVNC optional container provides a basic lightweight window manager (flu
 			`"-f $PWD/docker-compose.yml "` \
 			`"-f $PWD/docker-compose.expose-ports.yml "` \
 			`"-f $PWD/optional/docker-compose.vnc.yml "` \
-			`"-f $PWD/optional/docker-compose.vnc.expose-ports.yml"
+			`"-f $PWD/optional/docker-compose.vnc.expose-ports.yml "`
 		docker volume prune -f
 		mydc build
 		mydc kill
diff --git a/docs/source/admin/traffic_ops.rst b/docs/source/admin/traffic_ops.rst
index 601391b50..d0b1d8412 100644
--- a/docs/source/admin/traffic_ops.rst
+++ b/docs/source/admin/traffic_ops.rst
@@ -682,7 +682,7 @@ You must then take the output file :file:`trafficops.csr` and submit a request t
 	sudo cp trafficops.crt /etc/pki/tls/certs
 	sudo chown trafops:trafops /etc/pki/tls/certs/trafficops.crt
 
-If necessary, install the :abbr:`CA (Certificate Authority) certificate's ``.pem`` and ``.crt`` files in ``/etc/pki/tls/certs``.
+If necessary, install the :abbr:`CA (Certificate Authority)` certificate's ``.pem`` and ``.crt`` files in ``/etc/pki/tls/certs``.
 
 You will need to update `cdn.conf`_ with any necessary changes.
 
diff --git a/docs/source/admin/traffic_server.rst b/docs/source/admin/traffic_server.rst
index 6cbe8a552..0b90f2a09 100644
--- a/docs/source/admin/traffic_server.rst
+++ b/docs/source/admin/traffic_server.rst
@@ -145,7 +145,7 @@ When processing configuration files, if ``traffic_ops_ort`` encounters any of th
 	| ``__SERVER_TCP_PORT__`` | If the :term:`cache server` on which ``t3c`` is being run has a    |
 	|                         | TCP port configured to something besides ``80``, this will be      |
 	|                         | replaced with that TCP port value. *If it* **is** *set to*         |
-	|                         | *``80``, this string will simply be removed,* **NOT** *replaced*   |
+	|                         | ``80``, *this string will simply be removed,* **NOT** *replaced*   |
 	|                         | *with* **ANYTHING**.                                               |
 	+-------------------------+--------------------------------------------------------------------+
 	| ``##OVERRIDE##``        | This string is only valid in the content of files named            |
diff --git a/docs/source/api/v3/steering.rst b/docs/source/api/v3/steering.rst
index 9a4924773..7695ebfe2 100644
--- a/docs/source/api/v3/steering.rst
+++ b/docs/source/api/v3/steering.rst
@@ -45,7 +45,7 @@ Response Structure
 ------------------
 :deliveryService:               A string that is the :ref:`ds-xmlid` of the steering :term:`Delivery Service`
 :clientSteering:                Whether this is a :ref:`client steering <ds-client-steering>` Delivery Service.
-:targets:                       The delivery services that the :ref`Steering Delivery Service <tr-steering>` targets.
+:targets:                       The delivery services that the :ref:`Steering Delivery Service <tr-steering>` targets.
 
 	:order:                 If this is a :ref:`STEERING_ORDER <ds-steering-order>` target, this is the value of the order. Otherwise, ``0``.
 	:weight:                If this is a :ref:`STEERING_WEIGHT <ds-steering-weight>` target, this is the value of the weight. Otherwise, ``0``.
diff --git a/docs/source/api/v4/servers_details.rst b/docs/source/api/v4/servers_details.rst
index afc95ed6a..dc0a917b5 100644
--- a/docs/source/api/v4/servers_details.rst
+++ b/docs/source/api/v4/servers_details.rst
@@ -98,8 +98,7 @@ Response Structure
 	:mgmtIpNetmask:  The IPv4 subnet mask of the server's management port
 	:offlineReason:         A user-entered reason why the server is in ADMIN_DOWN or OFFLINE status
 	:physLocation:          The name of the physical location where the server resides
-	:profile:               The :ref:`profile-name` of the :term:`Profile` used by this server
-	:profileDesc:           A :ref:`profile-description` of the :term:`Profile` used by this server
+	:profileNames:          List of :ref:`profile-name` of the :term:`Profiles` used by this server
 	:rack:  A string indicating "server rack" location
 	:status:                The status of the server
 
@@ -157,8 +156,7 @@ Response Structure
 				"mgmtIpNetmask": "",
 				"offlineReason": "",
 				"physLocation": "Apachecon North America 2018",
-				"profile": "ATS_EDGE_TIER_CACHE",
-				"profileDesc": "Edge Cache - Apache Traffic Server",
+				"profileNames": ["ATS_EDGE_TIER_CACHE"],
 				"rack": "",
 				"status": "REPORTED",
 				"tcpPort": 80,
diff --git a/docs/source/api/v4/steering.rst b/docs/source/api/v4/steering.rst
index e367fe744..8c36fe463 100644
--- a/docs/source/api/v4/steering.rst
+++ b/docs/source/api/v4/steering.rst
@@ -46,7 +46,7 @@ Response Structure
 ------------------
 :deliveryService:               A string that is the :ref:`ds-xmlid` of the steering :term:`Delivery Service`
 :clientSteering:                Whether this is a :ref:`client steering <ds-client-steering>` Delivery Service.
-:targets:                       The delivery services that the :ref`Steering Delivery Service <tr-steering>` targets.
+:targets:                       The delivery services that the :ref:`Steering Delivery Service <tr-steering>` targets.
 
 	:order:                 If this is a :ref:`STEERING_ORDER <ds-steering-order>` target, this is the value of the order. Otherwise, ``0``.
 	:weight:                If this is a :ref:`STEERING_WEIGHT <ds-steering-weight>` target, this is the value of the weight. Otherwise, ``0``.
diff --git a/docs/source/development/traffic_ops.rst b/docs/source/development/traffic_ops.rst
index 51174f807..68c5eda12 100644
--- a/docs/source/development/traffic_ops.rst
+++ b/docs/source/development/traffic_ops.rst
@@ -478,7 +478,7 @@ The integration tests are run using :manpage:`go-test(1)`, with two configuratio
 
 	Specify the path to a file containing static data for the tests to use. This should almost never be used, because many of the tests depend on the data having a certain content and structure. If not specified, it will attempt to read a file named ``tc-fixtures.json`` in the working directory.
 
-.. option:: --includeSystemTests ``no``/``yes``
+.. option:: --includeSystemTests {no|yes}
 
 	Specify whether to run tests that depend on additional components like an SMTP server or a Traffic Vault server. Default: ``no``
 
diff --git a/docs/source/glossary.rst b/docs/source/glossary.rst
index 84c564fb0..9e89396f7 100644
--- a/docs/source/glossary.rst
+++ b/docs/source/glossary.rst
@@ -131,7 +131,7 @@ Glossary
 		.. seealso:: See :ref:`delivery-service-requests` for a more in-depth explanation of :dfn:`Delivery Service Requests`, including their data model. See :ref:`ds_requests` for more information on how to use :dfn:`Delivery Service Requests` in Traffic Portal.
 
 	Delivery Service required capabilities
-		:dfn:`Delivery Services required capabilities` are capabilities, which correlate to server capabilities, that are required in order to assign a server to a delivery service.`
+		:dfn:`Delivery Services required capabilities` are capabilities, which correlate to server capabilities, that are required in order to assign a server to a delivery service.
 
 	Division
 	Divisions
diff --git a/docs/source/overview/delivery_services.rst b/docs/source/overview/delivery_services.rst
index 7b7e7e6f2..8c7932fa1 100644
--- a/docs/source/overview/delivery_services.rst
+++ b/docs/source/overview/delivery_services.rst
@@ -295,7 +295,7 @@ Geo Limit Redirect URL
 If `Geo Limit`_ is being used with this Delivery Service, this is optionally a URL to which clients will be redirected when Traffic Router determines that they are not in a geographic zone that permits their access to the Delivery Service content. This changes the response from Traffic Router from ``503 Service Unavailable`` to ``302 Found`` with a provided location that will be this URL. There is no restriction on the provided URL; it may even be the path to a resource served by this Delivery Service. In fact, this field need not even be a full URL, it can be a relative path. Both of these cases are handled specially by Traffic Router.
 
 - If the provided URL is a resource served by the Delivery Service (e.g. if the client requests ``http://cdn.dsXMLID.somedomain.example.com/index.html`` but are denied access by `Geo Limit`_ and the Geo Limit Redirect URL is something like ``http://cdn.dsXMLID.somedomain.example.com/help.php``), Traffic Router will find an appropriate :term:`Edge-tier cache server` and redirect the client, ignoring Geo Limit restrictions *for this request only*.
-- If the provided "URL" is actually a relative path, it will be considered *relative to the requested Delivery Service :abbr:`FQDN (Fully Qualified Domain Name)`*. This means that e.g. if the client requests ``http://cdn.dsXMLID.somedomain.example.com/index.html`` but are denied access by `Geo Limit`_ and the Geo Limit Redirect URL is something like ``/help.php``, Traffic Router will find an appropriate :term:`Edge-tier cache server` and redirect the client to it as though they had requested ``http://cdn.dsXMLID.somedomain.example.com/help.php``, ignoring `Geo Limit`_ restrictions *for this request only*.
+- If the provided "URL" is actually a relative path, it will be considered *relative to the requested Delivery Service* :abbr:`FQDN (Fully Qualified Domain Name)`. This means that e.g. if the client requests ``http://cdn.dsXMLID.somedomain.example.com/index.html`` but are denied access by `Geo Limit`_ and the Geo Limit Redirect URL is something like ``/help.php``, Traffic Router will find an appropriate :term:`Edge-tier cache server` and redirect the client to it as though they had requested ``http://cdn.dsXMLID.somedomain.example.com/help.php``, ignoring `Geo Limit`_ restrictions *for this request only*.
 
 .. table:: Aliases
 
@@ -997,6 +997,10 @@ The following :term:`Parameters` must have the :ref:`Config File <parameter-conf
 
 	.. caution:: The actual permitted TLS versions are the union of those laid out in this :term:`Parameter` and those configured as the `TLS Versions`_ property of the Delivery Service.
 
+- ``use_peering`` - on a Deliver Service :term:`Profile`, if this exists and is ``true``, the ``strategy ring_mode`` will be set to ``peering_ring`` for large library DNS support.
+
+	.. impl-detail:: This :term:`Parameter` does not affect the contents of ``parent.config``, but instead ``strategies.yaml`` in :abbr:`ATS (Apache Traffic Server)` 9. It has the ``parent.config`` :ref:`parameter-config-file` value for consistency.
+
 	.. deprecated:: ATCv6.2
 		In :ref:`to-api` version 4 (unstable at the time of this writing), TLS versions should be configured using the `TLS Versions`_ property of the Delivery Service, and support for this :term:`Parameter` will be removed at some point after the stabilization of :ref:`to-api` version 4.
 
diff --git a/lib/go-atscfg/parentabstraction.go b/lib/go-atscfg/parentabstraction.go
index 7259f3c59..7c4c19033 100644
--- a/lib/go-atscfg/parentabstraction.go
+++ b/lib/go-atscfg/parentabstraction.go
@@ -28,6 +28,9 @@ import (
 // ParentAbstraction contains all the data necessary to build either parent.config or strategies.yaml.
 type ParentAbstraction struct {
 	Services []*ParentAbstractionService
+	// Peers is the list of peer proxy caches to be used in a strategy peering group.
+	// a cache will only have one set of peers for potential use in all delivery services.
+	Peers []*ParentAbstractionServiceParent
 }
 
 // ParentAbstractionService represents a single delivery service's parent data.
@@ -68,6 +71,12 @@ type ParentAbstractionService struct {
 	// Becomes strategies.yaml TODO
 	SecondaryMode ParentAbstractionServiceParentSecondaryMode
 
+	// CachePeerResult is used only when the RetryPolicy is set to
+	// 'consistent_hash' and the SecondaryMode is set to 'peering'.
+	// In the case that it's used and set to 'true', query results
+	// from peer caches will not be cached locally.
+	CachePeerResult bool
+
 	// GoDirect is whether to go direct to parents via normal HTTP requests.
 	// False means to make proxy requests to the parents.
 	// Becomes parent.config go_direct and parent_is_proxy directives
@@ -131,6 +140,7 @@ type ParentAbstractionServiceParentSecondaryMode string
 
 const ParentAbstractionServiceParentSecondaryModeExhaust = ParentAbstractionServiceParentSecondaryMode("exhaust")
 const ParentAbstractionServiceParentSecondaryModeAlternate = ParentAbstractionServiceParentSecondaryMode("alternate")
+const ParentAbstractionServiceParentSecondaryModePeering = ParentAbstractionServiceParentSecondaryMode("peering")
 const ParentAbstractionServiceParentSecondaryModeInvalid = ParentAbstractionServiceParentSecondaryMode("")
 
 const ParentAbstractionServiceParentSecondaryModeDefault = ParentAbstractionServiceParentSecondaryModeAlternate
diff --git a/lib/go-atscfg/parentdotconfig.go b/lib/go-atscfg/parentdotconfig.go
index 9adc67a6d..f993ba53a 100644
--- a/lib/go-atscfg/parentdotconfig.go
+++ b/lib/go-atscfg/parentdotconfig.go
@@ -65,6 +65,7 @@ const ParentConfigCacheParamPort = "port"
 const ParentConfigCacheParamUseIP = "use_ip_address"
 const ParentConfigCacheParamRank = "rank"
 const ParentConfigCacheParamNotAParent = "not_a_parent"
+const StrategyConfigUsePeering = "use_peering"
 
 type OriginHost string
 type OriginFQDN string
@@ -237,6 +238,7 @@ func makeParentDotConfigData(
 
 	nameTopologies := makeTopologyNameMap(topologies)
 
+	cgPeers := map[int]Server{}   // map[serverID]server
 	cgServers := map[int]Server{} // map[serverID]server
 	for _, sv := range servers {
 		if sv.ID == nil {
@@ -258,6 +260,15 @@ func makeParentDotConfigData(
 		if *sv.CDNName != *server.CDNName {
 			continue
 		}
+		// save cachegroup peer servers
+		if *sv.CDNName == *server.CDNName && *sv.Cachegroup == *server.Cachegroup {
+			if *sv.Status == string(tc.CacheStatusReported) || *sv.Status == string(tc.CacheStatusOnline) {
+				if _, ok := cgPeers[*sv.ID]; !ok {
+					cgPeers[*sv.ID] = sv
+				}
+			}
+			continue
+		}
 		if _, ok := parentCacheGroups[*sv.Cachegroup]; !ok {
 			continue
 		}
@@ -272,6 +283,15 @@ func makeParentDotConfigData(
 		cgServers[*sv.ID] = sv
 	}
 
+	// save the cache group peers
+	for _, v := range cgPeers {
+		peer := &ParentAbstractionServiceParent{}
+		peer.FQDN = *v.HostName + "." + *v.DomainName
+		peer.Port = *v.TCPPort
+		peer.Weight = 0.999
+		parentAbstraction.Peers = append(parentAbstraction.Peers, peer)
+	}
+
 	cgServerIDs := map[int]struct{}{}
 	for serverID, _ := range cgServers {
 		cgServerIDs[serverID] = struct{}{}
@@ -472,6 +492,11 @@ func makeParentDotConfigData(
 			text := &ParentAbstractionService{}
 			text.Name = *ds.XMLID
 
+			// peering ring check
+			if dsParams.UsePeering {
+				secondaryMode = ParentAbstractionServiceParentSecondaryModePeering
+			}
+
 			orgFQDNStr := *ds.OrgServerFQDN
 			// if this cache isn't the last tier, i.e. we're not going to the origin, use http not https
 			if isLastCacheTier := noTopologyServerIsLastCacheForDS(server, &ds); !isLastCacheTier {
@@ -814,6 +839,7 @@ type parentDSParams struct {
 	MaxUnavailableServerRetries     string
 	QueryStringHandling             string
 	TryAllPrimariesBeforeSecondary  bool
+	UsePeering                      bool
 	MergeGroups                     []string
 }
 
@@ -839,7 +865,11 @@ func getParentDSParams(ds DeliveryService, profileParentConfigParams map[string]
 	if !ok {
 		return params, warnings
 	}
-
+	if val, ok := dsParams[StrategyConfigUsePeering]; ok {
+		if val == "true" {
+			params.UsePeering = true
+		}
+	}
 	// the following may be blank, no default
 	params.QueryStringHandling = dsParams[ParentConfigParamQStringHandling]
 	params.MergeGroups = strings.Split(dsParams[ParentConfigParamMergeGroups], " ")
@@ -970,7 +1000,13 @@ func getTopologyParentConfigLine(
 		return nil, warnings, errors.New("getting topology parents for '" + *ds.XMLID + "': skipping! " + err.Error())
 	}
 	if len(parents) == 0 {
-		return nil, warnings, errors.New("getting topology parents for '" + *ds.XMLID + "': no parents found! skipping! (Does your Topology have a CacheGroup with no servers in it?)")
+		if len(secondaryParents) > 0 {
+			warnings = append(warnings, "getting topology parents for '"+*ds.XMLID+"': no parents found! using secondary parents")
+			parents = secondaryParents
+			secondaryParents = nil
+		} else {
+			return nil, warnings, errors.New("getting topology parents for '" + *ds.XMLID + "': no parents found! skipping! (Does your Topology have a CacheGroup with no servers in it?)")
+		}
 	}
 
 	txt.Parents = parents
diff --git a/lib/go-atscfg/strategiesdotconfig.go b/lib/go-atscfg/strategiesdotconfig.go
index b568899d6..bd529a03c 100644
--- a/lib/go-atscfg/strategiesdotconfig.go
+++ b/lib/go-atscfg/strategiesdotconfig.go
@@ -147,6 +147,8 @@ func getStrategySecondaryMode(mode ParentAbstractionServiceParentSecondaryMode)
 		return `exhaust_ring`
 	case ParentAbstractionServiceParentSecondaryModeAlternate:
 		return `alternate_ring`
+	case ParentAbstractionServiceParentSecondaryModePeering:
+		return `peering_ring`
 	default:
 		return getStrategySecondaryMode(ParentAbstractionServiceParentSecondaryModeDefault)
 	}
@@ -162,11 +164,18 @@ func getStrategyErrorCodes(codes []int) string {
 
 func getStrategyGroups(svc *ParentAbstractionService) string {
 	txt := ""
-	if len(svc.Parents) != 0 {
-		txt += "\n" + `      - *group_parents_` + svc.Name
-	}
-	if len(svc.SecondaryParents) != 0 {
-		txt += "\n" + `      - *group_secondary_parents_` + svc.Name
+	if getStrategySecondaryMode(svc.SecondaryMode) == "peering_ring" {
+		txt += "\n" + `      - *peers_group`
+		if len(svc.Parents) != 0 {
+			txt += "\n" + `      - *group_parents_` + svc.Name
+		}
+	} else {
+		if len(svc.Parents) != 0 {
+			txt += "\n" + `      - *group_parents_` + svc.Name
+		}
+		if len(svc.SecondaryParents) != 0 {
+			txt += "\n" + `      - *group_secondary_parents_` + svc.Name
+		}
 	}
 	return txt
 }
@@ -182,6 +191,9 @@ func getStrategyStrategiesSection(pa *ParentAbstraction) string {
 			txt += "\n" + `    hash_key: path`
 		}
 		txt += "\n" + `    go_direct: ` + strconv.FormatBool(svc.GoDirect)
+		if getStrategySecondaryMode(svc.SecondaryMode) == "peering_ring" {
+			txt += "\n" + `    cache_peer_result: ` + strconv.FormatBool(svc.CachePeerResult)
+		}
 		txt += "\n" + `    groups:`
 		txt += getStrategyGroups(svc)
 		// TODO make strategies for both? add to parent?
@@ -237,6 +249,13 @@ func getStrategyGroupsSection(pa *ParentAbstraction) string {
 			txt += "\n" + `      weight: ` + strconv.FormatFloat(parent.Weight, 'f', 3, 64)
 		}
 	}
+	if len(pa.Peers) != 0 {
+		txt += "\n" + `  - &peers_group`
+		for i, peer := range pa.Peers {
+			txt += "\n" + `    - << *peer` + strconv.Itoa(i+1)
+			txt += "\n" + `      weight: ` + strconv.FormatFloat(peer.Weight, 'f', 3, 64)
+		}
+	}
 	return txt
 }
 
@@ -256,6 +275,22 @@ func getStrategyHostsSection(pa *ParentAbstraction) string {
 			txt += getStrategyHostsSectionHost(svc, parent)
 		}
 	}
+	if len(pa.Peers) != 0 {
+		count := 1
+		for _, peer := range pa.Peers {
+			txt += getStrategyHostsSectionPeer(peer, count)
+			count++
+		}
+	}
+	return txt
+}
+
+func getStrategyHostsSectionPeer(peer *ParentAbstractionServiceParent, count int) string {
+	txt := ""
+	txt += "\n" + `  - &peer` + strconv.Itoa(count)
+	txt += "\n" + `    host: ` + peer.FQDN
+	txt += "\n" + `    protocol:`
+	txt += "\n" + `      - port: ` + strconv.Itoa(peer.Port)
 	return txt
 }
 
diff --git a/lib/go-tc/servers.go b/lib/go-tc/servers.go
index 23971316b..2f435cdb3 100644
--- a/lib/go-tc/servers.go
+++ b/lib/go-tc/servers.go
@@ -87,8 +87,33 @@ type ServerDetailV30 struct {
 
 // ServerDetailV40 is the details for a server for API v4.
 type ServerDetailV40 struct {
-	ServerDetail
-	ServerInterfaces []ServerInterfaceInfoV40 `json:"interfaces"`
+	CacheGroup         *string                  `json:"cachegroup" db:"cachegroup"`
+	CDNName            *string                  `json:"cdnName" db:"cdn_name"`
+	DeliveryServiceIDs []int64                  `json:"deliveryservices,omitempty"`
+	DomainName         *string                  `json:"domainName" db:"domain_name"`
+	GUID               *string                  `json:"guid" db:"guid"`
+	HardwareInfo       map[string]string        `json:"hardwareInfo"`
+	HostName           *string                  `json:"hostName" db:"host_name"`
+	HTTPSPort          *int                     `json:"httpsPort" db:"https_port"`
+	ID                 *int                     `json:"id" db:"id"`
+	ILOIPAddress       *string                  `json:"iloIpAddress" db:"ilo_ip_address"`
+	ILOIPGateway       *string                  `json:"iloIpGateway" db:"ilo_ip_gateway"`
+	ILOIPNetmask       *string                  `json:"iloIpNetmask" db:"ilo_ip_netmask"`
+	ILOPassword        *string                  `json:"iloPassword" db:"ilo_password"`
+	ILOUsername        *string                  `json:"iloUsername" db:"ilo_username"`
+	MgmtIPAddress      *string                  `json:"mgmtIpAddress" db:"mgmt_ip_address"`
+	MgmtIPGateway      *string                  `json:"mgmtIpGateway" db:"mgmt_ip_gateway"`
+	MgmtIPNetmask      *string                  `json:"mgmtIpNetmask" db:"mgmt_ip_netmask"`
+	OfflineReason      *string                  `json:"offlineReason" db:"offline_reason"`
+	PhysLocation       *string                  `json:"physLocation" db:"phys_location"`
+	ProfileNames       []string                 `json:"profileNames" db:"profile_name"`
+	Rack               *string                  `json:"rack" db:"rack"`
+	Status             *string                  `json:"status" db:"status"`
+	TCPPort            *int                     `json:"tcpPort" db:"tcp_port"`
+	Type               string                   `json:"type" db:"server_type"`
+	XMPPID             *string                  `json:"xmppId" db:"xmpp_id"`
+	XMPPPasswd         *string                  `json:"xmppPasswd" db:"xmpp_passwd"`
+	ServerInterfaces   []ServerInterfaceInfoV40 `json:"interfaces"`
 }
 
 // ServersV1DetailResponse is the JSON object returned for a single server for v1.
diff --git a/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go b/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
index e024126d0..dc029086b 100644
--- a/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
+++ b/traffic_ops/traffic_ops_golang/dbhelpers/db_helpers.go
@@ -2091,6 +2091,43 @@ func UpdateServerProfileTableForV2V3(id *int, newProfile *string, origProfile st
 	return profileName, nil
 }
 
+// GetServerDetailFromV4 function converts server details from V4 to V3/V2
+func GetServerDetailFromV4(sd tc.ServerDetailV40, tx *sql.Tx) (tc.ServerDetail, error) {
+	var profileDesc *string
+	if err := tx.QueryRow(`SELECT p.description FROM profile p WHERE p.name=$1`, sd.ProfileNames[0]).Scan(&profileDesc); err != nil {
+		return tc.ServerDetail{}, fmt.Errorf("querying profile description by profile name: %w", err)
+	}
+	return tc.ServerDetail{
+		CacheGroup:         sd.CacheGroup,
+		CDNName:            sd.CDNName,
+		DeliveryServiceIDs: sd.DeliveryServiceIDs,
+		DomainName:         sd.DomainName,
+		GUID:               sd.GUID,
+		HardwareInfo:       sd.HardwareInfo,
+		HostName:           sd.HostName,
+		HTTPSPort:          sd.HTTPSPort,
+		ID:                 sd.ID,
+		ILOIPAddress:       sd.ILOIPAddress,
+		ILOIPGateway:       sd.ILOIPGateway,
+		ILOIPNetmask:       sd.ILOIPNetmask,
+		ILOPassword:        sd.ILOPassword,
+		ILOUsername:        sd.ILOUsername,
+		MgmtIPAddress:      sd.MgmtIPAddress,
+		MgmtIPGateway:      sd.MgmtIPGateway,
+		MgmtIPNetmask:      sd.MgmtIPNetmask,
+		OfflineReason:      sd.OfflineReason,
+		PhysLocation:       sd.PhysLocation,
+		Profile:            &sd.ProfileNames[0],
+		ProfileDesc:        profileDesc,
+		Rack:               sd.Rack,
+		Status:             sd.Status,
+		TCPPort:            sd.TCPPort,
+		Type:               sd.Type,
+		XMPPID:             sd.XMPPID,
+		XMPPPasswd:         sd.XMPPPasswd,
+	}, nil
+}
+
 // GetProfileIDDesc gets profile ID and desc for V3 servers
 func GetProfileIDDesc(tx *sql.Tx, name string) (id int, desc string) {
 	err := tx.QueryRow(`SELECT id, description from "profile" p WHERE p.name=$1`, name).Scan(&id, &desc)
diff --git a/traffic_ops/traffic_ops_golang/server/detail.go b/traffic_ops/traffic_ops_golang/server/detail.go
index 7f2eb9b1f..aa45dd955 100644
--- a/traffic_ops/traffic_ops_golang/server/detail.go
+++ b/traffic_ops/traffic_ops_golang/server/detail.go
@@ -91,7 +91,11 @@ func GetDetailParamHandler(w http.ResponseWriter, r *http.Request) {
 				routerPortName = interfaces[0].RouterPortName
 			}
 			v11server := tc.ServerDetailV11{}
-			v11server.ServerDetail = server.ServerDetail
+			v11server.ServerDetail, err = dbhelpers.GetServerDetailFromV4(server, inf.Tx.Tx)
+			if err != nil {
+				api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("failed to GetServerDetailFromV4: %w", err))
+				return
+			}
 			v11server.RouterHostName = &routerHostName
 			v11server.RouterPortName = &routerPortName
 			legacyInterface, err := tc.V4InterfaceInfoToLegacyInterfaces(interfaces)
@@ -117,7 +121,11 @@ func GetDetailParamHandler(w http.ResponseWriter, r *http.Request) {
 				routerHostName = interfaces[0].RouterHostName
 				routerPortName = interfaces[0].RouterPortName
 			}
-			v3Server.ServerDetail = server.ServerDetail
+			v3Server.ServerDetail, err = dbhelpers.GetServerDetailFromV4(server, inf.Tx.Tx)
+			if err != nil {
+				api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, fmt.Errorf("failed to GetServerDetailFromV4: %w", err))
+				return
+			}
 			v3Server.RouterHostName = &routerHostName
 			v3Server.RouterPortName = &routerPortName
 			v3Interfaces, err := tc.V4InterfaceInfoToV3Interfaces(interfaces)
@@ -209,8 +217,7 @@ server.mgmt_ip_gateway,
 server.mgmt_ip_netmask,
 server.offline_reason,
 pl.name as phys_location,
-p.name as profile,
-p.description as profile_desc,
+(SELECT ARRAY_AGG(profile_name) FROM server_profile WHERE server_profile.server=server.id) AS profile_name,
 server.rack,
 st.name as status,
 server.tcp_port,
@@ -275,7 +282,38 @@ JOIN type t ON server.type = t.id
 
 	for rows.Next() {
 		s := tc.ServerDetailV40{}
-		if err := rows.Scan(&s.ID, &s.CacheGroup, &s.CDNName, pq.Array(&s.DeliveryServiceIDs), &s.DomainName, &s.GUID, &s.HostName, &s.HTTPSPort, &s.ILOIPAddress, &s.ILOIPGateway, &s.ILOIPNetmask, &s.ILOPassword, &s.ILOUsername, &serviceAddress, &service6Address, &serviceGateway, &service6Gateway, &serviceNetmask, &serviceInterface, &serviceMtu, &s.MgmtIPAddress, &s.MgmtIPGateway, &s.MgmtIPNetmask, &s.OfflineReason, &s.PhysLocation, &s.Profile, &s.ProfileDesc, &s.Rack, &s.Status, &s.TCPPort, &s.Type, &s.XMPPID, &s.XMPPPasswd); err != nil {
+		if err := rows.Scan(&s.ID,
+			&s.CacheGroup,
+			&s.CDNName,
+			pq.Array(&s.DeliveryServiceIDs),
+			&s.DomainName,
+			&s.GUID,
+			&s.HostName,
+			&s.HTTPSPort,
+			&s.ILOIPAddress,
+			&s.ILOIPGateway,
+			&s.ILOIPNetmask,
+			&s.ILOPassword,
+			&s.ILOUsername,
+			&serviceAddress,
+			&service6Address,
+			&serviceGateway,
+			&service6Gateway,
+			&serviceNetmask,
+			&serviceInterface,
+			&serviceMtu,
+			&s.MgmtIPAddress,
+			&s.MgmtIPGateway,
+			&s.MgmtIPNetmask,
+			&s.OfflineReason,
+			&s.PhysLocation,
+			pq.Array(&s.ProfileNames),
+			&s.Rack,
+			&s.Status,
+			&s.TCPPort,
+			&s.Type,
+			&s.XMPPID,
+			&s.XMPPPasswd); err != nil {
 			return nil, errors.New("Error scanning detail server: " + err.Error())
 		}
 		s.ServerInterfaces = []tc.ServerInterfaceInfoV40{}


Test patch:
diff --git a/lib/go-atscfg/parentdotconfig_test.go b/lib/go-atscfg/parentdotconfig_test.go
index 628b973cb..3710c9752 100644
--- a/lib/go-atscfg/parentdotconfig_test.go
+++ b/lib/go-atscfg/parentdotconfig_test.go
@@ -404,6 +404,122 @@ func TestMakeParentDotConfigMSOSecondaryParent(t *testing.T) {
 	}
 }
 
+func TestMakeParentDotConfigMSONoPrimaryParent(t *testing.T) {
+	hdr := &ParentConfigOpts{AddComments: false, HdrComment: "myHeaderComment"}
+
+	ds0 := makeParentDS()
+	ds0Type := tc.DSTypeHTTP
+	ds0.Type = &ds0Type
+	ds0.QStringIgnore = util.IntPtr(int(tc.QStringIgnoreUseInCacheKeyAndPassUp))
+	ds0.OrgServerFQDN = util.StrPtr("http://ds0.example.net")
+	ds0.MultiSiteOrigin = util.BoolPtr(true)
+	dses := []DeliveryService{*ds0}
+
+	parentConfigParams := []tc.Parameter{
+		tc.Parameter{
+			Name:       ParentConfigParamQStringHandling,
+			ConfigFile: "parent.config",
+			Value:      "myQStringHandlingParam",
+			Profiles:   []byte(`["serverprofile"]`),
+		},
+		tc.Parameter{
+			Name:       ParentConfigParamAlgorithm,
+			ConfigFile: "parent.config",
+			Value:      tc.AlgorithmConsistentHash,
+			Profiles:   []byte(`["serverprofile"]`),
+		},
+		tc.Parameter{
+			Name:       ParentConfigParamQString,
+			ConfigFile: "parent.config",
+			Value:      "myQstringParam",
+			Profiles:   []byte(`["serverprofile"]`),
+		},
+	}
+
+	serverParams := []tc.Parameter{
+		tc.Parameter{
+			Name:       "trafficserver",
+			ConfigFile: "package",
+			Value:      "7",
+			Profiles:   []byte(`["global"]`),
+		},
+	}
+
+	server := makeTestParentServer()
+
+	mid0 := makeTestParentServer()
+	mid0.Cachegroup = util.StrPtr("midCG0")
+	mid0.CachegroupID = util.IntPtr(500)
+	mid0.HostName = util.StrPtr("my-parent-0")
+	mid0.DomainName = util.StrPtr("my-parent-0-domain")
+	mid0.Status = util.StrPtr(string(tc.CacheStatusAdminDown))
+	mid0.ID = util.IntPtr(45)
+	setIP(mid0, "192.168.2.2")
+
+	mid1 := makeTestParentServer()
+	mid1.Cachegroup = util.StrPtr("midCG1")
+	mid1.CachegroupID = util.IntPtr(501)
+	mid1.HostName = util.StrPtr("my-parent-1")
+	mid1.DomainName = util.StrPtr("my-parent-1-domain")
+	mid1.ID = util.IntPtr(46)
+	setIP(mid1, "192.168.2.3")
+
+	servers := []Server{*server, *mid0, *mid1}
+
+	topologies := []tc.Topology{}
+	serverCapabilities := map[int]map[ServerCapability]struct{}{}
+	dsRequiredCapabilities := map[int]map[ServerCapability]struct{}{}
+
+	eCG := &tc.CacheGroupNullable{}
+	eCG.Name = server.Cachegroup
+	eCG.ID = server.CachegroupID
+	eCG.ParentName = mid0.Cachegroup
+	eCG.ParentCachegroupID = mid0.CachegroupID
+	eCG.SecondaryParentName = mid1.Cachegroup
+	eCG.SecondaryParentCachegroupID = mid1.CachegroupID
+	eCGType := tc.CacheGroupEdgeTypeName
+	eCG.Type = &eCGType
+
+	mCG := &tc.CacheGroupNullable{}
+	mCG.Name = mid0.Cachegroup
+	mCG.ID = mid0.CachegroupID
+	mCGType := tc.CacheGroupMidTypeName
+	mCG.Type = &mCGType
+
+	mCG1 := &tc.CacheGroupNullable{}
+	mCG1.Name = mid1.Cachegroup
+	mCG1.ID = mid1.CachegroupID
+	mCGType1 := tc.CacheGroupMidTypeName
+	mCG1.Type = &mCGType1
+
+	cgs := []tc.CacheGroupNullable{*eCG, *mCG, *mCG1}
+
+	dss := []DeliveryServiceServer{
+		DeliveryServiceServer{
+			Server:          *server.ID,
+			DeliveryService: *ds0.ID,
+		},
+	}
+	cdn := &tc.CDN{
+		DomainName: "cdndomain.example",
+		Name:       "my-cdn-name",
+	}
+
+	cfg, err := MakeParentDotConfig(dses, server, servers, topologies, serverParams, parentConfigParams, serverCapabilities, dsRequiredCapabilities, cgs, dss, cdn, hdr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	txt := cfg.Text
+
+	testComment(t, txt, hdr.HdrComment)
+
+	txtx := strings.Replace(txt, " ", "", -1)
+
+	if !strings.Contains(txtx, `parent="my-parent-1.my-parent-1-domain:80|0.999`) {
+		t.Errorf("expected primary parent 'my-parent-1.my-parent-1-domain', actual: '%v'", txt)
+	}
+}
+
 func TestMakeParentDotConfigTopologies(t *testing.T) {
 	hdr := &ParentConfigOpts{AddComments: false, HdrComment: "myHeaderComment"}
 
@@ -2932,6 +3048,153 @@ func TestMakeParentDotConfigHTTPSOriginTopology(t *testing.T) {
 	}
 }
 
+func TestMakeParentDotConfigHTTPSOriginTopologyNoPrimaryParent(t *testing.T) {
+	hdr := &ParentConfigOpts{AddComments: true, HdrComment: "myHeaderComment"}
+
+	ds0 := makeParentDS()
+	ds0Type := tc.DSTypeHTTP
+	ds0.Type = &ds0Type
+	ds0.QStringIgnore = util.IntPtr(int(tc.QStringIgnoreUseInCacheKeyAndPassUp))
+	ds0.OrgServerFQDN = util.StrPtr("https://ds0.example.net")
+	ds0.ProfileID = util.IntPtr(311)
+	ds0.ProfileName = util.StrPtr("ds0Profile")
+
+	ds1 := makeParentDS()
+	ds1.ID = util.IntPtr(43)
+	ds1Type := tc.DSTypeDNS
+	ds1.Type = &ds1Type
+	ds1.QStringIgnore = util.IntPtr(int(tc.QStringIgnoreDrop))
+	ds1.OrgServerFQDN = util.StrPtr("http://ds1.example.net")
+	ds1.Topology = util.StrPtr("t0")
+	ds1.ProfileID = util.IntPtr(312)
+	ds1.ProfileName = util.StrPtr("ds1Profile")
+
+	dses := []DeliveryService{*ds0, *ds1}
+
+	parentConfigParams := []tc.Parameter{
+		{
+			Name:       ParentConfigParamQStringHandling,
+			ConfigFile: "parent.config",
+			Value:      "myQStringHandlingParam",
+			Profiles:   []byte(`["serverprofile"]`),
+		},
+		{
+			Name:       ParentConfigParamAlgorithm,
+			ConfigFile: "parent.config",
+			Value:      tc.AlgorithmConsistentHash,
+			Profiles:   []byte(`["serverprofile"]`),
+		},
+		{
+			Name:       ParentConfigParamQString,
+			ConfigFile: "parent.config",
+			Value:      "myQstringParam",
+			Profiles:   []byte(`["serverprofile"]`),
+		},
+	}
+
+	serverParams := []tc.Parameter{
+		{
+			Name:       "trafficserver",
+			ConfigFile: "package",
+			Value:      "8",
+			Profiles:   []byte(`["global"]`),
+		},
+	}
+
+	server := makeTestParentServer()
+	server.Cachegroup = util.StrPtr("edgeCG")
+	server.CachegroupID = util.IntPtr(400)
+
+	mid0 := makeTestParentServer()
+	mid0.Cachegroup = util.StrPtr("midCG")
+	mid0.CachegroupID = util.IntPtr(500)
+	mid0.HostName = util.StrPtr("mymid")
+	mid0.ID = util.IntPtr(45)
+	mid0.Status = util.StrPtr(string(tc.CacheStatusAdminDown))
+	setIP(mid0, "192.168.2.2")
+
+	mid1 := makeTestParentServer()
+	mid1.Cachegroup = util.StrPtr("midCG2")
+	mid1.CachegroupID = util.IntPtr(501)
+	mid1.HostName = util.StrPtr("mymid1")
+	mid1.ID = util.IntPtr(46)
+	setIP(mid1, "192.168.2.3")
+
+	servers := []Server{*server, *mid0, *mid1}
+
+	topologies := []tc.Topology{
+		{
+			Name: "t0",
+			Nodes: []tc.TopologyNode{
+				{
+					Cachegroup: "edgeCG",
+					Parents:    []int{1, 2},
+				},
+				{
+					Cachegroup: "midCG",
+				},
+				{
+					Cachegroup: "midCG2",
+				},
+			},
+		},
+	}
+
+	serverCapabilities := map[int]map[ServerCapability]struct{}{}
+	dsRequiredCapabilities := map[int]map[ServerCapability]struct{}{}
+
+	eCG := &tc.CacheGroupNullable{}
+	eCG.Name = server.Cachegroup
+	eCG.ID = server.CachegroupID
+	eCG.ParentName = mid0.Cachegroup
+	eCG.ParentCachegroupID = mid0.CachegroupID
+	eCG.SecondaryParentName = mid1.Cachegroup
+	eCG.SecondaryParentCachegroupID = mid1.CachegroupID
+	eCGType := tc.CacheGroupEdgeTypeName
+	eCG.Type = &eCGType
+
+	mCG := &tc.CacheGroupNullable{}
+	mCG.Name = mid0.Cachegroup
+	mCG.ID = mid0.CachegroupID
+	mCGType := tc.CacheGroupMidTypeName
+	mCG.Type = &mCGType
+
+	mCG2 := &tc.CacheGroupNullable{}
+	mCG2.Name = mid1.Cachegroup
+	mCG2.ID = mid1.CachegroupID
+	mCGType2 := tc.CacheGroupMidTypeName
+	mCG2.Type = &mCGType2
+
+	cgs := []tc.CacheGroupNullable{*eCG, *mCG, *mCG2}
+
+	dss := []DeliveryServiceServer{
+		{
+			Server:          *server.ID,
+			DeliveryService: *ds0.ID,
+		},
+		{
+			Server:          *server.ID,
+			DeliveryService: *ds1.ID,
+		},
+	}
+	cdn := &tc.CDN{
+		DomainName: "cdndomain.example",
+		Name:       "my-cdn-name",
+	}
+
+	cfg, err := MakeParentDotConfig(dses, server, servers, topologies, serverParams, parentConfigParams, serverCapabilities, dsRequiredCapabilities, cgs, dss, cdn, hdr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	txt := cfg.Text
+
+	testComment(t, txt, hdr.HdrComment)
+
+	if !strings.Contains(txt, `parent="mymid1.mydomain.example.net:80|0.999"`) {
+		t.Errorf("expected topology parent.config withparent=\"mymid1.mydomain.example.net:80|0.999\", actual: '%v'", txt)
+	}
+}
+
 func TestMakeParentDotConfigMergeParentGroupTopology(t *testing.T) {
 	hdr := &ParentConfigOpts{AddComments: true, HdrComment: "myHeaderComment"}
 
diff --git a/traffic_ops/testing/api/v3/deliveryservice_request_comments_test.go b/traffic_ops/testing/api/v3/deliveryservice_request_comments_test.go
index f3aab587d..51dae401d 100644
--- a/traffic_ops/testing/api/v3/deliveryservice_request_comments_test.go
+++ b/traffic_ops/testing/api/v3/deliveryservice_request_comments_test.go
@@ -16,6 +16,7 @@ package v3
 */
 
 import (
+	"encoding/json"
 	"net/http"
 	"sort"
 	"testing"
@@ -23,182 +24,176 @@ import (
 
 	"github.com/apache/trafficcontrol/lib/go-rfc"
 	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/assert"
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/utils"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
 )
 
 func TestDeliveryServiceRequestComments(t *testing.T) {
 	WithObjs(t, []TCObj{CDNs, Types, Parameters, Tenants, DeliveryServiceRequests, DeliveryServiceRequestComments}, func() {
-		GetTestDeliveryServiceRequestCommentsIMS(t)
-		currentTime := time.Now().UTC().Add(-5 * time.Second)
-		time := currentTime.Format(time.RFC1123)
-		var header http.Header
-		header = make(map[string][]string)
-		header.Set(rfc.IfUnmodifiedSince, time)
-		header.Set(rfc.IfModifiedSince, time)
-		SortTestDeliveryServiceRequestComments(t)
-		UpdateTestDeliveryServiceRequestComments(t)
-		UpdateTestDeliveryServiceRequestCommentsWithHeaders(t, header)
-		header = make(map[string][]string)
-		etag := rfc.ETag(currentTime)
-		header.Set(rfc.IfMatch, etag)
-		UpdateTestDeliveryServiceRequestCommentsWithHeaders(t, header)
-		GetTestDeliveryServiceRequestComments(t)
-		GetTestDeliveryServiceRequestCommentsIMSAfterChange(t, header)
-	})
-}
-
-func UpdateTestDeliveryServiceRequestCommentsWithHeaders(t *testing.T, header http.Header) {
-	comments, _, _ := TOSession.GetDeliveryServiceRequestCommentsWithHdr(header)
 
-	if len(comments) > 0 {
-		firstComment := comments[0]
-		newFirstCommentValue := "new comment value"
-		firstComment.Value = newFirstCommentValue
-
-		_, reqInf, err := TOSession.UpdateDeliveryServiceRequestCommentByIDWithHdr(firstComment.ID, firstComment, header)
-		if err == nil {
-			t.Errorf("expected precondition failed error, but got none")
-		}
-		if reqInf.StatusCode != http.StatusPreconditionFailed {
-			t.Errorf("Expected status code 412, got %v", reqInf.StatusCode)
+		currentTime := time.Now().UTC().Add(-15 * time.Second)
+		currentTimeRFC := currentTime.Format(time.RFC1123)
+		tomorrow := currentTime.AddDate(0, 0, 1).Format(time.RFC1123)
+
+		methodTests := utils.V3TestCase{
+			"GET": {
+				"NOT MODIFIED when NO CHANGES made": {
+					ClientSession:  TOSession,
+					RequestHeaders: http.Header{rfc.IfModifiedSince: {tomorrow}},
+					Expectations:   utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusNotModified)),
+				},
+				"OK when VALID request": {
+					ClientSession: TOSession,
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK)),
+				},
+				"OK when VALID ID parameter": {
+					EndpointId:    GetDSRequestCommentId(t),
+					ClientSession: TOSession,
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK), utils.ResponseHasLength(1)),
+				},
+				"VALIDATE SORT when DEFAULT is ASC ORDER": {
+					ClientSession: TOSession,
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK), validateSortedDSRequestComments()),
+				},
+			},
+			"PUT": {
+				"OK when VALID request": {
+					EndpointId:    GetDSRequestCommentId(t),
+					ClientSession: TOSession,
+					RequestBody: map[string]interface{}{
+						"deliveryServiceRequestId": GetDSRequestId(t, "test-ds1")(),
+						"value":                    "updated comment",
+					},
+					Expectations: utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK)),
+				},
+				"PRECONDITION FAILED when updating with IF-UNMODIFIED-SINCE Header": {
+					EndpointId:     GetDSRequestCommentId(t),
+					ClientSession:  TOSession,
+					RequestHeaders: http.Header{rfc.IfUnmodifiedSince: {currentTimeRFC}},
+					RequestBody:    map[string]interface{}{},
+					Expectations:   utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusPreconditionFailed)),
+				},
+				"PRECONDITION FAILED when updating with IFMATCH ETAG Header": {
+					EndpointId:     GetDSRequestCommentId(t),
+					ClientSession:  TOSession,
+					RequestBody:    map[string]interface{}{},
+					RequestHeaders: http.Header{rfc.IfMatch: {rfc.ETag(currentTime)}},
+					Expectations:   utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusPreconditionFailed)),
+				},
+			},
+			"GET AFTER CHANGES": {
+				"OK when CHANGES made": {
+					ClientSession:  TOSession,
+					RequestHeaders: http.Header{rfc.IfModifiedSince: {currentTimeRFC}},
+					Expectations:   utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK)),
+				},
+			},
 		}
-	}
-}
 
-func GetTestDeliveryServiceRequestCommentsIMSAfterChange(t *testing.T, header http.Header) {
-	_, reqInf, err := TOSession.GetDeliveryServiceRequestCommentsWithHdr(header)
-	if err != nil {
-		t.Fatalf("could not GET delivery service request comments: %v", err)
-	}
-	if reqInf.StatusCode != http.StatusOK {
-		t.Fatalf("Expected 200 status code, got %v", reqInf.StatusCode)
-	}
-	header = make(map[string][]string)
-	futureTime := time.Now().AddDate(0, 0, 1)
-	time := futureTime.Format(time.RFC1123)
-	header.Set(rfc.IfModifiedSince, time)
-	_, reqInf, err = TOSession.GetDeliveryServiceRequestCommentsWithHdr(header)
-	if err != nil {
-		t.Fatalf("could not GET delivery service request comments: %v", err)
-	}
-	if reqInf.StatusCode != http.StatusNotModified {
-		t.Fatalf("Expected 304 status code, got %v", reqInf.StatusCode)
-	}
-}
-
-func CreateTestDeliveryServiceRequestComments(t *testing.T) {
-
-	// Retrieve a delivery service request by xmlId so we can get the ID needed to create a dsr comment
-	dsr := testData.DeliveryServiceRequests[0].DeliveryService
-
-	resp, _, err := TOSession.GetDeliveryServiceRequestByXMLID(dsr.XMLID)
-	if err != nil {
-		t.Errorf("cannot GET delivery service request by xml id: %v - %v", dsr.XMLID, err)
-	}
-	if len(resp) != 1 {
-		t.Errorf("found %d delivery service request by xml id, expected %d: %s", len(resp), 1, dsr.XMLID)
-	} else {
-		respDSR := resp[0]
-
-		for _, comment := range testData.DeliveryServiceRequestComments {
-			comment.DeliveryServiceRequestID = respDSR.ID
-			resp, _, err := TOSession.CreateDeliveryServiceRequestComment(comment)
-			if err != nil {
-				t.Errorf("could not CREATE delivery service request comment: %v - %v", err, resp)
-			}
+		for method, testCases := range methodTests {
+			t.Run(method, func(t *testing.T) {
+				for name, testCase := range testCases {
+					comment := tc.DeliveryServiceRequestComment{}
+
+					if testCase.RequestBody != nil {
+						dat, err := json.Marshal(testCase.RequestBody)
+						assert.NoError(t, err, "Error occurred when marshalling request body: %v", err)
+						err = json.Unmarshal(dat, &comment)
+						assert.NoError(t, err, "Error occurred when unmarshalling request body: %v", err)
+					}
+
+					switch method {
+					case "GET", "GET AFTER CHANGES":
+						t.Run(name, func(t *testing.T) {
+							if name == "OK when VALID ID parameter" {
+								resp, reqInf, err := testCase.ClientSession.GetDeliveryServiceRequestCommentByIDWithHdr(testCase.EndpointId(), testCase.RequestHeaders)
+								for _, check := range testCase.Expectations {
+									check(t, reqInf, resp, tc.Alerts{}, err)
+								}
+							} else {
+								resp, reqInf, err := testCase.ClientSession.GetDeliveryServiceRequestCommentsWithHdr(testCase.RequestHeaders)
+								for _, check := range testCase.Expectations {
+									check(t, reqInf, resp, tc.Alerts{}, err)
+								}
+							}
+						})
+					case "PUT":
+						t.Run(name, func(t *testing.T) {
+							alerts, reqInf, err := testCase.ClientSession.UpdateDeliveryServiceRequestCommentByIDWithHdr(testCase.EndpointId(), comment, testCase.RequestHeaders)
+							for _, check := range testCase.Expectations {
+								check(t, reqInf, nil, alerts, err)
+							}
+						})
+					}
+				}
+			})
 		}
-	}
-
+	})
 }
 
-func SortTestDeliveryServiceRequestComments(t *testing.T) {
-	var header http.Header
-	var sortedList []string
-	resp, _, err := TOSession.GetDeliveryServiceRequestCommentsWithHdr(header)
-	if err != nil {
-		t.Fatalf("Expected no error, but got %v", err.Error())
-	}
-	for i, _ := range resp {
-		sortedList = append(sortedList, resp[i].XMLID)
-	}
+func GetDSRequestCommentId(t *testing.T) func() int {
+	return func() int {
+		resp, _, err := TOSession.GetDeliveryServiceRequestCommentsWithHdr(http.Header{})
+		assert.RequireNoError(t, err, "Get Delivery Service Request Comments failed with error: %v", err)
+		assert.RequireGreaterOrEqual(t, len(resp), 1, "Expected delivery service request comments response object length of atleast 1, but got %d", len(resp))
+		assert.RequireNotNil(t, resp[0].ID, "Expected id to not be nil")
 
-	res := sort.SliceIsSorted(sortedList, func(p, q int) bool {
-		return sortedList[p] < sortedList[q]
-	})
-	if res != true {
-		t.Errorf("list is not sorted by their names: %v", sortedList)
+		return resp[0].ID
 	}
 }
 
-func UpdateTestDeliveryServiceRequestComments(t *testing.T) {
-
-	comments, _, err := TOSession.GetDeliveryServiceRequestComments()
+func GetDSRequestId(t *testing.T, xmlId string) func() int {
+	return func() int {
+		resp, _, err := TOSession.GetDeliveryServiceRequestByXMLIDWithHdr(xmlId, http.Header{})
+		assert.RequireNoError(t, err, "Get Delivery Service Requests failed with error: %v", err)
+		assert.RequireGreaterOrEqual(t, len(resp), 1, "Expected delivery service requests response object length of atleast 1, but got %d", len(resp))
+		assert.RequireNotNil(t, resp[0].ID, "Expected id to not be nil")
 
-	firstComment := comments[0]
-	newFirstCommentValue := "new comment value"
-	firstComment.Value = newFirstCommentValue
-
-	var alert tc.Alerts
-	alert, _, err = TOSession.UpdateDeliveryServiceRequestCommentByID(firstComment.ID, firstComment)
-	if err != nil {
-		t.Errorf("cannot UPDATE delivery service request comment by id: %v - %v", err, alert)
+		return resp[0].ID
 	}
+}
 
-	// Retrieve the delivery service request comment to check that the value got updated
-	resp, _, err := TOSession.GetDeliveryServiceRequestCommentByID(firstComment.ID)
-	if err != nil {
-		t.Errorf("cannot GET delivery service request comment by id: '$%d', %v", firstComment.ID, err)
-	}
-	respDSRC := resp[0]
-	if respDSRC.Value != newFirstCommentValue {
-		t.Errorf("results do not match actual: %s, expected: %s", respDSRC.Value, newFirstCommentValue)
-	}
+func validateSortedDSRequestComments() utils.CkReqFunc {
+	return func(t *testing.T, _ toclientlib.ReqInf, resp interface{}, _ tc.Alerts, err error) {
+		var sortedList []string
+		dsReqComments := resp.([]tc.DeliveryServiceRequestComment)
 
-}
+		for _, comment := range dsReqComments {
+			sortedList = append(sortedList, comment.XMLID)
+		}
 
-func GetTestDeliveryServiceRequestCommentsIMS(t *testing.T) {
-	var header http.Header
-	header = make(map[string][]string)
-	futureTime := time.Now().AddDate(0, 0, 1)
-	time := futureTime.Format(time.RFC1123)
-	header.Set(rfc.IfModifiedSince, time)
-	_, reqInf, err := TOSession.GetDeliveryServiceRequestCommentsWithHdr(header)
-	if err != nil {
-		t.Fatalf("could not GET delivery service request comments: %v", err)
-	}
-	if reqInf.StatusCode != http.StatusNotModified {
-		t.Fatalf("Expected 304 status code, got %v", reqInf.StatusCode)
+		res := sort.SliceIsSorted(sortedList, func(p, q int) bool {
+			return sortedList[p] < sortedList[q]
+		})
+		assert.Equal(t, res, true, "List is not sorted by their names: %v", sortedList)
 	}
 }
 
-func GetTestDeliveryServiceRequestComments(t *testing.T) {
-
-	comments, _, _ := TOSession.GetDeliveryServiceRequestComments()
+func CreateTestDeliveryServiceRequestComments(t *testing.T) {
+	for _, comment := range testData.DeliveryServiceRequestComments {
+		resp, _, err := TOSession.GetDeliveryServiceRequestByXMLIDWithHdr(comment.XMLID, http.Header{})
+		assert.NoError(t, err, "Cannot get Delivery Service Request by XMLID '%s': %v", comment.XMLID, err)
+		assert.Equal(t, len(resp), 1, "Found %d Delivery Service request by XMLID '%s, expected exactly one", len(resp), comment.XMLID)
+		assert.NotNil(t, resp[0].ID, "Got Delivery Service Request with xml_id '%s' that had a null ID", comment.XMLID)
 
-	for _, comment := range comments {
-		resp, _, err := TOSession.GetDeliveryServiceRequestCommentByID(comment.ID)
-		if err != nil {
-			t.Errorf("cannot GET delivery service request comment by id: %v - %v", err, resp)
-		}
+		comment.DeliveryServiceRequestID = resp[0].ID
+		alerts, _, err := TOSession.CreateDeliveryServiceRequestComment(comment)
+		assert.NoError(t, err, "Could not create Delivery Service Request Comment: %v - alerts: %+v", err, alerts.Alerts)
 	}
 }
 
 func DeleteTestDeliveryServiceRequestComments(t *testing.T) {
+	resp, _, err := TOSession.GetDeliveryServiceRequestCommentsWithHdr(http.Header{})
+	assert.NoError(t, err, "Unexpected error getting Delivery Service Request Comments: %v", err)
 
-	comments, _, _ := TOSession.GetDeliveryServiceRequestComments()
-
-	for _, comment := range comments {
-		_, _, err := TOSession.DeleteDeliveryServiceRequestCommentByID(comment.ID)
-		if err != nil {
-			t.Errorf("cannot DELETE delivery service request comment by id: '%d' %v", comment.ID, err)
-		}
+	for _, comment := range resp {
+		alerts, _, err := TOSession.DeleteDeliveryServiceRequestCommentByID(comment.ID)
+		assert.NoError(t, err, "Cannot delete Delivery Service Request Comment #%d: %v - alerts: %+v", comment.ID, err, alerts.Alerts)
 
 		// Retrieve the delivery service request comment to see if it got deleted
-		comments, _, err := TOSession.GetDeliveryServiceRequestCommentByID(comment.ID)
-		if err != nil {
-			t.Errorf("error deleting delivery service request comment: %s", err.Error())
-		}
-		if len(comments) > 0 {
-			t.Errorf("expected delivery service request comment: %d to be deleted", comment.ID)
-		}
+		resp, _, err := TOSession.GetDeliveryServiceRequestCommentByIDWithHdr(comment.ID, http.Header{})
+		assert.NoError(t, err, "Unexpected error fetching Delivery Service Request Comment %d after deletion: %v", comment.ID, err)
+		assert.Equal(t, len(resp), 0, "Expected Delivery Service Request Comment #%d to be deleted, but it was found in Traffic Ops", comment.ID)
 	}
 }
diff --git a/traffic_ops/testing/api/v3/tc-fixtures.json b/traffic_ops/testing/api/v3/tc-fixtures.json
index ab8616ae4..7c2f2994f 100644
--- a/traffic_ops/testing/api/v3/tc-fixtures.json
+++ b/traffic_ops/testing/api/v3/tc-fixtures.json
@@ -281,16 +281,20 @@
     ],
     "deliveryServiceRequestComments": [
         {
-            "value": "this is comment one"
+            "value": "this is comment one",
+            "xmlId": "test-ds1"
         },
         {
-            "value": "this is comment two"
+            "value": "this is comment two",
+            "xmlId": "test-ds1"
         },
         {
-            "value": "this is comment three"
+            "value": "this is comment three",
+            "xmlId": "test-ds1"
         },
         {
-            "value": "this is comment four"
+            "value": "this is comment four",
+            "xmlId": "test-ds1"
         }
     ],
     "deliveryServiceRequests": [
diff --git a/traffic_ops/testing/api/v4/deliveryservice_request_comments_test.go b/traffic_ops/testing/api/v4/deliveryservice_request_comments_test.go
index dd5c41080..512289661 100644
--- a/traffic_ops/testing/api/v4/deliveryservice_request_comments_test.go
+++ b/traffic_ops/testing/api/v4/deliveryservice_request_comments_test.go
@@ -16,7 +16,9 @@ package v4
 */
 
 import (
+	"encoding/json"
 	"net/http"
+	"net/url"
 	"sort"
 	"strconv"
 	"testing"
@@ -24,220 +26,180 @@ import (
 
 	"github.com/apache/trafficcontrol/lib/go-rfc"
 	"github.com/apache/trafficcontrol/lib/go-tc"
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/assert"
+	"github.com/apache/trafficcontrol/traffic_ops/testing/api/utils"
+	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
 	client "github.com/apache/trafficcontrol/traffic_ops/v4-client"
 )
 
 func TestDeliveryServiceRequestComments(t *testing.T) {
 	WithObjs(t, []TCObj{CDNs, Types, Parameters, Tenants, DeliveryServiceRequests, DeliveryServiceRequestComments}, func() {
-		GetTestDeliveryServiceRequestCommentsIMS(t)
-		currentTime := time.Now().UTC().Add(-5 * time.Second)
-		time := currentTime.Format(time.RFC1123)
-		var header http.Header
-		header = make(map[string][]string)
-		header.Set(rfc.IfUnmodifiedSince, time)
-		header.Set(rfc.IfModifiedSince, time)
-		SortTestDeliveryServiceRequestComments(t)
-		UpdateTestDeliveryServiceRequestComments(t)
-		UpdateTestDeliveryServiceRequestCommentsWithHeaders(t, header)
-		header = make(map[string][]string)
-		etag := rfc.ETag(currentTime)
-		header.Set(rfc.IfMatch, etag)
-		UpdateTestDeliveryServiceRequestCommentsWithHeaders(t, header)
-		GetTestDeliveryServiceRequestComments(t)
-		GetTestDeliveryServiceRequestCommentsIMSAfterChange(t, header)
-	})
-}
-
-func UpdateTestDeliveryServiceRequestCommentsWithHeaders(t *testing.T, header http.Header) {
-	opts := client.NewRequestOptions()
-	opts.Header = header
-	comments, _, _ := TOSession.GetDeliveryServiceRequestComments(opts)
-
-	if len(comments.Response) > 0 {
-		firstComment := comments.Response[0]
-		newFirstCommentValue := "new comment value"
-		firstComment.Value = newFirstCommentValue
 
-		_, reqInf, err := TOSession.UpdateDeliveryServiceRequestComment(firstComment.ID, firstComment, opts)
-		if err == nil {
-			t.Errorf("expected precondition failed error, but got none")
+		currentTime := time.Now().UTC().Add(-15 * time.Second)
+		currentTimeRFC := currentTime.Format(time.RFC1123)
+		tomorrow := currentTime.AddDate(0, 0, 1).Format(time.RFC1123)
+
+		methodTests := utils.V4TestCase{
+			"GET": {
+				"NOT MODIFIED when NO CHANGES made": {
+					ClientSession: TOSession,
+					RequestOpts:   client.RequestOptions{Header: http.Header{rfc.IfModifiedSince: {tomorrow}}},
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusNotModified)),
+				},
+				"OK when VALID request": {
+					ClientSession: TOSession,
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK)),
+				},
+				"OK when VALID ID parameter": {
+					ClientSession: TOSession,
+					RequestOpts:   client.RequestOptions{QueryParameters: url.Values{"id": {strconv.Itoa(GetDSRequestCommentId(t, "admin")())}}},
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK), utils.ResponseHasLength(1)),
+				},
+				"VALIDATE SORT when DEFAULT is ASC ORDER": {
+					ClientSession: TOSession,
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK), validateSortedDSRequestComments()),
+				},
+			},
+			"PUT": {
+				"OK when VALID request": {
+					EndpointId:    GetDSRequestCommentId(t, "admin"),
+					ClientSession: TOSession,
+					RequestBody: map[string]interface{}{
+						"deliveryServiceRequestId": GetDSRequestId(t, "test-ds1")(),
+						"value":                    "updated comment",
+					},
+					Expectations: utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK)),
+				},
+				"PRECONDITION FAILED when updating with IF-UNMODIFIED-SINCE Header": {
+					EndpointId:    GetDSRequestCommentId(t, "admin"),
+					ClientSession: TOSession,
+					RequestOpts:   client.RequestOptions{Header: http.Header{rfc.IfUnmodifiedSince: {currentTimeRFC}}},
+					RequestBody:   map[string]interface{}{},
+					Expectations:  utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusPreconditionFailed)),
+				},
+				"PRECONDITION FAILED when updating with IFMATCH ETAG Header": {
+					EndpointId:    GetDSRequestCommentId(t, "admin"),
+					ClientSession: TOSession,
+					RequestBody:   map[string]interface{}{},
+					RequestOpts:   client.RequestOptions{Header: http.Header{rfc.IfMatch: {rfc.ETag(currentTime)}}},
+					Expectations:  utils.CkRequest(utils.HasError(), utils.HasStatus(http.StatusPreconditionFailed)),
+				},
+			},
+			"GET AFTER CHANGES": {
+				"OK when CHANGES made": {
+					ClientSession: TOSession,
+					RequestOpts:   client.RequestOptions{Header: http.Header{rfc.IfModifiedSince: {currentTimeRFC}}},
+					Expectations:  utils.CkRequest(utils.NoError(), utils.HasStatus(http.StatusOK)),
+				},
+			},
 		}
-		if reqInf.StatusCode != http.StatusPreconditionFailed {
-			t.Errorf("Expected status code 412, got %v", reqInf.StatusCode)
-		}
-	}
-}
 
-func GetTestDeliveryServiceRequestCommentsIMSAfterChange(t *testing.T, header http.Header) {
-	opts := client.NewRequestOptions()
-	opts.Header = header
-	resp, reqInf, err := TOSession.GetDeliveryServiceRequestComments(opts)
-	if err != nil {
-		t.Fatalf("could not get Delivery Service Request Comments: %v - alerts: %+v", err, resp.Alerts)
-	}
-	if reqInf.StatusCode != http.StatusOK {
-		t.Fatalf("Expected 200 status code, got %v", reqInf.StatusCode)
-	}
-	opts.Header = make(map[string][]string)
-	futureTime := time.Now().AddDate(0, 0, 1)
-	time := futureTime.Format(time.RFC1123)
-	opts.Header.Set(rfc.IfModifiedSince, time)
-	resp, reqInf, err = TOSession.GetDeliveryServiceRequestComments(opts)
-	if err != nil {
-		t.Fatalf("could not get Delivery Service Request Comments: %v - alerts: %+v", err, resp.Alerts)
-	}
-	if reqInf.StatusCode != http.StatusNotModified {
-		t.Fatalf("Expected 304 status code, got %v", reqInf.StatusCode)
-	}
-}
-
-func CreateTestDeliveryServiceRequestComments(t *testing.T) {
-	if len(testData.DeliveryServiceRequests) < 1 {
-		t.Fatal("Need at least one Delivery Service Request to test creating Delivery Service Request Comments")
-	}
-
-	// Retrieve a delivery service request by xmlId so we can get the ID needed to create a dsr comment
-	dsr := testData.DeliveryServiceRequests[0]
-	var ds *tc.DeliveryServiceV4
-	if dsr.ChangeType == tc.DSRChangeTypeDelete {
-		ds = dsr.Original
-	} else {
-		ds = dsr.Requested
-	}
-	resetDS(ds)
-	if ds == nil || ds.XMLID == nil {
-		t.Fatal("first DSR in the test data had a nil Delivery Service, or one with no XMLID")
-	}
-
-	opts := client.NewRequestOptions()
-	opts.QueryParameters.Set("xmlId", *ds.XMLID)
-	resp, _, err := TOSession.GetDeliveryServiceRequests(opts)
-	if err != nil {
-		t.Fatalf("cannot get Delivery Service Request by XMLID '%s': %v - alerts: %+v", *ds.XMLID, err, resp.Alerts)
-	}
-	if len(resp.Response) != 1 {
-		t.Fatalf("found %d Delivery Service request by XMLID '%s, expected exactly one", len(resp.Response), *ds.XMLID)
-	}
-	respDSR := resp.Response[0]
-	if respDSR.ID == nil {
-		t.Fatalf("got Delivery Service Request with xml_id '%s' that had a null ID", *ds.XMLID)
-	}
-
-	for _, comment := range testData.DeliveryServiceRequestComments {
-		comment.DeliveryServiceRequestID = *respDSR.ID
-		resp, _, err := TOSession.CreateDeliveryServiceRequestComment(comment, client.RequestOptions{})
-		if err != nil {
-			t.Errorf("could not create Delivery Service Request Comment: %v - alerts: %+v", err, resp.Alerts)
+		for method, testCases := range methodTests {
+			t.Run(method, func(t *testing.T) {
+				for name, testCase := range testCases {
+					comment := tc.DeliveryServiceRequestComment{}
+
+					if testCase.RequestBody != nil {
+						dat, err := json.Marshal(testCase.RequestBody)
+						assert.NoError(t, err, "Error occurred when marshalling request body: %v", err)
+						err = json.Unmarshal(dat, &comment)
+						assert.NoError(t, err, "Error occurred when unmarshalling request body: %v", err)
+					}
+
+					switch method {
+					case "GET", "GET AFTER CHANGES":
+						t.Run(name, func(t *testing.T) {
+							resp, reqInf, err := testCase.ClientSession.GetDeliveryServiceRequestComments(testCase.RequestOpts)
+							for _, check := range testCase.Expectations {
+								check(t, reqInf, resp.Response, resp.Alerts, err)
+							}
+						})
+					case "PUT":
+						t.Run(name, func(t *testing.T) {
+							alerts, reqInf, err := testCase.ClientSession.UpdateDeliveryServiceRequestComment(testCase.EndpointId(), comment, testCase.RequestOpts)
+							for _, check := range testCase.Expectations {
+								check(t, reqInf, nil, alerts, err)
+							}
+						})
+					}
+				}
+			})
 		}
-	}
+	})
 }
 
-func SortTestDeliveryServiceRequestComments(t *testing.T) {
-	var sortedList []string
-	resp, _, err := TOSession.GetDeliveryServiceRequestComments(client.RequestOptions{})
-	if err != nil {
-		t.Fatalf("Expected no error, but got: %v - alerts: %+v", err, resp.Alerts)
-	}
-	for _, dsrc := range resp.Response {
-		sortedList = append(sortedList, dsrc.XMLID)
-	}
+func GetDSRequestCommentId(t *testing.T, author string) func() int {
+	return func() int {
+		opts := client.NewRequestOptions()
+		opts.QueryParameters.Set("author", author)
 
-	res := sort.SliceIsSorted(sortedList, func(p, q int) bool {
-		return sortedList[p] < sortedList[q]
-	})
-	if res != true {
-		t.Errorf("list is not sorted by their names: %v", sortedList)
+		resp, _, err := TOSession.GetDeliveryServiceRequestComments(opts)
+		assert.RequireNoError(t, err, "Get Delivery Service Request Comments failed with error: %v", err)
+		assert.RequireGreaterOrEqual(t, len(resp.Response), 1, "Expected delivery service request comments response object length of atleast 1, but got %d", len(resp.Response))
+		assert.RequireNotNil(t, resp.Response[0].ID, "Expected id to not be nil")
+
+		return resp.Response[0].ID
 	}
 }
 
-func UpdateTestDeliveryServiceRequestComments(t *testing.T) {
+func GetDSRequestId(t *testing.T, xmlId string) func() int {
+	return func() int {
+		opts := client.NewRequestOptions()
+		opts.QueryParameters.Set("xmlId", xmlId)
 
-	comments, _, err := TOSession.GetDeliveryServiceRequestComments(client.RequestOptions{})
-	if err != nil {
-		t.Errorf("Unexpected error getting Delivery Service Request Comments: %v - alerts: %+v", err, comments.Alerts)
-	}
-	if len(comments.Response) < 1 {
-		t.Fatal("Expected at least one Delivery Service Request Comment to exist in Traffic Ops - none did")
-	}
-	firstComment := comments.Response[0]
-	newFirstCommentValue := "new comment value"
-	firstComment.Value = newFirstCommentValue
-
-	var alert tc.Alerts
-	alert, _, err = TOSession.UpdateDeliveryServiceRequestComment(firstComment.ID, firstComment, client.RequestOptions{})
-	if err != nil {
-		t.Errorf("cannot update Delivery Service Request Comment #%d: %v - alerts: %+v", firstComment.ID, err, alert.Alerts)
-	}
+		resp, _, err := TOSession.GetDeliveryServiceRequests(opts)
+		assert.RequireNoError(t, err, "Get Delivery Service Requests failed with error: %v", err)
+		assert.RequireGreaterOrEqual(t, len(resp.Response), 1, "Expected delivery service requests response object length of atleast 1, but got %d", len(resp.Response))
+		assert.RequireNotNil(t, resp.Response[0].ID, "Expected id to not be nil")
 
-	// Retrieve the delivery service request comment to check that the value got updated
-	opts := client.NewRequestOptions()
-	opts.QueryParameters.Set("id", strconv.Itoa(firstComment.ID))
-	resp, _, err := TOSession.GetDeliveryServiceRequestComments(opts)
-	if err != nil {
-		t.Errorf("cannot get Delivery Service Request Comment #%d: %v - alerts: %+v", firstComment.ID, err, resp.Alerts)
-	}
-	if len(resp.Response) != 1 {
-		t.Fatalf("Expected exactly one Delivery Service Request Comment to exist with ID %d, found: %d", firstComment.ID, len(resp.Response))
-	}
-	respDSRC := resp.Response[0]
-	if respDSRC.Value != newFirstCommentValue {
-		t.Errorf("results do not match actual: %s, expected: %s", respDSRC.Value, newFirstCommentValue)
+		return *resp.Response[0].ID
 	}
-
 }
 
-func GetTestDeliveryServiceRequestCommentsIMS(t *testing.T) {
-	futureTime := time.Now().AddDate(0, 0, 1)
-	time := futureTime.Format(time.RFC1123)
+func validateSortedDSRequestComments() utils.CkReqFunc {
+	return func(t *testing.T, _ toclientlib.ReqInf, resp interface{}, _ tc.Alerts, err error) {
+		var sortedList []string
+		dsReqComments := resp.([]tc.DeliveryServiceRequestComment)
 
-	opts := client.NewRequestOptions()
-	opts.Header.Set(rfc.IfModifiedSince, time)
-	resp, reqInf, err := TOSession.GetDeliveryServiceRequestComments(opts)
-	if err != nil {
-		t.Fatalf("could not get Delivery Service Request Comments: %v - alerts: %+v", err, resp.Alerts)
-	}
-	if reqInf.StatusCode != http.StatusNotModified {
-		t.Fatalf("Expected 304 status code, got %v", reqInf.StatusCode)
+		for _, comment := range dsReqComments {
+			sortedList = append(sortedList, comment.XMLID)
+		}
+
+		res := sort.SliceIsSorted(sortedList, func(p, q int) bool {
+			return sortedList[p] < sortedList[q]
+		})
+		assert.Equal(t, res, true, "List is not sorted by their names: %v", sortedList)
 	}
 }
 
-func GetTestDeliveryServiceRequestComments(t *testing.T) {
-	comments, _, err := TOSession.GetDeliveryServiceRequestComments(client.RequestOptions{})
-	if err != nil {
-		t.Errorf("Unexpected error getting Delivery Service Request Comments: %v - alerts: %+v", err, comments.Alerts)
-	}
+func CreateTestDeliveryServiceRequestComments(t *testing.T) {
+	for _, comment := range testData.DeliveryServiceRequestComments {
+		opts := client.NewRequestOptions()
+		opts.QueryParameters.Set("xmlId", comment.XMLID)
+		resp, _, err := TOSession.GetDeliveryServiceRequests(opts)
+		assert.NoError(t, err, "Cannot get Delivery Service Request by XMLID '%s': %v - alerts: %+v", comment.XMLID, err, resp.Alerts)
+		assert.Equal(t, len(resp.Response), 1, "Found %d Delivery Service request by XMLID '%s, expected exactly one", len(resp.Response), comment.XMLID)
+		assert.NotNil(t, resp.Response[0].ID, "Got Delivery Service Request with xml_id '%s' that had a null ID", comment.XMLID)
 
-	opts := client.NewRequestOptions()
-	for _, comment := range comments.Response {
-		opts.QueryParameters.Set("id", strconv.Itoa(comment.ID))
-		resp, _, err := TOSession.GetDeliveryServiceRequestComments(opts)
-		if err != nil {
-			t.Errorf("cannot get Delivery Service Request Comment by id %d: %v - alerts: %+v", comment.ID, err, resp.Alerts)
-		}
+		comment.DeliveryServiceRequestID = *resp.Response[0].ID
+		alerts, _, err := TOSession.CreateDeliveryServiceRequestComment(comment, client.RequestOptions{})
+		assert.NoError(t, err, "Could not create Delivery Service Request Comment: %v - alerts: %+v", err, alerts.Alerts)
 	}
 }
 
 func DeleteTestDeliveryServiceRequestComments(t *testing.T) {
 	comments, _, err := TOSession.GetDeliveryServiceRequestComments(client.RequestOptions{})
-	if err != nil {
-		t.Errorf("Unexpected error getting Delivery Service Request Comments: %v - alerts: %+v", err, comments.Alerts)
-	}
+	assert.NoError(t, err, "Unexpected error getting Delivery Service Request Comments: %v - alerts: %+v", err, comments.Alerts)
 
-	opts := client.NewRequestOptions()
 	for _, comment := range comments.Response {
 		resp, _, err := TOSession.DeleteDeliveryServiceRequestComment(comment.ID, client.RequestOptions{})
-		if err != nil {
-			t.Errorf("cannot delete Delivery Service Request Comment #%d: %v - alerts: %+v", comment.ID, err, resp.Alerts)
-		}
+		assert.NoError(t, err, "Cannot delete Delivery Service Request Comment #%d: %v - alerts: %+v", comment.ID, err, resp.Alerts)
 
 		// Retrieve the delivery service request comment to see if it got deleted
+		opts := client.NewRequestOptions()
 		opts.QueryParameters.Set("id", strconv.Itoa(comment.ID))
 		comments, _, err := TOSession.GetDeliveryServiceRequestComments(opts)
-		if err != nil {
-			t.Errorf("Unexpected error fetching Delivery Service Request Comment %d after deletion: %v - alerts: %+v", comment.ID, err, comments.Alerts)
-		}
-		if len(comments.Response) > 0 {
-			t.Errorf("expected Delivery Service Request Comment #%d to be deleted, but it was found in Traffic Ops", comment.ID)
-		}
+		assert.NoError(t, err, "Unexpected error fetching Delivery Service Request Comment %d after deletion: %v - alerts: %+v", comment.ID, err, comments.Alerts)
+		assert.Equal(t, len(comments.Response), 0, "Expected Delivery Service Request Comment #%d to be deleted, but it was found in Traffic Ops", comment.ID)
 	}
 }
diff --git a/traffic_ops/testing/api/v4/tc-fixtures.json b/traffic_ops/testing/api/v4/tc-fixtures.json
index 7af7a485c..ebf1fab2a 100644
--- a/traffic_ops/testing/api/v4/tc-fixtures.json
+++ b/traffic_ops/testing/api/v4/tc-fixtures.json
@@ -310,16 +310,20 @@
     ],
     "deliveryServiceRequestComments": [
         {
-            "value": "this is comment one"
+            "value": "this is comment one",
+            "xmlId": "test-ds1"
         },
         {
-            "value": "this is comment two"
+            "value": "this is comment two",
+            "xmlId": "test-ds1"
         },
         {
-            "value": "this is comment three"
+            "value": "this is comment three",
+            "xmlId": "test-ds1"
         },
         {
-            "value": "this is comment four"
+            "value": "this is comment four",
+            "xmlId": "test-ds1"
         }
     ],
     "deliveryServiceRequests": [
@@ -5005,8 +5009,8 @@
         {
             "cachegroup": "cachegroup1",
             "cdnName": "cdn1",
-			"configUpdateTime": "2022-01-01T17:00:00-07:00",
-			"configApplyTime": "1969-12-31T17:00:00-07:00",
+            "configUpdateTime": "2022-01-01T17:00:00-07:00",
+            "configApplyTime": "1969-12-31T17:00:00-07:00",
             "domainName": "ga.atlanta.kabletown.net",
             "guid": null,
             "hostName": "config-update-time",
@@ -5056,8 +5060,8 @@
         {
             "cachegroup": "cachegroup1",
             "cdnName": "cdn1",
-			"configUpdateTime": "2022-01-01T17:00:00-07:00",
-			"configApplyTime": "1969-12-31T17:00:00-07:00",
+            "configUpdateTime": "2022-01-01T17:00:00-07:00",
+            "configApplyTime": "1969-12-31T17:00:00-07:00",
             "domainName": "ga.atlanta.kabletown.net",
             "guid": null,
             "hostName": "config-update-time-no-updpend",
@@ -5145,8 +5149,8 @@
             "profileNames": ["EDGE1"],
             "rack": "RR 119.02",
             "revalPending": false,
-			"revalUpdateTime": "2022-01-01T17:00:00-07:00",
-			"revalApplyTime": "1969-12-31T17:00:00-07:00",
+            "revalUpdateTime": "2022-01-01T17:00:00-07:00",
+            "revalApplyTime": "1969-12-31T17:00:00-07:00",
             "status": "REPORTED",
             "tcpPort": 80,
             "type": "EDGE",
@@ -5195,8 +5199,8 @@
             "physLocation": "Denver",
             "profileNames": ["EDGE1"],
             "rack": "RR 119.02",
-			"revalUpdateTime": "2022-01-01T17:00:00-07:00",
-			"revalApplyTime": "1969-12-31T17:00:00-07:00",
+            "revalUpdateTime": "2022-01-01T17:00:00-07:00",
+            "revalApplyTime": "1969-12-31T17:00:00-07:00",
             "status": "REPORTED",
             "tcpPort": 80,
             "type": "EDGE",
@@ -5207,8 +5211,8 @@
         {
             "cachegroup": "cachegroup1",
             "cdnName": "cdn1",
-			"configUpdateTime": "2022-01-01T17:00:00-07:00",
-			"configApplyTime": "1969-12-31T17:00:00-07:00",
+            "configUpdateTime": "2022-01-01T17:00:00-07:00",
+            "configApplyTime": "1969-12-31T17:00:00-07:00",
             "domainName": "ga.atlanta.kabletown.net",
             "guid": null,
             "hostName": "config-reval-update-time",
@@ -5248,8 +5252,8 @@
             "profileNames": ["EDGE1"],
             "rack": "RR 119.02",
             "revalPending": false,
-			"revalUpdateTime": "2022-01-01T17:00:00-07:00",
-			"revalApplyTime": "1969-12-31T17:00:00-07:00",
+            "revalUpdateTime": "2022-01-01T17:00:00-07:00",
+            "revalApplyTime": "1969-12-31T17:00:00-07:00",
             "status": "REPORTED",
             "tcpPort": 80,
             "type": "EDGE",
@@ -5260,8 +5264,8 @@
         {
             "cachegroup": "cachegroup1",
             "cdnName": "cdn1",
-			"configUpdateTime": "2022-01-01T17:00:00-07:00",
-			"configApplyTime": "1969-12-31T17:00:00-07:00",
+            "configUpdateTime": "2022-01-01T17:00:00-07:00",
+            "configApplyTime": "1969-12-31T17:00:00-07:00",
             "domainName": "ga.atlanta.kabletown.net",
             "guid": null,
             "hostName": "config-reval-update-time-only",
@@ -5300,8 +5304,8 @@
             "physLocation": "Denver",
             "profileNames": ["EDGE1"],
             "rack": "RR 119.02",
-			"revalUpdateTime": "2022-01-01T17:00:00-07:00",
-			"revalApplyTime": "1969-12-31T17:00:00-07:00",
+            "revalUpdateTime": "2022-01-01T17:00:00-07:00",
+            "revalApplyTime": "1969-12-31T17:00:00-07:00",
             "status": "REPORTED",
             "tcpPort": 80,
             "type": "EDGE",
diff --git a/traffic_ops/traffic_ops_golang/server/detail_test.go b/traffic_ops/traffic_ops_golang/server/detail_test.go
index 9ad95ac56..bbcfa5378 100644
--- a/traffic_ops/traffic_ops_golang/server/detail_test.go
+++ b/traffic_ops/traffic_ops_golang/server/detail_test.go
@@ -13,7 +13,9 @@ package server
 */
 
 import (
+	"fmt"
 	"strconv"
+	"strings"
 	"testing"
 
 	"github.com/apache/trafficcontrol/lib/go-tc"
@@ -75,8 +77,7 @@ func TestGetDetailServers(t *testing.T) {
 		"mgmt_ip_netmask",
 		"offline_reason",
 		"phys_location",
-		"profile",
-		"profile_desc",
+		"profile_name",
 		"rack",
 		"status",
 		"tcp_port",
@@ -93,8 +94,6 @@ func TestGetDetailServers(t *testing.T) {
 	serviceNetmask := util.StrPtr("")
 	serviceInterface := util.StrPtr("")
 	serviceMtu := util.StrPtr("")
-	//routerHostName := util.StrPtr("")
-	//routerPort := util.StrPtr("")
 
 	for _, sd := range testServerDetails {
 		detailRows = detailRows.AddRow(
@@ -123,8 +122,7 @@ func TestGetDetailServers(t *testing.T) {
 			sd.MgmtIPNetmask,
 			sd.OfflineReason,
 			sd.PhysLocation,
-			sd.Profile,
-			sd.ProfileDesc,
+			fmt.Sprintf("{%s}", strings.Join(sd.ProfileNames, ",")),
 			sd.Rack,
 			sd.Status,
 			sd.TCPPort,
@@ -143,7 +141,7 @@ func TestGetDetailServers(t *testing.T) {
 	mock.ExpectQuery("SELECT serverid").WillReturnRows(hwInfoRows)
 	mock.ExpectCommit()
 
-	actualSrvs, err := getDetailServers(db.MustBegin().Tx, &auth.CurrentUser{PrivLevel: 30}, "test", 1, "id", 10, api.Version{Major: 3})
+	actualSrvs, err := getDetailServers(db.MustBegin().Tx, &auth.CurrentUser{PrivLevel: 30}, "test", 1, "id", 10, api.Version{Major: 4})
 	if err != nil {
 		t.Fatalf("an error '%s' occurred during read", err)
 	}
@@ -170,9 +168,7 @@ func TestGetDetailServers(t *testing.T) {
 
 func getMockServerDetails() []tc.ServerDetailV40 {
 	srvData := tc.ServerDetailV40{
-		ServerDetail: tc.ServerDetail{
-			ID: util.IntPtr(1),
-		},
+		ID:               util.IntPtr(1),
 		ServerInterfaces: []tc.ServerInterfaceInfoV40{}, // left empty because it must be written as json above since sqlmock does not support nested arrays
 	}
 	return []tc.ServerDetailV40{srvData}